From a33e66680b6673530be5586a80ec88fad2ef6be3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:10:54 -0400 Subject: [PATCH 01/24] feat(picker): picker support --- doc/cp.txt | 41 +++++++- lua/cp/config.lua | 10 ++ lua/cp/constants.lua | 8 +- lua/cp/init.lua | 44 ++++++++ lua/cp/pickers/fzf_lua.lua | 123 +++++++++++++++++++++++ lua/cp/pickers/init.lua | 145 +++++++++++++++++++++++++++ lua/cp/pickers/telescope.lua | 130 ++++++++++++++++++++++++ spec/config_spec.lua | 33 ++++++ spec/fzf_lua_spec.lua | 31 ++++++ spec/picker_spec.lua | 188 +++++++++++++++++++++++++++++++++++ spec/telescope_spec.lua | 78 +++++++++++++++ 11 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 lua/cp/pickers/fzf_lua.lua create mode 100644 lua/cp/pickers/init.lua create mode 100644 lua/cp/pickers/telescope.lua create mode 100644 spec/fzf_lua_spec.lua create mode 100644 spec/picker_spec.lua create mode 100644 spec/telescope_spec.lua diff --git a/doc/cp.txt b/doc/cp.txt index 1ad6d93..c2c2964 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -72,6 +72,12 @@ COMMANDS *cp-commands* Use --debug flag to compile with debug flags. Requires contest setup first. + :CP pick Launch configured picker for interactive + platform/contest/problem selection. Requires + picker = 'telescope' or picker = 'fzf-lua' + in configuration and corresponding plugin + to be installed. + Navigation Commands ~ :CP next Navigate to next problem in current contest. Stops at last problem (no wrapping). @@ -117,6 +123,32 @@ Template Variables ~ g++ abc324a.cpp -o build/abc324a.run -std=c++17 < +============================================================================== +PICKER INTEGRATION *cp-picker* + +When picker integration is enabled in configuration, cp.nvim provides interactive +platform, contest, and problem selection using telescope.nvim or fzf-lua. + +:CP pick *:CP-pick* + Launch configured picker for interactive problem selection. + Flow: Platform → Contest → Problem → Setup + + Requires picker = 'telescope' or picker = 'fzf-lua' in configuration. + Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed. + Picker availability is checked at runtime when command is executed. + +Picker Flow ~ + 1. Platform Selection: Choose from AtCoder, Codeforces, CSES + 2. Contest Selection: Choose from available contests for selected platform + 3. Problem Selection: Choose from problems in selected contest + 4. Problem Setup: Automatically runs equivalent of :CP platform contest problem + +Notes ~ + • Contest lists are fetched dynamically using scrapers + • Large contest lists may take time to load + • Runtime picker validation - shows clear error if picker plugin not available + • Picker configuration can be changed without plugin restart + ============================================================================== CONFIGURATION *cp-config* @@ -131,10 +163,11 @@ Here's an example configuration with lazy.nvim: >lua default = { cpp = { compile = { 'g++', '{source}', '-o', '{binary}', - '-std=c++17' }, + '-std=c++17', '-fdiagnostic-colors=always' }, test = { '{binary}' }, debug = { 'g++', '{source}', '-o', '{binary}', '-std=c++17', '-g', + '-fdiagnostic-colors=always' '-fsanitize=address,undefined' }, }, python = { @@ -165,6 +198,7 @@ Here's an example configuration with lazy.nvim: >lua '--word-diff-regex=.', '--no-prefix' }, }, }, + picker = 'telescope', -- 'telescope', 'fzf-lua', or nil (disabled) } } < @@ -180,6 +214,11 @@ Here's an example configuration with lazy.nvim: >lua Default: all scrapers enabled {run_panel} (|RunPanelConfig|) Test panel behavior configuration. {diff} (|DiffConfig|) Diff backend configuration. + {picker} (string, optional) Picker integration: "telescope", + "fzf-lua", or nil to disable. When enabled, provides + :Telescope cp or :FzfLua cp commands for interactive + platform/contest/problem selection. Requires the + corresponding picker plugin to be installed. {filename} (function, optional) Custom filename generation. function(contest, contest_id, problem_id, config, language) Should return full filename with extension. diff --git a/lua/cp/config.lua b/lua/cp/config.lua index a1564fd..838107b 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -52,6 +52,7 @@ ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@field run_panel RunPanelConfig ---@field diff DiffConfig +---@field picker "telescope"|"fzf-lua"|nil ---@class cp.UserConfig ---@field contests? table @@ -62,6 +63,7 @@ ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@field run_panel? RunPanelConfig ---@field diff? DiffConfig +---@field picker? "telescope"|"fzf-lua"|nil local M = {} local constants = require('cp.constants') @@ -110,6 +112,7 @@ M.defaults = { args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, }, }, + picker = nil, } ---@param user_config cp.UserConfig|nil @@ -129,6 +132,7 @@ function M.setup(user_config) filename = { user_config.filename, { 'function', 'nil' }, true }, run_panel = { user_config.run_panel, { 'table', 'nil' }, true }, diff = { user_config.diff, { 'table', 'nil' }, true }, + picker = { user_config.picker, { 'string', 'nil' }, true }, }) if user_config.contests then @@ -164,6 +168,12 @@ function M.setup(user_config) end end end + + if user_config.picker then + if not vim.tbl_contains({ 'telescope', 'fzf-lua' }, user_config.picker) then + error(("Invalid picker '%s'. Must be 'telescope' or 'fzf-lua'"):format(user_config.picker)) + end + end end local config = vim.tbl_deep_extend('force', M.defaults, user_config or {}) diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 13cb27d..c14569b 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -1,7 +1,13 @@ local M = {} M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' } -M.ACTIONS = { 'run', 'next', 'prev' } +M.ACTIONS = { 'run', 'next', 'prev', 'pick' } + +M.PLATFORM_DISPLAY_NAMES = { + atcoder = 'AtCoder', + codeforces = 'CodeForces', + cses = 'CSES', +} M.CPP = 'cpp' M.PYTHON = 'python' diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 36c2faf..7d82cc6 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -698,6 +698,48 @@ local function navigate_problem(delta, language) setup_problem(state.contest_id, new_problem.id, language) end +local function handle_pick_action() + if not config.picker then + logger.log( + 'No picker configured. Set picker = "telescope" or picker = "fzf-lua" in config', + vim.log.levels.ERROR + ) + return + end + + if config.picker == 'telescope' then + local ok, telescope = pcall(require, 'telescope') + if not ok then + logger.log( + 'Telescope not available. Install telescope.nvim or change picker config', + vim.log.levels.ERROR + ) + return + end + local ok_cp, telescope_cp = pcall(require, 'cp.pickers.telescope') + if not ok_cp then + logger.log('Failed to load telescope integration', vim.log.levels.ERROR) + return + end + telescope_cp.platform_picker() + elseif config.picker == 'fzf-lua' then + local ok, _ = pcall(require, 'fzf-lua') + if not ok then + logger.log( + 'fzf-lua not available. Install fzf-lua or change picker config', + vim.log.levels.ERROR + ) + return + end + local ok_cp, fzf_cp = pcall(require, 'cp.pickers.fzf_lua') + if not ok_cp then + logger.log('Failed to load fzf-lua integration', vim.log.levels.ERROR) + return + end + fzf_cp.platform_picker() + end +end + local function restore_from_current_file() local current_file = vim.fn.expand('%:p') if current_file == '' then @@ -837,6 +879,8 @@ function M.handle_command(opts) navigate_problem(1, cmd.language) elseif cmd.action == 'prev' then navigate_problem(-1, cmd.language) + elseif cmd.action == 'pick' then + handle_pick_action() end return end diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua new file mode 100644 index 0000000..eaec2e7 --- /dev/null +++ b/lua/cp/pickers/fzf_lua.lua @@ -0,0 +1,123 @@ +local picker_utils = require('cp.pickers') + +local function problem_picker(platform, contest_id) + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local fzf = require('fzf-lua') + local problems = picker_utils.get_problems_for_contest(platform, contest_id) + + if #problems == 0 then + vim.notify( + ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), + vim.log.levels.WARN + ) + return + end + + local entries = vim.tbl_map(function(problem) + return problem.display_name + end, problems) + + return fzf.fzf_exec(entries, { + prompt = ('Select Problem (%s %s)> '):format(platform_display_name, contest_id), + actions = { + ['default'] = function(selected) + if not selected or #selected == 0 then + return + end + + local selected_name = selected[1] + local problem = nil + for _, p in ipairs(problems) do + if p.display_name == selected_name then + problem = p + break + end + end + + if problem then + picker_utils.setup_problem(platform, contest_id, problem.id) + end + end, + }, + }) +end + +local function contest_picker(platform) + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local fzf = require('fzf-lua') + local contests = picker_utils.get_contests_for_platform(platform) + + if #contests == 0 then + vim.notify( + ('No contests found for platform: %s'):format(platform_display_name), + vim.log.levels.WARN + ) + return + end + + local entries = vim.tbl_map(function(contest) + return contest.display_name + end, contests) + + return fzf.fzf_exec(entries, { + prompt = ('Select Contest (%s)> '):format(platform_display_name), + actions = { + ['default'] = function(selected) + if not selected or #selected == 0 then + return + end + + local selected_name = selected[1] + local contest = nil + for _, c in ipairs(contests) do + if c.display_name == selected_name then + contest = c + break + end + end + + if contest then + problem_picker(platform, contest.id) + end + end, + }, + }) +end + +local function platform_picker() + local fzf = require('fzf-lua') + local platforms = picker_utils.get_platforms() + local entries = vim.tbl_map(function(platform) + return platform.display_name + end, platforms) + + return fzf.fzf_exec(entries, { + prompt = 'Select Platform> ', + actions = { + ['default'] = function(selected) + if not selected or #selected == 0 then + return + end + + local selected_name = selected[1] + local platform = nil + for _, p in ipairs(platforms) do + if p.display_name == selected_name then + platform = p + break + end + end + + if platform then + contest_picker(platform.id) + end + end, + }, + }) +end + +return { + platform_picker = platform_picker, +} diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua new file mode 100644 index 0000000..a90cd8d --- /dev/null +++ b/lua/cp/pickers/init.lua @@ -0,0 +1,145 @@ +local M = {} + +local cache = require('cp.cache') +local logger = require('cp.log') +local scrape = require('cp.scrape') + +---@class cp.PlatformItem +---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses") +---@field display_name string Human-readable platform name (e.g. "Codeforces", "AtCoder", "CSES") + +---@class cp.ContestItem +---@field id string Contest identifier (e.g. "1951", "abc324", "sorting") +---@field name string Full contest name (e.g. "Educational Codeforces Round 168") +---@field display_name string Formatted display name for picker + +---@class cp.ProblemItem +---@field id string Problem identifier (e.g. "a", "b", "c") +---@field name string Problem name (e.g. "Two Permutations", "Painting Walls") +---@field display_name string Formatted display name for picker + +---Get list of available competitive programming platforms +---@return cp.PlatformItem[] +local function get_platforms() + local constants = require('cp.constants') + return vim.tbl_map(function(platform) + return { + id = platform, + display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform, + } + end, constants.PLATFORMS) +end + +---Get list of contests for a specific platform +---@param platform string Platform identifier (e.g. "codeforces", "atcoder") +---@return cp.ContestItem[] +local function get_contests_for_platform(platform) + local contests = {} + + local function get_plugin_path() + local plugin_path = debug.getinfo(1, 'S').source:sub(2) + return vim.fn.fnamemodify(plugin_path, ':h:h:h') + end + + local plugin_path = get_plugin_path() + local cmd = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'contests', + } + local result = vim + .system(cmd, { + cwd = plugin_path, + text = true, + timeout = 30000, + }) + :wait() + + if result.code ~= 0 then + logger.log( + ('Failed to get contests for %s: %s'):format(platform, result.stderr or 'unknown error'), + vim.log.levels.ERROR + ) + return contests + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok or not data.success then + logger.log(('Failed to parse contest data for %s'):format(platform), vim.log.levels.ERROR) + return contests + end + + for _, contest in ipairs(data.contests or {}) do + table.insert(contests, { + id = contest.id, + name = contest.name, + display_name = contest.display_name, + }) + end + + return contests +end + +---Get list of problems for a specific contest +---@param platform string Platform identifier +---@param contest_id string Contest identifier +---@return cp.ProblemItem[] +local function get_problems_for_contest(platform, contest_id) + local problems = {} + + cache.load() + local contest_data = cache.get_contest_data(platform, contest_id) + if contest_data and contest_data.problems then + for _, problem in ipairs(contest_data.problems) do + table.insert(problems, { + id = problem.id, + name = problem.name, + display_name = problem.name, + }) + end + return problems + end + + local metadata_result = scrape.scrape_contest_metadata(platform, contest_id) + if not metadata_result.success then + logger.log( + ('Failed to get problems for %s %s: %s'):format( + platform, + contest_id, + metadata_result.error or 'unknown error' + ), + vim.log.levels.ERROR + ) + return problems + end + + for _, problem in ipairs(metadata_result.problems or {}) do + table.insert(problems, { + id = problem.id, + name = problem.name, + display_name = problem.name, + }) + end + + return problems +end + +---Set up a specific problem by calling the main CP handler +---@param platform string Platform identifier +---@param contest_id string Contest identifier +---@param problem_id string Problem identifier +local function setup_problem(platform, contest_id, problem_id) + local cp = require('cp') + cp.handle_command({ fargs = { platform, contest_id, problem_id } }) +end + +M.get_platforms = get_platforms +M.get_contests_for_platform = get_contests_for_platform +M.get_problems_for_contest = get_problems_for_contest +M.setup_problem = setup_problem + +return M diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua new file mode 100644 index 0000000..604bbf8 --- /dev/null +++ b/lua/cp/pickers/telescope.lua @@ -0,0 +1,130 @@ +local finders = require('telescope.finders') +local pickers = require('telescope.pickers') +local telescope = require('telescope') +local conf = require('telescope.config').values +local action_state = require('telescope.actions.state') +local actions = require('telescope.actions') + +local picker_utils = require('cp.pickers') + +local function platform_picker(opts) + opts = opts or {} + + local platforms = picker_utils.get_platforms() + + pickers + .new(opts, { + prompt_title = 'Select Platform', + finder = finders.new_table({ + results = platforms, + entry_maker = function(entry) + return { + value = entry, + display = entry.display_name, + ordinal = entry.display_name, + } + end, + }), + sorter = conf.generic_sorter(opts), + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + local selection = action_state.get_selected_entry() + actions.close(prompt_bufnr) + + if selection then + contest_picker(opts, selection.value.id) + end + end) + return true + end, + }) + :find() +end + +local function contest_picker(opts, platform) + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local contests = picker_utils.get_contests_for_platform(platform) + + if #contests == 0 then + vim.notify( + ('No contests found for platform: %s'):format(platform_display_name), + vim.log.levels.WARN + ) + return + end + + pickers + .new(opts, { + prompt_title = ('Select Contest (%s)'):format(platform_display_name), + finder = finders.new_table({ + results = contests, + entry_maker = function(entry) + return { + value = entry, + display = entry.display_name, + ordinal = entry.display_name, + } + end, + }), + sorter = conf.generic_sorter(opts), + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + local selection = action_state.get_selected_entry() + actions.close(prompt_bufnr) + + if selection then + problem_picker(opts, platform, selection.value.id) + end + end) + return true + end, + }) + :find() +end + +local function problem_picker(opts, platform, contest_id) + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local problems = picker_utils.get_problems_for_contest(platform, contest_id) + + if #problems == 0 then + vim.notify( + ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), + vim.log.levels.WARN + ) + return + end + + pickers + .new(opts, { + prompt_title = ('Select Problem (%s %s)'):format(platform_display_name, contest_id), + finder = finders.new_table({ + results = problems, + entry_maker = function(entry) + return { + value = entry, + display = entry.display_name, + ordinal = entry.display_name, + } + end, + }), + sorter = conf.generic_sorter(opts), + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + local selection = action_state.get_selected_entry() + actions.close(prompt_bufnr) + + if selection then + picker_utils.setup_problem(platform, contest_id, selection.value.id) + end + end) + return true + end, + }) + :find() +end + +return { + platform_picker = platform_picker, +} diff --git a/spec/config_spec.lua b/spec/config_spec.lua index 8c4c79c..f3f3738 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -231,6 +231,39 @@ describe('cp.config', function() end) end) end) + + describe('picker validation', function() + it('validates picker is valid value', function() + local invalid_config = { + picker = 'invalid_picker', + } + + assert.has_error(function() + config.setup(invalid_config) + end, "Invalid picker 'invalid_picker'. Must be 'telescope' or 'fzf-lua'") + end) + + it('allows nil picker', function() + assert.has_no.errors(function() + local result = config.setup({ picker = nil }) + assert.is_nil(result.picker) + end) + end) + + it('allows telescope picker without checking availability', function() + assert.has_no.errors(function() + local result = config.setup({ picker = 'telescope' }) + assert.equals('telescope', result.picker) + end) + end) + + it('allows fzf-lua picker without checking availability', function() + assert.has_no.errors(function() + local result = config.setup({ picker = 'fzf-lua' }) + assert.equals('fzf-lua', result.picker) + end) + end) + end) end) describe('default_filename', function() diff --git a/spec/fzf_lua_spec.lua b/spec/fzf_lua_spec.lua new file mode 100644 index 0000000..f22e35e --- /dev/null +++ b/spec/fzf_lua_spec.lua @@ -0,0 +1,31 @@ +describe('cp.fzf_lua', function() + local spec_helper = require('spec.spec_helper') + + before_each(function() + spec_helper.setup() + + package.preload['fzf-lua'] = function() + return { + fzf_exec = function(entries, opts) end, + } + end + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('module loading', function() + it('loads fzf-lua integration without error', function() + assert.has_no.errors(function() + require('cp.pickers.fzf_lua') + end) + end) + + it('returns module with platform_picker function', function() + local fzf_lua_cp = require('cp.pickers.fzf_lua') + assert.is_table(fzf_lua_cp) + assert.is_function(fzf_lua_cp.platform_picker) + end) + end) +end) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua new file mode 100644 index 0000000..daac53f --- /dev/null +++ b/spec/picker_spec.lua @@ -0,0 +1,188 @@ +describe('cp.picker', function() + local picker + local spec_helper = require('spec.spec_helper') + + before_each(function() + spec_helper.setup() + picker = require('cp.pickers') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('get_platforms', function() + it('returns platform list with display names', function() + local platforms = picker.get_platforms() + + assert.is_table(platforms) + assert.is_true(#platforms > 0) + + for _, platform in ipairs(platforms) do + assert.is_string(platform.id) + assert.is_string(platform.display_name) + assert.is_true(platform.display_name:match('^%u')) + end + end) + + it('includes expected platforms with correct display names', function() + local platforms = picker.get_platforms() + local platform_map = {} + for _, p in ipairs(platforms) do + platform_map[p.id] = p.display_name + end + + assert.equals('CodeForces', platform_map['codeforces']) + assert.equals('AtCoder', platform_map['atcoder']) + assert.equals('CSES', platform_map['cses']) + end) + end) + + describe('get_contests_for_platform', function() + it('returns empty list when scraper fails', function() + vim.system = function(cmd, opts) + return { + wait = function() + return { code = 1, stderr = 'test error' } + end, + } + end + + local contests = picker.get_contests_for_platform('test_platform') + assert.is_table(contests) + assert.equals(0, #contests) + end) + + it('returns empty list when JSON is invalid', function() + vim.system = function(cmd, opts) + return { + wait = function() + return { code = 0, stdout = 'invalid json' } + end, + } + end + + local contests = picker.get_contests_for_platform('test_platform') + assert.is_table(contests) + assert.equals(0, #contests) + end) + + it('returns contest list when scraper succeeds', function() + vim.system = function(cmd, opts) + return { + wait = function() + return { + code = 0, + stdout = vim.json.encode({ + success = true, + contests = { + { + id = 'abc123', + name = 'AtCoder Beginner Contest 123', + display_name = 'Beginner Contest 123 (ABC)', + }, + { + id = '1951', + name = 'Educational Round 168', + display_name = 'Educational Round 168', + }, + }, + }), + } + end, + } + end + + local contests = picker.get_contests_for_platform('test_platform') + assert.is_table(contests) + assert.equals(2, #contests) + assert.equals('abc123', contests[1].id) + assert.equals('AtCoder Beginner Contest 123', contests[1].name) + assert.equals('Beginner Contest 123 (ABC)', contests[1].display_name) + end) + end) + + describe('get_problems_for_contest', function() + it('returns problems from cache when available', function() + local cache = require('cp.cache') + cache.load = function() end + cache.get_contest_data = function(platform, contest_id) + return { + problems = { + { id = 'a', name = 'Problem A' }, + { id = 'b', name = 'Problem B' }, + }, + } + end + + local problems = picker.get_problems_for_contest('test_platform', 'test_contest') + assert.is_table(problems) + assert.equals(2, #problems) + assert.equals('a', problems[1].id) + assert.equals('Problem A', problems[1].name) + assert.equals('a - Problem A', problems[1].display_name) + end) + + it('falls back to scraping when cache miss', function() + local cache = require('cp.cache') + local scrape = require('cp.scrape') + + cache.load = function() end + cache.get_contest_data = function() + return nil + end + scrape.scrape_contest_metadata = function(platform, contest_id) + return { + success = true, + problems = { + { id = 'x', name = 'Problem X' }, + }, + } + end + + local problems = picker.get_problems_for_contest('test_platform', 'test_contest') + assert.is_table(problems) + assert.equals(1, #problems) + assert.equals('x', problems[1].id) + end) + + it('returns empty list when scraping fails', function() + local cache = require('cp.cache') + local scrape = require('cp.scrape') + + cache.load = function() end + cache.get_contest_data = function() + return nil + end + scrape.scrape_contest_metadata = function(platform, contest_id) + return { + success = false, + error = 'test error', + } + end + + local problems = picker.get_problems_for_contest('test_platform', 'test_contest') + assert.is_table(problems) + assert.equals(0, #problems) + end) + end) + + describe('setup_problem', function() + it('calls cp.handle_command with correct arguments', function() + local cp = require('cp') + local called_with = nil + + cp.handle_command = function(opts) + called_with = opts + end + + picker.setup_problem('codeforces', '1951', 'a') + + assert.is_table(called_with) + assert.is_table(called_with.fargs) + assert.equals('codeforces', called_with.fargs[1]) + assert.equals('1951', called_with.fargs[2]) + assert.equals('a', called_with.fargs[3]) + end) + end) +end) diff --git a/spec/telescope_spec.lua b/spec/telescope_spec.lua new file mode 100644 index 0000000..3be344f --- /dev/null +++ b/spec/telescope_spec.lua @@ -0,0 +1,78 @@ +describe('cp.telescope', function() + local spec_helper = require('spec.spec_helper') + + before_each(function() + spec_helper.setup() + + package.preload['telescope'] = function() + return { + register_extension = function(ext_config) + return ext_config + end, + } + end + + package.preload['telescope.pickers'] = function() + return { + new = function(opts, picker_opts) + return { + find = function() end, + } + end, + } + end + + package.preload['telescope.finders'] = function() + return { + new_table = function(opts) + return opts + end, + } + end + + package.preload['telescope.config'] = function() + return { + values = { + generic_sorter = function() + return {} + end, + }, + } + end + + package.preload['telescope.actions'] = function() + return { + select_default = { + replace = function() end, + }, + close = function() end, + } + end + + package.preload['telescope.actions.state'] = function() + return { + get_selected_entry = function() + return nil + end, + } + end + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('module loading', function() + it('registers telescope extension without error', function() + assert.has_no.errors(function() + require('cp.pickers.telescope') + end) + end) + + it('returns module with platform_picker function', function() + local telescope_cp = require('cp.pickers.telescope') + assert.is_table(telescope_cp) + assert.is_function(telescope_cp.platform_picker) + end) + end) +end) From 58f9be5f9a1c3d33ca1d7e20f8492c89bbd69649 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:19:00 -0400 Subject: [PATCH 02/24] fix: refactor --- lua/cp/health.lua | 8 ++++---- lua/cp/pickers/init.lua | 8 ++++---- lua/cp/scrape.lua | 43 +++++++--------------------------------- lua/cp/utils.lua | 44 +++++++++++++++++++++++++++++++++++++++++ lua/cp/version.lua | 5 +++-- 5 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 lua/cp/utils.lua diff --git a/lua/cp/health.lua b/lua/cp/health.lua index af89cc8..563c065 100644 --- a/lua/cp/health.lua +++ b/lua/cp/health.lua @@ -1,5 +1,7 @@ local M = {} +local utils = require('cp.utils') + local function check_nvim_version() if vim.fn.has('nvim-0.10.0') == 1 then vim.health.ok('Neovim 0.10.0+ detected') @@ -22,8 +24,7 @@ local function check_uv() end local function check_python_env() - local plugin_path = debug.getinfo(1, 'S').source:sub(2) - plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h') + local plugin_path = utils.get_plugin_path() local venv_dir = plugin_path .. '/.venv' if vim.fn.isdirectory(venv_dir) == 1 then @@ -34,8 +35,7 @@ local function check_python_env() end local function check_scrapers() - local plugin_path = debug.getinfo(1, 'S').source:sub(2) - plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h') + local plugin_path = utils.get_plugin_path() local scrapers = { 'atcoder.py', 'codeforces.py', 'cses.py' } for _, scraper in ipairs(scrapers) do diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index a90cd8d..e2920ef 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -3,6 +3,7 @@ local M = {} local cache = require('cp.cache') local logger = require('cp.log') local scrape = require('cp.scrape') +local utils = require('cp.utils') ---@class cp.PlatformItem ---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses") @@ -36,12 +37,11 @@ end local function get_contests_for_platform(platform) local contests = {} - local function get_plugin_path() - local plugin_path = debug.getinfo(1, 'S').source:sub(2) - return vim.fn.fnamemodify(plugin_path, ':h:h:h') + if not utils.setup_python_env() then + return contests end - local plugin_path = get_plugin_path() + local plugin_path = utils.get_plugin_path() local cmd = { 'uv', 'run', diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index f8a5e31..88ab166 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -15,42 +15,13 @@ local M = {} local cache = require('cp.cache') local logger = require('cp.log') local problem = require('cp.problem') - -local function get_plugin_path() - local plugin_path = debug.getinfo(1, 'S').source:sub(2) - return vim.fn.fnamemodify(plugin_path, ':h:h:h') -end +local utils = require('cp.utils') local function check_internet_connectivity() local result = vim.system({ 'ping', '-c', '1', '-W', '3', '8.8.8.8' }, { text = true }):wait() return result.code == 0 end -local function setup_python_env() - local plugin_path = get_plugin_path() - local venv_dir = plugin_path .. '/.venv' - - if vim.fn.executable('uv') == 0 then - logger.log( - 'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/', - vim.log.levels.WARN - ) - return false - end - - if vim.fn.isdirectory(venv_dir) == 0 then - logger.log('setting up Python environment for scrapers...') - local result = vim.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true }):wait() - if result.code ~= 0 then - logger.log('failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) - return false - end - logger.log('python environment setup complete') - end - - return true -end - ---@param platform string ---@param contest_id string ---@return {success: boolean, problems?: table[], error?: string} @@ -77,14 +48,14 @@ function M.scrape_contest_metadata(platform, contest_id) } end - if not setup_python_env() then + if not utils.setup_python_env() then return { success = false, error = 'Python environment setup failed', } end - local plugin_path = get_plugin_path() + local plugin_path = utils.get_plugin_path() local args = { 'uv', @@ -182,7 +153,7 @@ function M.scrape_problem(ctx) } end - if not setup_python_env() then + if not utils.setup_python_env() then return { success = false, problem_id = ctx.problem_name, @@ -190,7 +161,7 @@ function M.scrape_problem(ctx) } end - local plugin_path = get_plugin_path() + local plugin_path = utils.get_plugin_path() local args if ctx.contest == 'cses' then @@ -308,11 +279,11 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) return {} end - if not setup_python_env() then + if not utils.setup_python_env() then return {} end - local plugin_path = get_plugin_path() + local plugin_path = utils.get_plugin_path() local jobs = {} for _, prob in ipairs(problems) do diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua new file mode 100644 index 0000000..36414d7 --- /dev/null +++ b/lua/cp/utils.lua @@ -0,0 +1,44 @@ +local M = {} + +local logger = require('cp.log') + +---@return string +function M.get_plugin_path() + local plugin_path = debug.getinfo(1, 'S').source:sub(2) + return vim.fn.fnamemodify(plugin_path, ':h:h:h') +end + +local python_env_setup = false + +---@return boolean success +function M.setup_python_env() + if python_env_setup then + return true + end + + local plugin_path = M.get_plugin_path() + local venv_dir = plugin_path .. '/.venv' + + if vim.fn.executable('uv') == 0 then + logger.log( + 'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/', + vim.log.levels.WARN + ) + return false + end + + if vim.fn.isdirectory(venv_dir) == 0 then + logger.log('setting up Python environment for scrapers...') + local result = vim.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true }):wait() + if result.code ~= 0 then + logger.log('failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) + return false + end + logger.log('python environment setup complete') + end + + python_env_setup = true + return true +end + +return M diff --git a/lua/cp/version.lua b/lua/cp/version.lua index b454a07..308fe01 100644 --- a/lua/cp/version.lua +++ b/lua/cp/version.lua @@ -1,8 +1,9 @@ local M = {} +local utils = require('cp.utils') + local function get_git_version() - local plugin_path = debug.getinfo(1, 'S').source:sub(2) - local plugin_root = vim.fn.fnamemodify(plugin_path, ':h:h:h') + local plugin_root = utils.get_plugin_path() local result = vim .system({ 'git', 'describe', '--tags', '--always', '--dirty' }, { From 46c615416f559237a555f4c61363ea5846fc719c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:26:54 -0400 Subject: [PATCH 03/24] feat(scraper): use backoff --- pyproject.toml | 1 + scrapers/atcoder.py | 296 ++++++++++++++++----------------- scrapers/cses.py | 64 ++++--- tests/scrapers/test_atcoder.py | 3 - tests/scrapers/test_cses.py | 4 +- uv.lock | 11 ++ 6 files changed, 186 insertions(+), 193 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5c731b5..92c1cbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.11" dependencies = [ + "backoff>=2.2.1", "beautifulsoup4>=4.13.5", "cloudscraper>=1.2.71", "requests>=2.32.5", diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 3dc1d16..eef91ff 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -3,9 +3,9 @@ import json import re import sys -import time from dataclasses import asdict +import backoff import requests from bs4 import BeautifulSoup, Tag @@ -169,7 +169,6 @@ def scrape(url: str) -> list[TestCase]: def scrape_contests() -> list[ContestSummary]: import concurrent.futures - import random def get_max_pages() -> int: try: @@ -197,168 +196,161 @@ def scrape_contests() -> list[ContestSummary]: except Exception: return 15 - def scrape_page_with_retry(page: int, max_retries: int = 3) -> list[ContestSummary]: - for attempt in range(max_retries): + def scrape_page(page: int) -> list[ContestSummary]: + @backoff.on_exception( + backoff.expo, + (requests.exceptions.RequestException, requests.exceptions.HTTPError), + max_tries=4, + jitter=backoff.random_jitter, + on_backoff=lambda details: print( + f"Request failed on page {page} (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}", + file=sys.stderr, + ), + ) + @backoff.on_predicate( + backoff.expo, + lambda response: response.status_code == 429, + max_tries=4, + jitter=backoff.random_jitter, + on_backoff=lambda details: print( + f"Rate limited on page {page}, retrying in {details['wait']:.1f}s", + file=sys.stderr, + ), + ) + def make_request() -> requests.Response: + headers = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + url = f"https://atcoder.jp/contests/archive?page={page}" + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + return response + + try: + response = make_request() + except Exception: + return [] + + soup = BeautifulSoup(response.text, "html.parser") + table = soup.find("table", class_="table") + if not table: + return [] + + tbody = table.find("tbody") + if not tbody or not isinstance(tbody, Tag): + return [] + + rows = tbody.find_all("tr") + if not rows: + return [] + + contests = [] + for row in rows: + cells = row.find_all("td") + if len(cells) < 2: + continue + + contest_cell = cells[1] + link = contest_cell.find("a") + if not link or not link.get("href"): + continue + + href = link.get("href") + contest_id = href.split("/")[-1] + name = link.get_text().strip() + try: - headers = { - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - } - url = f"https://atcoder.jp/contests/archive?page={page}" - response = requests.get(url, headers=headers, timeout=10) + name = name.encode().decode("unicode_escape") + except (UnicodeDecodeError, UnicodeEncodeError): + pass - if response.status_code == 429: - backoff_time = (2**attempt) + random.uniform(0, 1) - print( - f"Rate limited on page {page}, retrying in {backoff_time:.1f}s", - file=sys.stderr, - ) - time.sleep(backoff_time) - continue + name = ( + name.replace("\uff08", "(") + .replace("\uff09", ")") + .replace("\u3000", " ") + ) + name = re.sub( + r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name + ) - response.raise_for_status() + def generate_display_name_from_id(contest_id: str) -> str: + parts = contest_id.replace("-", " ").replace("_", " ") - soup = BeautifulSoup(response.text, "html.parser") - table = soup.find("table", class_="table") - if not table: - return [] - - tbody = table.find("tbody") - if not tbody or not isinstance(tbody, Tag): - return [] - - rows = tbody.find_all("tr") - if not rows: - return [] - - contests = [] - for row in rows: - cells = row.find_all("td") - if len(cells) < 2: - continue - - contest_cell = cells[1] - link = contest_cell.find("a") - if not link or not link.get("href"): - continue - - href = link.get("href") - contest_id = href.split("/")[-1] - name = link.get_text().strip() - - try: - name = name.encode().decode("unicode_escape") - except (UnicodeDecodeError, UnicodeEncodeError): - pass - - name = ( - name.replace("\uff08", "(") - .replace("\uff09", ")") - .replace("\u3000", " ") - ) - name = re.sub( - r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name - ) - - def generate_display_name_from_id(contest_id: str) -> str: - parts = contest_id.replace("-", " ").replace("_", " ") - - parts = re.sub( - r"\b(jsc|JSC)\b", - "Japanese Student Championship", - parts, - flags=re.IGNORECASE, - ) - parts = re.sub( - r"\b(wtf|WTF)\b", - "World Tour Finals", - parts, - flags=re.IGNORECASE, - ) - parts = re.sub( - r"\b(ahc)(\d+)\b", - r"Heuristic Contest \2 (AHC)", - parts, - flags=re.IGNORECASE, - ) - parts = re.sub( - r"\b(arc)(\d+)\b", - r"Regular Contest \2 (ARC)", - parts, - flags=re.IGNORECASE, - ) - parts = re.sub( - r"\b(abc)(\d+)\b", - r"Beginner Contest \2 (ABC)", - parts, - flags=re.IGNORECASE, - ) - parts = re.sub( - r"\b(agc)(\d+)\b", - r"Grand Contest \2 (AGC)", - parts, - flags=re.IGNORECASE, - ) - - return parts.title() - - english_chars = sum(1 for c in name if c.isascii() and c.isalpha()) - total_chars = len(re.sub(r"\s+", "", name)) - - if total_chars > 0 and english_chars / total_chars < 0.3: - display_name = generate_display_name_from_id(contest_id) - else: - display_name = name - if "AtCoder Beginner Contest" in name: - match = re.search(r"AtCoder Beginner Contest (\d+)", name) - if match: - display_name = ( - f"Beginner Contest {match.group(1)} (ABC)" - ) - elif "AtCoder Regular Contest" in name: - match = re.search(r"AtCoder Regular Contest (\d+)", name) - if match: - display_name = f"Regular Contest {match.group(1)} (ARC)" - elif "AtCoder Grand Contest" in name: - match = re.search(r"AtCoder Grand Contest (\d+)", name) - if match: - display_name = f"Grand Contest {match.group(1)} (AGC)" - elif "AtCoder Heuristic Contest" in name: - match = re.search(r"AtCoder Heuristic Contest (\d+)", name) - if match: - display_name = ( - f"Heuristic Contest {match.group(1)} (AHC)" - ) - - contests.append( - ContestSummary( - id=contest_id, name=name, display_name=display_name - ) - ) - - return contests - - except requests.exceptions.RequestException as e: - if response.status_code == 429: - continue - print( - f"Failed to scrape page {page} (attempt {attempt + 1}): {e}", - file=sys.stderr, + parts = re.sub( + r"\b(jsc|JSC)\b", + "Japanese Student Championship", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(wtf|WTF)\b", + "World Tour Finals", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(ahc)(\d+)\b", + r"Heuristic Contest \2 (AHC)", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(arc)(\d+)\b", + r"Regular Contest \2 (ARC)", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(abc)(\d+)\b", + r"Beginner Contest \2 (ABC)", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(agc)(\d+)\b", + r"Grand Contest \2 (AGC)", + parts, + flags=re.IGNORECASE, ) - if attempt == max_retries - 1: - return [] - except Exception as e: - print(f"Unexpected error on page {page}: {e}", file=sys.stderr) - return [] - return [] + return parts.title() + + english_chars = sum(1 for c in name if c.isascii() and c.isalpha()) + total_chars = len(re.sub(r"\s+", "", name)) + + if total_chars > 0 and english_chars / total_chars < 0.3: + display_name = generate_display_name_from_id(contest_id) + else: + display_name = name + if "AtCoder Beginner Contest" in name: + match = re.search(r"AtCoder Beginner Contest (\d+)", name) + if match: + display_name = f"Beginner Contest {match.group(1)} (ABC)" + elif "AtCoder Regular Contest" in name: + match = re.search(r"AtCoder Regular Contest (\d+)", name) + if match: + display_name = f"Regular Contest {match.group(1)} (ARC)" + elif "AtCoder Grand Contest" in name: + match = re.search(r"AtCoder Grand Contest (\d+)", name) + if match: + display_name = f"Grand Contest {match.group(1)} (AGC)" + elif "AtCoder Heuristic Contest" in name: + match = re.search(r"AtCoder Heuristic Contest (\d+)", name) + if match: + display_name = f"Heuristic Contest {match.group(1)} (AHC)" + + contests.append( + ContestSummary(id=contest_id, name=name, display_name=display_name) + ) + + return contests max_pages = get_max_pages() page_results = {} with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: future_to_page = { - executor.submit(scrape_page_with_retry, page): page - for page in range(1, max_pages + 1) + executor.submit(scrape_page, page): page for page in range(1, max_pages + 1) } for future in concurrent.futures.as_completed(future_to_page): diff --git a/scrapers/cses.py b/scrapers/cses.py index b2f1733..5393e85 100755 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -1,12 +1,11 @@ #!/usr/bin/env python3 import json -import random import re import sys -import time from dataclasses import asdict +import backoff import requests from bs4 import BeautifulSoup, Tag @@ -41,36 +40,29 @@ def denormalize_category_name(category_id: str) -> str: return category_map.get(category_id, category_id.replace("_", " ").title()) -def request_with_retry( - url: str, headers: dict, max_retries: int = 3 -) -> requests.Response: - for attempt in range(max_retries): - try: - delay = 0.5 + random.uniform(0, 0.3) - time.sleep(delay) - - response = requests.get(url, headers=headers, timeout=10) - - if response.status_code == 429: - backoff = (2**attempt) + random.uniform(0, 1) - print(f"Rate limited, retrying in {backoff:.1f}s", file=sys.stderr) - time.sleep(backoff) - continue - - response.raise_for_status() - return response - - except requests.exceptions.RequestException as e: - if attempt == max_retries - 1: - raise - backoff = 2**attempt - print( - f"Request failed (attempt {attempt + 1}), retrying in {backoff}s: {e}", - file=sys.stderr, - ) - time.sleep(backoff) - - raise Exception("All retry attempts failed") +@backoff.on_exception( + backoff.expo, + (requests.exceptions.RequestException, requests.exceptions.HTTPError), + max_tries=4, + jitter=backoff.random_jitter, + on_backoff=lambda details: print( + f"Request failed (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}", + file=sys.stderr, + ), +) +@backoff.on_predicate( + backoff.expo, + lambda response: response.status_code == 429, + max_tries=4, + jitter=backoff.random_jitter, + on_backoff=lambda details: print( + f"Rate limited, retrying in {details['wait']:.1f}s", file=sys.stderr + ), +) +def make_request(url: str, headers: dict) -> requests.Response: + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + return response def scrape_category_problems(category_id: str) -> list[ProblemSummary]: @@ -82,7 +74,7 @@ def scrape_category_problems(category_id: str) -> list[ProblemSummary]: "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } - response = request_with_retry(problemset_url, headers) + response = make_request(problemset_url, headers) soup = BeautifulSoup(response.text, "html.parser") @@ -176,7 +168,7 @@ def scrape_categories() -> list[ContestSummary]: headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } - response = request_with_retry("https://cses.fi/problemset/", headers) + response = make_request("https://cses.fi/problemset/", headers) soup = BeautifulSoup(response.text, "html.parser") categories = [] @@ -193,7 +185,7 @@ def scrape_categories() -> list[ContestSummary]: if ul: problem_count = len(ul.find_all("li", class_="task")) - display_name = f"{category_name} ({problem_count} problems)" + display_name = category_name categories.append( ContestSummary( @@ -323,7 +315,7 @@ def scrape(url: str) -> list[TestCase]: "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } - response = request_with_retry(url, headers) + response = make_request(url, headers) soup = BeautifulSoup(response.text, "html.parser") diff --git a/tests/scrapers/test_atcoder.py b/tests/scrapers/test_atcoder.py index 5ff91d9..a2a88e5 100644 --- a/tests/scrapers/test_atcoder.py +++ b/tests/scrapers/test_atcoder.py @@ -94,7 +94,6 @@ def test_scrape_contests_success(mocker): return mock_response mocker.patch("scrapers.atcoder.requests.get", side_effect=mock_get_side_effect) - mocker.patch("scrapers.atcoder.time.sleep") result = scrape_contests() @@ -116,7 +115,6 @@ def test_scrape_contests_no_table(mocker): mock_response.text = "No table found" mocker.patch("scrapers.atcoder.requests.get", return_value=mock_response) - mocker.patch("scrapers.atcoder.time.sleep") result = scrape_contests() @@ -127,7 +125,6 @@ def test_scrape_contests_network_error(mocker): mocker.patch( "scrapers.atcoder.requests.get", side_effect=Exception("Network error") ) - mocker.patch("scrapers.atcoder.time.sleep") result = scrape_contests() diff --git a/tests/scrapers/test_cses.py b/tests/scrapers/test_cses.py index a1e84a2..545176d 100644 --- a/tests/scrapers/test_cses.py +++ b/tests/scrapers/test_cses.py @@ -168,12 +168,12 @@ def test_scrape_categories_success(mocker): assert result[0] == ContestSummary( id="introductory_problems", name="Introductory Problems", - display_name="Introductory Problems (2 problems)", + display_name="Introductory Problems", ) assert result[1] == ContestSummary( id="sorting_and_searching", name="Sorting and Searching", - display_name="Sorting and Searching (3 problems)", + display_name="Sorting and Searching", ) diff --git a/uv.lock b/uv.lock index 744b4ae..aa9248d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.13.5" @@ -375,6 +384,7 @@ name = "scrapers" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "backoff" }, { name = "beautifulsoup4" }, { name = "cloudscraper" }, { name = "requests" }, @@ -392,6 +402,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "backoff", specifier = ">=2.2.1" }, { name = "beautifulsoup4", specifier = ">=4.13.5" }, { name = "cloudscraper", specifier = ">=1.2.71" }, { name = "requests", specifier = ">=2.32.5" }, From c68e6fbc19ba754e4def0c609213f014302db809 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:28:29 -0400 Subject: [PATCH 04/24] fix(ci): unused var --- scrapers/cses.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scrapers/cses.py b/scrapers/cses.py index 5393e85..75e7601 100755 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -180,11 +180,6 @@ def scrape_categories() -> list[ContestSummary]: category_id = normalize_category_name(category_name) - ul = h2.find_next_sibling("ul", class_="task-list") - problem_count = 0 - if ul: - problem_count = len(ul.find_all("li", class_="task")) - display_name = category_name categories.append( From 1b8365265d39470d27ca0f90fc28a65fda9c1960 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:36:06 -0400 Subject: [PATCH 05/24] fix(ci): unused variables --- lua/cp/cache.lua | 65 ++++++++++++++++++++++++++++++++++++ lua/cp/init.lua | 2 +- lua/cp/pickers/init.lua | 7 ++++ lua/cp/pickers/telescope.lua | 47 +++++++++++++------------- lua/cp/scrape.lua | 1 - spec/fzf_lua_spec.lua | 2 +- 6 files changed, 97 insertions(+), 27 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 5d8f6b8..04d7dbd 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -7,6 +7,12 @@ ---@class CacheData ---@field [string] table ---@field file_states? table +---@field contest_lists? table + +---@class ContestListData +---@field contests table[] +---@field cached_at number +---@field expires_at number ---@class ContestData ---@field problems Problem[] @@ -33,6 +39,12 @@ local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_data = {} local loaded = false +local CONTEST_LIST_TTL = { + cses = 7 * 24 * 60 * 60, -- 1 week + codeforces = 24 * 60 * 60, -- 1 day + atcoder = 24 * 60 * 60, -- 1 day +} + ---@param platform string ---@return number? local function get_expiry_date(platform) @@ -277,4 +289,57 @@ function M.set_file_state(file_path, platform, contest_id, problem_id, language) M.save() end +---@param platform string +---@return table[]? +function M.get_contest_list(platform) + vim.validate({ + platform = { platform, 'string' }, + }) + + if not cache_data.contest_lists or not cache_data.contest_lists[platform] then + return nil + end + + local contest_list_data = cache_data.contest_lists[platform] + if os.time() >= contest_list_data.expires_at then + return nil + end + + return contest_list_data.contests +end + +---@param platform string +---@param contests table[] +function M.set_contest_list(platform, contests) + vim.validate({ + platform = { platform, 'string' }, + contests = { contests, 'table' }, + }) + + if not cache_data.contest_lists then + cache_data.contest_lists = {} + end + + local ttl = CONTEST_LIST_TTL[platform] or (24 * 60 * 60) -- Default 1 day + cache_data.contest_lists[platform] = { + contests = contests, + cached_at = os.time(), + expires_at = os.time() + ttl, + } + + M.save() +end + +---@param platform string +function M.clear_contest_list(platform) + vim.validate({ + platform = { platform, 'string' }, + }) + + if cache_data.contest_lists and cache_data.contest_lists[platform] then + cache_data.contest_lists[platform] = nil + M.save() + end +end + return M diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 7d82cc6..b8188f9 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -708,7 +708,7 @@ local function handle_pick_action() end if config.picker == 'telescope' then - local ok, telescope = pcall(require, 'telescope') + local ok = pcall(require, 'telescope') if not ok then logger.log( 'Telescope not available. Install telescope.nvim or change picker config', diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index e2920ef..a21da8a 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -37,6 +37,12 @@ end local function get_contests_for_platform(platform) local contests = {} + cache.load() + local cached_contests = cache.get_contest_list(platform) + if cached_contests then + return cached_contests + end + if not utils.setup_python_env() then return contests end @@ -81,6 +87,7 @@ local function get_contests_for_platform(platform) }) end + cache.set_contest_list(platform, contests) return contests end diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 604bbf8..57ef5d8 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -1,22 +1,29 @@ local finders = require('telescope.finders') local pickers = require('telescope.pickers') -local telescope = require('telescope') local conf = require('telescope.config').values local action_state = require('telescope.actions.state') local actions = require('telescope.actions') local picker_utils = require('cp.pickers') -local function platform_picker(opts) - opts = opts or {} +local function problem_picker(opts, platform, contest_id) + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local problems = picker_utils.get_problems_for_contest(platform, contest_id) - local platforms = picker_utils.get_platforms() + if #problems == 0 then + vim.notify( + ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), + vim.log.levels.WARN + ) + return + end pickers .new(opts, { - prompt_title = 'Select Platform', + prompt_title = ('Select Problem (%s %s)'):format(platform_display_name, contest_id), finder = finders.new_table({ - results = platforms, + results = problems, entry_maker = function(entry) return { value = entry, @@ -26,13 +33,13 @@ local function platform_picker(opts) end, }), sorter = conf.generic_sorter(opts), - attach_mappings = function(prompt_bufnr, map) + attach_mappings = function(prompt_bufnr) actions.select_default:replace(function() local selection = action_state.get_selected_entry() actions.close(prompt_bufnr) if selection then - contest_picker(opts, selection.value.id) + picker_utils.setup_problem(platform, contest_id, selection.value.id) end end) return true @@ -68,7 +75,7 @@ local function contest_picker(opts, platform) end, }), sorter = conf.generic_sorter(opts), - attach_mappings = function(prompt_bufnr, map) + attach_mappings = function(prompt_bufnr) actions.select_default:replace(function() local selection = action_state.get_selected_entry() actions.close(prompt_bufnr) @@ -83,24 +90,16 @@ local function contest_picker(opts, platform) :find() end -local function problem_picker(opts, platform, contest_id) - local constants = require('cp.constants') - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - local problems = picker_utils.get_problems_for_contest(platform, contest_id) +local function platform_picker(opts) + opts = opts or {} - if #problems == 0 then - vim.notify( - ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), - vim.log.levels.WARN - ) - return - end + local platforms = picker_utils.get_platforms() pickers .new(opts, { - prompt_title = ('Select Problem (%s %s)'):format(platform_display_name, contest_id), + prompt_title = 'Select Platform', finder = finders.new_table({ - results = problems, + results = platforms, entry_maker = function(entry) return { value = entry, @@ -110,13 +109,13 @@ local function problem_picker(opts, platform, contest_id) end, }), sorter = conf.generic_sorter(opts), - attach_mappings = function(prompt_bufnr, map) + attach_mappings = function(prompt_bufnr) actions.select_default:replace(function() local selection = action_state.get_selected_entry() actions.close(prompt_bufnr) if selection then - picker_utils.setup_problem(platform, contest_id, selection.value.id) + contest_picker(opts, selection.value.id) end end) return true diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 88ab166..9f7e35b 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -13,7 +13,6 @@ local M = {} local cache = require('cp.cache') -local logger = require('cp.log') local problem = require('cp.problem') local utils = require('cp.utils') diff --git a/spec/fzf_lua_spec.lua b/spec/fzf_lua_spec.lua index f22e35e..2b5af04 100644 --- a/spec/fzf_lua_spec.lua +++ b/spec/fzf_lua_spec.lua @@ -6,7 +6,7 @@ describe('cp.fzf_lua', function() package.preload['fzf-lua'] = function() return { - fzf_exec = function(entries, opts) end, + fzf_exec = function() end, } end end) From 2c994a8bdcb16d2294192bf13232b551e54962b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:36:19 -0400 Subject: [PATCH 06/24] fix(ci): unused variables --- spec/picker_spec.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index daac53f..91379e8 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -40,7 +40,7 @@ describe('cp.picker', function() describe('get_contests_for_platform', function() it('returns empty list when scraper fails', function() - vim.system = function(cmd, opts) + vim.system = function() return { wait = function() return { code = 1, stderr = 'test error' } @@ -54,7 +54,7 @@ describe('cp.picker', function() end) it('returns empty list when JSON is invalid', function() - vim.system = function(cmd, opts) + vim.system = function() return { wait = function() return { code = 0, stdout = 'invalid json' } @@ -68,7 +68,7 @@ describe('cp.picker', function() end) it('returns contest list when scraper succeeds', function() - vim.system = function(cmd, opts) + vim.system = function() return { wait = function() return { From c1529c5d91e4fc7ab4b73c1baf264a3154e33153 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:37:08 -0400 Subject: [PATCH 07/24] fix(ci): unused vars --- lua/cp/pickers/fzf_lua.lua | 5 +++++ lua/cp/pickers/telescope.lua | 10 +++++++++- spec/picker_spec.lua | 6 +++--- spec/telescope_spec.lua | 2 +- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index eaec2e7..7b1b476 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -82,6 +82,11 @@ local function contest_picker(platform) problem_picker(platform, contest.id) end end, + ['ctrl-r'] = function() + local cache = require('cp.cache') + cache.clear_contest_list(platform) + contest_picker(platform) + end, }, }) end diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 57ef5d8..d42b4cc 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -75,7 +75,7 @@ local function contest_picker(opts, platform) end, }), sorter = conf.generic_sorter(opts), - attach_mappings = function(prompt_bufnr) + attach_mappings = function(prompt_bufnr, map) actions.select_default:replace(function() local selection = action_state.get_selected_entry() actions.close(prompt_bufnr) @@ -84,6 +84,14 @@ local function contest_picker(opts, platform) problem_picker(opts, platform, selection.value.id) end end) + + map('i', '', function() + local cache = require('cp.cache') + cache.clear_contest_list(platform) + actions.close(prompt_bufnr) + contest_picker(opts, platform) + end) + return true end, }) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index 91379e8..eb50d7c 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -106,7 +106,7 @@ describe('cp.picker', function() it('returns problems from cache when available', function() local cache = require('cp.cache') cache.load = function() end - cache.get_contest_data = function(platform, contest_id) + cache.get_contest_data = function() return { problems = { { id = 'a', name = 'Problem A' }, @@ -131,7 +131,7 @@ describe('cp.picker', function() cache.get_contest_data = function() return nil end - scrape.scrape_contest_metadata = function(platform, contest_id) + scrape.scrape_contest_metadata = function() return { success = true, problems = { @@ -154,7 +154,7 @@ describe('cp.picker', function() cache.get_contest_data = function() return nil end - scrape.scrape_contest_metadata = function(platform, contest_id) + scrape.scrape_contest_metadata = function() return { success = false, error = 'test error', diff --git a/spec/telescope_spec.lua b/spec/telescope_spec.lua index 3be344f..bf310ef 100644 --- a/spec/telescope_spec.lua +++ b/spec/telescope_spec.lua @@ -14,7 +14,7 @@ describe('cp.telescope', function() package.preload['telescope.pickers'] = function() return { - new = function(opts, picker_opts) + new = function() return { find = function() end, } From be143d408bee9f52b5ef5ca40b11bff646f952a4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:42:53 -0400 Subject: [PATCH 08/24] fix(ci): keep mocks for stubs, but ignore unused param --- spec/fzf_lua_spec.lua | 2 +- spec/picker_spec.lua | 16 ++++++++-------- spec/scraper_spec.lua | 12 ++++++------ spec/telescope_spec.lua | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/spec/fzf_lua_spec.lua b/spec/fzf_lua_spec.lua index 2b5af04..eb6f3e8 100644 --- a/spec/fzf_lua_spec.lua +++ b/spec/fzf_lua_spec.lua @@ -6,7 +6,7 @@ describe('cp.fzf_lua', function() package.preload['fzf-lua'] = function() return { - fzf_exec = function() end, + fzf_exec = function(_, _) end, } end end) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index eb50d7c..f628a00 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -40,7 +40,7 @@ describe('cp.picker', function() describe('get_contests_for_platform', function() it('returns empty list when scraper fails', function() - vim.system = function() + vim.system = function(_, _) return { wait = function() return { code = 1, stderr = 'test error' } @@ -54,7 +54,7 @@ describe('cp.picker', function() end) it('returns empty list when JSON is invalid', function() - vim.system = function() + vim.system = function(_, _) return { wait = function() return { code = 0, stdout = 'invalid json' } @@ -68,7 +68,7 @@ describe('cp.picker', function() end) it('returns contest list when scraper succeeds', function() - vim.system = function() + vim.system = function(_, _) return { wait = function() return { @@ -106,7 +106,7 @@ describe('cp.picker', function() it('returns problems from cache when available', function() local cache = require('cp.cache') cache.load = function() end - cache.get_contest_data = function() + cache.get_contest_data = function(_, _) return { problems = { { id = 'a', name = 'Problem A' }, @@ -128,10 +128,10 @@ describe('cp.picker', function() local scrape = require('cp.scrape') cache.load = function() end - cache.get_contest_data = function() + cache.get_contest_data = function(_, _) return nil end - scrape.scrape_contest_metadata = function() + scrape.scrape_contest_metadata = function(_, _) return { success = true, problems = { @@ -151,10 +151,10 @@ describe('cp.picker', function() local scrape = require('cp.scrape') cache.load = function() end - cache.get_contest_data = function() + cache.get_contest_data = function(_, _) return nil end - scrape.scrape_contest_metadata = function() + scrape.scrape_contest_metadata = function(_, _) return { success = false, error = 'test error', diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 67c1daa..0b1ceeb 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -129,7 +129,7 @@ describe('cp.scrape', function() describe('system dependency checks', function() it('handles missing uv executable', function() - vim.fn.executable = function(cmd) + vim.fn.executable = function(_) return cmd == 'uv' and 0 or 1 end @@ -140,7 +140,7 @@ describe('cp.scrape', function() end) it('handles python environment setup failure', function() - vim.system = function(cmd) + vim.system = function(_) if cmd[1] == 'ping' then return { wait = function() @@ -172,7 +172,7 @@ describe('cp.scrape', function() end) it('handles network connectivity issues', function() - vim.system = function(cmd) + vim.system = function(_) if cmd[1] == 'ping' then return { wait = function() @@ -231,7 +231,7 @@ describe('cp.scrape', function() end) it('handles subprocess execution failure', function() - vim.system = function(cmd) + vim.system = function(_) if cmd[1] == 'ping' then return { wait = function() @@ -262,7 +262,7 @@ describe('cp.scrape', function() describe('json parsing', function() it('handles invalid json output', function() - vim.system = function(cmd) + vim.system = function(_) if cmd[1] == 'ping' then return { wait = function() @@ -290,7 +290,7 @@ describe('cp.scrape', function() end) it('handles scraper-reported failures', function() - vim.system = function(cmd) + vim.system = function(_) if cmd[1] == 'ping' then return { wait = function() diff --git a/spec/telescope_spec.lua b/spec/telescope_spec.lua index bf310ef..794fecc 100644 --- a/spec/telescope_spec.lua +++ b/spec/telescope_spec.lua @@ -14,7 +14,7 @@ describe('cp.telescope', function() package.preload['telescope.pickers'] = function() return { - new = function() + new = function(_, _) return { find = function() end, } From 9d92021fcf3f247c61e11a167c85133fc02f4055 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:45:24 -0400 Subject: [PATCH 09/24] fix(test): include necessary variables --- lua/cp/pickers/init.lua | 7 +++++++ spec/scraper_spec.lua | 12 ++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index a21da8a..add0532 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -47,6 +47,13 @@ local function get_contests_for_platform(platform) return contests end + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + logger.log( + ('Scraping %s for contests, this may take a few seconds...'):format(platform_display_name), + vim.log.levels.INFO + ) + local plugin_path = utils.get_plugin_path() local cmd = { 'uv', diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 0b1ceeb..67c1daa 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -129,7 +129,7 @@ describe('cp.scrape', function() describe('system dependency checks', function() it('handles missing uv executable', function() - vim.fn.executable = function(_) + vim.fn.executable = function(cmd) return cmd == 'uv' and 0 or 1 end @@ -140,7 +140,7 @@ describe('cp.scrape', function() end) it('handles python environment setup failure', function() - vim.system = function(_) + vim.system = function(cmd) if cmd[1] == 'ping' then return { wait = function() @@ -172,7 +172,7 @@ describe('cp.scrape', function() end) it('handles network connectivity issues', function() - vim.system = function(_) + vim.system = function(cmd) if cmd[1] == 'ping' then return { wait = function() @@ -231,7 +231,7 @@ describe('cp.scrape', function() end) it('handles subprocess execution failure', function() - vim.system = function(_) + vim.system = function(cmd) if cmd[1] == 'ping' then return { wait = function() @@ -262,7 +262,7 @@ describe('cp.scrape', function() describe('json parsing', function() it('handles invalid json output', function() - vim.system = function(_) + vim.system = function(cmd) if cmd[1] == 'ping' then return { wait = function() @@ -290,7 +290,7 @@ describe('cp.scrape', function() end) it('handles scraper-reported failures', function() - vim.system = function(_) + vim.system = function(cmd) if cmd[1] == 'ping' then return { wait = function() From 3edc3db8aa1b5371d9de26f2490d7fc6dfd5b7ec Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 11:46:10 -0400 Subject: [PATCH 10/24] feat(picker): announce scraping to user for clarification --- lua/cp/scrape.lua | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 9f7e35b..5ecaf43 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -162,31 +162,17 @@ function M.scrape_problem(ctx) local plugin_path = utils.get_plugin_path() - local args - if ctx.contest == 'cses' then - args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. ctx.contest, - 'tests', - ctx.problem_id, - } - else - args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. ctx.contest, - 'tests', - ctx.contest_id, - ctx.problem_id, - } - end + local args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. ctx.contest, + 'tests', + ctx.contest_id, + ctx.problem_id, + } local result = vim .system(args, { From 0dd145b71e428c564a052fe652ccbe6fe6d98a7c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:06:45 -0400 Subject: [PATCH 11/24] feat(doc): make docs more concise --- .github/workflows/quality.yml | 3 +- doc/cp.txt | 244 ++++++++-------------------------- scrapers/atcoder.py | 1 - scrapers/codeforces.py | 2 +- scrapers/cses.py | 4 +- 5 files changed, 58 insertions(+), 196 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a608648..97b7786 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -27,8 +27,7 @@ jobs: - 'ftdetect/**' - '*.lua' - '.luarc.json' - - 'stylua.toml' - - 'selene.toml' + - '*.toml' python: - 'scrapers/**' - 'tests/scrapers/**' diff --git a/doc/cp.txt b/doc/cp.txt index c2c2964..67493f8 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -9,8 +9,7 @@ INTRODUCTION *cp* *cp.nvim* cp.nvim is a competitive programming plugin that automates problem setup, compilation, and testing workflow for online judges. -Supported platforms: AtCoder, Codeforces, CSES -Supported languages: C++, Python +Supported platforms (for now!): AtCoder, Codeforces, CSES ============================================================================== REQUIREMENTS *cp-requirements* @@ -73,10 +72,7 @@ COMMANDS *cp-commands* Requires contest setup first. :CP pick Launch configured picker for interactive - platform/contest/problem selection. Requires - picker = 'telescope' or picker = 'fzf-lua' - in configuration and corresponding plugin - to be installed. + platform/contest/problem selection. Navigation Commands ~ :CP next Navigate to next problem in current contest. @@ -123,32 +119,6 @@ Template Variables ~ g++ abc324a.cpp -o build/abc324a.run -std=c++17 < -============================================================================== -PICKER INTEGRATION *cp-picker* - -When picker integration is enabled in configuration, cp.nvim provides interactive -platform, contest, and problem selection using telescope.nvim or fzf-lua. - -:CP pick *:CP-pick* - Launch configured picker for interactive problem selection. - Flow: Platform → Contest → Problem → Setup - - Requires picker = 'telescope' or picker = 'fzf-lua' in configuration. - Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed. - Picker availability is checked at runtime when command is executed. - -Picker Flow ~ - 1. Platform Selection: Choose from AtCoder, Codeforces, CSES - 2. Contest Selection: Choose from available contests for selected platform - 3. Problem Selection: Choose from problems in selected contest - 4. Problem Setup: Automatically runs equivalent of :CP platform contest problem - -Notes ~ - • Contest lists are fetched dynamically using scrapers - • Large contest lists may take time to load - • Runtime picker validation - shows clear error if picker plugin not available - • Picker configuration can be changed without plugin restart - ============================================================================== CONFIGURATION *cp-config* @@ -176,14 +146,8 @@ Here's an example configuration with lazy.nvim: >lua }, }, snippets = {}, - hooks = { - before_run = nil, - before_debug = nil, - setup_code = nil, - }, debug = false, scrapers = { 'atcoder', 'codeforces', 'cses' }, - filename = default_filename, -- + run_panel = { ansi = true, diff_mode = 'vim', @@ -216,9 +180,7 @@ Here's an example configuration with lazy.nvim: >lua {diff} (|DiffConfig|) Diff backend configuration. {picker} (string, optional) Picker integration: "telescope", "fzf-lua", or nil to disable. When enabled, provides - :Telescope cp or :FzfLua cp commands for interactive - platform/contest/problem selection. Requires the - corresponding picker plugin to be installed. + :CP pick for interactive platform/contest/problem selection. {filename} (function, optional) Custom filename generation. function(contest, contest_id, problem_id, config, language) Should return full filename with extension. @@ -318,21 +280,6 @@ AtCoder ~ *cp-atcoder* URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a -AtCoder contests use consistent naming patterns where contest ID and problem -ID are combined to form the task name. - -Platform characteristics: -• Contest types: ABC (Beginner), ARC (Regular), AGC (Grand), etc. -• Problem naming: Contest ID + problem letter (e.g. "abc324_a") -• Multi-test problems: Handled with conditional compilation directives -• Template features: Includes fast I/O and common competitive programming - headers - -In terms of cp.nvim, this corresponds to: -- Platform: atcoder -- Contest ID: abc123 (from URL path segment) -- Problem ID: a (single letter, extracted from task name) - Usage examples: > :CP atcoder abc324 a " Full setup: problem A from contest ABC324 :CP atcoder abc324 " Contest setup: load contest metadata only @@ -347,21 +294,6 @@ Codeforces ~ *cp-codeforces* URL format: https://codeforces.com/contest/1234/problem/A -Codeforces uses numeric contest IDs with letter-based problem identifiers. -Educational rounds, gym contests, and regular contests all follow this pattern. - -Platform characteristics: -• Contest types: Regular, Educational, Div. 1/2/3, Global rounds -• Problem naming: Numeric contest + problem letter -• Multi-test support: Template includes test case loop structure -• Interactive problems: Supported with flush handling -• Time/memory limits: Typically 1-2 seconds, 256 MB - -In terms of cp.nvim, this corresponds to: -- Platform: codeforces -- Contest ID: 1234 (numeric, from URL) -- Problem ID: a (lowercase letter, normalized from URL) - Usage examples: > :CP codeforces 1934 a " Full setup: problem A from contest 1934 :CP codeforces 1934 " Contest setup: load contest metadata only @@ -375,21 +307,6 @@ CSES ~ *cp-cses* URL format: https://cses.fi/problemset/task/1068 -CSES (Code Submission Evaluation System) is organized by problem categories -rather than traditional contests. Problems are grouped by topic and difficulty. - -Platform characteristics: -• Organization: Category-based (Introductory, Sorting, Dynamic Programming) -• Problem numbering: Sequential numeric IDs (1001, 1068, etc.) -• Difficulty progression: Problems increase in complexity within categories -• No time pressure: Educational focus rather than contest environment -• Cache expiry: 30 days (problems may be updated periodically) - -In terms of cp.nvim, this corresponds to: -- Platform: cses -- Contest ID: Category name (introductory_problems, sorting_and_searching) -- Problem ID: Problem number (1068, 1640) - Usage examples: > :CP cses dynamic_programming 1633 " Set up problem 1633 from DP category :CP cses dynamic_programming " Set up ALL problems from DP category @@ -400,7 +317,7 @@ Usage examples: > ============================================================================== -COMPLETE WORKFLOW EXAMPLE *cp-example* +COMPLETE WORKFLOW EXAMPLE *cp-example* Example: Setting up and solving AtCoder contest ABC324 @@ -437,6 +354,39 @@ Example: Setting up and solving AtCoder contest ABC324 9. Submit solutions on AtCoder website +============================================================================== +PICKER INTEGRATION *cp-picker* + +When picker integration is enabled in configuration, cp.nvim provides interactive +platform, contest, and problem selection using telescope.nvim or fzf-lua. + +:CP pick *:CP-pick* + Launch configured picker for interactive problem selection. + Control Flow: Select Platform → Contest → Problem → Code! + + Requires picker = 'telescope' or picker = 'fzf-lua' in configuration. + Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed. + +Picker Controls ~ + *cp-picker-controls* + The picker interface provides several keyboard shortcuts for enhanced control: + + Force refresh contest list, bypassing cache + Useful when contest lists are outdated or incomplete + Shows loading indicator during refresh operation + + Standard picker controls (telescope.nvim/fzf-lua): + Select current item and proceed to next step + / Cancel picker and return to editor + / Navigate to next item + / Navigate to previous item + / Start filtering/searching items + +Notes ~ + • Contest lists are fetched dynamically using scrapers with a TTL of 1 day + • Use to force refresh + • Large contest lists may take time to load + ============================================================================== RUN PANEL *cp-run* @@ -487,13 +437,13 @@ Test cases use competitive programming terminology with color highlighting: < ============================================================================== -ANSI COLORS AND HIGHLIGHTING *cp-ansi* +ANSI COLORS AND HIGHLIGHTING *cp-ansi* cp.nvim provides comprehensive ANSI color support and highlighting for compiler output, program stderr, and diff visualization. ============================================================================== -HIGHLIGHT GROUPS *cp-highlights* +HIGHLIGHT GROUPS *cp-highlights* Test Status Groups ~ @@ -553,20 +503,19 @@ These groups are automatically used by the git diff backend for character-level difference visualization with optimal colorscheme integration. ============================================================================== -TERMINAL COLOR INTEGRATION *cp-terminal-colors* +TERMINAL COLOR INTEGRATION *cp-terminal-colors* ANSI colors automatically use your terminal's color palette through Neovim's vim.g.terminal_color_* variables. This ensures compiler colors match your colorscheme without manual configuration. -If your colorscheme doesn't set terminal colors, cp.nvim falls back to -sensible defaults. You can override terminal colors in your configuration: >vim - let g:terminal_color_1 = '#ff6b6b' " Custom red - let g:terminal_color_2 = '#51cf66' " Custom green -< +If your colorscheme doesn't set terminal colors, cp.nvim will warn you and +ANSI colors won't display properly - set them like so: >vim + let g:terminal_color_1 = '#ff6b6b' + ... ============================================================================== -HIGHLIGHT CUSTOMIZATION *cp-highlight-custom* +HIGHLIGHT CUSTOMIZATION *cp-highlight-custom* You can customize any highlight group by linking to existing groups or defining custom colors: >lua @@ -591,7 +540,7 @@ prevent them from being overridden: >lua < ============================================================================== -RUN PANEL KEYMAPS *cp-test-keys* +RUN PANEL KEYMAPS *cp-test-keys* Navigate to next test case (configurable via run_panel.next_test_key) Navigate to previous test case (configurable via @@ -630,97 +579,6 @@ cp.nvim creates the following file structure upon problem setup: > {problem_id}.n.cpout " nth program output {problem_id}.expected " Expected output < - -The plugin automatically manages this structure and navigation between problems -maintains proper file associations. - -============================================================================== -CACHING SYSTEM *cp-caching* - -cp.nvim maintains a persistent cache to improve performance and enable offline -functionality. The cache stores contest metadata, problem lists, test cases, -and file-to-context mappings. - -Cache Location ~ - *cp-cache-location* -Cache is stored at: > - vim.fn.stdpath('data') .. '/cp-nvim.json' -< -Cache Structure ~ - *cp-cache-structure* -The cache contains four main sections: - -contest_data Contest metadata and problem lists -• Indexed by: `platform:contest_id` -• Contains: Problem names, IDs, URLs, constraints -• Expiry: Platform-dependent (see |cp-cache-expiry|) - -test_cases Scraped test case input/output pairs -• Indexed by: `platform:contest_id:problem_id` -• Contains: Input data, expected output, test case count -• Expiry: Never (local test data persists) - -file_states File-to-context mapping -• Indexed by: Absolute file path -• Contains: Platform, contest_id, problem_id, language -• Purpose: Enables context restoration with `:CP` - -timestamps Last update times for cache validation -• Tracks: When each cache entry was last refreshed -• Used for: Expiry checking and incremental updates - -Cache Expiry Policy ~ - *cp-cache-expiry* -Different data types have different expiry policies: - -AtCoder/Codeforces contest data Never expires (contests are immutable) -CSES problem data 30 days (problems may be updated) -Test cases Never expires (local test data) -File states Never expires (tracks user workspace) - -Manual Cache Management ~ - *cp-cache-management* -While cache management is automatic, you can manually intervene: - -Clear specific contest cache: > - :lua require('cp.cache').clear_contest('atcoder', 'abc324') -< -Clear all cache data: > - :lua require('cp.cache').clear_all() -< -Force refresh contest metadata: > - :lua require('cp.cache').refresh_contest('codeforces', '1934') -< -View cache statistics: > - :lua print(vim.inspect(require('cp.cache').get_stats())) -< - Note: Manual cache operations require Lua - knowledge and are primarily for debugging. - -Offline Functionality ~ - *cp-cache-offline* -The cache enables limited offline functionality: - -✓ Restore context from cached file states -✓ Navigate between cached problems in a contest -✓ Access cached test cases for local development -✓ Use cached templates and configuration - -✗ Scrape new problems without internet connection -✗ Download new contest metadata -✗ Update problem constraints or test cases - -Performance Considerations ~ - *cp-cache-performance* -The cache provides several performance benefits: - -• Instant context restoration: No network requests needed -• Fast problem navigation: Problem lists loaded from cache -• Reduced scraping: Test cases cached after first download -• Batch operations: Multiple problems can be set up quickly - -Cache size typically remains under 1MB even with extensive usage. - ============================================================================== SNIPPETS *cp-snippets* @@ -729,7 +587,15 @@ snippets include basic C++ and Python templates for each contest type. Snippet trigger names must match the following format exactly: > - cp.nvim/{platform} + cp.nvim/{platform}.{language} +< +Where {platform} is the contest platform (atcoder, codeforces, cses) and +{language} is the programming language (cpp, python). + +Examples: > + cp.nvim/atcoder.cpp + cp.nvim/codeforces.python + cp.nvim/cses.cpp < Custom snippets can be added via the `snippets` configuration field. diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index eef91ff..83e1cc1 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -99,7 +99,6 @@ def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]: if problem: problems.append(problem) - problems.sort(key=lambda x: x.id) return problems except Exception as e: diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 0aa7d07..68672b8 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -203,7 +203,7 @@ def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]: ProblemSummary(id=problem_letter, name=problem_name) ) - problems.sort(key=lambda x: x.id) + # Preserve contest order - do not sort seen: set[str] = set() unique_problems: list[ProblemSummary] = [] diff --git a/scrapers/cses.py b/scrapers/cses.py index 75e7601..ce7a755 100755 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -114,7 +114,6 @@ def scrape_category_problems(category_id: str) -> list[ProblemSummary]: problems.append(ProblemSummary(id=problem_id, name=problem_name)) - problems.sort(key=lambda x: int(x.id)) return problems except Exception as e: @@ -263,8 +262,7 @@ def scrape_all_problems() -> dict[str, list[ProblemSummary]]: problem = ProblemSummary(id=problem_id, name=problem_name) all_categories[current_category].append(problem) - for category in all_categories: - all_categories[category].sort(key=lambda x: int(x.id)) + # Preserve HTML document order - do not sort print( f"Found {len(all_categories)} categories with {sum(len(probs) for probs in all_categories.values())} problems", From 0938b9bbd6e9a25b30aa4b0cfeb8996144f786d5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:13:59 -0400 Subject: [PATCH 12/24] feat(pickers): ctrl-r to refresh --- lua/cp/pickers/fzf_lua.lua | 3 +++ lua/cp/pickers/telescope.lua | 1 + 2 files changed, 4 insertions(+) diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index 7b1b476..cf3a47e 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -63,6 +63,9 @@ local function contest_picker(platform) return fzf.fzf_exec(entries, { prompt = ('Select Contest (%s)> '):format(platform_display_name), + fzf_opts = { + ['--header'] = 'ctrl-r: refresh', + }, actions = { ['default'] = function(selected) if not selected or #selected == 0 then diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index d42b4cc..6f65c93 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -64,6 +64,7 @@ local function contest_picker(opts, platform) pickers .new(opts, { prompt_title = ('Select Contest (%s)'):format(platform_display_name), + results_title = ' refresh', finder = finders.new_table({ results = contests, entry_maker = function(entry) From a827d4f67cb35927229e99070965a7c0fab0d3b6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:21:40 -0400 Subject: [PATCH 13/24] fix(picker): use consisten messaging --- lua/cp/pickers/init.lua | 10 ++++++++++ lua/cp/utils.lua | 2 +- spec/picker_spec.lua | 4 ++-- spec/scraper_spec.lua | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index add0532..31b22ed 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -118,6 +118,16 @@ local function get_problems_for_contest(platform, contest_id) return problems end + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + logger.log( + ('Scraping %s %s for problems, this may take a few seconds...'):format( + platform_display_name, + contest_id + ), + vim.log.levels.INFO + ) + local metadata_result = scrape.scrape_contest_metadata(platform, contest_id) if not metadata_result.success then logger.log( diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 36414d7..ff97a11 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -34,7 +34,7 @@ function M.setup_python_env() logger.log('failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) return false end - logger.log('python environment setup complete') + logger.log('Python environment setup complete') end python_env_setup = true diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index f628a00..8722544 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -21,7 +21,7 @@ describe('cp.picker', function() for _, platform in ipairs(platforms) do assert.is_string(platform.id) assert.is_string(platform.display_name) - assert.is_true(platform.display_name:match('^%u')) + assert.is_not_nil(platform.display_name:match('^%u')) end end) @@ -120,7 +120,7 @@ describe('cp.picker', function() assert.equals(2, #problems) assert.equals('a', problems[1].id) assert.equals('Problem A', problems[1].name) - assert.equals('a - Problem A', problems[1].display_name) + assert.equals('Problem A', problems[1].display_name) end) it('falls back to scraping when cache miss', function() diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 67c1daa..880146e 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -136,7 +136,7 @@ describe('cp.scrape', function() local result = scrape.scrape_contest_metadata('atcoder', 'abc123') assert.is_false(result.success) - assert.is_not_nil(result.error:match('Python environment setup failed')) + assert.is_not_nil(result.error) end) it('handles python environment setup failure', function() From 1822714a0cd5bc494403f7e77c66cbfceec92a87 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:24:42 -0400 Subject: [PATCH 14/24] fix(picker): propagate logs --- lua/cp/init.lua | 11 +++++++++++ lua/cp/pickers/init.lua | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index b8188f9..6389e14 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -83,6 +83,17 @@ local function setup_problem(contest_id, problem_id, language) state.test_cases = cached_test_cases logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) elseif vim.tbl_contains(config.scrapers, state.platform) then + local constants = require('cp.constants') + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[state.platform] or state.platform + logger.log( + ('Scraping %s %s %s for test cases, this may take a few seconds...'):format( + platform_display_name, + contest_id, + problem_id + ), + vim.log.levels.WARN + ) + local scrape_result = scrape.scrape_problem(ctx) if not scrape_result.success then diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 31b22ed..ebaa0ce 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -51,7 +51,7 @@ local function get_contests_for_platform(platform) local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform logger.log( ('Scraping %s for contests, this may take a few seconds...'):format(platform_display_name), - vim.log.levels.INFO + vim.log.levels.WARN ) local plugin_path = utils.get_plugin_path() @@ -125,7 +125,7 @@ local function get_problems_for_contest(platform, contest_id) platform_display_name, contest_id ), - vim.log.levels.INFO + vim.log.levels.WARN ) local metadata_result = scrape.scrape_contest_metadata(platform, contest_id) From 373e7f6e7608fd1ceb0c1ad7080ca70d10f1c204 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:26:06 -0400 Subject: [PATCH 15/24] fix(test): mock caches and everything else --- lua/cp/init.lua | 3 ++- lua/cp/log.lua | 4 ++-- lua/cp/pickers/init.lua | 6 ++++-- spec/picker_spec.lua | 16 ++++++++++++++++ spec/scraper_spec.lua | 27 +++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 6389e14..71b6f43 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -91,7 +91,8 @@ local function setup_problem(contest_id, problem_id, language) contest_id, problem_id ), - vim.log.levels.WARN + vim.log.levels.INFO, + true ) local scrape_result = scrape.scrape_problem(ctx) diff --git a/lua/cp/log.lua b/lua/cp/log.lua index ca80de1..fd98c8a 100644 --- a/lua/cp/log.lua +++ b/lua/cp/log.lua @@ -6,9 +6,9 @@ function M.set_config(user_config) config = user_config end -function M.log(msg, level) +function M.log(msg, level, override) level = level or vim.log.levels.INFO - if not config or config.debug or level >= vim.log.levels.WARN then + if not config or config.debug or level >= vim.log.levels.WARN or override then vim.notify(('[cp.nvim]: %s'):format(msg), level) end end diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index ebaa0ce..5a1b644 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -51,7 +51,8 @@ local function get_contests_for_platform(platform) local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform logger.log( ('Scraping %s for contests, this may take a few seconds...'):format(platform_display_name), - vim.log.levels.WARN + vim.log.levels.INFO, + true ) local plugin_path = utils.get_plugin_path() @@ -125,7 +126,8 @@ local function get_problems_for_contest(platform, contest_id) platform_display_name, contest_id ), - vim.log.levels.WARN + vim.log.levels.INFO, + true ) local metadata_result = scrape.scrape_contest_metadata(platform, contest_id) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index 8722544..ab4d36e 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -68,6 +68,22 @@ describe('cp.picker', function() end) it('returns contest list when scraper succeeds', function() + local cache = require('cp.cache') + local utils = require('cp.utils') + + cache.load = function() end + cache.get_contest_list = function() + return nil + end + cache.set_contest_list = function() end + + utils.setup_python_env = function() + return true + end + utils.get_plugin_path = function() + return '/test/path' + end + vim.system = function(_, _) return { wait = function() diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 880146e..7ea38bb 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -129,10 +129,37 @@ describe('cp.scrape', function() describe('system dependency checks', function() it('handles missing uv executable', function() + local cache = require('cp.cache') + local utils = require('cp.utils') + + cache.load = function() end + cache.get_contest_data = function() + return nil + end + vim.fn.executable = function(cmd) return cmd == 'uv' and 0 or 1 end + utils.setup_python_env = function() + return vim.fn.executable('uv') == 1 + end + + vim.system = function(cmd) + if cmd[1] == 'ping' then + return { + wait = function() + return { code = 0 } + end, + } + end + return { + wait = function() + return { code = 0 } + end, + } + end + local result = scrape.scrape_contest_metadata('atcoder', 'abc123') assert.is_false(result.success) From 4f31678a295ec35815763a7917d59ad3b029ccc6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:26:52 -0400 Subject: [PATCH 16/24] fix(picker): propagate logs with override --- spec/scraper_spec.lua | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 7ea38bb..40b427b 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -167,6 +167,18 @@ describe('cp.scrape', function() end) it('handles python environment setup failure', function() + local cache = require('cp.cache') + local utils = require('cp.utils') + + cache.load = function() end + cache.get_contest_data = function() + return nil + end + + utils.setup_python_env = function() + return false + end + vim.system = function(cmd) if cmd[1] == 'ping' then return { @@ -174,12 +186,6 @@ describe('cp.scrape', function() return { code = 0 } end, } - elseif cmd[1] == 'uv' and cmd[2] == 'sync' then - return { - wait = function() - return { code = 1, stderr = 'setup failed' } - end, - } end return { wait = function() @@ -188,10 +194,6 @@ describe('cp.scrape', function() } end - vim.fn.isdirectory = function() - return 0 - end - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') assert.is_false(result.success) From 1fd7fa2a81727f198e82aa06ac07672f348c429b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:27:46 -0400 Subject: [PATCH 17/24] fix(ci): expect true --- spec/scraper_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 40b427b..d2714d3 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -425,7 +425,7 @@ describe('cp.scrape', function() assert.is_not_nil(tests_call) assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests')) assert.is_true(vim.tbl_contains(tests_call.cmd, '1001')) - assert.is_false(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching')) + assert.is_true(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching')) end) end) From fdc1441fa3087b885bb0df290662ca22017818d5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:29:28 -0400 Subject: [PATCH 18/24] fix: cleanup varnames --- lua/cp/init.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 71b6f43..1c21880 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -83,7 +83,6 @@ local function setup_problem(contest_id, problem_id, language) state.test_cases = cached_test_cases logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) elseif vim.tbl_contains(config.scrapers, state.platform) then - local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[state.platform] or state.platform logger.log( ('Scraping %s %s %s for test cases, this may take a few seconds...'):format( @@ -574,9 +573,9 @@ local function toggle_run_panel(is_debug) config.hooks.before_debug(ctx) end - local execute_module = require('cp.execute') + local execute = require('cp.execute') local contest_config = config.contests[state.platform] - local compile_result = execute_module.compile_problem(ctx, contest_config, is_debug) + local compile_result = execute.compile_problem(ctx, contest_config, is_debug) if compile_result.success then run.run_all_test_cases(ctx, contest_config, config) else From 07756d5da84042a6213b34521270000090baed59 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:32:04 -0400 Subject: [PATCH 19/24] fix(ci): var shadowing and proper mocking --- spec/scraper_spec.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index d2714d3..d64445d 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -45,7 +45,17 @@ describe('cp.scrape', function() } end + local mock_utils = { + setup_python_env = function() + return true + end, + get_plugin_path = function() + return '/test/plugin/path' + end, + } + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.utils'] = mock_utils scrape = require('cp.scrape') local original_fn = vim.fn From 3f713131eb0c17fa710a1dce55ed227059dd145f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:32:33 -0400 Subject: [PATCH 20/24] fix(ci): var shadowing and proper mocking --- spec/scraper_spec.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index d64445d..96030e2 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -1,6 +1,7 @@ describe('cp.scrape', function() local scrape local mock_cache + local mock_utils local mock_system_calls local temp_files local spec_helper = require('spec.spec_helper') @@ -45,7 +46,7 @@ describe('cp.scrape', function() } end - local mock_utils = { + mock_utils = { setup_python_env = function() return true end, @@ -178,14 +179,13 @@ describe('cp.scrape', function() it('handles python environment setup failure', function() local cache = require('cp.cache') - local utils = require('cp.utils') cache.load = function() end cache.get_contest_data = function() return nil end - utils.setup_python_env = function() + mock_utils.setup_python_env = function() return false end From 36ef39479ff6410b810b42dfede7ede0229d467d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:32:53 -0400 Subject: [PATCH 21/24] fix(ci): var shadowing and proper mocking --- spec/scraper_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 96030e2..54e9a8f 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -207,7 +207,7 @@ describe('cp.scrape', function() local result = scrape.scrape_contest_metadata('atcoder', 'abc123') assert.is_false(result.success) - assert.is_not_nil(result.error:match('Python environment setup failed')) + assert.equals('Python environment setup failed', result.error) end) it('handles network connectivity issues', function() From d96d81032827f8c0dbed6cd3624aa2c05b4bdc9f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 12:34:54 -0400 Subject: [PATCH 22/24] fix(ci): var shadowing and proper mocking --- spec/scraper_spec.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 54e9a8f..36bd310 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -189,6 +189,10 @@ describe('cp.scrape', function() return false end + -- Force reload the scrape module to pick up the updated mock + package.loaded['cp.scrape'] = nil + scrape = require('cp.scrape') + vim.system = function(cmd) if cmd[1] == 'ping' then return { From 45d439a7b26c8e67fc4d00f5b3ac1c88ece40ed1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 13:54:23 -0400 Subject: [PATCH 23/24] fiox --- spec/scraper_spec.lua | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 36bd310..08c4738 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -9,6 +9,7 @@ describe('cp.scrape', function() before_each(function() spec_helper.setup() temp_files = {} + mock_system_calls = {} mock_cache = { load = function() end, @@ -19,7 +20,14 @@ describe('cp.scrape', function() set_test_cases = function() end, } - mock_system_calls = {} + mock_utils = { + setup_python_env = function() + return true + end, + get_plugin_path = function() + return '/test/plugin/path' + end, + } vim.system = function(cmd, opts) table.insert(mock_system_calls, { cmd = cmd, opts = opts }) @@ -46,17 +54,9 @@ describe('cp.scrape', function() } end - mock_utils = { - setup_python_env = function() - return true - end, - get_plugin_path = function() - return '/test/plugin/path' - end, - } - package.loaded['cp.cache'] = mock_cache package.loaded['cp.utils'] = mock_utils + package.loaded['cp.scrape'] = nil -- Force reload scrape = require('cp.scrape') local original_fn = vim.fn @@ -189,10 +189,6 @@ describe('cp.scrape', function() return false end - -- Force reload the scrape module to pick up the updated mock - package.loaded['cp.scrape'] = nil - scrape = require('cp.scrape') - vim.system = function(cmd) if cmd[1] == 'ping' then return { From fe158aa65fb07680b3506a17422a911d8986d129 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 14:00:38 -0400 Subject: [PATCH 24/24] fix(qol): remove ai-like comments --- scrapers/codeforces.py | 2 -- scrapers/cses.py | 2 -- spec/scraper_spec.lua | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 68672b8..1402827 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -203,8 +203,6 @@ def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]: ProblemSummary(id=problem_letter, name=problem_name) ) - # Preserve contest order - do not sort - seen: set[str] = set() unique_problems: list[ProblemSummary] = [] for p in problems: diff --git a/scrapers/cses.py b/scrapers/cses.py index ce7a755..3c5db7a 100755 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -262,8 +262,6 @@ def scrape_all_problems() -> dict[str, list[ProblemSummary]]: problem = ProblemSummary(id=problem_id, name=problem_name) all_categories[current_category].append(problem) - # Preserve HTML document order - do not sort - print( f"Found {len(all_categories)} categories with {sum(len(probs) for probs in all_categories.values())} problems", file=sys.stderr, diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index 08c4738..cc02b6b 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -56,7 +56,7 @@ describe('cp.scrape', function() package.loaded['cp.cache'] = mock_cache package.loaded['cp.utils'] = mock_utils - package.loaded['cp.scrape'] = nil -- Force reload + package.loaded['cp.scrape'] = nil scrape = require('cp.scrape') local original_fn = vim.fn