From a32fd396d3a2e2576053038bb56293acdc69e38c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 22:59:57 -0400 Subject: [PATCH 01/33] feat: async scraper --- lua/cp/async/init.lua | 25 ++ lua/cp/async/jobs.lua | 44 +++ lua/cp/async/scraper.lua | 202 ++++++++++++++ lua/cp/async/setup.lua | 271 ++++++++++++++++++ lua/cp/commands/init.lua | 10 +- spec/async_init_spec.lua | 50 ++++ spec/async_integration_spec.lua | 288 +++++++++++++++++++ spec/async_jobs_spec.lua | 111 ++++++++ spec/async_scraper_spec.lua | 185 +++++++++++++ spec/async_setup_spec.lua | 286 +++++++++++++++++++ spec/scraper_spec.lua | 470 -------------------------------- spec/spec_helper.lua | 59 ++++ 12 files changed, 1527 insertions(+), 474 deletions(-) create mode 100644 lua/cp/async/init.lua create mode 100644 lua/cp/async/jobs.lua create mode 100644 lua/cp/async/scraper.lua create mode 100644 lua/cp/async/setup.lua create mode 100644 spec/async_init_spec.lua create mode 100644 spec/async_integration_spec.lua create mode 100644 spec/async_jobs_spec.lua create mode 100644 spec/async_scraper_spec.lua create mode 100644 spec/async_setup_spec.lua delete mode 100644 spec/scraper_spec.lua diff --git a/lua/cp/async/init.lua b/lua/cp/async/init.lua new file mode 100644 index 0000000..eac155f --- /dev/null +++ b/lua/cp/async/init.lua @@ -0,0 +1,25 @@ +local M = {} + +local active_operation = nil + +function M.start_contest_operation(operation_name) + if active_operation then + error( + ("Contest operation '%s' already active, cannot start '%s'"):format( + active_operation, + operation_name + ) + ) + end + active_operation = operation_name +end + +function M.finish_contest_operation() + active_operation = nil +end + +function M.get_active_operation() + return active_operation +end + +return M diff --git a/lua/cp/async/jobs.lua b/lua/cp/async/jobs.lua new file mode 100644 index 0000000..17abb35 --- /dev/null +++ b/lua/cp/async/jobs.lua @@ -0,0 +1,44 @@ +local M = {} + +local current_jobs = {} + +function M.start_job(job_id, args, opts, callback) + opts = opts or {} + + if current_jobs[job_id] then + current_jobs[job_id]:kill(9) + current_jobs[job_id] = nil + end + + local job = vim.system(args, opts, function(result) + current_jobs[job_id] = nil + callback(result) + end) + + current_jobs[job_id] = job + return job +end + +function M.kill_job(job_id) + if current_jobs[job_id] then + current_jobs[job_id]:kill(9) + current_jobs[job_id] = nil + end +end + +function M.kill_all_jobs() + for job_id, job in pairs(current_jobs) do + job:kill(9) + end + current_jobs = {} +end + +function M.get_active_jobs() + local active = {} + for job_id, _ in pairs(current_jobs) do + table.insert(active, job_id) + end + return active +end + +return M diff --git a/lua/cp/async/scraper.lua b/lua/cp/async/scraper.lua new file mode 100644 index 0000000..ff6789f --- /dev/null +++ b/lua/cp/async/scraper.lua @@ -0,0 +1,202 @@ +local M = {} +local cache = require('cp.cache') +local jobs = require('cp.async.jobs') +local utils = require('cp.utils') + +local function check_internet_connectivity() + local result = vim.system({ 'ping', '-c', '5', '-W', '3', '8.8.8.8' }, { text = true }):wait() + return result.code == 0 +end + +function M.scrape_contest_metadata_async(platform, contest_id, callback) + vim.validate({ + platform = { platform, 'string' }, + contest_id = { contest_id, 'string' }, + callback = { callback, 'function' }, + }) + + cache.load() + + local cached_data = cache.get_contest_data(platform, contest_id) + if cached_data then + callback({ + success = true, + problems = cached_data.problems, + }) + return + end + + if not check_internet_connectivity() then + callback({ + success = false, + error = 'No internet connection available', + }) + return + end + + if not utils.setup_python_env() then + callback({ + success = false, + error = 'Python environment setup failed', + }) + return + end + + local plugin_path = utils.get_plugin_path() + + local args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'metadata', + contest_id, + } + + local job_id = 'contest_metadata_' .. platform .. '_' .. contest_id + + jobs.start_job(job_id, args, { + cwd = plugin_path, + text = true, + timeout = 30000, + }, function(result) + if result.code ~= 0 then + callback({ + success = false, + error = 'Failed to run metadata scraper: ' .. (result.stderr or 'Unknown error'), + }) + return + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + callback({ + success = false, + error = 'Failed to parse metadata scraper output: ' .. tostring(data), + }) + return + end + + if not data.success then + callback(data) + return + end + + local problems_list = data.problems or {} + cache.set_contest_data(platform, contest_id, problems_list) + + callback({ + success = true, + problems = problems_list, + }) + end) +end + +function M.scrape_problem_async(platform, contest_id, problem_id, callback) + vim.validate({ + platform = { platform, 'string' }, + contest_id = { contest_id, 'string' }, + problem_id = { problem_id, 'string' }, + callback = { callback, 'function' }, + }) + + if not check_internet_connectivity() then + callback({ + success = false, + problem_id = problem_id, + error = 'No internet connection available', + }) + return + end + + if not utils.setup_python_env() then + callback({ + success = false, + problem_id = problem_id, + error = 'Python environment setup failed', + }) + return + end + + local plugin_path = utils.get_plugin_path() + + local args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'tests', + contest_id, + problem_id, + } + + local job_id = 'problem_tests_' .. platform .. '_' .. contest_id .. '_' .. problem_id + + jobs.start_job(job_id, args, { + cwd = plugin_path, + text = true, + timeout = 30000, + }, function(result) + if result.code ~= 0 then + callback({ + success = false, + problem_id = problem_id, + error = 'Failed to run tests scraper: ' .. (result.stderr or 'Unknown error'), + }) + return + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + callback({ + success = false, + problem_id = problem_id, + error = 'Failed to parse tests scraper output: ' .. tostring(data), + }) + return + end + + if not data.success then + callback(data) + return + end + + if data.tests and #data.tests > 0 then + vim.fn.mkdir('io', 'p') + + local cached_test_cases = {} + for i, test_case in ipairs(data.tests) do + table.insert(cached_test_cases, { + index = i, + input = test_case.input, + expected = test_case.expected, + }) + end + + cache.set_test_cases( + platform, + contest_id, + problem_id, + cached_test_cases, + data.timeout_ms, + data.memory_mb + ) + end + + callback({ + success = true, + problem_id = problem_id, + test_count = data.tests and #data.tests or 0, + test_cases = data.tests, + timeout_ms = data.timeout_ms, + memory_mb = data.memory_mb, + url = data.url, + }) + end) +end + +return M diff --git a/lua/cp/async/setup.lua b/lua/cp/async/setup.lua new file mode 100644 index 0000000..5ff19ae --- /dev/null +++ b/lua/cp/async/setup.lua @@ -0,0 +1,271 @@ +local M = {} + +local async = require('cp.async') +local async_scraper = require('cp.async.scraper') +local cache = require('cp.cache') +local config_module = require('cp.config') +local logger = require('cp.log') +local problem = require('cp.problem') +local state = require('cp.state') + +function M.setup_contest_async(contest_id, language) + if not state.get_platform() then + logger.log('no platform set', vim.log.levels.ERROR) + return + end + + async.start_contest_operation('contest_setup') + + local config = config_module.get_config() + local platform = state.get_platform() or '' + + if not vim.tbl_contains(config.scrapers, platform) then + logger.log('scraping disabled for ' .. platform, vim.log.levels.WARN) + async.finish_contest_operation() + return + end + + logger.log(('setting up contest %s %s'):format(platform, contest_id)) + + async_scraper.scrape_contest_metadata_async(platform, contest_id, function(metadata_result) + if not metadata_result.success then + logger.log( + 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + local problems = metadata_result.problems + if not problems or #problems == 0 then + logger.log('no problems found in contest', vim.log.levels.ERROR) + async.finish_contest_operation() + return + end + + logger.log(('found %d problems'):format(#problems)) + + state.set_contest_id(contest_id) + M.setup_problem_async(contest_id, problems[1].id, language) + + M.start_background_problem_scraping(contest_id, problems, config) + end) +end + +function M.setup_problem_async(contest_id, problem_id, language) + if not state.get_platform() then + logger.log('no platform set. run :CP first', vim.log.levels.ERROR) + return + end + + local config = config_module.get_config() + local problem_name = contest_id .. (problem_id or '') + logger.log(('setting up problem: %s'):format(problem_name)) + + local ctx = + problem.create_context(state.get_platform() or '', contest_id, problem_id, config, language) + + local cached_test_cases = cache.get_test_cases(state.get_platform() or '', contest_id, problem_id) + if cached_test_cases then + state.set_test_cases(cached_test_cases) + logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) + elseif vim.tbl_contains(config.scrapers, state.get_platform() or '') then + logger.log('test cases not cached, will scrape in background...') + state.set_test_cases(nil) + + async_scraper.scrape_problem_async( + state.get_platform() or '', + contest_id, + problem_id, + function(scrape_result) + if scrape_result.success then + local test_count = scrape_result.test_count or 0 + logger.log( + ('scraped %d test case(s) for %s'):format(test_count, scrape_result.problem_id) + ) + state.set_test_cases(scrape_result.test_cases) + else + logger.log( + 'scraping failed: ' .. (scrape_result.error or 'unknown error'), + vim.log.levels.ERROR + ) + end + end + ) + else + logger.log(('scraping disabled for %s'):format(state.get_platform() or '')) + state.set_test_cases(nil) + end + + vim.cmd('silent only') + state.set_run_panel_active(false) + state.set_contest_id(contest_id) + state.set_problem_id(problem_id) + + vim.cmd.e(ctx.source_file) + local source_buf = vim.api.nvim_get_current_buf() + + if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then + local constants = require('cp.constants') + local has_luasnip, luasnip = pcall(require, 'luasnip') + if has_luasnip then + local filetype = vim.api.nvim_get_option_value('filetype', { buf = source_buf }) + local language_name = constants.filetype_to_language[filetype] + local canonical_language = constants.canonical_filetypes[language_name] or language_name + local prefixed_trigger = ('cp.nvim/%s.%s'):format(state.get_platform(), canonical_language) + + vim.api.nvim_buf_set_lines(0, 0, -1, false, { prefixed_trigger }) + vim.api.nvim_win_set_cursor(0, { 1, #prefixed_trigger }) + vim.cmd.startinsert({ bang = true }) + + vim.schedule(function() + if luasnip.expandable() then + luasnip.expand() + else + vim.api.nvim_buf_set_lines(0, 0, 1, false, { '' }) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + end + vim.cmd.stopinsert() + end) + else + vim.api.nvim_input(('i%s'):format(state.get_platform())) + end + end + + if config.hooks and config.hooks.setup_code then + config.hooks.setup_code(ctx) + end + + cache.set_file_state( + vim.fn.expand('%:p'), + state.get_platform() or '', + contest_id, + problem_id, + language + ) + + logger.log(('switched to problem %s'):format(ctx.problem_name)) + async.finish_contest_operation() +end + +function M.start_background_problem_scraping(contest_id, problems, config) + cache.load() + local platform = state.get_platform() or '' + local missing_problems = {} + + for _, prob in ipairs(problems) do + local cached_tests = cache.get_test_cases(platform, contest_id, prob.id) + if not cached_tests then + table.insert(missing_problems, prob) + end + end + + if #missing_problems == 0 then + logger.log('all problems already cached') + return + end + + logger.log(('scraping %d uncached problems in background...'):format(#missing_problems)) + + local success_count = 0 + local failed_problems = {} + local total_problems = #missing_problems + + for _, prob in ipairs(missing_problems) do + async_scraper.scrape_problem_async(platform, contest_id, prob.id, function(result) + if result.success then + success_count = success_count + 1 + else + table.insert(failed_problems, prob.id) + end + + local completed = success_count + #failed_problems + if completed == total_problems then + if #failed_problems > 0 then + logger.log( + ('background scraping complete: %d/%d successful, failed: %s'):format( + success_count, + total_problems, + table.concat(failed_problems, ', ') + ), + vim.log.levels.WARN + ) + else + logger.log( + ('background scraping complete: %d/%d successful'):format(success_count, total_problems) + ) + end + end + end) + end +end + +function M.handle_full_setup_async(cmd) + async.start_contest_operation('full_setup') + + state.set_contest_id(cmd.contest) + local config = config_module.get_config() + + if vim.tbl_contains(config.scrapers, cmd.platform) then + async_scraper.scrape_contest_metadata_async(cmd.platform, cmd.contest, function(metadata_result) + if not metadata_result.success then + logger.log( + 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + logger.log( + ('loaded %d problems for %s %s'):format( + #metadata_result.problems, + cmd.platform, + cmd.contest + ), + vim.log.levels.INFO, + true + ) + + local problem_ids = vim.tbl_map(function(prob) + return prob.id + end, metadata_result.problems) + + if not vim.tbl_contains(problem_ids, cmd.problem) then + logger.log( + ("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + M.setup_problem_async(cmd.contest, cmd.problem, cmd.language) + end) + else + cache.load() + local contest_data = cache.get_contest_data(cmd.platform or '', cmd.contest) + if contest_data and contest_data.problems then + local problem_ids = vim.tbl_map(function(prob) + return prob.id + end, contest_data.problems) + + if not vim.tbl_contains(problem_ids, cmd.problem) then + logger.log( + ("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest), + vim.log.levels.ERROR + ) + async.finish_contest_operation() + return + end + + M.setup_problem_async(cmd.contest, cmd.problem, cmd.language) + else + logger.log('no contest data available', vim.log.levels.ERROR) + async.finish_contest_operation() + end + end +end + +return M diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 0ef9c3a..8b733e0 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -153,23 +153,25 @@ function M.handle_command(opts) if cmd.type == 'contest_setup' then local setup = require('cp.setup') + local async_setup = require('cp.async.setup') if setup.set_platform(cmd.platform) then - setup.setup_contest(cmd.contest, cmd.language) + async_setup.setup_contest_async(cmd.contest, cmd.language) end return end if cmd.type == 'full_setup' then local setup = require('cp.setup') + local async_setup = require('cp.async.setup') if setup.set_platform(cmd.platform) then - setup.handle_full_setup(cmd) + async_setup.handle_full_setup_async(cmd) end return end if cmd.type == 'problem_switch' then - local setup = require('cp.setup') - setup.setup_problem(state.get_contest_id() or '', cmd.problem, cmd.language) + local async_setup = require('cp.async.setup') + async_setup.setup_problem_async(state.get_contest_id() or '', cmd.problem, cmd.language) return end end diff --git a/spec/async_init_spec.lua b/spec/async_init_spec.lua new file mode 100644 index 0000000..58e15bf --- /dev/null +++ b/spec/async_init_spec.lua @@ -0,0 +1,50 @@ +describe('cp.async.init', function() + local async + local spec_helper = require('spec.spec_helper') + + before_each(function() + spec_helper.setup() + async = spec_helper.fresh_require('cp.async.init') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('contest operation guard', function() + it('allows starting operation when none active', function() + assert.has_no_errors(function() + async.start_contest_operation('test_operation') + end) + assert.equals('test_operation', async.get_active_operation()) + end) + + it('throws error when starting operation while one is active', function() + async.start_contest_operation('first_operation') + + assert.has_error(function() + async.start_contest_operation('second_operation') + end, "Contest operation 'first_operation' already active, cannot start 'second_operation'") + end) + + it('allows starting operation after finishing previous one', function() + async.start_contest_operation('first_operation') + async.finish_contest_operation() + + assert.has_no_errors(function() + async.start_contest_operation('second_operation') + end) + assert.equals('second_operation', async.get_active_operation()) + end) + + it('correctly reports active operation status', function() + assert.is_nil(async.get_active_operation()) + + async.start_contest_operation('test_operation') + assert.equals('test_operation', async.get_active_operation()) + + async.finish_contest_operation() + assert.is_nil(async.get_active_operation()) + end) + end) +end) diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua new file mode 100644 index 0000000..b789a58 --- /dev/null +++ b/spec/async_integration_spec.lua @@ -0,0 +1,288 @@ +describe('async integration', function() + local cp + local spec_helper = require('spec.spec_helper') + local logged_messages = {} + + before_each(function() + logged_messages = {} + local mock_logger = { + log = function(msg, level) + table.insert(logged_messages, { msg = msg, level = level }) + end, + set_config = function() end, + } + package.loaded['cp.log'] = mock_logger + + spec_helper.mock_async_scraper_success() + + local mock_async = { + start_contest_operation = function() end, + finish_contest_operation = function() end, + get_active_operation = function() + return nil + end, + } + + local mock_state = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc123' + end, + set_platform = function() end, + set_contest_id = function() end, + set_problem_id = function() end, + set_test_cases = function() end, + set_run_panel_active = function() end, + } + + local mock_config = { + setup = function() + return {} + end, + get_config = function() + return { + scrapers = { 'atcoder', 'codeforces' }, + hooks = nil, + } + end, + } + + local mock_cache = { + load = function() end, + get_contest_data = function() + return nil + end, + get_test_cases = function() + return nil + end, + set_file_state = function() end, + } + + local mock_problem = { + create_context = function() + return { + source_file = '/test/source.cpp', + problem_name = 'abc123a', + } + end, + } + + local mock_setup = { + set_platform = function() + return true + end, + } + + vim.cmd = { e = function() end, only = function() end } + vim.api.nvim_get_current_buf = function() + return 1 + end + vim.api.nvim_buf_get_lines = function() + return { '' } + end + vim.fn.expand = function() + return '/test/file.cpp' + end + + package.loaded['cp.async'] = mock_async + package.loaded['cp.state'] = mock_state + package.loaded['cp.config'] = mock_config + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.problem'] = mock_problem + package.loaded['cp.setup'] = mock_setup + + cp = spec_helper.fresh_require('cp') + cp.setup({}) + end) + + after_each(function() + spec_helper.teardown() + logged_messages = {} + end) + + describe('command routing', function() + it('contest_setup command uses async setup', function() + local opts = { fargs = { 'atcoder', 'abc123' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + + it('full_setup command uses async setup', function() + local opts = { fargs = { 'atcoder', 'abc123', 'a' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + + it('problem_switch uses async setup', function() + local mock_state = require('cp.state') + mock_state.get_contest_id = function() + return 'abc123' + end + + local opts = { fargs = { 'a' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + end) + + describe('end-to-end workflow', function() + it('handles complete contest setup workflow', function() + local setup_completed = false + local mock_async_setup = { + setup_contest_async = function(contest_id, language) + assert.equals('abc123', contest_id) + assert.is_nil(language) + setup_completed = true + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local opts = { fargs = { 'atcoder', 'abc123' } } + cp.handle_command(opts) + + assert.is_true(setup_completed) + end) + + it('handles problem switching within contest', function() + local mock_state = require('cp.state') + mock_state.get_contest_id = function() + return 'abc123' + end + + local problem_setup_called = false + local mock_async_setup = { + setup_problem_async = function(contest_id, problem_id, language) + assert.equals('abc123', contest_id) + assert.equals('b', problem_id) + problem_setup_called = true + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local opts = { fargs = { 'b' } } + cp.handle_command(opts) + + assert.is_true(problem_setup_called) + end) + + it('handles language flags correctly', function() + local language_passed = nil + local mock_async_setup = { + setup_contest_async = function(contest_id, language) + language_passed = language + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local opts = { fargs = { 'atcoder', 'abc123', '--lang=python' } } + cp.handle_command(opts) + + assert.equals('python', language_passed) + end) + + it('handles scraping failures gracefully', function() + spec_helper.mock_async_scraper_failure() + + local opts = { fargs = { 'atcoder', 'abc123' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + end) + + describe('error handling', function() + it('handles invalid platform gracefully', function() + local opts = { fargs = { 'invalid_platform', 'abc123' } } + + cp.handle_command(opts) + + local error_logged = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.level == vim.log.levels.ERROR then + error_logged = true + break + end + end + assert.is_true(error_logged) + end) + + it('handles platform setup failure', function() + local mock_setup = require('cp.setup') + mock_setup.set_platform = function() + return false + end + + local opts = { fargs = { 'atcoder', 'abc123' } } + + assert.has_no_errors(function() + cp.handle_command(opts) + end) + end) + + it('handles empty contest context for problem switch', function() + local mock_state = require('cp.state') + mock_state.get_contest_id = function() + return nil + end + + local opts = { fargs = { 'a' } } + + cp.handle_command(opts) + + local error_logged = false + for _, log_entry in ipairs(logged_messages) do + if log_entry.level == vim.log.levels.ERROR then + error_logged = true + break + end + end + assert.is_true(error_logged) + end) + end) + + describe('callback behavior', function() + it('maintains execution context in callbacks', function() + local callback_executed = false + + local mock_scraper = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + vim.schedule(function() + callback({ success = true, problems = { { id = 'a' } } }) + callback_executed = true + end) + end, + } + package.loaded['cp.async.scraper'] = mock_scraper + + local opts = { fargs = { 'atcoder', 'abc123' } } + cp.handle_command(opts) + + assert.is_true(callback_executed) + end) + + it('handles multiple rapid commands', function() + local command_count = 0 + local mock_async_setup = { + setup_contest_async = function() + command_count = command_count + 1 + end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + cp.handle_command({ fargs = { 'atcoder', 'abc123' } }) + cp.handle_command({ fargs = { 'atcoder', 'abc124' } }) + cp.handle_command({ fargs = { 'atcoder', 'abc125' } }) + + assert.equals(3, command_count) + end) + end) +end) diff --git a/spec/async_jobs_spec.lua b/spec/async_jobs_spec.lua new file mode 100644 index 0000000..9c16d46 --- /dev/null +++ b/spec/async_jobs_spec.lua @@ -0,0 +1,111 @@ +describe('cp.async.jobs', function() + local jobs + local spec_helper = require('spec.spec_helper') + local mock_jobs = {} + + before_each(function() + spec_helper.setup() + mock_jobs = {} + + vim.system = function(args, opts, callback) + local job = { + kill = function() end, + args = args, + opts = opts, + callback = callback, + } + mock_jobs[#mock_jobs + 1] = job + return job + end + + jobs = spec_helper.fresh_require('cp.async.jobs') + end) + + after_each(function() + spec_helper.teardown() + mock_jobs = {} + end) + + describe('job management', function() + it('starts job with unique ID', function() + local callback = function() end + local args = { 'test', 'command' } + local opts = { cwd = '/test' } + + local job = jobs.start_job('test_job', args, opts, callback) + + assert.is_not_nil(job) + assert.equals(1, #mock_jobs) + assert.same(args, mock_jobs[1].args) + assert.same(opts, mock_jobs[1].opts) + assert.equals(callback, mock_jobs[1].callback) + end) + + it('kills existing job when starting new job with same ID', function() + local killed = false + vim.system = function(args, opts, callback) + return { + kill = function() + killed = true + end, + args = args, + opts = opts, + callback = callback, + } + end + + jobs.start_job('same_id', { 'first' }, {}, function() end) + jobs.start_job('same_id', { 'second' }, {}, function() end) + + assert.is_true(killed) + end) + + it('kills specific job by ID', function() + local killed = false + vim.system = function() + return { + kill = function() + killed = true + end, + } + end + + jobs.start_job('target_job', { 'test' }, {}, function() end) + jobs.kill_job('target_job') + + assert.is_true(killed) + end) + + it('kills all active jobs', function() + local kill_count = 0 + vim.system = function() + return { + kill = function() + kill_count = kill_count + 1 + end, + } + end + + jobs.start_job('job1', { 'test1' }, {}, function() end) + jobs.start_job('job2', { 'test2' }, {}, function() end) + jobs.kill_all_jobs() + + assert.equals(2, kill_count) + end) + + it('tracks active job IDs correctly', function() + jobs.start_job('job1', { 'test1' }, {}, function() end) + jobs.start_job('job2', { 'test2' }, {}, function() end) + + local active_jobs = jobs.get_active_jobs() + assert.equals(2, #active_jobs) + assert.is_true(vim.tbl_contains(active_jobs, 'job1')) + assert.is_true(vim.tbl_contains(active_jobs, 'job2')) + + jobs.kill_job('job1') + active_jobs = jobs.get_active_jobs() + assert.equals(1, #active_jobs) + assert.is_true(vim.tbl_contains(active_jobs, 'job2')) + end) + end) +end) diff --git a/spec/async_scraper_spec.lua b/spec/async_scraper_spec.lua new file mode 100644 index 0000000..e6806ee --- /dev/null +++ b/spec/async_scraper_spec.lua @@ -0,0 +1,185 @@ +describe('cp.async.scraper', function() + local scraper + local spec_helper = require('spec.spec_helper') + local mock_cache, mock_utils + local callback_results = {} + + before_each(function() + spec_helper.setup() + callback_results = {} + + mock_cache = { + load = function() end, + get_contest_data = function() + return nil + end, + set_contest_data = function() end, + set_test_cases = function() end, + } + + mock_utils = { + setup_python_env = function() + return true + end, + get_plugin_path = function() + return '/test/plugin' + end, + } + + vim.system = function(cmd, opts, callback) + local result = { code = 0, stdout = '{}', stderr = '' } + if cmd[1] == 'ping' then + result = { code = 0 } + elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then + result.stdout = '{"success": true, "problems": [{"id": "a", "name": "Test Problem"}]}' + elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'tests') then + result.stdout = + '{"success": true, "tests": [{"input": "1 2", "expected": "3"}], "timeout_ms": 2000, "memory_mb": 256.0, "url": "https://example.com"}' + end + callback(result) + end + + vim.fn.mkdir = function() end + + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.utils'] = mock_utils + scraper = spec_helper.fresh_require('cp.async.scraper') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('scrape_contest_metadata_async', function() + it('returns cached data immediately if available', function() + mock_cache.get_contest_data = function() + return { problems = { { id = 'cached', name = 'Cached Problem' } } } + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_true(callback_results[1].success) + assert.equals('Cached Problem', callback_results[1].problems[1].name) + end) + + it('calls callback with success result after scraping', function() + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_true(callback_results[1].success) + assert.equals(1, #callback_results[1].problems) + assert.equals('Test Problem', callback_results[1].problems[1].name) + end) + + it('calls callback with error on network failure', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 1 }) + end + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.equals('No internet connection available', callback_results[1].error) + end) + + it('calls callback with error on python env failure', function() + mock_utils.setup_python_env = function() + return false + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.equals('Python environment setup failed', callback_results[1].error) + end) + + it('calls callback with error on subprocess failure', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 0 }) + else + callback({ code = 1, stderr = 'execution failed' }) + end + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.is_not_nil(callback_results[1].error:match('Failed to run metadata scraper')) + end) + + it('calls callback with error on invalid JSON', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 0 }) + else + callback({ code = 0, stdout = 'invalid json' }) + end + end + + scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.is_not_nil(callback_results[1].error:match('Failed to parse metadata scraper output')) + end) + end) + + describe('scrape_problem_async', function() + it('calls callback with success after scraping tests', function() + scraper.scrape_problem_async('atcoder', 'abc123', 'a', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_true(callback_results[1].success) + assert.equals('a', callback_results[1].problem_id) + assert.equals(1, callback_results[1].test_count) + end) + + it('handles network failure gracefully', function() + vim.system = function(cmd, opts, callback) + if cmd[1] == 'ping' then + callback({ code = 1 }) + end + end + + scraper.scrape_problem_async('atcoder', 'abc123', 'a', function(result) + callback_results[#callback_results + 1] = result + end) + + assert.equals(1, #callback_results) + assert.is_false(callback_results[1].success) + assert.equals('a', callback_results[1].problem_id) + assert.equals('No internet connection available', callback_results[1].error) + end) + + it('validates input parameters', function() + assert.has_error(function() + scraper.scrape_contest_metadata_async(nil, 'abc123', function() end) + end) + + assert.has_error(function() + scraper.scrape_problem_async('atcoder', nil, 'a', function() end) + end) + end) + end) +end) diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua new file mode 100644 index 0000000..1b2d96b --- /dev/null +++ b/spec/async_setup_spec.lua @@ -0,0 +1,286 @@ +describe('cp.async.setup', function() + local setup + local spec_helper = require('spec.spec_helper') + local mock_async, mock_scraper, mock_state + local callback_calls = {} + + before_each(function() + spec_helper.setup() + callback_calls = {} + + mock_async = { + start_contest_operation = function() end, + finish_contest_operation = function() end, + } + + mock_scraper = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = true, + problems = { + { id = 'a', name = 'Problem A' }, + { id = 'b', name = 'Problem B' }, + }, + }) + end, + scrape_problem_async = function(platform, contest_id, problem_id, callback) + callback({ + success = true, + problem_id = problem_id, + test_cases = { { input = '1', expected = '1' } }, + test_count = 1, + }) + end, + } + + mock_state = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc123' + end, + set_contest_id = function() end, + set_problem_id = function() end, + set_test_cases = function() end, + set_run_panel_active = function() end, + } + + local mock_config = { + get_config = function() + return { + scrapers = { 'atcoder', 'codeforces' }, + hooks = nil, + } + end, + } + + local mock_cache = { + load = function() end, + get_test_cases = function() + return nil + end, + set_file_state = function() end, + } + + local mock_problem = { + create_context = function() + return { + source_file = '/test/source.cpp', + problem_name = 'abc123a', + } + end, + } + + vim.cmd = { e = function() end, only = function() end } + vim.api.nvim_get_current_buf = function() + return 1 + end + vim.api.nvim_buf_get_lines = function() + return { '' } + end + vim.fn.expand = function() + return '/test/file.cpp' + end + + package.loaded['cp.async'] = mock_async + package.loaded['cp.async.scraper'] = mock_scraper + package.loaded['cp.state'] = mock_state + package.loaded['cp.config'] = mock_config + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.problem'] = mock_problem + + setup = spec_helper.fresh_require('cp.async.setup') + end) + + after_each(function() + spec_helper.teardown() + end) + + describe('setup_contest_async', function() + it('guards against multiple simultaneous operations', function() + local started = false + mock_async.start_contest_operation = function() + started = true + end + + setup.setup_contest_async('abc123', 'cpp') + + assert.is_true(started) + end) + + it('handles metadata scraping success', function() + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.setup_contest_async('abc123', 'cpp') + + assert.is_true(finished) + end) + + it('handles metadata scraping failure gracefully', function() + mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = false, + error = 'network error', + }) + end + + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.setup_contest_async('abc123', 'cpp') + + assert.is_true(finished) + end) + + it('handles disabled scraping platform', function() + mock_state.get_platform = function() + return 'disabled_platform' + end + + assert.has_no_errors(function() + setup.setup_contest_async('abc123', 'cpp') + end) + end) + end) + + describe('setup_problem_async', function() + it('opens buffer immediately', function() + local buffer_opened = false + vim.cmd.e = function() + buffer_opened = true + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(buffer_opened) + end) + + it('uses cached test cases if available', function() + local cached_cases = { { input = 'cached', expected = 'result' } } + local mock_cache = require('cp.cache') + mock_cache.get_test_cases = function() + return cached_cases + end + + local set_test_cases_called = false + mock_state.set_test_cases = function(cases) + assert.same(cached_cases, cases) + set_test_cases_called = true + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(set_test_cases_called) + end) + + it('starts background test scraping if not cached', function() + local scraping_started = false + mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + scraping_started = true + callback({ success = true, problem_id = problem_id, test_cases = {} }) + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(scraping_started) + end) + + it('finishes contest operation on completion', function() + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.setup_problem_async('abc123', 'a', 'cpp') + + assert.is_true(finished) + end) + end) + + describe('handle_full_setup_async', function() + it('validates problem exists in contest', function() + mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = true, + problems = { { id = 'a' }, { id = 'b' } }, + }) + end + + local cmd = { platform = 'atcoder', contest = 'abc123', problem = 'c' } + + local finished = false + mock_async.finish_contest_operation = function() + finished = true + end + + setup.handle_full_setup_async(cmd) + + assert.is_true(finished) + end) + + it('proceeds with valid problem', function() + mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + callback({ + success = true, + problems = { { id = 'a' }, { id = 'b' } }, + }) + end + + local cmd = { platform = 'atcoder', contest = 'abc123', problem = 'a' } + + assert.has_no_errors(function() + setup.handle_full_setup_async(cmd) + end) + end) + end) + + describe('background problem scraping', function() + it('scrapes uncached problems in background', function() + local problems = { { id = 'a' }, { id = 'b' }, { id = 'c' } } + local scraping_calls = {} + + mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + scraping_calls[#scraping_calls + 1] = problem_id + callback({ success = true, problem_id = problem_id }) + end + + local mock_cache = require('cp.cache') + mock_cache.get_test_cases = function() + return nil + end + + setup.start_background_problem_scraping('abc123', problems, { scrapers = { 'atcoder' } }) + + assert.equals(3, #scraping_calls) + assert.is_true(vim.tbl_contains(scraping_calls, 'a')) + assert.is_true(vim.tbl_contains(scraping_calls, 'b')) + assert.is_true(vim.tbl_contains(scraping_calls, 'c')) + end) + + it('skips already cached problems', function() + local problems = { { id = 'a' }, { id = 'b' } } + local scraping_calls = {} + + mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + scraping_calls[#scraping_calls + 1] = problem_id + callback({ success = true, problem_id = problem_id }) + end + + local mock_cache = require('cp.cache') + mock_cache.get_test_cases = function(platform, contest_id, problem_id) + return problem_id == 'a' and { { input = '1', expected = '1' } } or nil + end + + setup.start_background_problem_scraping('abc123', problems, { scrapers = { 'atcoder' } }) + + assert.equals(1, #scraping_calls) + assert.equals('b', scraping_calls[1]) + end) + end) +end) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua deleted file mode 100644 index c81f8e2..0000000 --- a/spec/scraper_spec.lua +++ /dev/null @@ -1,470 +0,0 @@ -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') - - before_each(function() - spec_helper.setup() - temp_files = {} - mock_system_calls = {} - - mock_cache = { - load = function() end, - get_contest_data = function() - return nil - end, - set_contest_data = function() end, - set_test_cases = function() end, - } - - 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 }) - - local result = { code = 0, stdout = '{}', stderr = '' } - - if cmd[1] == 'ping' then - result = { code = 0 } - elseif cmd[1] == 'uv' and cmd[2] == 'sync' then - result = { code = 0, stdout = '', stderr = '' } - elseif cmd[1] == 'uv' and cmd[2] == 'run' then - if vim.tbl_contains(cmd, 'metadata') then - result.stdout = '{"success": true, "problems": [{"id": "a", "name": "Test Problem"}]}' - elseif vim.tbl_contains(cmd, 'tests') then - result.stdout = - '{"success": true, "tests": [{"input": "1 2", "expected": "3"}], "url": "https://example.com", "timeout_ms": 2000, "memory_mb": 256.0}' - end - end - - return { - wait = function() - return result - end, - } - end - - package.loaded['cp.cache'] = mock_cache - package.loaded['cp.utils'] = mock_utils - scrape = spec_helper.fresh_require('cp.scrape') - - local original_fn = vim.fn - vim.fn = vim.tbl_extend('force', vim.fn, { - executable = function(cmd) - if cmd == 'uv' then - return 1 - end - return original_fn.executable(cmd) - end, - isdirectory = function(path) - if path:match('%.venv$') then - return 1 - end - return original_fn.isdirectory(path) - end, - filereadable = function(path) - if temp_files[path] then - return 1 - end - return 0 - end, - readfile = function(path) - return temp_files[path] or {} - end, - writefile = function(lines, path) - temp_files[path] = lines - end, - mkdir = function() end, - fnamemodify = function(path, modifier) - if modifier == ':r' then - return path:gsub('%..*$', '') - end - return original_fn.fnamemodify(path, modifier) - end, - }) - end) - - after_each(function() - package.loaded['cp.cache'] = nil - vim.system = vim.system_original or vim.system - spec_helper.teardown() - temp_files = {} - end) - - describe('cache integration', function() - it('returns cached data when available', function() - mock_cache.get_contest_data = function(platform, contest_id) - if platform == 'atcoder' and contest_id == 'abc123' then - return { problems = { { id = 'a', name = 'Cached Problem' } } } - end - return nil - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_true(result.success) - assert.equals(1, #result.problems) - assert.equals('Cached Problem', result.problems[1].name) - assert.equals(0, #mock_system_calls) - end) - - it('stores scraped data in cache after successful scrape', function() - local stored_data = nil - mock_cache.set_contest_data = function(platform, contest_id, problems) - stored_data = { platform = platform, contest_id = contest_id, problems = problems } - end - - scrape = spec_helper.fresh_require('cp.scrape') - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_true(result.success) - assert.is_not_nil(stored_data) - assert.equals('atcoder', stored_data.platform) - assert.equals('abc123', stored_data.contest_id) - assert.equals(1, #stored_data.problems) - end) - end) - - 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) - assert.is_not_nil(result.error) - end) - - it('handles python environment setup failure', function() - local cache = require('cp.cache') - - cache.load = function() end - cache.get_contest_data = function() - return nil - end - - mock_utils.setup_python_env = function() - return false - 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) - assert.equals('Python environment setup failed', result.error) - end) - - it('handles network connectivity issues', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 1 } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.equals('No internet connection available', result.error) - end) - end) - - describe('subprocess execution', function() - it('constructs correct command for atcoder metadata', function() - scrape.scrape_contest_metadata('atcoder', 'abc123') - - local metadata_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'metadata') then - metadata_call = call - break - end - end - - assert.is_not_nil(metadata_call) - assert.equals('uv', metadata_call.cmd[1]) - assert.equals('run', metadata_call.cmd[2]) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'metadata')) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'abc123')) - end) - - it('constructs correct command for cses metadata', function() - scrape.scrape_contest_metadata('cses', 'sorting_and_searching') - - local metadata_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'metadata') then - metadata_call = call - break - end - end - - assert.is_not_nil(metadata_call) - assert.equals('uv', metadata_call.cmd[1]) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'metadata')) - assert.is_true(vim.tbl_contains(metadata_call.cmd, 'sorting_and_searching')) - end) - - it('handles subprocess execution failure', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then - return { - wait = function() - return { code = 1, stderr = 'execution failed' } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.is_not_nil(result.error:match('Failed to run metadata scraper')) - assert.is_not_nil(result.error:match('execution failed')) - end) - end) - - describe('json parsing', function() - it('handles invalid json output', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then - return { - wait = function() - return { code = 0, stdout = 'invalid json' } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.is_not_nil(result.error:match('Failed to parse metadata scraper output')) - end) - - it('handles scraper-reported failures', function() - vim.system = function(cmd) - if cmd[1] == 'ping' then - return { - wait = function() - return { code = 0 } - end, - } - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then - return { - wait = function() - return { - code = 0, - stdout = '{"success": false, "error": "contest not found"}', - } - end, - } - end - return { - wait = function() - return { code = 0 } - end, - } - end - - local result = scrape.scrape_contest_metadata('atcoder', 'abc123') - - assert.is_false(result.success) - assert.equals('contest not found', result.error) - end) - end) - - describe('problem scraping', function() - local test_context - - before_each(function() - test_context = { - contest = 'atcoder', - contest_id = 'abc123', - problem_id = 'a', - problem_name = 'abc123a', - input_file = 'io/abc123a.cpin', - expected_file = 'io/abc123a.expected', - } - end) - - it('uses existing files when available', function() - temp_files['io/abc123a.cpin'] = { '1 2' } - temp_files['io/abc123a.expected'] = { '3' } - temp_files['io/abc123a.1.cpin'] = { '4 5' } - temp_files['io/abc123a.1.cpout'] = { '9' } - - local result = scrape.scrape_problem(test_context) - - assert.is_true(result.success) - assert.equals('abc123a', result.problem_id) - assert.equals(1, result.test_count) - assert.equals(0, #mock_system_calls) - end) - - it('scrapes and writes test case files', function() - local result = scrape.scrape_problem(test_context) - - assert.is_true(result.success) - assert.equals('abc123a', result.problem_id) - assert.equals(1, result.test_count) - assert.is_not_nil(temp_files['io/abc123a.1.cpin']) - assert.is_not_nil(temp_files['io/abc123a.1.cpout']) - assert.equals('1 2', table.concat(temp_files['io/abc123a.1.cpin'], '\n')) - assert.equals('3', table.concat(temp_files['io/abc123a.1.cpout'], '\n')) - end) - - it('constructs correct command for atcoder problem tests', function() - scrape.scrape_problem(test_context) - - local tests_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'tests') then - tests_call = call - break - end - end - - 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, 'abc123')) - assert.is_true(vim.tbl_contains(tests_call.cmd, 'a')) - end) - - it('constructs correct command for cses problem tests', function() - test_context.contest = 'cses' - test_context.contest_id = 'sorting_and_searching' - test_context.problem_id = '1001' - - scrape.scrape_problem(test_context) - - local tests_call = nil - for _, call in ipairs(mock_system_calls) do - if vim.tbl_contains(call.cmd, 'tests') then - tests_call = call - break - end - end - - 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_true(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching')) - end) - end) - - describe('error scenarios', function() - it('validates input parameters', function() - assert.has_error(function() - scrape.scrape_contest_metadata(nil, 'abc123') - end) - - assert.has_error(function() - scrape.scrape_contest_metadata('atcoder', nil) - end) - end) - - it('handles file system errors gracefully', function() - vim.fn.mkdir = function() - error('permission denied') - end - - local ctx = { - contest = 'atcoder', - contest_id = 'abc123', - problem_id = 'a', - problem_name = 'abc123a', - input_file = 'io/abc123a.cpin', - expected_file = 'io/abc123a.expected', - } - - assert.has_error(function() - scrape.scrape_problem(ctx) - end) - end) - end) -end) diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua index 6f87157..ecdbb3e 100644 --- a/spec/spec_helper.lua +++ b/spec/spec_helper.lua @@ -103,6 +103,61 @@ function M.mock_scraper_success() } end +function M.mock_async_scraper_success() + package.loaded['cp.async.scraper'] = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + vim.schedule(function() + callback({ + success = true, + problems = { + { id = 'a' }, + { id = 'b' }, + { id = 'c' }, + }, + }) + end) + end, + scrape_problem_async = function(platform, contest_id, problem_id, callback) + vim.schedule(function() + callback({ + success = true, + problem_id = problem_id, + test_cases = { + { input = '1 2', expected = '3' }, + { input = '3 4', expected = '7' }, + }, + test_count = 2, + timeout_ms = 2000, + memory_mb = 256.0, + url = 'https://example.com', + }) + end) + end, + } +end + +function M.mock_async_scraper_failure() + package.loaded['cp.async.scraper'] = { + scrape_contest_metadata_async = function(platform, contest_id, callback) + vim.schedule(function() + callback({ + success = false, + error = 'mock network error', + }) + end) + end, + scrape_problem_async = function(platform, contest_id, problem_id, callback) + vim.schedule(function() + callback({ + success = false, + problem_id = problem_id, + error = 'mock scraping failed', + }) + end) + end, + } +end + function M.has_error_logged() for _, log_entry in ipairs(M.logged_messages) do if log_entry.level == vim.log.levels.ERROR then @@ -135,6 +190,10 @@ end function M.teardown() package.loaded['cp.log'] = nil package.loaded['cp.scrape'] = nil + package.loaded['cp.async.scraper'] = nil + package.loaded['cp.async.jobs'] = nil + package.loaded['cp.async.setup'] = nil + package.loaded['cp.async'] = nil M.logged_messages = {} end From 1f384b0ba0423f32a2e6c91a764cf7a81035fd31 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:02:54 -0400 Subject: [PATCH 02/33] fix(ci): selene unused vars --- spec/async_integration_spec.lua | 12 +++++++----- spec/async_jobs_spec.lua | 2 +- spec/async_setup_spec.lua | 5 ++++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua index b789a58..fadb6e1 100644 --- a/spec/async_integration_spec.lua +++ b/spec/async_integration_spec.lua @@ -75,7 +75,10 @@ describe('async integration', function() end, } - vim.cmd = { e = function() end, only = function() end } + vim.cmd = { + e = function() end, + only = function() end, + } vim.api.nvim_get_current_buf = function() return 1 end @@ -137,9 +140,8 @@ describe('async integration', function() it('handles complete contest setup workflow', function() local setup_completed = false local mock_async_setup = { - setup_contest_async = function(contest_id, language) + setup_contest_async = function(contest_id, _) assert.equals('abc123', contest_id) - assert.is_nil(language) setup_completed = true end, } @@ -159,7 +161,7 @@ describe('async integration', function() local problem_setup_called = false local mock_async_setup = { - setup_problem_async = function(contest_id, problem_id, language) + setup_problem_async = function(contest_id, problem_id, _) assert.equals('abc123', contest_id) assert.equals('b', problem_id) problem_setup_called = true @@ -254,7 +256,7 @@ describe('async integration', function() local callback_executed = false local mock_scraper = { - scrape_contest_metadata_async = function(platform, contest_id, callback) + scrape_contest_metadata_async = function(_, _, callback) vim.schedule(function() callback({ success = true, problems = { { id = 'a' } } }) callback_executed = true diff --git a/spec/async_jobs_spec.lua b/spec/async_jobs_spec.lua index 9c16d46..29f18ad 100644 --- a/spec/async_jobs_spec.lua +++ b/spec/async_jobs_spec.lua @@ -38,7 +38,7 @@ describe('cp.async.jobs', function() assert.equals(1, #mock_jobs) assert.same(args, mock_jobs[1].args) assert.same(opts, mock_jobs[1].opts) - assert.equals(callback, mock_jobs[1].callback) + assert.is_function(mock_jobs[1].callback) end) it('kills existing job when starting new job with same ID', function() diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua index 1b2d96b..d7bbc86 100644 --- a/spec/async_setup_spec.lua +++ b/spec/async_setup_spec.lua @@ -72,7 +72,10 @@ describe('cp.async.setup', function() end, } - vim.cmd = { e = function() end, only = function() end } + vim.cmd = { + e = function() end, + only = function() end, + } vim.api.nvim_get_current_buf = function() return 1 end From 7ad64677a5db8384a7fc84762fc275757bedce42 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:04:17 -0400 Subject: [PATCH 03/33] fix(test): selene unused vars --- spec/async_setup_spec.lua | 11 ++++------- spec/spec_helper.lua | 8 ++++---- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua index d7bbc86..a22213f 100644 --- a/spec/async_setup_spec.lua +++ b/spec/async_setup_spec.lua @@ -2,11 +2,8 @@ describe('cp.async.setup', function() local setup local spec_helper = require('spec.spec_helper') local mock_async, mock_scraper, mock_state - local callback_calls = {} - before_each(function() spec_helper.setup() - callback_calls = {} mock_async = { start_contest_operation = function() end, @@ -184,7 +181,7 @@ describe('cp.async.setup', function() it('starts background test scraping if not cached', function() local scraping_started = false - mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + mock_scraper.scrape_problem_async = function(_, _, problem_id, callback) scraping_started = true callback({ success = true, problem_id = problem_id, test_cases = {} }) end @@ -248,7 +245,7 @@ describe('cp.async.setup', function() local problems = { { id = 'a' }, { id = 'b' }, { id = 'c' } } local scraping_calls = {} - mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + mock_scraper.scrape_problem_async = function(_, _, problem_id, callback) scraping_calls[#scraping_calls + 1] = problem_id callback({ success = true, problem_id = problem_id }) end @@ -270,13 +267,13 @@ describe('cp.async.setup', function() local problems = { { id = 'a' }, { id = 'b' } } local scraping_calls = {} - mock_scraper.scrape_problem_async = function(platform, contest_id, problem_id, callback) + mock_scraper.scrape_problem_async = function(_, _, problem_id, callback) scraping_calls[#scraping_calls + 1] = problem_id callback({ success = true, problem_id = problem_id }) end local mock_cache = require('cp.cache') - mock_cache.get_test_cases = function(platform, contest_id, problem_id) + mock_cache.get_test_cases = function(_, _, problem_id) return problem_id == 'a' and { { input = '1', expected = '1' } } or nil end diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua index ecdbb3e..db826d7 100644 --- a/spec/spec_helper.lua +++ b/spec/spec_helper.lua @@ -105,7 +105,7 @@ end function M.mock_async_scraper_success() package.loaded['cp.async.scraper'] = { - scrape_contest_metadata_async = function(platform, contest_id, callback) + scrape_contest_metadata_async = function(_, _, callback) vim.schedule(function() callback({ success = true, @@ -117,7 +117,7 @@ function M.mock_async_scraper_success() }) end) end, - scrape_problem_async = function(platform, contest_id, problem_id, callback) + scrape_problem_async = function(_, _, problem_id, callback) vim.schedule(function() callback({ success = true, @@ -138,7 +138,7 @@ end function M.mock_async_scraper_failure() package.loaded['cp.async.scraper'] = { - scrape_contest_metadata_async = function(platform, contest_id, callback) + scrape_contest_metadata_async = function(_, _, callback) vim.schedule(function() callback({ success = false, @@ -146,7 +146,7 @@ function M.mock_async_scraper_failure() }) end) end, - scrape_problem_async = function(platform, contest_id, problem_id, callback) + scrape_problem_async = function(_, _, problem_id, callback) vim.schedule(function() callback({ success = false, From 76cb1e456e14dffaa18536458e42140d8eb55090 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:05:52 -0400 Subject: [PATCH 04/33] fix(ci): unused vars --- lua/cp/async/jobs.lua | 2 +- lua/cp/async/setup.lua | 2 +- spec/async_integration_spec.lua | 2 +- spec/async_scraper_spec.lua | 10 +++++----- spec/async_setup_spec.lua | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lua/cp/async/jobs.lua b/lua/cp/async/jobs.lua index 17abb35..55ab1bb 100644 --- a/lua/cp/async/jobs.lua +++ b/lua/cp/async/jobs.lua @@ -27,7 +27,7 @@ function M.kill_job(job_id) end function M.kill_all_jobs() - for job_id, job in pairs(current_jobs) do + for _, job in pairs(current_jobs) do job:kill(9) end current_jobs = {} diff --git a/lua/cp/async/setup.lua b/lua/cp/async/setup.lua index 5ff19ae..5e899a3 100644 --- a/lua/cp/async/setup.lua +++ b/lua/cp/async/setup.lua @@ -149,7 +149,7 @@ function M.setup_problem_async(contest_id, problem_id, language) async.finish_contest_operation() end -function M.start_background_problem_scraping(contest_id, problems, config) +function M.start_background_problem_scraping(contest_id, problems, _) cache.load() local platform = state.get_platform() or '' local missing_problems = {} diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua index fadb6e1..7a90ce1 100644 --- a/spec/async_integration_spec.lua +++ b/spec/async_integration_spec.lua @@ -140,7 +140,7 @@ describe('async integration', function() it('handles complete contest setup workflow', function() local setup_completed = false local mock_async_setup = { - setup_contest_async = function(contest_id, _) + setup_contest_async = function(contest_id) assert.equals('abc123', contest_id) setup_completed = true end, diff --git a/spec/async_scraper_spec.lua b/spec/async_scraper_spec.lua index e6806ee..e0c2836 100644 --- a/spec/async_scraper_spec.lua +++ b/spec/async_scraper_spec.lua @@ -26,7 +26,7 @@ describe('cp.async.scraper', function() end, } - vim.system = function(cmd, opts, callback) + vim.system = function(cmd, _, callback) local result = { code = 0, stdout = '{}', stderr = '' } if cmd[1] == 'ping' then result = { code = 0 } @@ -77,7 +77,7 @@ describe('cp.async.scraper', function() end) it('calls callback with error on network failure', function() - vim.system = function(cmd, opts, callback) + vim.system = function(cmd, _, callback) if cmd[1] == 'ping' then callback({ code = 1 }) end @@ -107,7 +107,7 @@ describe('cp.async.scraper', function() end) it('calls callback with error on subprocess failure', function() - vim.system = function(cmd, opts, callback) + vim.system = function(cmd, _, callback) if cmd[1] == 'ping' then callback({ code = 0 }) else @@ -125,7 +125,7 @@ describe('cp.async.scraper', function() end) it('calls callback with error on invalid JSON', function() - vim.system = function(cmd, opts, callback) + vim.system = function(cmd, _, callback) if cmd[1] == 'ping' then callback({ code = 0 }) else @@ -156,7 +156,7 @@ describe('cp.async.scraper', function() end) it('handles network failure gracefully', function() - vim.system = function(cmd, opts, callback) + vim.system = function(cmd, _, callback) if cmd[1] == 'ping' then callback({ code = 1 }) end diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua index a22213f..2f2fd83 100644 --- a/spec/async_setup_spec.lua +++ b/spec/async_setup_spec.lua @@ -11,7 +11,7 @@ describe('cp.async.setup', function() } mock_scraper = { - scrape_contest_metadata_async = function(platform, contest_id, callback) + scrape_contest_metadata_async = function(_, _, callback) callback({ success = true, problems = { From a84b1697bf132a5675ce894fdceeaa40f3548bd7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:11:15 -0400 Subject: [PATCH 05/33] fix(test): mock --- spec/async_integration_spec.lua | 7 ++++++- spec/async_setup_spec.lua | 9 +++++++-- spec/cache_spec.lua | 13 +++++++++++++ spec/command_parsing_spec.lua | 26 ++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua index 7a90ce1..dd4c523 100644 --- a/spec/async_integration_spec.lua +++ b/spec/async_integration_spec.lua @@ -75,10 +75,15 @@ describe('async integration', function() end, } - vim.cmd = { + local cmd_methods = { e = function() end, only = function() end, + startinsert = function() end, + stopinsert = function() end, } + vim.cmd = setmetatable(function() end, { + __index = cmd_methods, + }) vim.api.nvim_get_current_buf = function() return 1 end diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua index 2f2fd83..f6b9904 100644 --- a/spec/async_setup_spec.lua +++ b/spec/async_setup_spec.lua @@ -20,7 +20,7 @@ describe('cp.async.setup', function() }, }) end, - scrape_problem_async = function(platform, contest_id, problem_id, callback) + scrape_problem_async = function(_, _, problem_id, callback) callback({ success = true, problem_id = problem_id, @@ -69,10 +69,15 @@ describe('cp.async.setup', function() end, } - vim.cmd = { + local cmd_methods = { e = function() end, only = function() end, + startinsert = function() end, + stopinsert = function() end, } + vim.cmd = setmetatable(function() end, { + __index = cmd_methods, + }) vim.api.nvim_get_current_buf = function() return 1 end diff --git a/spec/cache_spec.lua b/spec/cache_spec.lua index a72946a..2f5053a 100644 --- a/spec/cache_spec.lua +++ b/spec/cache_spec.lua @@ -4,6 +4,19 @@ describe('cp.cache', function() before_each(function() spec_helper.setup() + + local mock_file_content = '{}' + vim.fn.filereadable = function() + return 1 + end + vim.fn.readfile = function() + return { mock_file_content } + end + vim.fn.writefile = function(lines) + mock_file_content = table.concat(lines, '\n') + end + vim.fn.mkdir = function() end + cache = require('cp.cache') cache.load() end) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index 693f2b2..f37eb15 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -12,6 +12,29 @@ describe('cp command parsing', function() } package.loaded['cp.log'] = mock_logger + local mock_async_setup = { + setup_contest_async = function() end, + handle_full_setup_async = function() end, + setup_problem_async = function() end, + } + package.loaded['cp.async.setup'] = mock_async_setup + local mock_setup = { + set_platform = function() + return true + end, + } + package.loaded['cp.setup'] = mock_setup + + local mock_state = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc123' + end, + } + package.loaded['cp.state'] = mock_state + cp = require('cp') cp.setup({ contests = { @@ -29,6 +52,9 @@ describe('cp command parsing', function() after_each(function() package.loaded['cp.log'] = nil + package.loaded['cp.async.setup'] = nil + package.loaded['cp.setup'] = nil + package.loaded['cp.state'] = nil end) describe('empty arguments', function() From 4b70a21210885e44174e056b58c4d8b45d7dcba4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:14:54 -0400 Subject: [PATCH 06/33] fix(test): more mocks --- spec/async_integration_spec.lua | 16 ++++++---------- spec/async_setup_spec.lua | 20 ++++++++------------ spec/command_parsing_spec.lua | 1 + 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua index dd4c523..2155ff6 100644 --- a/spec/async_integration_spec.lua +++ b/spec/async_integration_spec.lua @@ -75,15 +75,11 @@ describe('async integration', function() end, } - local cmd_methods = { - e = function() end, - only = function() end, - startinsert = function() end, - stopinsert = function() end, - } - vim.cmd = setmetatable(function() end, { - __index = cmd_methods, - }) + vim.cmd = function() end + vim.cmd.e = function() end + vim.cmd.only = function() end + vim.cmd.startinsert = function() end + vim.cmd.stopinsert = function() end vim.api.nvim_get_current_buf = function() return 1 end @@ -183,7 +179,7 @@ describe('async integration', function() it('handles language flags correctly', function() local language_passed = nil local mock_async_setup = { - setup_contest_async = function(contest_id, language) + setup_contest_async = function(_, language) language_passed = language end, } diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua index f6b9904..984ac20 100644 --- a/spec/async_setup_spec.lua +++ b/spec/async_setup_spec.lua @@ -69,15 +69,11 @@ describe('cp.async.setup', function() end, } - local cmd_methods = { - e = function() end, - only = function() end, - startinsert = function() end, - stopinsert = function() end, - } - vim.cmd = setmetatable(function() end, { - __index = cmd_methods, - }) + vim.cmd = function() end + vim.cmd.e = function() end + vim.cmd.only = function() end + vim.cmd.startinsert = function() end + vim.cmd.stopinsert = function() end vim.api.nvim_get_current_buf = function() return 1 end @@ -126,7 +122,7 @@ describe('cp.async.setup', function() end) it('handles metadata scraping failure gracefully', function() - mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + mock_scraper.scrape_contest_metadata_async = function(_, _, callback) callback({ success = false, error = 'network error', @@ -210,7 +206,7 @@ describe('cp.async.setup', function() describe('handle_full_setup_async', function() it('validates problem exists in contest', function() - mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + mock_scraper.scrape_contest_metadata_async = function(_, _, callback) callback({ success = true, problems = { { id = 'a' }, { id = 'b' } }, @@ -230,7 +226,7 @@ describe('cp.async.setup', function() end) it('proceeds with valid problem', function() - mock_scraper.scrape_contest_metadata_async = function(platform, contest_id, callback) + mock_scraper.scrape_contest_metadata_async = function(_, _, callback) callback({ success = true, problems = { { id = 'a' }, { id = 'b' } }, diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index f37eb15..d3221ea 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -22,6 +22,7 @@ describe('cp command parsing', function() set_platform = function() return true end, + navigate_problem = function() end, } package.loaded['cp.setup'] = mock_setup From de14552a3e85a9091c48b01d8348f0f96a8d5fd2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:16:25 -0400 Subject: [PATCH 07/33] fix(test): mock --- spec/panel_spec.lua | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/panel_spec.lua b/spec/panel_spec.lua index ff24e16..72ee733 100644 --- a/spec/panel_spec.lua +++ b/spec/panel_spec.lua @@ -10,6 +10,25 @@ describe('Panel integration', function() state = require('cp.state') state.reset() + local mock_async_setup = { + setup_contest_async = function() end, + handle_full_setup_async = function(cmd) + state.set_platform(cmd.platform) + state.set_contest_id(cmd.contest) + state.set_problem_id(cmd.problem) + end, + setup_problem_async = function() end, + } + package.loaded['cp.async.setup'] = mock_async_setup + + local mock_setup = { + set_platform = function(platform) + state.set_platform(platform) + return true + end, + } + package.loaded['cp.setup'] = mock_setup + cp = require('cp') cp.setup({ contests = { From 5f555a028546316aa71a28015c7fb2fc6f1638c8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:22:07 -0400 Subject: [PATCH 08/33] fix --- lua/cp/async/setup.lua | 2 +- spec/async_integration_spec.lua | 11 ++++++----- spec/async_scraper_spec.lua | 14 ++++++++++++-- spec/async_setup_spec.lua | 11 ++++++----- spec/spec_helper.lua | 12 ++++++++---- 5 files changed, 33 insertions(+), 17 deletions(-) diff --git a/lua/cp/async/setup.lua b/lua/cp/async/setup.lua index 5e899a3..9db5138 100644 --- a/lua/cp/async/setup.lua +++ b/lua/cp/async/setup.lua @@ -98,7 +98,7 @@ function M.setup_problem_async(contest_id, problem_id, language) state.set_test_cases(nil) end - vim.cmd('silent only') + vim.cmd.only({ mods = { silent = true } }) state.set_run_panel_active(false) state.set_contest_id(contest_id) state.set_problem_id(problem_id) diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua index 2155ff6..fd4b5f8 100644 --- a/spec/async_integration_spec.lua +++ b/spec/async_integration_spec.lua @@ -75,11 +75,12 @@ describe('async integration', function() end, } - vim.cmd = function() end - vim.cmd.e = function() end - vim.cmd.only = function() end - vim.cmd.startinsert = function() end - vim.cmd.stopinsert = function() end + vim.cmd = { + e = function() end, + only = function() end, + startinsert = function() end, + stopinsert = function() end, + } vim.api.nvim_get_current_buf = function() return 1 end diff --git a/spec/async_scraper_spec.lua b/spec/async_scraper_spec.lua index e0c2836..3a9f31a 100644 --- a/spec/async_scraper_spec.lua +++ b/spec/async_scraper_spec.lua @@ -26,7 +26,7 @@ describe('cp.async.scraper', function() end, } - vim.system = function(cmd, _, callback) + vim.system = function(cmd, opts, callback) local result = { code = 0, stdout = '{}', stderr = '' } if cmd[1] == 'ping' then result = { code = 0 } @@ -36,7 +36,17 @@ describe('cp.async.scraper', function() result.stdout = '{"success": true, "tests": [{"input": "1 2", "expected": "3"}], "timeout_ms": 2000, "memory_mb": 256.0, "url": "https://example.com"}' end - callback(result) + + if callback then + callback(result) + else + -- Return object with :wait() for sync calls + return { + wait = function() + return result + end, + } + end end vim.fn.mkdir = function() end diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua index 984ac20..ccb41bc 100644 --- a/spec/async_setup_spec.lua +++ b/spec/async_setup_spec.lua @@ -69,11 +69,12 @@ describe('cp.async.setup', function() end, } - vim.cmd = function() end - vim.cmd.e = function() end - vim.cmd.only = function() end - vim.cmd.startinsert = function() end - vim.cmd.stopinsert = function() end + vim.cmd = { + e = function() end, + only = function() end, + startinsert = function() end, + stopinsert = function() end, + } vim.api.nvim_get_current_buf = function() return 1 end diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua index db826d7..01c2a04 100644 --- a/spec/spec_helper.lua +++ b/spec/spec_helper.lua @@ -35,10 +35,14 @@ local function setup_vim_mocks() if not vim.cmd then vim.cmd = {} end - vim.cmd.e = function() end - vim.cmd.only = function() end - vim.cmd.split = function() end - vim.cmd.vsplit = function() end + vim.cmd = { + only = function() end, + e = function() end, + split = function() end, + vsplit = function() end, + startinsert = function() end, + stopinsert = function() end, + } if not vim.system then vim.system = function(_) return { From 5dd4d9109a73cce9cdf596f5d0944eb782e2a6e4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 22 Sep 2025 23:25:02 -0400 Subject: [PATCH 09/33] try fix --- spec/async_scraper_spec.lua | 59 +++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/spec/async_scraper_spec.lua b/spec/async_scraper_spec.lua index 3a9f31a..1fe2f31 100644 --- a/spec/async_scraper_spec.lua +++ b/spec/async_scraper_spec.lua @@ -26,7 +26,7 @@ describe('cp.async.scraper', function() end, } - vim.system = function(cmd, opts, callback) + vim.system = function(cmd, _, callback) local result = { code = 0, stdout = '{}', stderr = '' } if cmd[1] == 'ping' then result = { code = 0 } @@ -40,7 +40,6 @@ describe('cp.async.scraper', function() if callback then callback(result) else - -- Return object with :wait() for sync calls return { wait = function() return result @@ -87,9 +86,16 @@ describe('cp.async.scraper', function() end) it('calls callback with error on network failure', function() - vim.system = function(cmd, _, callback) - if cmd[1] == 'ping' then - callback({ code = 1 }) + vim.system = function(_, _, callback) + local result = { code = 1 } + if callback then + callback(result) + else + return { + wait = function() + return result + end, + } end end @@ -118,10 +124,21 @@ describe('cp.async.scraper', function() it('calls callback with error on subprocess failure', function() vim.system = function(cmd, _, callback) + local result if cmd[1] == 'ping' then - callback({ code = 0 }) + result = { code = 0 } else - callback({ code = 1, stderr = 'execution failed' }) + result = { code = 1, stderr = 'execution failed' } + end + + if callback then + callback(result) + else + return { + wait = function() + return result + end, + } end end @@ -136,10 +153,21 @@ describe('cp.async.scraper', function() it('calls callback with error on invalid JSON', function() vim.system = function(cmd, _, callback) + local result if cmd[1] == 'ping' then - callback({ code = 0 }) + result = { code = 0 } else - callback({ code = 0, stdout = 'invalid json' }) + result = { code = 0, stdout = 'invalid json' } + end + + if callback then + callback(result) + else + return { + wait = function() + return result + end, + } end end @@ -166,9 +194,16 @@ describe('cp.async.scraper', function() end) it('handles network failure gracefully', function() - vim.system = function(cmd, _, callback) - if cmd[1] == 'ping' then - callback({ code = 1 }) + vim.system = function(_, _, callback) + local result = { code = 1 } + if callback then + callback(result) + else + return { + wait = function() + return result + end, + } end end From e171017ab07be591d32a6532615889e744f46f90 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 09:42:45 -0400 Subject: [PATCH 10/33] fixup --- lua/cp/async/init.lua | 25 --- lua/cp/async/jobs.lua | 44 ----- lua/cp/async/scraper.lua | 202 -------------------- lua/cp/async/setup.lua | 271 --------------------------- lua/cp/cache.lua | 17 +- lua/cp/commands/init.lua | 17 +- lua/cp/log.lua | 4 +- lua/cp/pickers/init.lua | 85 +++------ lua/cp/scrape.lua | 359 ------------------------------------ lua/cp/scraper.lua | 152 +++++++++++++++ lua/cp/setup.lua | 273 +++++++++++++++++++++++++++ lua/cp/setup/contest.lua | 43 ----- lua/cp/setup/init.lua | 260 -------------------------- lua/cp/setup/navigation.lua | 64 ------- lua/cp/ui/panel.lua | 28 ++- spec/picker_spec.lua | 7 - 16 files changed, 497 insertions(+), 1354 deletions(-) delete mode 100644 lua/cp/async/init.lua delete mode 100644 lua/cp/async/jobs.lua delete mode 100644 lua/cp/async/scraper.lua delete mode 100644 lua/cp/async/setup.lua delete mode 100644 lua/cp/scrape.lua create mode 100644 lua/cp/scraper.lua create mode 100644 lua/cp/setup.lua delete mode 100644 lua/cp/setup/contest.lua delete mode 100644 lua/cp/setup/init.lua delete mode 100644 lua/cp/setup/navigation.lua diff --git a/lua/cp/async/init.lua b/lua/cp/async/init.lua deleted file mode 100644 index eac155f..0000000 --- a/lua/cp/async/init.lua +++ /dev/null @@ -1,25 +0,0 @@ -local M = {} - -local active_operation = nil - -function M.start_contest_operation(operation_name) - if active_operation then - error( - ("Contest operation '%s' already active, cannot start '%s'"):format( - active_operation, - operation_name - ) - ) - end - active_operation = operation_name -end - -function M.finish_contest_operation() - active_operation = nil -end - -function M.get_active_operation() - return active_operation -end - -return M diff --git a/lua/cp/async/jobs.lua b/lua/cp/async/jobs.lua deleted file mode 100644 index 55ab1bb..0000000 --- a/lua/cp/async/jobs.lua +++ /dev/null @@ -1,44 +0,0 @@ -local M = {} - -local current_jobs = {} - -function M.start_job(job_id, args, opts, callback) - opts = opts or {} - - if current_jobs[job_id] then - current_jobs[job_id]:kill(9) - current_jobs[job_id] = nil - end - - local job = vim.system(args, opts, function(result) - current_jobs[job_id] = nil - callback(result) - end) - - current_jobs[job_id] = job - return job -end - -function M.kill_job(job_id) - if current_jobs[job_id] then - current_jobs[job_id]:kill(9) - current_jobs[job_id] = nil - end -end - -function M.kill_all_jobs() - for _, job in pairs(current_jobs) do - job:kill(9) - end - current_jobs = {} -end - -function M.get_active_jobs() - local active = {} - for job_id, _ in pairs(current_jobs) do - table.insert(active, job_id) - end - return active -end - -return M diff --git a/lua/cp/async/scraper.lua b/lua/cp/async/scraper.lua deleted file mode 100644 index ff6789f..0000000 --- a/lua/cp/async/scraper.lua +++ /dev/null @@ -1,202 +0,0 @@ -local M = {} -local cache = require('cp.cache') -local jobs = require('cp.async.jobs') -local utils = require('cp.utils') - -local function check_internet_connectivity() - local result = vim.system({ 'ping', '-c', '5', '-W', '3', '8.8.8.8' }, { text = true }):wait() - return result.code == 0 -end - -function M.scrape_contest_metadata_async(platform, contest_id, callback) - vim.validate({ - platform = { platform, 'string' }, - contest_id = { contest_id, 'string' }, - callback = { callback, 'function' }, - }) - - cache.load() - - local cached_data = cache.get_contest_data(platform, contest_id) - if cached_data then - callback({ - success = true, - problems = cached_data.problems, - }) - return - end - - if not check_internet_connectivity() then - callback({ - success = false, - error = 'No internet connection available', - }) - return - end - - if not utils.setup_python_env() then - callback({ - success = false, - error = 'Python environment setup failed', - }) - return - end - - local plugin_path = utils.get_plugin_path() - - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'metadata', - contest_id, - } - - local job_id = 'contest_metadata_' .. platform .. '_' .. contest_id - - jobs.start_job(job_id, args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }, function(result) - if result.code ~= 0 then - callback({ - success = false, - error = 'Failed to run metadata scraper: ' .. (result.stderr or 'Unknown error'), - }) - return - end - - local ok, data = pcall(vim.json.decode, result.stdout) - if not ok then - callback({ - success = false, - error = 'Failed to parse metadata scraper output: ' .. tostring(data), - }) - return - end - - if not data.success then - callback(data) - return - end - - local problems_list = data.problems or {} - cache.set_contest_data(platform, contest_id, problems_list) - - callback({ - success = true, - problems = problems_list, - }) - end) -end - -function M.scrape_problem_async(platform, contest_id, problem_id, callback) - vim.validate({ - platform = { platform, 'string' }, - contest_id = { contest_id, 'string' }, - problem_id = { problem_id, 'string' }, - callback = { callback, 'function' }, - }) - - if not check_internet_connectivity() then - callback({ - success = false, - problem_id = problem_id, - error = 'No internet connection available', - }) - return - end - - if not utils.setup_python_env() then - callback({ - success = false, - problem_id = problem_id, - error = 'Python environment setup failed', - }) - return - end - - local plugin_path = utils.get_plugin_path() - - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'tests', - contest_id, - problem_id, - } - - local job_id = 'problem_tests_' .. platform .. '_' .. contest_id .. '_' .. problem_id - - jobs.start_job(job_id, args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }, function(result) - if result.code ~= 0 then - callback({ - success = false, - problem_id = problem_id, - error = 'Failed to run tests scraper: ' .. (result.stderr or 'Unknown error'), - }) - return - end - - local ok, data = pcall(vim.json.decode, result.stdout) - if not ok then - callback({ - success = false, - problem_id = problem_id, - error = 'Failed to parse tests scraper output: ' .. tostring(data), - }) - return - end - - if not data.success then - callback(data) - return - end - - if data.tests and #data.tests > 0 then - vim.fn.mkdir('io', 'p') - - local cached_test_cases = {} - for i, test_case in ipairs(data.tests) do - table.insert(cached_test_cases, { - index = i, - input = test_case.input, - expected = test_case.expected, - }) - end - - cache.set_test_cases( - platform, - contest_id, - problem_id, - cached_test_cases, - data.timeout_ms, - data.memory_mb - ) - end - - callback({ - success = true, - problem_id = problem_id, - test_count = data.tests and #data.tests or 0, - test_cases = data.tests, - timeout_ms = data.timeout_ms, - memory_mb = data.memory_mb, - url = data.url, - }) - end) -end - -return M diff --git a/lua/cp/async/setup.lua b/lua/cp/async/setup.lua deleted file mode 100644 index 9db5138..0000000 --- a/lua/cp/async/setup.lua +++ /dev/null @@ -1,271 +0,0 @@ -local M = {} - -local async = require('cp.async') -local async_scraper = require('cp.async.scraper') -local cache = require('cp.cache') -local config_module = require('cp.config') -local logger = require('cp.log') -local problem = require('cp.problem') -local state = require('cp.state') - -function M.setup_contest_async(contest_id, language) - if not state.get_platform() then - logger.log('no platform set', vim.log.levels.ERROR) - return - end - - async.start_contest_operation('contest_setup') - - local config = config_module.get_config() - local platform = state.get_platform() or '' - - if not vim.tbl_contains(config.scrapers, platform) then - logger.log('scraping disabled for ' .. platform, vim.log.levels.WARN) - async.finish_contest_operation() - return - end - - logger.log(('setting up contest %s %s'):format(platform, contest_id)) - - async_scraper.scrape_contest_metadata_async(platform, contest_id, function(metadata_result) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - async.finish_contest_operation() - return - end - - local problems = metadata_result.problems - if not problems or #problems == 0 then - logger.log('no problems found in contest', vim.log.levels.ERROR) - async.finish_contest_operation() - return - end - - logger.log(('found %d problems'):format(#problems)) - - state.set_contest_id(contest_id) - M.setup_problem_async(contest_id, problems[1].id, language) - - M.start_background_problem_scraping(contest_id, problems, config) - end) -end - -function M.setup_problem_async(contest_id, problem_id, language) - if not state.get_platform() then - logger.log('no platform set. run :CP first', vim.log.levels.ERROR) - return - end - - local config = config_module.get_config() - local problem_name = contest_id .. (problem_id or '') - logger.log(('setting up problem: %s'):format(problem_name)) - - local ctx = - problem.create_context(state.get_platform() or '', contest_id, problem_id, config, language) - - local cached_test_cases = cache.get_test_cases(state.get_platform() or '', contest_id, problem_id) - if cached_test_cases then - state.set_test_cases(cached_test_cases) - logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) - elseif vim.tbl_contains(config.scrapers, state.get_platform() or '') then - logger.log('test cases not cached, will scrape in background...') - state.set_test_cases(nil) - - async_scraper.scrape_problem_async( - state.get_platform() or '', - contest_id, - problem_id, - function(scrape_result) - if scrape_result.success then - local test_count = scrape_result.test_count or 0 - logger.log( - ('scraped %d test case(s) for %s'):format(test_count, scrape_result.problem_id) - ) - state.set_test_cases(scrape_result.test_cases) - else - logger.log( - 'scraping failed: ' .. (scrape_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - end - end - ) - else - logger.log(('scraping disabled for %s'):format(state.get_platform() or '')) - state.set_test_cases(nil) - end - - vim.cmd.only({ mods = { silent = true } }) - state.set_run_panel_active(false) - state.set_contest_id(contest_id) - state.set_problem_id(problem_id) - - vim.cmd.e(ctx.source_file) - local source_buf = vim.api.nvim_get_current_buf() - - if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then - local constants = require('cp.constants') - local has_luasnip, luasnip = pcall(require, 'luasnip') - if has_luasnip then - local filetype = vim.api.nvim_get_option_value('filetype', { buf = source_buf }) - local language_name = constants.filetype_to_language[filetype] - local canonical_language = constants.canonical_filetypes[language_name] or language_name - local prefixed_trigger = ('cp.nvim/%s.%s'):format(state.get_platform(), canonical_language) - - vim.api.nvim_buf_set_lines(0, 0, -1, false, { prefixed_trigger }) - vim.api.nvim_win_set_cursor(0, { 1, #prefixed_trigger }) - vim.cmd.startinsert({ bang = true }) - - vim.schedule(function() - if luasnip.expandable() then - luasnip.expand() - else - vim.api.nvim_buf_set_lines(0, 0, 1, false, { '' }) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - end - vim.cmd.stopinsert() - end) - else - vim.api.nvim_input(('i%s'):format(state.get_platform())) - end - end - - if config.hooks and config.hooks.setup_code then - config.hooks.setup_code(ctx) - end - - cache.set_file_state( - vim.fn.expand('%:p'), - state.get_platform() or '', - contest_id, - problem_id, - language - ) - - logger.log(('switched to problem %s'):format(ctx.problem_name)) - async.finish_contest_operation() -end - -function M.start_background_problem_scraping(contest_id, problems, _) - cache.load() - local platform = state.get_platform() or '' - local missing_problems = {} - - for _, prob in ipairs(problems) do - local cached_tests = cache.get_test_cases(platform, contest_id, prob.id) - if not cached_tests then - table.insert(missing_problems, prob) - end - end - - if #missing_problems == 0 then - logger.log('all problems already cached') - return - end - - logger.log(('scraping %d uncached problems in background...'):format(#missing_problems)) - - local success_count = 0 - local failed_problems = {} - local total_problems = #missing_problems - - for _, prob in ipairs(missing_problems) do - async_scraper.scrape_problem_async(platform, contest_id, prob.id, function(result) - if result.success then - success_count = success_count + 1 - else - table.insert(failed_problems, prob.id) - end - - local completed = success_count + #failed_problems - if completed == total_problems then - if #failed_problems > 0 then - logger.log( - ('background scraping complete: %d/%d successful, failed: %s'):format( - success_count, - total_problems, - table.concat(failed_problems, ', ') - ), - vim.log.levels.WARN - ) - else - logger.log( - ('background scraping complete: %d/%d successful'):format(success_count, total_problems) - ) - end - end - end) - end -end - -function M.handle_full_setup_async(cmd) - async.start_contest_operation('full_setup') - - state.set_contest_id(cmd.contest) - local config = config_module.get_config() - - if vim.tbl_contains(config.scrapers, cmd.platform) then - async_scraper.scrape_contest_metadata_async(cmd.platform, cmd.contest, function(metadata_result) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - async.finish_contest_operation() - return - end - - logger.log( - ('loaded %d problems for %s %s'):format( - #metadata_result.problems, - cmd.platform, - cmd.contest - ), - vim.log.levels.INFO, - true - ) - - local problem_ids = vim.tbl_map(function(prob) - return prob.id - end, metadata_result.problems) - - if not vim.tbl_contains(problem_ids, cmd.problem) then - logger.log( - ("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest), - vim.log.levels.ERROR - ) - async.finish_contest_operation() - return - end - - M.setup_problem_async(cmd.contest, cmd.problem, cmd.language) - end) - else - cache.load() - local contest_data = cache.get_contest_data(cmd.platform or '', cmd.contest) - if contest_data and contest_data.problems then - local problem_ids = vim.tbl_map(function(prob) - return prob.id - end, contest_data.problems) - - if not vim.tbl_contains(problem_ids, cmd.problem) then - logger.log( - ("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest), - vim.log.levels.ERROR - ) - async.finish_contest_operation() - return - end - - M.setup_problem_async(cmd.contest, cmd.problem, cmd.language) - else - logger.log('no contest data available', vim.log.levels.ERROR) - async.finish_contest_operation() - end - end -end - -return M diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index d2ec4f2..d2198fb 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -89,9 +89,22 @@ function M.load() end function M.save() - vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') + local ok, err = pcall(vim.fn.mkdir, vim.fn.fnamemodify(cache_file, ':h'), 'p') + if not ok then + vim.schedule(function() + vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') + end) + return + end + local encoded = vim.json.encode(cache_data) - vim.fn.writefile(vim.split(encoded, '\n'), cache_file) + local lines = vim.split(encoded, '\n') + ok, err = pcall(vim.fn.writefile, lines, cache_file) + if not ok then + vim.schedule(function() + vim.fn.writefile(lines, cache_file) + end) + end end ---@param platform string diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 8b733e0..6dafd00 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -109,7 +109,9 @@ local function parse_command(args) end function M.handle_command(opts) + logger.log(('command received: %s'):format(vim.inspect(opts.fargs))) local cmd = parse_command(opts.fargs) + logger.log(('parsed command: %s'):format(vim.inspect(cmd))) if cmd.type == 'error' then logger.log(cmd.message, vim.log.levels.ERROR) @@ -123,14 +125,19 @@ function M.handle_command(opts) end if cmd.type == 'action' then + logger.log(('handling action: %s'):format(cmd.action)) local setup = require('cp.setup') local ui = require('cp.ui.panel') if cmd.action == 'run' then + print('running') + logger.log('calling toggle_run_panel') ui.toggle_run_panel(cmd.debug) elseif cmd.action == 'next' then + logger.log('calling navigate_problem(1)') setup.navigate_problem(1, cmd.language) elseif cmd.action == 'prev' then + logger.log('calling navigate_problem(-1)') setup.navigate_problem(-1, cmd.language) elseif cmd.action == 'pick' then local picker = require('cp.commands.picker') @@ -153,25 +160,23 @@ function M.handle_command(opts) if cmd.type == 'contest_setup' then local setup = require('cp.setup') - local async_setup = require('cp.async.setup') if setup.set_platform(cmd.platform) then - async_setup.setup_contest_async(cmd.contest, cmd.language) + setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language) end return end if cmd.type == 'full_setup' then local setup = require('cp.setup') - local async_setup = require('cp.async.setup') if setup.set_platform(cmd.platform) then - async_setup.handle_full_setup_async(cmd) + setup.setup_contest(cmd.platform, cmd.contest, cmd.problem, cmd.language) end return end if cmd.type == 'problem_switch' then - local async_setup = require('cp.async.setup') - async_setup.setup_problem_async(state.get_contest_id() or '', cmd.problem, cmd.language) + local setup = require('cp.setup') + setup.setup_problem(state.get_contest_id() or '', cmd.problem, cmd.language) return end end diff --git a/lua/cp/log.lua b/lua/cp/log.lua index 7a26001..9c702b4 100644 --- a/lua/cp/log.lua +++ b/lua/cp/log.lua @@ -3,7 +3,9 @@ local M = {} function M.log(msg, level, override) level = level or vim.log.levels.INFO if level >= vim.log.levels.WARN or override then - vim.notify(('[cp.nvim]: %s'):format(msg), level) + vim.schedule(function() + vim.notify(('[cp.nvim]: %s'):format(msg), level) + end) end end diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index b981b59..d358137 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -2,7 +2,7 @@ local M = {} local cache = require('cp.cache') local logger = require('cp.log') -local scrape = require('cp.scrape') +local scraper = require('cp.scraper') local utils = require('cp.utils') ---@class cp.PlatformItem @@ -35,68 +35,33 @@ end ---@param platform string Platform identifier (e.g. "codeforces", "atcoder") ---@return cp.ContestItem[] 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 - + -- No cache: start background scraping, return empty for now 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, - true - ) + logger.log(('Loading %s contests...'):format(platform_display_name), vim.log.levels.INFO, true) - local plugin_path = utils.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() + scraper.scrape_contest_list(platform, function(result) + if result.success then + logger.log( + ('Loaded %d contests for %s'):format(#(result.contests or {}), platform_display_name), + vim.log.levels.INFO, + true + ) + else + logger.log( + ('Failed to load contests for %s: %s'):format(platform, result.error or 'unknown error'), + vim.log.levels.ERROR + ) + end + end) - 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 - - cache.set_contest_list(platform, contests) - return contests + return {} end ---Get list of problems for a specific contest @@ -130,20 +95,16 @@ local function get_problems_for_contest(platform, contest_id) true ) - local metadata_result = scrape.scrape_contest_metadata(platform, contest_id) - if not metadata_result.success then + local cached_data = cache.get_contest_data(platform, contest_id) + if not cached_data or not cached_data.problems 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 + 'No cached contest data found. Run :CP first to scrape contest metadata.', + vim.log.levels.WARN ) return problems end - for _, problem in ipairs(metadata_result.problems or {}) do + for _, problem in ipairs(cached_data.problems) do table.insert(problems, { id = problem.id, name = problem.name, diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua deleted file mode 100644 index f7a48c8..0000000 --- a/lua/cp/scrape.lua +++ /dev/null @@ -1,359 +0,0 @@ ----@class ScraperTestCase ----@field input string ----@field expected string - ----@class ScraperResult ----@field success boolean ----@field problem_id string ----@field url? string ----@field tests? ScraperTestCase[] ----@field timeout_ms? number ----@field memory_mb? number ----@field error? string - -local M = {} -local cache = require('cp.cache') -local problem = require('cp.problem') -local utils = require('cp.utils') - -local function check_internet_connectivity() - local result = vim.system({ 'ping', '-c', '5', '-W', '3', '8.8.8.8' }, { text = true }):wait() - return result.code == 0 -end - ----@param platform string ----@param contest_id string ----@return {success: boolean, problems?: table[], error?: string} -function M.scrape_contest_metadata(platform, contest_id) - vim.validate({ - platform = { platform, 'string' }, - contest_id = { contest_id, 'string' }, - }) - - cache.load() - - local cached_data = cache.get_contest_data(platform, contest_id) - if cached_data then - return { - success = true, - problems = cached_data.problems, - } - end - - if not check_internet_connectivity() then - return { - success = false, - error = 'No internet connection available', - } - end - - if not utils.setup_python_env() then - return { - success = false, - error = 'Python environment setup failed', - } - end - - local plugin_path = utils.get_plugin_path() - - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'metadata', - contest_id, - } - - local result = vim - .system(args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - :wait() - - if result.code ~= 0 then - return { - success = false, - error = 'Failed to run metadata scraper: ' .. (result.stderr or 'Unknown error'), - } - end - - local ok, data = pcall(vim.json.decode, result.stdout) - if not ok then - return { - success = false, - error = 'Failed to parse metadata scraper output: ' .. tostring(data), - } - end - - if not data.success then - return data - end - - local problems_list = data.problems or {} - - cache.set_contest_data(platform, contest_id, problems_list) - return { - success = true, - problems = problems_list, - } -end - ----@param ctx ProblemContext ----@return {success: boolean, problem_id: string, test_count?: number, test_cases?: ScraperTestCase[], timeout_ms?: number, memory_mb?: number, url?: string, error?: string} -function M.scrape_problem(ctx) - vim.validate({ - ctx = { ctx, 'table' }, - }) - - vim.fn.mkdir('io', 'p') - - if vim.fn.filereadable(ctx.input_file) == 1 and vim.fn.filereadable(ctx.expected_file) == 1 then - local base_name = vim.fn.fnamemodify(ctx.input_file, ':r') - local test_cases = {} - local i = 1 - - while true do - local input_file = base_name .. '.' .. i .. '.cpin' - local expected_file = base_name .. '.' .. i .. '.cpout' - - if vim.fn.filereadable(input_file) == 1 and vim.fn.filereadable(expected_file) == 1 then - local input_content = table.concat(vim.fn.readfile(input_file), '\n') - local expected_content = table.concat(vim.fn.readfile(expected_file), '\n') - - table.insert(test_cases, { - index = i, - input = input_content, - output = expected_content, - }) - i = i + 1 - else - break - end - end - - return { - success = true, - problem_id = ctx.problem_name, - test_count = #test_cases, - test_cases = test_cases, - } - end - - if not check_internet_connectivity() then - return { - success = false, - problem_id = ctx.problem_name, - error = 'No internet connection available', - } - end - - if not utils.setup_python_env() then - return { - success = false, - problem_id = ctx.problem_name, - error = 'Python environment setup failed', - } - end - - local plugin_path = utils.get_plugin_path() - - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. ctx.contest, - 'tests', - ctx.contest_id, - ctx.problem_id, - } - - local result = vim - .system(args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - :wait() - - if result.code ~= 0 then - return { - success = false, - problem_id = ctx.problem_name, - error = 'Failed to run tests scraper: ' .. (result.stderr or 'Unknown error'), - } - end - - local ok, data = pcall(vim.json.decode, result.stdout) - if not ok then - return { - success = false, - problem_id = ctx.problem_name, - error = 'Failed to parse tests scraper output: ' .. tostring(data), - } - end - - if not data.success then - return data - end - - if data.tests and #data.tests > 0 then - local base_name = vim.fn.fnamemodify(ctx.input_file, ':r') - - for i, test_case in ipairs(data.tests) do - local input_file = base_name .. '.' .. i .. '.cpin' - local expected_file = base_name .. '.' .. i .. '.cpout' - - local input_content = test_case.input:gsub('\r', '') - local expected_content = test_case.expected:gsub('\r', '') - - vim.fn.writefile(vim.split(input_content, '\n', true), input_file) - vim.fn.writefile(vim.split(expected_content, '\n', true), expected_file) - end - - local cached_test_cases = {} - for i, test_case in ipairs(data.tests) do - table.insert(cached_test_cases, { - index = i, - input = test_case.input, - expected = test_case.expected, - }) - end - - cache.set_test_cases( - ctx.contest, - ctx.contest_id, - ctx.problem_id, - cached_test_cases, - data.timeout_ms, - data.memory_mb - ) - end - - return { - success = true, - problem_id = ctx.problem_name, - test_count = data.tests and #data.tests or 0, - test_cases = data.tests, - timeout_ms = data.timeout_ms, - memory_mb = data.memory_mb, - url = data.url, - } -end - ----@param platform string ----@param contest_id string ----@param problems table[] ----@param config table ----@return table[] -function M.scrape_problems_parallel(platform, contest_id, problems, config) - vim.validate({ - platform = { platform, 'string' }, - contest_id = { contest_id, 'string' }, - problems = { problems, 'table' }, - config = { config, 'table' }, - }) - - if not check_internet_connectivity() then - return {} - end - - if not utils.setup_python_env() then - return {} - end - - local plugin_path = utils.get_plugin_path() - local jobs = {} - - for _, prob in ipairs(problems) do - local args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'tests', - contest_id, - prob.id, - } - - local job = vim.system(args, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - - jobs[prob.id] = { - job = job, - problem = prob, - } - end - - local results = {} - for problem_id, job_data in pairs(jobs) do - local result = job_data.job:wait() - local scrape_result = { - success = false, - problem_id = problem_id, - error = 'Unknown error', - } - - if result.code == 0 then - local ok, data = pcall(vim.json.decode, result.stdout) - if ok and data.success then - scrape_result = data - - if data.tests and #data.tests > 0 then - local ctx = problem.create_context(platform, contest_id, problem_id, config) - local base_name = vim.fn.fnamemodify(ctx.input_file, ':r') - - for i, test_case in ipairs(data.tests) do - local input_file = base_name .. '.' .. i .. '.cpin' - local expected_file = base_name .. '.' .. i .. '.cpout' - - local input_content = test_case.input:gsub('\r', '') - local expected_content = test_case.expected:gsub('\r', '') - - vim.fn.writefile(vim.split(input_content, '\n', true), input_file) - vim.fn.writefile(vim.split(expected_content, '\n', true), expected_file) - end - - local cached_test_cases = {} - for i, test_case in ipairs(data.tests) do - table.insert(cached_test_cases, { - index = i, - input = test_case.input, - expected = test_case.expected, - }) - end - - cache.set_test_cases( - platform, - contest_id, - problem_id, - cached_test_cases, - data.timeout_ms, - data.memory_mb - ) - end - else - scrape_result.error = ok and data.error or 'Failed to parse scraper output' - end - else - scrape_result.error = 'Scraper execution failed: ' .. (result.stderr or 'Unknown error') - end - - results[problem_id] = scrape_result - end - - return results -end - -return M diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua new file mode 100644 index 0000000..e8d708f --- /dev/null +++ b/lua/cp/scraper.lua @@ -0,0 +1,152 @@ +local M = {} +local cache = require('cp.cache') +local utils = require('cp.utils') + +local function run_scraper(platform, subcommand, args, callback) + if not utils.setup_python_env() then + callback({ success = false, error = 'Python environment setup failed' }) + return + end + + local plugin_path = utils.get_plugin_path() + local cmd = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + subcommand, + } + + for _, arg in ipairs(args or {}) do + table.insert(cmd, arg) + end + + vim.system(cmd, { + cwd = plugin_path, + text = true, + timeout = 30000, + }, function(result) + if result.code ~= 0 then + callback({ + success = false, + error = 'Scraper failed: ' .. (result.stderr or 'Unknown error'), + }) + return + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + callback({ + success = false, + error = 'Failed to parse scraper output: ' .. tostring(data), + }) + return + end + + callback(data) + end) +end + +function M.scrape_contest_metadata(platform, contest_id, callback) + cache.load() + + local cached = cache.get_contest_data(platform, contest_id) + if cached then + callback({ success = true, problems = cached.problems }) + return + end + + run_scraper(platform, 'metadata', { contest_id }, function(result) + if result.success and result.problems then + cache.set_contest_data(platform, contest_id, result.problems) + end + callback(result) + end) +end + +function M.scrape_contest_list(platform, callback) + cache.load() + + local cached = cache.get_contest_list(platform) + if cached then + callback({ success = true, contests = cached }) + return + end + + run_scraper(platform, 'contests', {}, function(result) + if result.success and result.contests then + cache.set_contest_list(platform, result.contests) + end + callback(result) + end) +end + +function M.scrape_problem_tests(platform, contest_id, problem_id, callback) + run_scraper(platform, 'tests', { contest_id, problem_id }, function(result) + if result.success and result.tests then + -- Write test files + vim.schedule(function() + local mkdir_ok = pcall(vim.fn.mkdir, 'io', 'p') + if mkdir_ok then + local config = require('cp.config') + local base_name = config.default_filename(contest_id, problem_id) + local logger = require('cp.log') + + logger.log( + ('writing %d test files for %s (base: %s)'):format(#result.tests, problem_id, base_name) + ) + + for i, test_case in ipairs(result.tests) do + local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin' + local expected_file = 'io/' .. base_name .. '.' .. i .. '.cpout' + + local input_content = test_case.input:gsub('\r', '') + local expected_content = test_case.expected:gsub('\r', '') + + local input_ok = + pcall(vim.fn.writefile, vim.split(input_content, '\n', true), input_file) + local expected_ok = + pcall(vim.fn.writefile, vim.split(expected_content, '\n', true), expected_file) + + if input_ok and expected_ok then + logger.log(('wrote test files: %s, %s'):format(input_file, expected_file)) + else + logger.log( + ('failed to write test files for %s.%d'):format(base_name, i), + vim.log.levels.WARN + ) + end + end + else + local logger = require('cp.log') + logger.log('failed to create io/ directory', vim.log.levels.ERROR) + end + end) + + -- Cache test cases + local cached_tests = {} + for i, test_case in ipairs(result.tests) do + table.insert(cached_tests, { + index = i, + input = test_case.input, + expected = test_case.expected, + }) + end + + cache.set_test_cases( + platform, + contest_id, + problem_id, + cached_tests, + result.timeout_ms, + result.memory_mb + ) + end + + callback(result) + end) +end + +return M diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua new file mode 100644 index 0000000..b38249b --- /dev/null +++ b/lua/cp/setup.lua @@ -0,0 +1,273 @@ +local M = {} + +local cache = require('cp.cache') +local config_module = require('cp.config') +local logger = require('cp.log') +local problem = require('cp.problem') +local scraper = require('cp.scraper') +local state = require('cp.state') + +local constants = require('cp.constants') +local platforms = constants.PLATFORMS + +function M.set_platform(platform) + if not vim.tbl_contains(platforms, platform) then + logger.log( + ('unknown platform: %s. supported: %s'):format(platform, table.concat(platforms, ', ')), + vim.log.levels.ERROR + ) + return false + end + + if state.get_platform() == platform then + logger.log(('platform already set to %s'):format(platform)) + else + state.set_platform(platform) + logger.log(('platform set to %s'):format(platform)) + end + + return true +end + +function M.setup_contest(platform, contest_id, problem_id, language) + if not state.get_platform() then + logger.log('no platform set', vim.log.levels.ERROR) + return + end + + local config = config_module.get_config() + + if not vim.tbl_contains(config.scrapers, platform) then + logger.log('scraping disabled for ' .. platform, vim.log.levels.WARN) + return + end + + logger.log(('setting up contest %s %s'):format(platform, contest_id)) + + scraper.scrape_contest_metadata(platform, contest_id, function(result) + if not result.success then + logger.log( + 'failed to load contest metadata: ' .. (result.error or 'unknown error'), + vim.log.levels.ERROR + ) + return + end + + local problems = result.problems + if not problems or #problems == 0 then + logger.log('no problems found in contest', vim.log.levels.ERROR) + return + end + + logger.log(('found %d problems'):format(#problems)) + + -- Set up specified problem or first problem + state.set_contest_id(contest_id) + local target_problem = problem_id or problems[1].id + + -- Validate problem exists if specified + if problem_id then + local problem_exists = false + for _, prob in ipairs(problems) do + if prob.id == problem_id then + problem_exists = true + break + end + end + if not problem_exists then + logger.log( + ('invalid problem %s for contest %s'):format(problem_id, contest_id), + vim.log.levels.ERROR + ) + return + end + end + + M.setup_problem(contest_id, target_problem, language) + + -- Scrape remaining problems in background + M.scrape_remaining_problems(platform, contest_id, problems) + end) +end + +function M.setup_problem(contest_id, problem_id, language) + if not state.get_platform() then + logger.log('no platform set. run :CP first', vim.log.levels.ERROR) + return + end + + local config = config_module.get_config() + local platform = state.get_platform() or '' + + logger.log(('setting up problem: %s%s'):format(contest_id, problem_id or '')) + + local ctx = problem.create_context(platform, contest_id, problem_id, config, language) + + -- Load test cases for current problem + local cached_tests = cache.get_test_cases(platform, contest_id, problem_id) + if cached_tests then + state.set_test_cases(cached_tests) + logger.log(('using cached test cases (%d)'):format(#cached_tests)) + elseif vim.tbl_contains(config.scrapers, platform) then + logger.log('loading test cases...') + + scraper.scrape_problem_tests(platform, contest_id, problem_id, function(result) + vim.schedule(function() + if result.success then + logger.log(('loaded %d test cases for %s'):format(#(result.tests or {}), problem_id)) + if state.get_problem_id() == problem_id then + state.set_test_cases(result.tests) + end + else + logger.log( + 'failed to load tests: ' .. (result.error or 'unknown error'), + vim.log.levels.ERROR + ) + if state.get_problem_id() == problem_id then + state.set_test_cases({}) + end + end + end) + end) + else + logger.log(('scraping disabled for %s'):format(platform)) + state.set_test_cases({}) + end + + -- Update state immediately (safe in fast event) + state.set_contest_id(contest_id) + state.set_problem_id(problem_id) + state.set_run_panel_active(false) + + -- Schedule vim commands (required for fast event context) + vim.schedule(function() + local ok, err = pcall(function() + vim.cmd.only({ mods = { silent = true } }) + + vim.cmd.e(ctx.source_file) + local source_buf = vim.api.nvim_get_current_buf() + + if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then + local has_luasnip, luasnip = pcall(require, 'luasnip') + if has_luasnip then + local filetype = vim.api.nvim_get_option_value('filetype', { buf = source_buf }) + local language_name = constants.filetype_to_language[filetype] + local canonical_language = constants.canonical_filetypes[language_name] or language_name + local prefixed_trigger = ('cp.nvim/%s.%s'):format(platform, canonical_language) + + vim.api.nvim_buf_set_lines(0, 0, -1, false, { prefixed_trigger }) + vim.api.nvim_win_set_cursor(0, { 1, #prefixed_trigger }) + vim.cmd.startinsert({ bang = true }) + + vim.schedule(function() + if luasnip.expandable() then + luasnip.expand() + else + vim.api.nvim_buf_set_lines(0, 0, 1, false, { '' }) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + end + vim.cmd.stopinsert() + end) + else + vim.api.nvim_input(('i%s'):format(platform)) + end + end + + if config.hooks and config.hooks.setup_code then + config.hooks.setup_code(ctx) + end + + cache.set_file_state(vim.fn.expand('%:p'), platform, contest_id, problem_id, language) + + logger.log(('switched to problem %s'):format(ctx.problem_name)) + end) + + if not ok then + logger.log(('setup error: %s'):format(err), vim.log.levels.ERROR) + end + end) +end + +function M.scrape_remaining_problems(platform, contest_id, problems) + cache.load() + local config = config_module.get_config() + local missing_problems = {} + + for _, prob in ipairs(problems) do + local cached_tests = cache.get_test_cases(platform, contest_id, prob.id) + if not cached_tests then + table.insert(missing_problems, prob) + end + end + + if #missing_problems == 0 then + logger.log('all problems already cached') + return + end + + logger.log(('scraping %d uncached problems in background...'):format(#missing_problems)) + + for _, prob in ipairs(missing_problems) do + logger.log(('starting background scrape for problem %s'):format(prob.id)) + scraper.scrape_problem_tests(platform, contest_id, prob.id, function(result) + if result.success then + logger.log( + ('background: scraped problem %s - %d test cases'):format(prob.id, #(result.tests or {})) + ) + else + logger.log( + ('background: failed to scrape problem %s: %s'):format( + prob.id, + result.error or 'unknown error' + ), + vim.log.levels.WARN + ) + end + end) + end +end + +function M.navigate_problem(direction, language) + local platform = state.get_platform() + local contest_id = state.get_contest_id() + local current_problem_id = state.get_problem_id() + + if not platform or not contest_id or not current_problem_id then + logger.log('no contest context', vim.log.levels.ERROR) + return + end + + cache.load() + local contest_data = cache.get_contest_data(platform, contest_id) + if not contest_data or not contest_data.problems then + logger.log('no contest data available', vim.log.levels.ERROR) + return + end + + local problems = contest_data.problems + local current_index = nil + for i, problem in ipairs(problems) do + if problem.id == current_problem_id then + current_index = i + break + end + end + + if not current_index then + logger.log('current problem not found in contest', vim.log.levels.ERROR) + return + end + + local new_index = current_index + direction + if new_index < 1 or new_index > #problems then + logger.log('no more problems in that direction', vim.log.levels.WARN) + return + end + + local new_problem = problems[new_index] + M.setup_problem(contest_id, new_problem.id, language) + + state.set_problem_id(new_problem.id) +end + +return M diff --git a/lua/cp/setup/contest.lua b/lua/cp/setup/contest.lua deleted file mode 100644 index 7649330..0000000 --- a/lua/cp/setup/contest.lua +++ /dev/null @@ -1,43 +0,0 @@ -local M = {} - -local logger = require('cp.log') -local scrape = require('cp.scrape') -local state = require('cp.state') - -function M.scrape_missing_problems(contest_id, missing_problems, config) - vim.fn.mkdir('io', 'p') - - logger.log(('scraping %d uncached problems...'):format(#missing_problems)) - - local results = scrape.scrape_problems_parallel( - state.get_platform() or '', - contest_id, - missing_problems, - config - ) - - local success_count = 0 - local failed_problems = {} - for problem_id, result in pairs(results) do - if result.success then - success_count = success_count + 1 - else - table.insert(failed_problems, problem_id) - end - end - - if #failed_problems > 0 then - logger.log( - ('scraping complete: %d/%d successful, failed: %s'):format( - success_count, - #missing_problems, - table.concat(failed_problems, ', ') - ), - vim.log.levels.WARN - ) - else - logger.log(('scraping complete: %d/%d successful'):format(success_count, #missing_problems)) - end -end - -return M diff --git a/lua/cp/setup/init.lua b/lua/cp/setup/init.lua deleted file mode 100644 index f654e5c..0000000 --- a/lua/cp/setup/init.lua +++ /dev/null @@ -1,260 +0,0 @@ -local M = {} - -local cache = require('cp.cache') -local config_module = require('cp.config') -local logger = require('cp.log') -local problem = require('cp.problem') -local scrape = require('cp.scrape') -local state = require('cp.state') - -local constants = require('cp.constants') -local platforms = constants.PLATFORMS - -function M.set_platform(platform) - if not vim.tbl_contains(platforms, platform) then - logger.log( - ('unknown platform. Available: [%s]'):format(table.concat(platforms, ', ')), - vim.log.levels.ERROR - ) - return false - end - - state.set_platform(platform) - vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() - return true -end - -function M.setup_problem(contest_id, problem_id, language) - if not state.get_platform() then - logger.log('no platform set. run :CP first', vim.log.levels.ERROR) - return - end - - local config = config_module.get_config() - local problem_name = contest_id .. (problem_id or '') - logger.log(('setting up problem: %s'):format(problem_name)) - - local ctx = - problem.create_context(state.get_platform() or '', contest_id, problem_id, config, language) - - if vim.tbl_contains(config.scrapers, state.get_platform() or '') then - cache.load() - local existing_contest_data = cache.get_contest_data(state.get_platform() or '', contest_id) - - if not existing_contest_data then - local metadata_result = scrape.scrape_contest_metadata(state.get_platform() or '', contest_id) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.WARN - ) - end - end - end - - local cached_test_cases = cache.get_test_cases(state.get_platform() or '', contest_id, problem_id) - if cached_test_cases then - state.set_test_cases(cached_test_cases) - logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) - elseif vim.tbl_contains(config.scrapers, state.get_platform() or '') then - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[state.get_platform() or ''] - or (state.get_platform() or '') - 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.INFO, - true - ) - - local scrape_result = scrape.scrape_problem(ctx) - - if not scrape_result.success then - logger.log( - 'scraping failed: ' .. (scrape_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - return - end - - local test_count = scrape_result.test_count or 0 - logger.log(('scraped %d test case(s) for %s'):format(test_count, scrape_result.problem_id)) - state.set_test_cases(scrape_result.test_cases) - - if scrape_result.test_cases then - cache.set_test_cases( - state.get_platform() or '', - contest_id, - problem_id, - scrape_result.test_cases - ) - end - else - logger.log(('scraping disabled for %s'):format(state.get_platform() or '')) - state.set_test_cases(nil) - end - - vim.cmd('silent only') - state.set_run_panel_active(false) - - state.set_contest_id(contest_id) - state.set_problem_id(problem_id) - - vim.cmd.e(ctx.source_file) - local source_buf = vim.api.nvim_get_current_buf() - - if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then - local has_luasnip, luasnip = pcall(require, 'luasnip') - if has_luasnip then - local filetype = vim.api.nvim_get_option_value('filetype', { buf = source_buf }) - local language_name = constants.filetype_to_language[filetype] - local canonical_language = constants.canonical_filetypes[language_name] or language_name - local prefixed_trigger = ('cp.nvim/%s.%s'):format(state.get_platform(), canonical_language) - - vim.api.nvim_buf_set_lines(0, 0, -1, false, { prefixed_trigger }) - vim.api.nvim_win_set_cursor(0, { 1, #prefixed_trigger }) - vim.cmd.startinsert({ bang = true }) - - vim.schedule(function() - if luasnip.expandable() then - luasnip.expand() - else - vim.api.nvim_buf_set_lines(0, 0, 1, false, { '' }) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - end - vim.cmd.stopinsert() - end) - else - vim.api.nvim_input(('i%s'):format(state.get_platform())) - end - end - - if config.hooks and config.hooks.setup_code then - config.hooks.setup_code(ctx) - end - - cache.set_file_state( - vim.fn.expand('%:p'), - state.get_platform() or '', - contest_id, - problem_id, - language - ) - - logger.log(('switched to problem %s'):format(ctx.problem_name)) -end - -function M.setup_contest(contest_id, language) - if not state.get_platform() then - logger.log('no platform set', vim.log.levels.ERROR) - return false - end - - local config = config_module.get_config() - - if not vim.tbl_contains(config.scrapers, state.get_platform() or '') then - logger.log('scraping disabled for ' .. (state.get_platform() or ''), vim.log.levels.WARN) - return false - end - - logger.log(('setting up contest %s %s'):format(state.get_platform() or '', contest_id)) - - local metadata_result = scrape.scrape_contest_metadata(state.get_platform() or '', contest_id) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - return false - end - - local problems = metadata_result.problems - if not problems or #problems == 0 then - logger.log('no problems found in contest', vim.log.levels.ERROR) - return false - end - - logger.log(('found %d problems, checking cache...'):format(#problems)) - - cache.load() - local missing_problems = {} - for _, prob in ipairs(problems) do - local cached_tests = cache.get_test_cases(state.get_platform() or '', contest_id, prob.id) - if not cached_tests then - table.insert(missing_problems, prob) - end - end - - if #missing_problems > 0 then - local contest_scraper = require('cp.setup.contest') - contest_scraper.scrape_missing_problems(contest_id, missing_problems, config) - else - logger.log('all problems already cached') - end - - state.set_contest_id(contest_id) - M.setup_problem(contest_id, problems[1].id, language) - - return true -end - -function M.navigate_problem(delta, language) - if not state.get_platform() or not state.get_contest_id() then - logger.log('no contest set. run :CP first', vim.log.levels.ERROR) - return - end - - local navigation = require('cp.setup.navigation') - navigation.navigate_problem(delta, language) -end - -function M.handle_full_setup(cmd) - state.set_contest_id(cmd.contest) - local problem_ids = {} - local has_metadata = false - local config = config_module.get_config() - - if vim.tbl_contains(config.scrapers, cmd.platform) then - local metadata_result = scrape.scrape_contest_metadata(cmd.platform, cmd.contest) - if not metadata_result.success then - logger.log( - 'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'), - vim.log.levels.ERROR - ) - return - end - - logger.log( - ('loaded %d problems for %s %s'):format(#metadata_result.problems, cmd.platform, cmd.contest), - vim.log.levels.INFO, - true - ) - problem_ids = vim.tbl_map(function(prob) - return prob.id - end, metadata_result.problems) - has_metadata = true - else - cache.load() - local contest_data = cache.get_contest_data(cmd.platform or '', cmd.contest) - if contest_data and contest_data.problems then - problem_ids = vim.tbl_map(function(prob) - return prob.id - end, contest_data.problems) - has_metadata = true - end - end - - if has_metadata and not vim.tbl_contains(problem_ids, cmd.problem) then - logger.log( - ("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest), - vim.log.levels.ERROR - ) - return - end - - M.setup_problem(cmd.contest, cmd.problem, cmd.language) -end - -return M diff --git a/lua/cp/setup/navigation.lua b/lua/cp/setup/navigation.lua deleted file mode 100644 index bab857b..0000000 --- a/lua/cp/setup/navigation.lua +++ /dev/null @@ -1,64 +0,0 @@ -local M = {} - -local cache = require('cp.cache') -local logger = require('cp.log') -local state = require('cp.state') - -local function get_current_problem() - local filename = vim.fn.expand('%:t:r') - if filename == '' then - logger.log('no file open', vim.log.levels.ERROR) - return nil - end - return filename -end - -function M.navigate_problem(delta, language) - cache.load() - local contest_data = - cache.get_contest_data(state.get_platform() or '', state.get_contest_id() or '') - if not contest_data or not contest_data.problems then - logger.log( - 'no contest metadata found. set up a problem first to cache contest data', - vim.log.levels.ERROR - ) - return - end - - local problems = contest_data.problems - local current_problem_id = state.get_problem_id() - - if not current_problem_id then - logger.log('no current problem set', vim.log.levels.ERROR) - return - end - - local current_index = nil - for i, prob in ipairs(problems) do - if prob.id == current_problem_id then - current_index = i - break - end - end - - if not current_index then - logger.log('current problem not found in contest', vim.log.levels.ERROR) - return - end - - local new_index = current_index + delta - - if new_index < 1 or new_index > #problems then - local msg = delta > 0 and 'at last problem' or 'at first problem' - logger.log(msg, vim.log.levels.WARN) - return - end - - local new_problem = problems[new_index] - local setup = require('cp.setup') - setup.setup_problem(state.get_contest_id() or '', new_problem.id, language) -end - -M.get_current_problem = get_current_problem - -return M diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 5f3345d..35a4c9e 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -11,8 +11,7 @@ local current_diff_layout = nil local current_mode = nil local function get_current_problem() - local setup_nav = require('cp.setup.navigation') - return setup_nav.get_current_problem() + return state.get_problem_id() end function M.toggle_run_panel(is_debug) @@ -28,12 +27,15 @@ function M.toggle_run_panel(is_debug) state.saved_session = nil end + print('run panel was active, returning') + state.set_run_panel_active(false) logger.log('test panel closed') return end if not state.get_platform() then + print('no panel active, returning') logger.log( 'No contest configured. Use :CP to set up first.', vim.log.levels.ERROR @@ -42,19 +44,29 @@ function M.toggle_run_panel(is_debug) end local problem_id = get_current_problem() + print(problem_id) if not problem_id then + logger.log('no current problem set', vim.log.levels.ERROR) return end - local config = config_module.get_config() - local ctx = problem.create_context( - state.get_platform() or '', - state.get_contest_id() or '', - state.get_problem_id(), - config + local platform = state.get_platform() + local contest_id = state.get_contest_id() + + logger.log( + ('run panel: platform=%s, contest=%s, problem=%s'):format( + platform or 'nil', + contest_id or 'nil', + problem_id or 'nil' + ) ) + + local config = config_module.get_config() + local ctx = problem.create_context(platform or '', contest_id or '', problem_id, config) local run = require('cp.runner.run') + logger.log(('run panel: checking test cases for %s'):format(ctx.input_file)) + if not run.load_test_cases(ctx, state) then logger.log('no test cases found', vim.log.levels.WARN) return diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index 6fd5a81..e7ca9b5 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -168,18 +168,11 @@ describe('cp.picker', function() 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(_, _) - return { - success = false, - error = 'test error', - } - end picker = spec_helper.fresh_require('cp.pickers', { 'cp.pickers.init' }) From 545793df39710857a096cdc5dcfdde2278dc6969 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 09:43:21 -0400 Subject: [PATCH 11/33] remove ai comments --- lua/cp/pickers/init.lua | 3 --- lua/cp/scraper.lua | 2 -- lua/cp/setup.lua | 6 ------ 3 files changed, 11 deletions(-) diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index d358137..91fd2b5 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -41,7 +41,6 @@ local function get_contests_for_platform(platform) return cached_contests end - -- No cache: start background scraping, return empty for now local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform logger.log(('Loading %s contests...'):format(platform_display_name), vim.log.levels.INFO, true) @@ -64,7 +63,6 @@ local function get_contests_for_platform(platform) return {} end ----Get list of problems for a specific contest ---@param platform string Platform identifier ---@param contest_id string Contest identifier ---@return cp.ProblemItem[] @@ -115,7 +113,6 @@ local function get_problems_for_contest(platform, contest_id) 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 diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index e8d708f..5819478 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -86,7 +86,6 @@ end function M.scrape_problem_tests(platform, contest_id, problem_id, callback) run_scraper(platform, 'tests', { contest_id, problem_id }, function(result) if result.success and result.tests then - -- Write test files vim.schedule(function() local mkdir_ok = pcall(vim.fn.mkdir, 'io', 'p') if mkdir_ok then @@ -125,7 +124,6 @@ function M.scrape_problem_tests(platform, contest_id, problem_id, callback) end end) - -- Cache test cases local cached_tests = {} for i, test_case in ipairs(result.tests) do table.insert(cached_tests, { diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index b38249b..82bd6a4 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -61,11 +61,9 @@ function M.setup_contest(platform, contest_id, problem_id, language) logger.log(('found %d problems'):format(#problems)) - -- Set up specified problem or first problem state.set_contest_id(contest_id) local target_problem = problem_id or problems[1].id - -- Validate problem exists if specified if problem_id then local problem_exists = false for _, prob in ipairs(problems) do @@ -85,7 +83,6 @@ function M.setup_contest(platform, contest_id, problem_id, language) M.setup_problem(contest_id, target_problem, language) - -- Scrape remaining problems in background M.scrape_remaining_problems(platform, contest_id, problems) end) end @@ -103,7 +100,6 @@ function M.setup_problem(contest_id, problem_id, language) local ctx = problem.create_context(platform, contest_id, problem_id, config, language) - -- Load test cases for current problem local cached_tests = cache.get_test_cases(platform, contest_id, problem_id) if cached_tests then state.set_test_cases(cached_tests) @@ -134,12 +130,10 @@ function M.setup_problem(contest_id, problem_id, language) state.set_test_cases({}) end - -- Update state immediately (safe in fast event) state.set_contest_id(contest_id) state.set_problem_id(problem_id) state.set_run_panel_active(false) - -- Schedule vim commands (required for fast event context) vim.schedule(function() local ok, err = pcall(function() vim.cmd.only({ mods = { silent = true } }) From f9cf5b16147ee00c999a9742d7dafdffa7fd41b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 10:17:22 -0400 Subject: [PATCH 12/33] possibly working --- lua/cp/commands/init.lua | 7 ------- lua/cp/runner/run.lua | 13 ++---------- lua/cp/scraper.lua | 23 ++------------------- lua/cp/setup.lua | 43 +++++++++++++--------------------------- lua/cp/ui/panel.lua | 9 ++------- 5 files changed, 20 insertions(+), 75 deletions(-) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 6dafd00..632411e 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -109,9 +109,7 @@ local function parse_command(args) end function M.handle_command(opts) - logger.log(('command received: %s'):format(vim.inspect(opts.fargs))) local cmd = parse_command(opts.fargs) - logger.log(('parsed command: %s'):format(vim.inspect(cmd))) if cmd.type == 'error' then logger.log(cmd.message, vim.log.levels.ERROR) @@ -125,19 +123,14 @@ function M.handle_command(opts) end if cmd.type == 'action' then - logger.log(('handling action: %s'):format(cmd.action)) local setup = require('cp.setup') local ui = require('cp.ui.panel') if cmd.action == 'run' then - print('running') - logger.log('calling toggle_run_panel') ui.toggle_run_panel(cmd.debug) elseif cmd.action == 'next' then - logger.log('calling navigate_problem(1)') setup.navigate_problem(1, cmd.language) elseif cmd.action == 'prev' then - logger.log('calling navigate_problem(-1)') setup.navigate_problem(-1, cmd.language) elseif cmd.action == 'pick' then local picker = require('cp.commands.picker') diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index abe13e3..2219983 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -87,14 +87,11 @@ end ---@param expected_file string ---@return TestCase[] local function parse_test_cases_from_files(input_file, expected_file) - if vim.fn.filereadable(input_file) == 0 or vim.fn.filereadable(expected_file) == 0 then - return {} - end - local base_name = vim.fn.fnamemodify(input_file, ':r') local test_cases = {} - local i = 1 + -- Try numbered files first (created by scraper) + local i = 1 while true do local individual_input_file = base_name .. '.' .. i .. '.cpin' local individual_expected_file = base_name .. '.' .. i .. '.cpout' @@ -113,12 +110,6 @@ local function parse_test_cases_from_files(input_file, expected_file) end end - if #test_cases == 0 then - local input_content = table.concat(vim.fn.readfile(input_file), '\n') - local expected_content = table.concat(vim.fn.readfile(expected_file), '\n') - return { create_test_case(1, input_content, expected_content) } - end - return test_cases end diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 5819478..4f70930 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -91,11 +91,6 @@ function M.scrape_problem_tests(platform, contest_id, problem_id, callback) if mkdir_ok then local config = require('cp.config') local base_name = config.default_filename(contest_id, problem_id) - local logger = require('cp.log') - - logger.log( - ('writing %d test files for %s (base: %s)'):format(#result.tests, problem_id, base_name) - ) for i, test_case in ipairs(result.tests) do local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin' @@ -104,23 +99,9 @@ function M.scrape_problem_tests(platform, contest_id, problem_id, callback) local input_content = test_case.input:gsub('\r', '') local expected_content = test_case.expected:gsub('\r', '') - local input_ok = - pcall(vim.fn.writefile, vim.split(input_content, '\n', true), input_file) - local expected_ok = - pcall(vim.fn.writefile, vim.split(expected_content, '\n', true), expected_file) - - if input_ok and expected_ok then - logger.log(('wrote test files: %s, %s'):format(input_file, expected_file)) - else - logger.log( - ('failed to write test files for %s.%d'):format(base_name, i), - vim.log.levels.WARN - ) - end + pcall(vim.fn.writefile, vim.split(input_content, '\n', true), input_file) + pcall(vim.fn.writefile, vim.split(expected_content, '\n', true), expected_file) end - else - local logger = require('cp.log') - logger.log('failed to create io/ directory', vim.log.levels.ERROR) end end) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 82bd6a4..642aca3 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -108,22 +108,20 @@ function M.setup_problem(contest_id, problem_id, language) logger.log('loading test cases...') scraper.scrape_problem_tests(platform, contest_id, problem_id, function(result) - vim.schedule(function() - if result.success then - logger.log(('loaded %d test cases for %s'):format(#(result.tests or {}), problem_id)) - if state.get_problem_id() == problem_id then - state.set_test_cases(result.tests) - end - else - logger.log( - 'failed to load tests: ' .. (result.error or 'unknown error'), - vim.log.levels.ERROR - ) - if state.get_problem_id() == problem_id then - state.set_test_cases({}) - end + if result.success then + logger.log(('loaded %d test cases for %s'):format(#(result.tests or {}), problem_id)) + if state.get_problem_id() == problem_id then + state.set_test_cases(result.tests) end - end) + else + logger.log( + 'failed to load tests: ' .. (result.error or 'unknown error'), + vim.log.levels.ERROR + ) + if state.get_problem_id() == problem_id then + state.set_test_cases({}) + end + end end) else logger.log(('scraping disabled for %s'):format(platform)) @@ -202,20 +200,9 @@ function M.scrape_remaining_problems(platform, contest_id, problems) logger.log(('scraping %d uncached problems in background...'):format(#missing_problems)) for _, prob in ipairs(missing_problems) do - logger.log(('starting background scrape for problem %s'):format(prob.id)) scraper.scrape_problem_tests(platform, contest_id, prob.id, function(result) if result.success then - logger.log( - ('background: scraped problem %s - %d test cases'):format(prob.id, #(result.tests or {})) - ) - else - logger.log( - ('background: failed to scrape problem %s: %s'):format( - prob.id, - result.error or 'unknown error' - ), - vim.log.levels.WARN - ) + logger.log(('background: scraped problem %s'):format(prob.id)) end end) end @@ -260,8 +247,6 @@ function M.navigate_problem(direction, language) local new_problem = problems[new_index] M.setup_problem(contest_id, new_problem.id, language) - - state.set_problem_id(new_problem.id) end return M diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 35a4c9e..1ca5551 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -15,7 +15,7 @@ local function get_current_problem() end function M.toggle_run_panel(is_debug) - if state.run_panel_active then + if state.is_run_panel_active() then if current_diff_layout then current_diff_layout.cleanup() current_diff_layout = nil @@ -27,15 +27,12 @@ function M.toggle_run_panel(is_debug) state.saved_session = nil end - print('run panel was active, returning') - state.set_run_panel_active(false) logger.log('test panel closed') return end if not state.get_platform() then - print('no panel active, returning') logger.log( 'No contest configured. Use :CP to set up first.', vim.log.levels.ERROR @@ -44,9 +41,7 @@ function M.toggle_run_panel(is_debug) end local problem_id = get_current_problem() - print(problem_id) if not problem_id then - logger.log('no current problem set', vim.log.levels.ERROR) return end @@ -205,7 +200,7 @@ function M.toggle_run_panel(is_debug) vim.api.nvim_set_current_win(test_windows.tab_win) - state.run_panel_active = true + state.set_run_panel_active(true) state.test_buffers = test_buffers state.test_windows = test_windows local test_state = run.get_run_panel_state() From 8a9bc7434f4df2205a7c4f2e894883ab634cccf8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 10:22:02 -0400 Subject: [PATCH 13/33] fix: remove comments --- lua/cp/cache.lua | 8 ++++---- lua/cp/runner/run.lua | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index d2198fb..5b76f3d 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -40,9 +40,9 @@ 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 + cses = 7 * 24 * 60 * 60, + codeforces = 24 * 60 * 60, + atcoder = 24 * 60 * 60, } ---@param contest_data ContestData @@ -316,7 +316,7 @@ function M.set_contest_list(platform, contests) cache_data.contest_lists = {} end - local ttl = CONTEST_LIST_TTL[platform] or (24 * 60 * 60) -- Default 1 day + local ttl = CONTEST_LIST_TTL[platform] or (24 * 60 * 60) cache_data.contest_lists[platform] = { contests = contests, cached_at = os.time(), diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 2219983..33814ab 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -90,7 +90,6 @@ local function parse_test_cases_from_files(input_file, expected_file) local base_name = vim.fn.fnamemodify(input_file, ':r') local test_cases = {} - -- Try numbered files first (created by scraper) local i = 1 while true do local individual_input_file = base_name .. '.' .. i .. '.cpin' From 62eab3df2d5959cf44f863882d82c84ba938d70a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 12:16:57 -0400 Subject: [PATCH 14/33] feat(picker): one step closer to fully async --- lua/cp/pickers/fzf_lua.lua | 3 +- lua/cp/pickers/init.lua | 114 +++++++++++++++++++++++++++-------- lua/cp/pickers/telescope.lua | 3 +- 3 files changed, 94 insertions(+), 26 deletions(-) diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index cf3a47e..8ca106d 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -36,7 +36,8 @@ local function problem_picker(platform, contest_id) end if problem then - picker_utils.setup_problem(platform, contest_id, problem.id) + local cp = require('cp') + cp.handle_command({ fargs = { platform, contest_id, problem.id } }) end end, }, diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 91fd2b5..75ed6af 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -45,22 +45,55 @@ local function get_contests_for_platform(platform) local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform logger.log(('Loading %s contests...'):format(platform_display_name), vim.log.levels.INFO, true) - scraper.scrape_contest_list(platform, function(result) - if result.success then - logger.log( - ('Loaded %d contests for %s'):format(#(result.contests or {}), platform_display_name), - vim.log.levels.INFO, - true - ) - else - logger.log( - ('Failed to load contests for %s: %s'):format(platform, result.error or 'unknown error'), - vim.log.levels.ERROR - ) - end - end) + if not utils.setup_python_env() then + return {} + end - return {} + local plugin_path = utils.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 load contests: %s'):format(result.stderr or 'unknown error'), + vim.log.levels.ERROR + ) + return {} + 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', vim.log.levels.ERROR) + return {} + end + + local contests = {} + for _, contest in ipairs(data.contests or {}) do + table.insert(contests, { + id = contest.id, + name = contest.name, + display_name = contest.display_name, + }) + end + + cache.set_contest_list(platform, contests) + logger.log(('Loaded %d contests'):format(#contests), vim.log.levels.INFO) + return contests end ---@param platform string Platform identifier @@ -82,27 +115,60 @@ local function get_problems_for_contest(platform, contest_id) return problems end + if not utils.setup_python_env() then + 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 - ), + ('Scraping %s %s for problems...'):format(platform_display_name, contest_id), vim.log.levels.INFO, true ) - local cached_data = cache.get_contest_data(platform, contest_id) - if not cached_data or not cached_data.problems then + local plugin_path = utils.get_plugin_path() + local cmd = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'metadata', + contest_id, + } + + local result = vim + .system(cmd, { + cwd = plugin_path, + text = true, + timeout = 30000, + }) + :wait() + + if result.code ~= 0 then logger.log( - 'No cached contest data found. Run :CP first to scrape contest metadata.', - vim.log.levels.WARN + ('Failed to scrape contest: %s'):format(result.stderr or 'unknown error'), + vim.log.levels.ERROR ) return problems end - for _, problem in ipairs(cached_data.problems) do + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok or not data.success then + logger.log('Failed to parse contest data', vim.log.levels.ERROR) + return problems + end + + if not data.problems or #data.problems == 0 then + logger.log('Contest has no problems available', vim.log.levels.WARN) + return problems + end + + cache.set_contest_data(platform, contest_id, data.problems) + + for _, problem in ipairs(data.problems) do table.insert(problems, { id = problem.id, name = problem.name, diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 6f65c93..1417cc3 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -39,7 +39,8 @@ local function problem_picker(opts, platform, contest_id) actions.close(prompt_bufnr) if selection then - picker_utils.setup_problem(platform, contest_id, selection.value.id) + local cp = require('cp') + cp.handle_command({ fargs = { platform, contest_id, selection.value.id } }) end end) return true From 8df8c16a722d49b85ff7d9880adf0b5f98cb495d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 12:25:53 -0400 Subject: [PATCH 15/33] fix(ci): selene lint --- lua/cp/cache.lua | 2 +- lua/cp/pickers/init.lua | 1 - lua/cp/runner/run.lua | 2 +- lua/cp/setup.lua | 5 ++--- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 5b76f3d..ecb30c5 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -89,7 +89,7 @@ function M.load() end function M.save() - local ok, err = pcall(vim.fn.mkdir, vim.fn.fnamemodify(cache_file, ':h'), 'p') + local ok, _ = pcall(vim.fn.mkdir, vim.fn.fnamemodify(cache_file, ':h'), 'p') if not ok then vim.schedule(function() vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 75ed6af..6cea2e0 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -2,7 +2,6 @@ local M = {} local cache = require('cp.cache') local logger = require('cp.log') -local scraper = require('cp.scraper') local utils = require('cp.utils') ---@class cp.PlatformItem diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 33814ab..e4ac669 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -86,7 +86,7 @@ end ---@param input_file string ---@param expected_file string ---@return TestCase[] -local function parse_test_cases_from_files(input_file, expected_file) +local function parse_test_cases_from_files(input_file, _) local base_name = vim.fn.fnamemodify(input_file, ':r') local test_cases = {} diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 642aca3..9b7afbb 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -182,7 +182,6 @@ end function M.scrape_remaining_problems(platform, contest_id, problems) cache.load() - local config = config_module.get_config() local missing_problems = {} for _, prob in ipairs(problems) do @@ -227,8 +226,8 @@ function M.navigate_problem(direction, language) local problems = contest_data.problems local current_index = nil - for i, problem in ipairs(problems) do - if problem.id == current_problem_id then + for i, prob in ipairs(problems) do + if prob.id == current_problem_id then current_index = i break end From 1f517309f260bd1ab0c869246adc75da34bf22a2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 12:27:23 -0400 Subject: [PATCH 16/33] fix(test): remove async tests --- spec/async_init_spec.lua | 50 ------ spec/async_integration_spec.lua | 292 -------------------------------- spec/async_jobs_spec.lua | 111 ------------ spec/async_scraper_spec.lua | 230 ------------------------- spec/async_setup_spec.lua | 288 ------------------------------- 5 files changed, 971 deletions(-) delete mode 100644 spec/async_init_spec.lua delete mode 100644 spec/async_integration_spec.lua delete mode 100644 spec/async_jobs_spec.lua delete mode 100644 spec/async_scraper_spec.lua delete mode 100644 spec/async_setup_spec.lua diff --git a/spec/async_init_spec.lua b/spec/async_init_spec.lua deleted file mode 100644 index 58e15bf..0000000 --- a/spec/async_init_spec.lua +++ /dev/null @@ -1,50 +0,0 @@ -describe('cp.async.init', function() - local async - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - async = spec_helper.fresh_require('cp.async.init') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('contest operation guard', function() - it('allows starting operation when none active', function() - assert.has_no_errors(function() - async.start_contest_operation('test_operation') - end) - assert.equals('test_operation', async.get_active_operation()) - end) - - it('throws error when starting operation while one is active', function() - async.start_contest_operation('first_operation') - - assert.has_error(function() - async.start_contest_operation('second_operation') - end, "Contest operation 'first_operation' already active, cannot start 'second_operation'") - end) - - it('allows starting operation after finishing previous one', function() - async.start_contest_operation('first_operation') - async.finish_contest_operation() - - assert.has_no_errors(function() - async.start_contest_operation('second_operation') - end) - assert.equals('second_operation', async.get_active_operation()) - end) - - it('correctly reports active operation status', function() - assert.is_nil(async.get_active_operation()) - - async.start_contest_operation('test_operation') - assert.equals('test_operation', async.get_active_operation()) - - async.finish_contest_operation() - assert.is_nil(async.get_active_operation()) - end) - end) -end) diff --git a/spec/async_integration_spec.lua b/spec/async_integration_spec.lua deleted file mode 100644 index fd4b5f8..0000000 --- a/spec/async_integration_spec.lua +++ /dev/null @@ -1,292 +0,0 @@ -describe('async integration', function() - local cp - local spec_helper = require('spec.spec_helper') - local logged_messages = {} - - before_each(function() - logged_messages = {} - local mock_logger = { - log = function(msg, level) - table.insert(logged_messages, { msg = msg, level = level }) - end, - set_config = function() end, - } - package.loaded['cp.log'] = mock_logger - - spec_helper.mock_async_scraper_success() - - local mock_async = { - start_contest_operation = function() end, - finish_contest_operation = function() end, - get_active_operation = function() - return nil - end, - } - - local mock_state = { - get_platform = function() - return 'atcoder' - end, - get_contest_id = function() - return 'abc123' - end, - set_platform = function() end, - set_contest_id = function() end, - set_problem_id = function() end, - set_test_cases = function() end, - set_run_panel_active = function() end, - } - - local mock_config = { - setup = function() - return {} - end, - get_config = function() - return { - scrapers = { 'atcoder', 'codeforces' }, - hooks = nil, - } - end, - } - - local mock_cache = { - load = function() end, - get_contest_data = function() - return nil - end, - get_test_cases = function() - return nil - end, - set_file_state = function() end, - } - - local mock_problem = { - create_context = function() - return { - source_file = '/test/source.cpp', - problem_name = 'abc123a', - } - end, - } - - local mock_setup = { - set_platform = function() - return true - end, - } - - vim.cmd = { - e = function() end, - only = function() end, - startinsert = function() end, - stopinsert = function() end, - } - vim.api.nvim_get_current_buf = function() - return 1 - end - vim.api.nvim_buf_get_lines = function() - return { '' } - end - vim.fn.expand = function() - return '/test/file.cpp' - end - - package.loaded['cp.async'] = mock_async - package.loaded['cp.state'] = mock_state - package.loaded['cp.config'] = mock_config - package.loaded['cp.cache'] = mock_cache - package.loaded['cp.problem'] = mock_problem - package.loaded['cp.setup'] = mock_setup - - cp = spec_helper.fresh_require('cp') - cp.setup({}) - end) - - after_each(function() - spec_helper.teardown() - logged_messages = {} - end) - - describe('command routing', function() - it('contest_setup command uses async setup', function() - local opts = { fargs = { 'atcoder', 'abc123' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('full_setup command uses async setup', function() - local opts = { fargs = { 'atcoder', 'abc123', 'a' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('problem_switch uses async setup', function() - local mock_state = require('cp.state') - mock_state.get_contest_id = function() - return 'abc123' - end - - local opts = { fargs = { 'a' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - end) - - describe('end-to-end workflow', function() - it('handles complete contest setup workflow', function() - local setup_completed = false - local mock_async_setup = { - setup_contest_async = function(contest_id) - assert.equals('abc123', contest_id) - setup_completed = true - end, - } - package.loaded['cp.async.setup'] = mock_async_setup - - local opts = { fargs = { 'atcoder', 'abc123' } } - cp.handle_command(opts) - - assert.is_true(setup_completed) - end) - - it('handles problem switching within contest', function() - local mock_state = require('cp.state') - mock_state.get_contest_id = function() - return 'abc123' - end - - local problem_setup_called = false - local mock_async_setup = { - setup_problem_async = function(contest_id, problem_id, _) - assert.equals('abc123', contest_id) - assert.equals('b', problem_id) - problem_setup_called = true - end, - } - package.loaded['cp.async.setup'] = mock_async_setup - - local opts = { fargs = { 'b' } } - cp.handle_command(opts) - - assert.is_true(problem_setup_called) - end) - - it('handles language flags correctly', function() - local language_passed = nil - local mock_async_setup = { - setup_contest_async = function(_, language) - language_passed = language - end, - } - package.loaded['cp.async.setup'] = mock_async_setup - - local opts = { fargs = { 'atcoder', 'abc123', '--lang=python' } } - cp.handle_command(opts) - - assert.equals('python', language_passed) - end) - - it('handles scraping failures gracefully', function() - spec_helper.mock_async_scraper_failure() - - local opts = { fargs = { 'atcoder', 'abc123' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - end) - - describe('error handling', function() - it('handles invalid platform gracefully', function() - local opts = { fargs = { 'invalid_platform', 'abc123' } } - - cp.handle_command(opts) - - local error_logged = false - for _, log_entry in ipairs(logged_messages) do - if log_entry.level == vim.log.levels.ERROR then - error_logged = true - break - end - end - assert.is_true(error_logged) - end) - - it('handles platform setup failure', function() - local mock_setup = require('cp.setup') - mock_setup.set_platform = function() - return false - end - - local opts = { fargs = { 'atcoder', 'abc123' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles empty contest context for problem switch', function() - local mock_state = require('cp.state') - mock_state.get_contest_id = function() - return nil - end - - local opts = { fargs = { 'a' } } - - cp.handle_command(opts) - - local error_logged = false - for _, log_entry in ipairs(logged_messages) do - if log_entry.level == vim.log.levels.ERROR then - error_logged = true - break - end - end - assert.is_true(error_logged) - end) - end) - - describe('callback behavior', function() - it('maintains execution context in callbacks', function() - local callback_executed = false - - local mock_scraper = { - scrape_contest_metadata_async = function(_, _, callback) - vim.schedule(function() - callback({ success = true, problems = { { id = 'a' } } }) - callback_executed = true - end) - end, - } - package.loaded['cp.async.scraper'] = mock_scraper - - local opts = { fargs = { 'atcoder', 'abc123' } } - cp.handle_command(opts) - - assert.is_true(callback_executed) - end) - - it('handles multiple rapid commands', function() - local command_count = 0 - local mock_async_setup = { - setup_contest_async = function() - command_count = command_count + 1 - end, - } - package.loaded['cp.async.setup'] = mock_async_setup - - cp.handle_command({ fargs = { 'atcoder', 'abc123' } }) - cp.handle_command({ fargs = { 'atcoder', 'abc124' } }) - cp.handle_command({ fargs = { 'atcoder', 'abc125' } }) - - assert.equals(3, command_count) - end) - end) -end) diff --git a/spec/async_jobs_spec.lua b/spec/async_jobs_spec.lua deleted file mode 100644 index 29f18ad..0000000 --- a/spec/async_jobs_spec.lua +++ /dev/null @@ -1,111 +0,0 @@ -describe('cp.async.jobs', function() - local jobs - local spec_helper = require('spec.spec_helper') - local mock_jobs = {} - - before_each(function() - spec_helper.setup() - mock_jobs = {} - - vim.system = function(args, opts, callback) - local job = { - kill = function() end, - args = args, - opts = opts, - callback = callback, - } - mock_jobs[#mock_jobs + 1] = job - return job - end - - jobs = spec_helper.fresh_require('cp.async.jobs') - end) - - after_each(function() - spec_helper.teardown() - mock_jobs = {} - end) - - describe('job management', function() - it('starts job with unique ID', function() - local callback = function() end - local args = { 'test', 'command' } - local opts = { cwd = '/test' } - - local job = jobs.start_job('test_job', args, opts, callback) - - assert.is_not_nil(job) - assert.equals(1, #mock_jobs) - assert.same(args, mock_jobs[1].args) - assert.same(opts, mock_jobs[1].opts) - assert.is_function(mock_jobs[1].callback) - end) - - it('kills existing job when starting new job with same ID', function() - local killed = false - vim.system = function(args, opts, callback) - return { - kill = function() - killed = true - end, - args = args, - opts = opts, - callback = callback, - } - end - - jobs.start_job('same_id', { 'first' }, {}, function() end) - jobs.start_job('same_id', { 'second' }, {}, function() end) - - assert.is_true(killed) - end) - - it('kills specific job by ID', function() - local killed = false - vim.system = function() - return { - kill = function() - killed = true - end, - } - end - - jobs.start_job('target_job', { 'test' }, {}, function() end) - jobs.kill_job('target_job') - - assert.is_true(killed) - end) - - it('kills all active jobs', function() - local kill_count = 0 - vim.system = function() - return { - kill = function() - kill_count = kill_count + 1 - end, - } - end - - jobs.start_job('job1', { 'test1' }, {}, function() end) - jobs.start_job('job2', { 'test2' }, {}, function() end) - jobs.kill_all_jobs() - - assert.equals(2, kill_count) - end) - - it('tracks active job IDs correctly', function() - jobs.start_job('job1', { 'test1' }, {}, function() end) - jobs.start_job('job2', { 'test2' }, {}, function() end) - - local active_jobs = jobs.get_active_jobs() - assert.equals(2, #active_jobs) - assert.is_true(vim.tbl_contains(active_jobs, 'job1')) - assert.is_true(vim.tbl_contains(active_jobs, 'job2')) - - jobs.kill_job('job1') - active_jobs = jobs.get_active_jobs() - assert.equals(1, #active_jobs) - assert.is_true(vim.tbl_contains(active_jobs, 'job2')) - end) - end) -end) diff --git a/spec/async_scraper_spec.lua b/spec/async_scraper_spec.lua deleted file mode 100644 index 1fe2f31..0000000 --- a/spec/async_scraper_spec.lua +++ /dev/null @@ -1,230 +0,0 @@ -describe('cp.async.scraper', function() - local scraper - local spec_helper = require('spec.spec_helper') - local mock_cache, mock_utils - local callback_results = {} - - before_each(function() - spec_helper.setup() - callback_results = {} - - mock_cache = { - load = function() end, - get_contest_data = function() - return nil - end, - set_contest_data = function() end, - set_test_cases = function() end, - } - - mock_utils = { - setup_python_env = function() - return true - end, - get_plugin_path = function() - return '/test/plugin' - end, - } - - vim.system = function(cmd, _, callback) - local result = { code = 0, stdout = '{}', stderr = '' } - if cmd[1] == 'ping' then - result = { code = 0 } - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then - result.stdout = '{"success": true, "problems": [{"id": "a", "name": "Test Problem"}]}' - elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'tests') then - result.stdout = - '{"success": true, "tests": [{"input": "1 2", "expected": "3"}], "timeout_ms": 2000, "memory_mb": 256.0, "url": "https://example.com"}' - end - - if callback then - callback(result) - else - return { - wait = function() - return result - end, - } - end - end - - vim.fn.mkdir = function() end - - package.loaded['cp.cache'] = mock_cache - package.loaded['cp.utils'] = mock_utils - scraper = spec_helper.fresh_require('cp.async.scraper') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('scrape_contest_metadata_async', function() - it('returns cached data immediately if available', function() - mock_cache.get_contest_data = function() - return { problems = { { id = 'cached', name = 'Cached Problem' } } } - end - - scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_true(callback_results[1].success) - assert.equals('Cached Problem', callback_results[1].problems[1].name) - end) - - it('calls callback with success result after scraping', function() - scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_true(callback_results[1].success) - assert.equals(1, #callback_results[1].problems) - assert.equals('Test Problem', callback_results[1].problems[1].name) - end) - - it('calls callback with error on network failure', function() - vim.system = function(_, _, callback) - local result = { code = 1 } - if callback then - callback(result) - else - return { - wait = function() - return result - end, - } - end - end - - scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_false(callback_results[1].success) - assert.equals('No internet connection available', callback_results[1].error) - end) - - it('calls callback with error on python env failure', function() - mock_utils.setup_python_env = function() - return false - end - - scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_false(callback_results[1].success) - assert.equals('Python environment setup failed', callback_results[1].error) - end) - - it('calls callback with error on subprocess failure', function() - vim.system = function(cmd, _, callback) - local result - if cmd[1] == 'ping' then - result = { code = 0 } - else - result = { code = 1, stderr = 'execution failed' } - end - - if callback then - callback(result) - else - return { - wait = function() - return result - end, - } - end - end - - scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_false(callback_results[1].success) - assert.is_not_nil(callback_results[1].error:match('Failed to run metadata scraper')) - end) - - it('calls callback with error on invalid JSON', function() - vim.system = function(cmd, _, callback) - local result - if cmd[1] == 'ping' then - result = { code = 0 } - else - result = { code = 0, stdout = 'invalid json' } - end - - if callback then - callback(result) - else - return { - wait = function() - return result - end, - } - end - end - - scraper.scrape_contest_metadata_async('atcoder', 'abc123', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_false(callback_results[1].success) - assert.is_not_nil(callback_results[1].error:match('Failed to parse metadata scraper output')) - end) - end) - - describe('scrape_problem_async', function() - it('calls callback with success after scraping tests', function() - scraper.scrape_problem_async('atcoder', 'abc123', 'a', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_true(callback_results[1].success) - assert.equals('a', callback_results[1].problem_id) - assert.equals(1, callback_results[1].test_count) - end) - - it('handles network failure gracefully', function() - vim.system = function(_, _, callback) - local result = { code = 1 } - if callback then - callback(result) - else - return { - wait = function() - return result - end, - } - end - end - - scraper.scrape_problem_async('atcoder', 'abc123', 'a', function(result) - callback_results[#callback_results + 1] = result - end) - - assert.equals(1, #callback_results) - assert.is_false(callback_results[1].success) - assert.equals('a', callback_results[1].problem_id) - assert.equals('No internet connection available', callback_results[1].error) - end) - - it('validates input parameters', function() - assert.has_error(function() - scraper.scrape_contest_metadata_async(nil, 'abc123', function() end) - end) - - assert.has_error(function() - scraper.scrape_problem_async('atcoder', nil, 'a', function() end) - end) - end) - end) -end) diff --git a/spec/async_setup_spec.lua b/spec/async_setup_spec.lua deleted file mode 100644 index ccb41bc..0000000 --- a/spec/async_setup_spec.lua +++ /dev/null @@ -1,288 +0,0 @@ -describe('cp.async.setup', function() - local setup - local spec_helper = require('spec.spec_helper') - local mock_async, mock_scraper, mock_state - before_each(function() - spec_helper.setup() - - mock_async = { - start_contest_operation = function() end, - finish_contest_operation = function() end, - } - - mock_scraper = { - scrape_contest_metadata_async = function(_, _, callback) - callback({ - success = true, - problems = { - { id = 'a', name = 'Problem A' }, - { id = 'b', name = 'Problem B' }, - }, - }) - end, - scrape_problem_async = function(_, _, problem_id, callback) - callback({ - success = true, - problem_id = problem_id, - test_cases = { { input = '1', expected = '1' } }, - test_count = 1, - }) - end, - } - - mock_state = { - get_platform = function() - return 'atcoder' - end, - get_contest_id = function() - return 'abc123' - end, - set_contest_id = function() end, - set_problem_id = function() end, - set_test_cases = function() end, - set_run_panel_active = function() end, - } - - local mock_config = { - get_config = function() - return { - scrapers = { 'atcoder', 'codeforces' }, - hooks = nil, - } - end, - } - - local mock_cache = { - load = function() end, - get_test_cases = function() - return nil - end, - set_file_state = function() end, - } - - local mock_problem = { - create_context = function() - return { - source_file = '/test/source.cpp', - problem_name = 'abc123a', - } - end, - } - - vim.cmd = { - e = function() end, - only = function() end, - startinsert = function() end, - stopinsert = function() end, - } - vim.api.nvim_get_current_buf = function() - return 1 - end - vim.api.nvim_buf_get_lines = function() - return { '' } - end - vim.fn.expand = function() - return '/test/file.cpp' - end - - package.loaded['cp.async'] = mock_async - package.loaded['cp.async.scraper'] = mock_scraper - package.loaded['cp.state'] = mock_state - package.loaded['cp.config'] = mock_config - package.loaded['cp.cache'] = mock_cache - package.loaded['cp.problem'] = mock_problem - - setup = spec_helper.fresh_require('cp.async.setup') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('setup_contest_async', function() - it('guards against multiple simultaneous operations', function() - local started = false - mock_async.start_contest_operation = function() - started = true - end - - setup.setup_contest_async('abc123', 'cpp') - - assert.is_true(started) - end) - - it('handles metadata scraping success', function() - local finished = false - mock_async.finish_contest_operation = function() - finished = true - end - - setup.setup_contest_async('abc123', 'cpp') - - assert.is_true(finished) - end) - - it('handles metadata scraping failure gracefully', function() - mock_scraper.scrape_contest_metadata_async = function(_, _, callback) - callback({ - success = false, - error = 'network error', - }) - end - - local finished = false - mock_async.finish_contest_operation = function() - finished = true - end - - setup.setup_contest_async('abc123', 'cpp') - - assert.is_true(finished) - end) - - it('handles disabled scraping platform', function() - mock_state.get_platform = function() - return 'disabled_platform' - end - - assert.has_no_errors(function() - setup.setup_contest_async('abc123', 'cpp') - end) - end) - end) - - describe('setup_problem_async', function() - it('opens buffer immediately', function() - local buffer_opened = false - vim.cmd.e = function() - buffer_opened = true - end - - setup.setup_problem_async('abc123', 'a', 'cpp') - - assert.is_true(buffer_opened) - end) - - it('uses cached test cases if available', function() - local cached_cases = { { input = 'cached', expected = 'result' } } - local mock_cache = require('cp.cache') - mock_cache.get_test_cases = function() - return cached_cases - end - - local set_test_cases_called = false - mock_state.set_test_cases = function(cases) - assert.same(cached_cases, cases) - set_test_cases_called = true - end - - setup.setup_problem_async('abc123', 'a', 'cpp') - - assert.is_true(set_test_cases_called) - end) - - it('starts background test scraping if not cached', function() - local scraping_started = false - mock_scraper.scrape_problem_async = function(_, _, problem_id, callback) - scraping_started = true - callback({ success = true, problem_id = problem_id, test_cases = {} }) - end - - setup.setup_problem_async('abc123', 'a', 'cpp') - - assert.is_true(scraping_started) - end) - - it('finishes contest operation on completion', function() - local finished = false - mock_async.finish_contest_operation = function() - finished = true - end - - setup.setup_problem_async('abc123', 'a', 'cpp') - - assert.is_true(finished) - end) - end) - - describe('handle_full_setup_async', function() - it('validates problem exists in contest', function() - mock_scraper.scrape_contest_metadata_async = function(_, _, callback) - callback({ - success = true, - problems = { { id = 'a' }, { id = 'b' } }, - }) - end - - local cmd = { platform = 'atcoder', contest = 'abc123', problem = 'c' } - - local finished = false - mock_async.finish_contest_operation = function() - finished = true - end - - setup.handle_full_setup_async(cmd) - - assert.is_true(finished) - end) - - it('proceeds with valid problem', function() - mock_scraper.scrape_contest_metadata_async = function(_, _, callback) - callback({ - success = true, - problems = { { id = 'a' }, { id = 'b' } }, - }) - end - - local cmd = { platform = 'atcoder', contest = 'abc123', problem = 'a' } - - assert.has_no_errors(function() - setup.handle_full_setup_async(cmd) - end) - end) - end) - - describe('background problem scraping', function() - it('scrapes uncached problems in background', function() - local problems = { { id = 'a' }, { id = 'b' }, { id = 'c' } } - local scraping_calls = {} - - mock_scraper.scrape_problem_async = function(_, _, problem_id, callback) - scraping_calls[#scraping_calls + 1] = problem_id - callback({ success = true, problem_id = problem_id }) - end - - local mock_cache = require('cp.cache') - mock_cache.get_test_cases = function() - return nil - end - - setup.start_background_problem_scraping('abc123', problems, { scrapers = { 'atcoder' } }) - - assert.equals(3, #scraping_calls) - assert.is_true(vim.tbl_contains(scraping_calls, 'a')) - assert.is_true(vim.tbl_contains(scraping_calls, 'b')) - assert.is_true(vim.tbl_contains(scraping_calls, 'c')) - end) - - it('skips already cached problems', function() - local problems = { { id = 'a' }, { id = 'b' } } - local scraping_calls = {} - - mock_scraper.scrape_problem_async = function(_, _, problem_id, callback) - scraping_calls[#scraping_calls + 1] = problem_id - callback({ success = true, problem_id = problem_id }) - end - - local mock_cache = require('cp.cache') - mock_cache.get_test_cases = function(_, _, problem_id) - return problem_id == 'a' and { { input = '1', expected = '1' } } or nil - end - - setup.start_background_problem_scraping('abc123', problems, { scrapers = { 'atcoder' } }) - - assert.equals(1, #scraping_calls) - assert.equals('b', scraping_calls[1]) - end) - end) -end) From f3666a30be26f8488996ead5baf2ceaeda37e4a5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 12:28:53 -0400 Subject: [PATCH 17/33] fix(ci): lint --- lua/cp/cache.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index ecb30c5..90c2c2b 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -99,8 +99,8 @@ function M.save() local encoded = vim.json.encode(cache_data) local lines = vim.split(encoded, '\n') - ok, err = pcall(vim.fn.writefile, lines, cache_file) - if not ok then + local write_ok, _ = pcall(vim.fn.writefile, lines, cache_file) + if not write_ok then vim.schedule(function() vim.fn.writefile(lines, cache_file) end) From 79e1f1096bee4aebd4905c8609120a5e596cdced Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 12:29:12 -0400 Subject: [PATCH 18/33] lint --- lua/cp/runner/run.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index e4ac669..bff8a0f 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -84,7 +84,6 @@ local function parse_test_cases_from_cache(platform, contest_id, problem_id) end ---@param input_file string ----@param expected_file string ---@return TestCase[] local function parse_test_cases_from_files(input_file, _) local base_name = vim.fn.fnamemodify(input_file, ':r') From 2707df28ceaf4b770e08567539b91571ef3671fe Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 12:36:15 -0400 Subject: [PATCH 19/33] fix(test): fix mocks --- spec/command_parsing_spec.lua | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index d3221ea..c099ccb 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -12,16 +12,11 @@ describe('cp command parsing', function() } package.loaded['cp.log'] = mock_logger - local mock_async_setup = { - setup_contest_async = function() end, - handle_full_setup_async = function() end, - setup_problem_async = function() end, - } - package.loaded['cp.async.setup'] = mock_async_setup local mock_setup = { set_platform = function() return true end, + setup_contest = function() end, navigate_problem = function() end, } package.loaded['cp.setup'] = mock_setup @@ -33,6 +28,9 @@ describe('cp command parsing', function() get_contest_id = function() return 'abc123' end, + is_run_panel_active = function() + return false + end, } package.loaded['cp.state'] = mock_state @@ -53,7 +51,6 @@ describe('cp command parsing', function() after_each(function() package.loaded['cp.log'] = nil - package.loaded['cp.async.setup'] = nil package.loaded['cp.setup'] = nil package.loaded['cp.state'] = nil end) From 4b9d63e4b8e681417d79e9773007ca536bf107bc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 14:48:01 -0400 Subject: [PATCH 20/33] fix(test): async impl --- spec/command_parsing_spec.lua | 48 ++++++++++++++++++++++++++++++++++ spec/error_boundaries_spec.lua | 45 +++++++++++++++---------------- spec/picker_spec.lua | 8 +++--- 3 files changed, 75 insertions(+), 26 deletions(-) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index c099ccb..54faa04 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -18,6 +18,7 @@ describe('cp command parsing', function() end, setup_contest = function() end, navigate_problem = function() end, + setup_problem = function() end, } package.loaded['cp.setup'] = mock_setup @@ -28,12 +29,54 @@ describe('cp command parsing', function() get_contest_id = function() return 'abc123' end, + get_problem_id = function() + return 'a' + end, is_run_panel_active = function() return false end, + set_platform = function() end, + set_contest_id = function() end, + set_problem_id = function() end, + set_run_panel_active = function() end, } package.loaded['cp.state'] = mock_state + local mock_ui_panel = { + toggle_run_panel = function() end, + } + package.loaded['cp.ui.panel'] = mock_ui_panel + + local mock_cache = { + load = function() end, + get_contest_data = function(platform, contest_id) + return { + problems = { + { id = 'a', name = 'Problem A' }, + { id = 'b', name = 'Problem B' }, + }, + } + end, + } + package.loaded['cp.cache'] = mock_cache + + local mock_restore = { + restore_from_current_file = function() + error('No file is currently open') + end, + } + package.loaded['cp.restore'] = mock_restore + + local mock_picker = { + handle_pick_action = function() end, + } + package.loaded['cp.commands.picker'] = mock_picker + + local mock_cache_commands = { + handle_cache_command = function() end, + } + package.loaded['cp.commands.cache'] = mock_cache_commands + cp = require('cp') cp.setup({ contests = { @@ -53,6 +96,11 @@ describe('cp command parsing', function() package.loaded['cp.log'] = nil package.loaded['cp.setup'] = nil package.loaded['cp.state'] = nil + package.loaded['cp.ui.panel'] = nil + package.loaded['cp.cache'] = nil + package.loaded['cp.restore'] = nil + package.loaded['cp.commands.picker'] = nil + package.loaded['cp.commands.cache'] = nil end) describe('empty arguments', function() diff --git a/spec/error_boundaries_spec.lua b/spec/error_boundaries_spec.lua index 9c711fb..0489ea8 100644 --- a/spec/error_boundaries_spec.lua +++ b/spec/error_boundaries_spec.lua @@ -13,46 +13,45 @@ describe('Error boundary handling', function() } package.loaded['cp.log'] = mock_logger - package.loaded['cp.scrape'] = { - scrape_problem = function(ctx) - if ctx.contest_id == 'fail_scrape' then - return { + package.loaded['cp.scraper'] = { + scrape_problem_tests = function(platform, contest_id, problem_id, callback) + if contest_id == 'fail_scrape' then + callback({ success = false, error = 'Network error', - } + }) + return end - return { + callback({ success = true, - problem_id = ctx.problem_id, - test_cases = { + problem_id = problem_id, + tests = { { input = '1', expected = '2' }, }, - test_count = 1, - } + }) end, - scrape_contest_metadata = function(_, contest_id) + scrape_contest_metadata = function(platform, contest_id, callback) if contest_id == 'fail_scrape' then - return { + callback({ success = false, error = 'Network error', - } + }) + return end if contest_id == 'fail_metadata' then - return { + callback({ success = false, error = 'Contest not found', - } + }) + return end - return { + callback({ success = true, problems = { { id = 'a' }, { id = 'b' }, }, - } - end, - scrape_problems_parallel = function() - return {} + }) end, } @@ -128,6 +127,9 @@ describe('Error boundary handling', function() it('should handle scraping failures without state corruption', function() cp.handle_command({ fargs = { 'codeforces', 'fail_scrape', 'a' } }) + -- Wait for async callback to complete + vim.wait(100) + local has_metadata_error = false for _, log_entry in ipairs(logged_messages) do if log_entry.msg and log_entry.msg:match('failed to load contest metadata') then @@ -139,7 +141,6 @@ describe('Error boundary handling', function() local context = cp.get_current_context() assert.equals('codeforces', context.platform) - assert.equals('fail_scrape', context.contest_id) assert.has_no_errors(function() cp.handle_command({ fargs = { 'run' } }) @@ -157,7 +158,7 @@ describe('Error boundary handling', function() local has_nav_error = false for _, log_entry in ipairs(logged_messages) do - if log_entry.msg and log_entry.msg:match('no contest metadata found') then + if log_entry.msg and log_entry.msg:match('no contest data available') then has_nav_error = true break end diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index e7ca9b5..0415277 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -147,14 +147,14 @@ describe('cp.picker', function() return nil end - package.loaded['cp.scrape'] = { - scrape_contest_metadata = function(_, _) - return { + package.loaded['cp.scraper'] = { + scrape_contest_metadata = function(platform, contest_id, callback) + callback({ success = true, problems = { { id = 'x', name = 'Problem X' }, }, - } + }) end, } From 1769ea079a59b9c7a79dd6136e805cad44d28d99 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 14:49:02 -0400 Subject: [PATCH 21/33] fix --- spec/picker_spec.lua | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index 0415277..2d731ee 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -141,22 +141,37 @@ describe('cp.picker', function() it('falls back to scraping when cache miss', function() local cache = require('cp.cache') + local utils = require('cp.utils') cache.load = function() end cache.get_contest_data = function(_, _) return nil end + cache.set_contest_data = function() end - package.loaded['cp.scraper'] = { - scrape_contest_metadata = function(platform, contest_id, callback) - callback({ - success = true, - problems = { - { id = 'x', name = 'Problem X' }, - }, - }) - end, - } + utils.setup_python_env = function() + return true + end + utils.get_plugin_path = function() + return '/tmp' + end + + -- Mock vim.system to return success with problems + vim.system = function(cmd, opts) + return { + wait = function() + return { + code = 0, + stdout = vim.json.encode({ + success = true, + problems = { + { id = 'x', name = 'Problem X' }, + }, + }), + } + end, + } + end picker = spec_helper.fresh_require('cp.pickers', { 'cp.pickers.init' }) From 75994c07a5c8aace7610fbc4268920778be455da Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 15:02:33 -0400 Subject: [PATCH 22/33] fix(ci): tests --- spec/command_parsing_spec.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index 54faa04..57cc84c 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -19,6 +19,7 @@ describe('cp command parsing', function() setup_contest = function() end, navigate_problem = function() end, setup_problem = function() end, + scrape_remaining_problems = function() end, } package.loaded['cp.setup'] = mock_setup @@ -101,6 +102,8 @@ describe('cp command parsing', function() package.loaded['cp.restore'] = nil package.loaded['cp.commands.picker'] = nil package.loaded['cp.commands.cache'] = nil + package.loaded['cp'] = nil + package.loaded['cp.commands.init'] = nil end) describe('empty arguments', function() From a08ad8e2ee0bbeb43f4799e0e45d2717a3fdfedb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 15:05:51 -0400 Subject: [PATCH 23/33] fix(test): use new st8 mgmt --- spec/command_parsing_spec.lua | 26 ++++++++++++++++---------- spec/error_boundaries_spec.lua | 6 ++---- spec/panel_spec.lua | 7 +++---- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index 57cc84c..f1a8859 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -476,13 +476,13 @@ describe('cp command parsing', function() if num_args == 2 then local candidates = {} - local cp_mod = require('cp') - local context = cp_mod.get_current_context() - if context.platform and context.contest_id then + local state = require('cp.state') + if state.get_platform() and state.get_contest_id() then vim.list_extend(candidates, actions) local cache = require('cp.cache') cache.load() - local contest_data = cache.get_contest_data(context.platform, context.contest_id) + local contest_data = + cache.get_contest_data(state.get_platform(), state.get_contest_id()) if contest_data and contest_data.problems then for _, problem in ipairs(contest_data.problems) do table.insert(candidates, problem.id) @@ -525,9 +525,12 @@ describe('cp command parsing', function() return {} end - package.loaded['cp'] = { - get_current_context = function() - return { platform = nil, contest_id = nil } + package.loaded['cp.state'] = { + get_platform = function() + return nil + end, + get_contest_id = function() + return nil end, } @@ -596,9 +599,12 @@ describe('cp command parsing', function() end) it('completes all actions and problems when contest context exists', function() - package.loaded['cp'] = { - get_current_context = function() - return { platform = 'atcoder', contest_id = 'abc350' } + package.loaded['cp.state'] = { + get_platform = function() + return 'atcoder' + end, + get_contest_id = function() + return 'abc350' end, } package.loaded['cp.cache'] = { diff --git a/spec/error_boundaries_spec.lua b/spec/error_boundaries_spec.lua index 0489ea8..577b16f 100644 --- a/spec/error_boundaries_spec.lua +++ b/spec/error_boundaries_spec.lua @@ -118,7 +118,7 @@ describe('Error boundary handling', function() after_each(function() package.loaded['cp.log'] = nil - package.loaded['cp.scrape'] = nil + package.loaded['cp.scraper'] = nil if state then state.reset() end @@ -127,7 +127,6 @@ describe('Error boundary handling', function() it('should handle scraping failures without state corruption', function() cp.handle_command({ fargs = { 'codeforces', 'fail_scrape', 'a' } }) - -- Wait for async callback to complete vim.wait(100) local has_metadata_error = false @@ -139,8 +138,7 @@ describe('Error boundary handling', function() end assert.is_true(has_metadata_error, 'Should log contest metadata failure') - local context = cp.get_current_context() - assert.equals('codeforces', context.platform) + assert.equals('codeforces', state.get_platform()) assert.has_no_errors(function() cp.handle_command({ fargs = { 'run' } }) diff --git a/spec/panel_spec.lua b/spec/panel_spec.lua index 72ee733..34eada6 100644 --- a/spec/panel_spec.lua +++ b/spec/panel_spec.lua @@ -51,10 +51,9 @@ describe('Panel integration', function() it('should handle run command with properly set contest context', function() cp.handle_command({ fargs = { 'codeforces', '2146', 'b' } }) - local context = cp.get_current_context() - assert.equals('codeforces', context.platform) - assert.equals('2146', context.contest_id) - assert.equals('b', context.problem_id) + assert.equals('codeforces', state.get_platform()) + assert.equals('2146', state.get_contest_id()) + assert.equals('b', state.get_problem_id()) assert.has_no_errors(function() cp.handle_command({ fargs = { 'run' } }) From 30c1c0f2cfcd0cee056c544fa1c2a6ca773c13fd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 15:09:13 -0400 Subject: [PATCH 24/33] fix(test): unused vars --- spec/command_parsing_spec.lua | 2 +- spec/error_boundaries_spec.lua | 4 ++-- spec/picker_spec.lua | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index f1a8859..ef4286b 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -50,7 +50,7 @@ describe('cp command parsing', function() local mock_cache = { load = function() end, - get_contest_data = function(platform, contest_id) + get_contest_data = function() return { problems = { { id = 'a', name = 'Problem A' }, diff --git a/spec/error_boundaries_spec.lua b/spec/error_boundaries_spec.lua index 577b16f..0af5f2a 100644 --- a/spec/error_boundaries_spec.lua +++ b/spec/error_boundaries_spec.lua @@ -14,7 +14,7 @@ describe('Error boundary handling', function() package.loaded['cp.log'] = mock_logger package.loaded['cp.scraper'] = { - scrape_problem_tests = function(platform, contest_id, problem_id, callback) + scrape_problem_tests = function(_, contest_id, problem_id, callback) if contest_id == 'fail_scrape' then callback({ success = false, @@ -30,7 +30,7 @@ describe('Error boundary handling', function() }, }) end, - scrape_contest_metadata = function(platform, contest_id, callback) + scrape_contest_metadata = function(_, contest_id, callback) if contest_id == 'fail_scrape' then callback({ success = false, diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index 2d731ee..78d1e07 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -157,7 +157,7 @@ describe('cp.picker', function() end -- Mock vim.system to return success with problems - vim.system = function(cmd, opts) + vim.system = function() return { wait = function() return { From a2b3de51d7f8d3b5acd79b68e65e775e205829a4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 15:32:04 -0400 Subject: [PATCH 25/33] fix: better tests --- spec/command_parsing_spec.lua | 13 +++++++++++-- spec/panel_spec.lua | 9 +++++++++ spec/picker_spec.lua | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index ef4286b..1c9264e 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -63,7 +63,8 @@ describe('cp command parsing', function() local mock_restore = { restore_from_current_file = function() - error('No file is currently open') + logged_messages[#logged_messages + 1] = + { msg = 'No file is currently open', level = vim.log.levels.ERROR } end, } package.loaded['cp.restore'] = mock_restore @@ -74,7 +75,15 @@ describe('cp command parsing', function() package.loaded['cp.commands.picker'] = mock_picker local mock_cache_commands = { - handle_cache_command = function() end, + handle_cache_command = function(cmd) + if cmd.subcommand == 'clear' then + if cmd.platform then + logged_messages[#logged_messages + 1] = { msg = 'cleared cache for ' .. cmd.platform } + else + logged_messages[#logged_messages + 1] = { msg = 'cleared all cache' } + end + end + end, } package.loaded['cp.commands.cache'] = mock_cache_commands diff --git a/spec/panel_spec.lua b/spec/panel_spec.lua index 34eada6..2b9d246 100644 --- a/spec/panel_spec.lua +++ b/spec/panel_spec.lua @@ -26,6 +26,15 @@ describe('Panel integration', function() state.set_platform(platform) return true end, + setup_contest = function(platform, contest, problem, language) + state.set_platform(platform) + state.set_contest_id(contest) + if problem then + state.set_problem_id(problem) + end + end, + setup_problem = function() end, + navigate_problem = function() end, } package.loaded['cp.setup'] = mock_setup diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index 78d1e07..e9bb5e2 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -183,12 +183,31 @@ describe('cp.picker', function() it('returns empty list when scraping fails', 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 true + end + utils.get_plugin_path = function() + return '/tmp' + end + + vim.system = function() + return { + wait = function() + return { + code = 1, + stderr = 'Scraping failed', + } + end, + } + end + picker = spec_helper.fresh_require('cp.pickers', { 'cp.pickers.init' }) local problems = picker.get_problems_for_contest('test_platform', 'test_contest') From ca652c04ffd06876c7f1564fdb381faa5c007301 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 15:32:56 -0400 Subject: [PATCH 26/33] fix(ci): unused var --- spec/panel_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/panel_spec.lua b/spec/panel_spec.lua index 2b9d246..b15ea84 100644 --- a/spec/panel_spec.lua +++ b/spec/panel_spec.lua @@ -26,7 +26,7 @@ describe('Panel integration', function() state.set_platform(platform) return true end, - setup_contest = function(platform, contest, problem, language) + setup_contest = function(platform, contest, problem, _) state.set_platform(platform) state.set_contest_id(contest) if problem then From 2d3432335cc1f4f1d9c4a3c2a0d28b9d33a81609 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 15:37:18 -0400 Subject: [PATCH 27/33] fix --- spec/command_parsing_spec.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index 1c9264e..775b5dc 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -78,7 +78,13 @@ describe('cp command parsing', function() handle_cache_command = function(cmd) if cmd.subcommand == 'clear' then if cmd.platform then - logged_messages[#logged_messages + 1] = { msg = 'cleared cache for ' .. cmd.platform } + local constants = require('cp.constants') + if vim.tbl_contains(constants.PLATFORMS, cmd.platform) then + logged_messages[#logged_messages + 1] = { msg = 'cleared cache for ' .. cmd.platform } + else + logged_messages[#logged_messages + 1] = + { msg = 'unknown platform: ' .. cmd.platform, level = vim.log.levels.ERROR } + end else logged_messages[#logged_messages + 1] = { msg = 'cleared all cache' } end From 540364926da771a68a1be04e2426e173027be52e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 23 Sep 2025 16:14:21 -0400 Subject: [PATCH 28/33] feat: improve logging --- lua/cp/log.lua | 6 ++++++ lua/cp/pickers/init.lua | 10 +++------- lua/cp/setup.lua | 12 ++++++------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lua/cp/log.lua b/lua/cp/log.lua index 9c702b4..6a05316 100644 --- a/lua/cp/log.lua +++ b/lua/cp/log.lua @@ -9,4 +9,10 @@ function M.log(msg, level, override) end end +function M.progress(msg) + vim.schedule(function() + vim.notify(('[cp.nvim]: %s'):format(msg), vim.log.levels.INFO) + end) +end + return M diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 6cea2e0..947210a 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -42,7 +42,7 @@ local function get_contests_for_platform(platform) local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - logger.log(('Loading %s contests...'):format(platform_display_name), vim.log.levels.INFO, true) + logger.progress(('loading %s contests...'):format(platform_display_name)) if not utils.setup_python_env() then return {} @@ -91,7 +91,7 @@ local function get_contests_for_platform(platform) end cache.set_contest_list(platform, contests) - logger.log(('Loaded %d contests'):format(#contests), vim.log.levels.INFO) + logger.progress(('loaded %d contests'):format(#contests)) return contests end @@ -120,11 +120,7 @@ local function get_problems_for_contest(platform, contest_id) local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform - logger.log( - ('Scraping %s %s for problems...'):format(platform_display_name, contest_id), - vim.log.levels.INFO, - true - ) + logger.progress(('loading %s %s problems...'):format(platform_display_name, contest_id)) local plugin_path = utils.get_plugin_path() local cmd = { diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 9b7afbb..4992543 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -42,7 +42,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) return end - logger.log(('setting up contest %s %s'):format(platform, contest_id)) + logger.progress(('fetching contest %s %s...'):format(platform, contest_id)) scraper.scrape_contest_metadata(platform, contest_id, function(result) if not result.success then @@ -59,7 +59,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) return end - logger.log(('found %d problems'):format(#problems)) + logger.progress(('found %d problems'):format(#problems)) state.set_contest_id(contest_id) local target_problem = problem_id or problems[1].id @@ -96,7 +96,7 @@ function M.setup_problem(contest_id, problem_id, language) local config = config_module.get_config() local platform = state.get_platform() or '' - logger.log(('setting up problem: %s%s'):format(contest_id, problem_id or '')) + logger.progress(('setting up problem %s%s...'):format(contest_id, problem_id or '')) local ctx = problem.create_context(platform, contest_id, problem_id, config, language) @@ -105,7 +105,7 @@ function M.setup_problem(contest_id, problem_id, language) state.set_test_cases(cached_tests) logger.log(('using cached test cases (%d)'):format(#cached_tests)) elseif vim.tbl_contains(config.scrapers, platform) then - logger.log('loading test cases...') + logger.progress('loading test cases...') scraper.scrape_problem_tests(platform, contest_id, problem_id, function(result) if result.success then @@ -171,7 +171,7 @@ function M.setup_problem(contest_id, problem_id, language) cache.set_file_state(vim.fn.expand('%:p'), platform, contest_id, problem_id, language) - logger.log(('switched to problem %s'):format(ctx.problem_name)) + logger.progress(('ready - problem %s'):format(ctx.problem_name)) end) if not ok then @@ -196,7 +196,7 @@ function M.scrape_remaining_problems(platform, contest_id, problems) return end - logger.log(('scraping %d uncached problems in background...'):format(#missing_problems)) + logger.progress(('caching %d remaining problems...'):format(#missing_problems)) for _, prob in ipairs(missing_problems) do scraper.scrape_problem_tests(platform, contest_id, prob.id, function(result) From 7ac91a3c4d7ed59bbfdc48b7443828f9b850018a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 00:41:10 -0400 Subject: [PATCH 29/33] fix async --- doc/cp.txt | 4 ++ lua/cp/pickers/fzf_lua.lua | 6 +- lua/cp/pickers/init.lua | 26 +++++++- lua/cp/pickers/telescope.lua | 6 +- scrapers/__init__.py | 54 ++++++++++++---- scrapers/atcoder.py | 6 +- tests/scrapers/test_atcoder.py | 68 +++++++++++++++++++++ tests/scrapers/test_interface_compliance.py | 6 +- 8 files changed, 155 insertions(+), 21 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index 6b06dd9..e4fc5a7 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -290,6 +290,10 @@ Usage examples: > for multi-test case problems commonly found in contests. + AtCoder Heuristic Contests (AHC) are excluded + from the contest list as they don't have + standard sample test cases. + Codeforces ~ *cp-codeforces* URL format: https://codeforces.com/contest/1234/problem/A diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index 8ca106d..d8acce9 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -8,9 +8,13 @@ local function problem_picker(platform, contest_id) if #problems == 0 then vim.notify( - ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), + ("Contest %s %s hasn't started yet or has no available problems"):format( + platform_display_name, + contest_id + ), vim.log.levels.WARN ) + contest_picker(platform) return end diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 947210a..f8cac85 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -59,6 +59,8 @@ local function get_contests_for_platform(platform) 'contests', } + logger.progress(('running: %s'):format(table.concat(cmd, ' '))) + local result = vim .system(cmd, { cwd = plugin_path, @@ -67,6 +69,11 @@ local function get_contests_for_platform(platform) }) :wait() + logger.progress(('exit code: %d, stdout length: %d'):format(result.code, #(result.stdout or ''))) + if result.stderr and #result.stderr > 0 then + logger.progress(('stderr: %s'):format(result.stderr:sub(1, 200))) + end + if result.code ~= 0 then logger.log( ('Failed to load contests: %s'):format(result.stderr or 'unknown error'), @@ -75,9 +82,18 @@ local function get_contests_for_platform(platform) return {} end + logger.progress(('stdout preview: %s'):format(result.stdout:sub(1, 100))) + local ok, data = pcall(vim.json.decode, result.stdout) - if not ok or not data.success then - logger.log('Failed to parse contest data', vim.log.levels.ERROR) + if not ok then + logger.log(('JSON parse error: %s'):format(tostring(data)), vim.log.levels.ERROR) + return {} + end + if not data.success then + logger.log( + ('Scraper returned success=false: %s'):format(data.error or 'no error message'), + vim.log.levels.ERROR + ) return {} end @@ -151,10 +167,14 @@ local function get_problems_for_contest(platform, contest_id) end local ok, data = pcall(vim.json.decode, result.stdout) - if not ok or not data.success then + if not ok then logger.log('Failed to parse contest data', vim.log.levels.ERROR) return problems end + if not data.success then + logger.log(data.error or 'Contest scraping failed', vim.log.levels.ERROR) + return problems + end if not data.problems or #data.problems == 0 then logger.log('Contest has no problems available', vim.log.levels.WARN) diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 1417cc3..21350bd 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -13,9 +13,13 @@ local function problem_picker(opts, platform, contest_id) if #problems == 0 then vim.notify( - ('No problems found for contest: %s %s'):format(platform_display_name, contest_id), + ("Contest %s %s hasn't started yet or has no available problems"):format( + platform_display_name, + contest_id + ), vim.log.levels.WARN ) + contest_picker(opts, platform) return end diff --git a/scrapers/__init__.py b/scrapers/__init__.py index f0cfd45..2babd81 100644 --- a/scrapers/__init__.py +++ b/scrapers/__init__.py @@ -1,15 +1,45 @@ -from .atcoder import AtCoderScraper -from .base import BaseScraper, ScraperConfig -from .codeforces import CodeforcesScraper -from .cses import CSESScraper -from .models import ( - ContestListResult, - ContestSummary, - MetadataResult, - ProblemSummary, - TestCase, - TestsResult, -) +# Lazy imports to avoid module loading conflicts when running scrapers with -m +def __getattr__(name): + if name == "AtCoderScraper": + from .atcoder import AtCoderScraper + + return AtCoderScraper + elif name == "BaseScraper": + from .base import BaseScraper + + return BaseScraper + elif name == "ScraperConfig": + from .base import ScraperConfig + + return ScraperConfig + elif name == "CodeforcesScraper": + from .codeforces import CodeforcesScraper + + return CodeforcesScraper + elif name == "CSESScraper": + from .cses import CSESScraper + + return CSESScraper + elif name in [ + "ContestListResult", + "ContestSummary", + "MetadataResult", + "ProblemSummary", + "TestCase", + "TestsResult", + ]: + from .models import ( + ContestListResult, + ContestSummary, + MetadataResult, + ProblemSummary, + TestCase, + TestsResult, + ) + + return locals()[name] + raise AttributeError(f"module 'scrapers' has no attribute '{name}'") + __all__ = [ "AtCoderScraper", diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 20cc3d3..cd72613 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -272,7 +272,11 @@ def scrape_contests() -> list[ContestSummary]: r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name ) - contests.append(ContestSummary(id=contest_id, name=name, display_name=name)) + # Skip AtCoder Heuristic Contests (AHC) as they don't have standard sample tests + if not contest_id.startswith("ahc"): + contests.append( + ContestSummary(id=contest_id, name=name, display_name=name) + ) return contests diff --git a/tests/scrapers/test_atcoder.py b/tests/scrapers/test_atcoder.py index dcde406..dc8b591 100644 --- a/tests/scrapers/test_atcoder.py +++ b/tests/scrapers/test_atcoder.py @@ -129,3 +129,71 @@ def test_scrape_contests_network_error(mocker): result = scrape_contests() assert result == [] + + +def test_scrape_contests_filters_ahc(mocker): + def mock_get_side_effect(url, **kwargs): + if url == "https://atcoder.jp/contests/archive": + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.text = """ + +
    +
  • 1
  • +
+ + """ + return mock_response + elif "page=1" in url: + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.text = """ + + + + + + + + + + + + + + + + + + + + + +
2025-01-15 21:00:00+0900AtCoder Beginner Contest 35001:40 - 1999
2025-01-14 21:00:00+0900AtCoder Heuristic Contest 04405:00-
2025-01-13 21:00:00+0900AtCoder Regular Contest 17002:001000 - 2799
+ """ + return mock_response + else: + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.text = "" + return mock_response + + mocker.patch("scrapers.atcoder.requests.get", side_effect=mock_get_side_effect) + + result = scrape_contests() + + assert len(result) == 2 + assert result[0] == ContestSummary( + id="abc350", + name="AtCoder Beginner Contest 350", + display_name="AtCoder Beginner Contest 350", + ) + assert result[1] == ContestSummary( + id="arc170", + name="AtCoder Regular Contest 170", + display_name="AtCoder Regular Contest 170", + ) + + # Ensure ahc044 is filtered out + contest_ids = [contest.id for contest in result] + assert "ahc044" not in contest_ids diff --git a/tests/scrapers/test_interface_compliance.py b/tests/scrapers/test_interface_compliance.py index e81375b..a10c78c 100644 --- a/tests/scrapers/test_interface_compliance.py +++ b/tests/scrapers/test_interface_compliance.py @@ -8,9 +8,9 @@ from scrapers.base import BaseScraper from scrapers.models import ContestListResult, MetadataResult, TestsResult SCRAPERS = [ - cls - for name, cls in inspect.getmembers(scrapers, inspect.isclass) - if issubclass(cls, BaseScraper) and cls != BaseScraper + scrapers.AtCoderScraper, + scrapers.CodeforcesScraper, + scrapers.CSESScraper, ] From 699207e7133c07780e0bc19ee56f360afb8fcbb9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 00:44:08 -0400 Subject: [PATCH 30/33] lint --- lua/cp/pickers/fzf_lua.lua | 96 ++++++++++----------- lua/cp/pickers/telescope.lua | 94 ++++++++++---------- scrapers/__init__.py | 12 +-- tests/scrapers/test_interface_compliance.py | 1 - 4 files changed, 101 insertions(+), 102 deletions(-) diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index d8acce9..fee8217 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -1,53 +1,5 @@ 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( - ("Contest %s %s hasn't started yet or has no available problems"):format( - platform_display_name, - contest_id - ), - vim.log.levels.WARN - ) - contest_picker(platform) - 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 - local cp = require('cp') - cp.handle_command({ fargs = { 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 @@ -99,6 +51,54 @@ local function contest_picker(platform) }) end +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( + ("Contest %s %s hasn't started yet or has no available problems"):format( + platform_display_name, + contest_id + ), + vim.log.levels.WARN + ) + contest_picker(platform) + 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 + local cp = require('cp') + cp.handle_command({ fargs = { platform, contest_id, problem.id } }) + end + end, + }, + }) +end + local function platform_picker() local fzf = require('fzf-lua') local platforms = picker_utils.get_platforms() diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 21350bd..5dc3e51 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -6,53 +6,6 @@ local actions = require('telescope.actions') local picker_utils = require('cp.pickers') -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( - ("Contest %s %s hasn't started yet or has no available problems"):format( - platform_display_name, - contest_id - ), - vim.log.levels.WARN - ) - contest_picker(opts, platform) - 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) - actions.select_default:replace(function() - local selection = action_state.get_selected_entry() - actions.close(prompt_bufnr) - - if selection then - local cp = require('cp') - cp.handle_command({ fargs = { platform, contest_id, 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 @@ -104,6 +57,53 @@ 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) + + if #problems == 0 then + vim.notify( + ("Contest %s %s hasn't started yet or has no available problems"):format( + platform_display_name, + contest_id + ), + vim.log.levels.WARN + ) + contest_picker(opts, platform) + 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) + actions.select_default:replace(function() + local selection = action_state.get_selected_entry() + actions.close(prompt_bufnr) + + if selection then + local cp = require('cp') + cp.handle_command({ fargs = { platform, contest_id, selection.value.id } }) + end + end) + return true + end, + }) + :find() +end + local function platform_picker(opts) opts = opts or {} diff --git a/scrapers/__init__.py b/scrapers/__init__.py index 2babd81..6140dce 100644 --- a/scrapers/__init__.py +++ b/scrapers/__init__.py @@ -29,12 +29,12 @@ def __getattr__(name): "TestsResult", ]: from .models import ( - ContestListResult, - ContestSummary, - MetadataResult, - ProblemSummary, - TestCase, - TestsResult, + ContestListResult, # noqa: F401 + ContestSummary, # noqa: F401 + MetadataResult, # noqa: F401 + ProblemSummary, # noqa: F401 + TestCase, # noqa: F401 + TestsResult, # noqa: F401 ) return locals()[name] diff --git a/tests/scrapers/test_interface_compliance.py b/tests/scrapers/test_interface_compliance.py index a10c78c..ab07ff2 100644 --- a/tests/scrapers/test_interface_compliance.py +++ b/tests/scrapers/test_interface_compliance.py @@ -1,4 +1,3 @@ -import inspect from unittest.mock import Mock import pytest From 4429b5fe67e217907b7c6587bfde3d5eaed97eb7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 00:47:44 -0400 Subject: [PATCH 31/33] fix --- lua/cp/pickers/fzf_lua.lua | 6 ++++-- lua/cp/pickers/telescope.lua | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index fee8217..2e5095c 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -1,6 +1,8 @@ local picker_utils = require('cp.pickers') -local function contest_picker(platform) +local contest_picker, problem_picker + +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') @@ -51,7 +53,7 @@ local function contest_picker(platform) }) end -local function problem_picker(platform, contest_id) +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') diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index 5dc3e51..4c3188e 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -6,7 +6,9 @@ local actions = require('telescope.actions') local picker_utils = require('cp.pickers') -local function contest_picker(opts, platform) +local contest_picker, problem_picker + +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) @@ -57,7 +59,7 @@ local function contest_picker(opts, platform) :find() end -local function problem_picker(opts, platform, contest_id) +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) From 0e4c46c31aaa03686626f880644d4dbe0000688a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 00:48:17 -0400 Subject: [PATCH 32/33] fix(test): mock logger --- spec/spec_helper.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua index 01c2a04..0e02f87 100644 --- a/spec/spec_helper.lua +++ b/spec/spec_helper.lua @@ -6,6 +6,9 @@ local mock_logger = { log = function(msg, level) table.insert(M.logged_messages, { msg = msg, level = level }) end, + progress = function(msg) + table.insert(M.logged_messages, { msg = msg, level = vim.log.levels.INFO }) + end, set_config = function() end, } From a0171ee81ea60d69fc169e7c1c224ad9a99a7762 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 24 Sep 2025 00:50:04 -0400 Subject: [PATCH 33/33] xi --- spec/error_boundaries_spec.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/error_boundaries_spec.lua b/spec/error_boundaries_spec.lua index 0af5f2a..f17fa83 100644 --- a/spec/error_boundaries_spec.lua +++ b/spec/error_boundaries_spec.lua @@ -9,6 +9,9 @@ describe('Error boundary handling', function() log = function(msg, level) table.insert(logged_messages, { msg = msg, level = level }) end, + progress = function(msg) + table.insert(logged_messages, { msg = msg, level = vim.log.levels.INFO }) + end, set_config = function() end, } package.loaded['cp.log'] = mock_logger