From b3ccce1ee762638c04abdb63fc537a3b939a3d8a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 22:09:20 -0400 Subject: [PATCH 01/17] fix(color): fix ansi hl condition --- lua/cp/ansi.lua | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lua/cp/ansi.lua b/lua/cp/ansi.lua index c8e2a01..642b624 100644 --- a/lua/cp/ansi.lua +++ b/lua/cp/ansi.lua @@ -194,17 +194,9 @@ function M.setup_highlight_groups() BrightWhite = vim.g.terminal_color_15, } - local missing_color = false - for _, terminal_color in pairs(color_map) do - if terminal_color == nil then - missing_color = true - break - end - end - - if missing_color or #color_map == 0 then + if vim.tbl_count(color_map) < 16 then logger.log( - 'ansi terminal colors (vim.g.terminal_color_*) not configured. . ANSI colors will not display properly. ', + 'ansi terminal colors (vim.g.terminal_color_*) not configured. ANSI colors will not display properly. ', vim.log.levels.WARN ) end From 67fad79fb681ce6d2ae5844969b8851fa8845da9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 22:18:55 -0400 Subject: [PATCH 02/17] fix(panel): toggle state correctly --- lua/cp/init.lua | 1 + spec/run_panel_integration_spec.lua | 364 ++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 spec/run_panel_integration_spec.lua diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 7560f01..15b2478 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -103,6 +103,7 @@ local function setup_problem(contest_id, problem_id, language) end vim.cmd('silent only') + state.run_panel_active = false state.contest_id = contest_id state.problem_id = problem_id diff --git a/spec/run_panel_integration_spec.lua b/spec/run_panel_integration_spec.lua new file mode 100644 index 0000000..f1e2f9d --- /dev/null +++ b/spec/run_panel_integration_spec.lua @@ -0,0 +1,364 @@ +describe('run panel state integration', function() + local cp + local logged_messages + local mock_vim_api_calls + local mock_vim_cmd_calls + + before_each(function() + logged_messages = {} + mock_vim_api_calls = {} + mock_vim_cmd_calls = {} + + local mock_logger = { + log = function(msg, level) + table.insert(logged_messages, { msg = msg, level = level }) + end, + set_config = function() end, + } + + local mock_cache = { + load = function() end, + get_test_cases = function() + return nil + end, + set_test_cases = function() end, + get_contest_data = function(platform, contest_id) + if platform == 'atcoder' and contest_id == 'abc123' then + return { + problems = { + { id = 'a', name = 'Problem A' }, + { id = 'b', name = 'Problem B' }, + { id = 'c', name = 'Problem C' }, + }, + } + end + return nil + end, + set_file_state = function() end, + get_file_state = function() + return nil + end, + } + + local mock_scrape = { + scrape_contest_metadata = function() + return { success = false, error = 'mocked disabled' } + end, + scrape_problem = function() + return { success = false, error = 'mocked disabled' } + end, + } + + local mock_problem = { + create_context = function(platform, contest_id, problem_id, config, language) + return { + platform = platform, + contest_id = contest_id, + problem_id = problem_id, + source_file = '/tmp/test.cpp', + problem_name = problem_id or contest_id, + } + end, + } + + local mock_snippets = { + setup = function() end, + } + + local mock_run = { + load_test_cases = function() + return false + end, + get_run_panel_state = function() + return { test_cases = {}, current_index = 1 } + end, + run_all_test_cases = function() end, + handle_compilation_failure = function() end, + } + + local mock_execute = { + compile_problem = function() + return { success = true } + end, + } + + local mock_run_render = { + setup_highlights = function() end, + render_test_list = function() + return {}, {} + end, + } + + local mock_vim = { + fn = { + has = function() + return 1 + end, + mkdir = function() end, + expand = function(str) + if str == '%:t:r' then + return 'test' + end + if str == '%:p' then + return '/tmp/test.cpp' + end + return str + end, + tempname = function() + return '/tmp/test_session' + end, + delete = function() end, + }, + cmd = { + e = function() end, + split = function() end, + vsplit = function() end, + startinsert = function() end, + stopinsert = function() end, + diffthis = function() end, + }, + api = { + nvim_get_current_buf = function() + return 1 + end, + nvim_get_current_win = function() + return 1 + end, + nvim_create_buf = function() + return 2 + end, + nvim_set_option_value = function(opt, val, opts) + table.insert(mock_vim_api_calls, { 'set_option_value', opt, val, opts }) + end, + nvim_get_option_value = function(opt, opts) + if opt == 'readonly' then + return false + end + if opt == 'filetype' then + return 'cpp' + end + return nil + end, + nvim_win_set_buf = function() end, + nvim_buf_set_lines = function() end, + nvim_buf_get_lines = function() + return { '' } + end, + nvim_win_set_cursor = function() end, + nvim_win_get_cursor = function() + return { 1, 0 } + end, + nvim_create_namespace = function() + return 1 + end, + nvim_win_close = function() end, + nvim_buf_delete = function() end, + nvim_win_call = function(win, fn) + fn() + end, + }, + keymap = { + set = function() end, + }, + schedule = function(fn) + fn() + end, + log = { + levels = { + ERROR = 1, + WARN = 2, + INFO = 3, + }, + }, + tbl_contains = function(tbl, val) + for _, v in ipairs(tbl) do + if v == val then + return true + end + end + return false + end, + tbl_map = function(fn, tbl) + local result = {} + for i, v in ipairs(tbl) do + result[i] = fn(v) + end + return result + end, + tbl_filter = function(fn, tbl) + local result = {} + for _, v in ipairs(tbl) do + if fn(v) then + table.insert(result, v) + end + end + return result + end, + split = function(str, sep) + local result = {} + for token in string.gmatch(str, '[^' .. sep .. ']+') do + table.insert(result, token) + end + return result + end, + } + + package.loaded['cp.log'] = mock_logger + package.loaded['cp.cache'] = mock_cache + package.loaded['cp.scrape'] = mock_scrape + package.loaded['cp.problem'] = mock_problem + package.loaded['cp.snippets'] = mock_snippets + package.loaded['cp.run'] = mock_run + package.loaded['cp.execute'] = mock_execute + package.loaded['cp.run_render'] = mock_run_render + + _G.vim = mock_vim + + mock_vim.cmd = function(cmd_str) + table.insert(mock_vim_cmd_calls, cmd_str) + if cmd_str == 'silent only' then + -- Simulate closing all windows + end + end + + cp = require('cp') + cp.setup({ + contests = { + atcoder = { + default_language = 'cpp', + cpp = { extension = 'cpp' }, + }, + }, + scrapers = {}, -- Disable scrapers for testing + run_panel = { + diff_mode = 'vim', + toggle_diff_key = 'd', + next_test_key = 'j', + prev_test_key = 'k', + ansi = false, + }, + }) + end) + + after_each(function() + package.loaded['cp.log'] = nil + package.loaded['cp.cache'] = nil + package.loaded['cp.scrape'] = nil + package.loaded['cp.problem'] = nil + package.loaded['cp.snippets'] = nil + package.loaded['cp.run'] = nil + package.loaded['cp.execute'] = nil + package.loaded['cp.run_render'] = nil + _G.vim = nil + end) + + describe('run panel state bug fix', function() + it('properly resets run panel state after navigation', function() + -- Setup: configure a contest with problems + cp.handle_command({ fargs = { 'atcoder', 'abc123', 'a' } }) + + -- Clear previous messages + logged_messages = {} + mock_vim_cmd_calls = {} + + -- Step 1: Run cp run to open the test panel + cp.handle_command({ fargs = { 'run' } }) + + -- Verify panel would be opened (no test cases means it logs a warning but state is set) + local panel_opened = false + for _, log in ipairs(logged_messages) do + if log.msg:match('no test cases found') then + panel_opened = true -- Panel was attempted to open + break + end + end + assert.is_true(panel_opened, 'First run command should attempt to open panel') + + -- Clear messages and cmd calls for next step + logged_messages = {} + mock_vim_cmd_calls = {} + + -- Step 2: Run cp next to navigate to next problem + cp.handle_command({ fargs = { 'next' } }) + + -- Verify that 'silent only' was called (which closes windows) + local silent_only_called = false + for _, cmd in ipairs(mock_vim_cmd_calls) do + if cmd == 'silent only' then + silent_only_called = true + break + end + end + assert.is_true(silent_only_called, 'Navigation should close all windows') + + -- Clear messages for final step + logged_messages = {} + + -- Step 3: Run cp run again - this should try to OPEN the panel, not close it + cp.handle_command({ fargs = { 'run' } }) + + -- Verify panel would be opened again (not closed) + local panel_opened_again = false + for _, log in ipairs(logged_messages) do + if log.msg:match('no test cases found') then + panel_opened_again = true -- Panel was attempted to open again + break + end + end + assert.is_true( + panel_opened_again, + 'Second run command should attempt to open panel, not close it' + ) + + -- Verify no "test panel closed" message + local panel_closed = false + for _, log in ipairs(logged_messages) do + if log.msg:match('test panel closed') then + panel_closed = true + break + end + end + assert.is_false(panel_closed, 'Second run command should not close panel') + end) + + it('handles navigation to previous problem correctly', function() + -- Setup: configure a contest starting with problem b + cp.handle_command({ fargs = { 'atcoder', 'abc123', 'b' } }) + + -- Clear messages + logged_messages = {} + mock_vim_cmd_calls = {} + + -- Open test panel + cp.handle_command({ fargs = { 'run' } }) + + -- Navigate to previous problem (should go to 'a') + logged_messages = {} + mock_vim_cmd_calls = {} + cp.handle_command({ fargs = { 'prev' } }) + + -- Verify 'silent only' was called + local silent_only_called = false + for _, cmd in ipairs(mock_vim_cmd_calls) do + if cmd == 'silent only' then + silent_only_called = true + break + end + end + assert.is_true(silent_only_called, 'Previous navigation should also close all windows') + + -- Run again - should open panel + logged_messages = {} + cp.handle_command({ fargs = { 'run' } }) + + local panel_opened = false + for _, log in ipairs(logged_messages) do + if log.msg:match('no test cases found') then + panel_opened = true + break + end + end + assert.is_true(panel_opened, 'After previous navigation, run should open panel') + end) + end) +end) From e6c54e01fd1a52dd3175e7249541bb13a3f987b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 22:26:56 -0400 Subject: [PATCH 03/17] fix(test): remove the doc --- spec/run_panel_integration_spec.lua | 364 ---------------------------- 1 file changed, 364 deletions(-) delete mode 100644 spec/run_panel_integration_spec.lua diff --git a/spec/run_panel_integration_spec.lua b/spec/run_panel_integration_spec.lua deleted file mode 100644 index f1e2f9d..0000000 --- a/spec/run_panel_integration_spec.lua +++ /dev/null @@ -1,364 +0,0 @@ -describe('run panel state integration', function() - local cp - local logged_messages - local mock_vim_api_calls - local mock_vim_cmd_calls - - before_each(function() - logged_messages = {} - mock_vim_api_calls = {} - mock_vim_cmd_calls = {} - - local mock_logger = { - log = function(msg, level) - table.insert(logged_messages, { msg = msg, level = level }) - end, - set_config = function() end, - } - - local mock_cache = { - load = function() end, - get_test_cases = function() - return nil - end, - set_test_cases = function() end, - get_contest_data = function(platform, contest_id) - if platform == 'atcoder' and contest_id == 'abc123' then - return { - problems = { - { id = 'a', name = 'Problem A' }, - { id = 'b', name = 'Problem B' }, - { id = 'c', name = 'Problem C' }, - }, - } - end - return nil - end, - set_file_state = function() end, - get_file_state = function() - return nil - end, - } - - local mock_scrape = { - scrape_contest_metadata = function() - return { success = false, error = 'mocked disabled' } - end, - scrape_problem = function() - return { success = false, error = 'mocked disabled' } - end, - } - - local mock_problem = { - create_context = function(platform, contest_id, problem_id, config, language) - return { - platform = platform, - contest_id = contest_id, - problem_id = problem_id, - source_file = '/tmp/test.cpp', - problem_name = problem_id or contest_id, - } - end, - } - - local mock_snippets = { - setup = function() end, - } - - local mock_run = { - load_test_cases = function() - return false - end, - get_run_panel_state = function() - return { test_cases = {}, current_index = 1 } - end, - run_all_test_cases = function() end, - handle_compilation_failure = function() end, - } - - local mock_execute = { - compile_problem = function() - return { success = true } - end, - } - - local mock_run_render = { - setup_highlights = function() end, - render_test_list = function() - return {}, {} - end, - } - - local mock_vim = { - fn = { - has = function() - return 1 - end, - mkdir = function() end, - expand = function(str) - if str == '%:t:r' then - return 'test' - end - if str == '%:p' then - return '/tmp/test.cpp' - end - return str - end, - tempname = function() - return '/tmp/test_session' - end, - delete = function() end, - }, - cmd = { - e = function() end, - split = function() end, - vsplit = function() end, - startinsert = function() end, - stopinsert = function() end, - diffthis = function() end, - }, - api = { - nvim_get_current_buf = function() - return 1 - end, - nvim_get_current_win = function() - return 1 - end, - nvim_create_buf = function() - return 2 - end, - nvim_set_option_value = function(opt, val, opts) - table.insert(mock_vim_api_calls, { 'set_option_value', opt, val, opts }) - end, - nvim_get_option_value = function(opt, opts) - if opt == 'readonly' then - return false - end - if opt == 'filetype' then - return 'cpp' - end - return nil - end, - nvim_win_set_buf = function() end, - nvim_buf_set_lines = function() end, - nvim_buf_get_lines = function() - return { '' } - end, - nvim_win_set_cursor = function() end, - nvim_win_get_cursor = function() - return { 1, 0 } - end, - nvim_create_namespace = function() - return 1 - end, - nvim_win_close = function() end, - nvim_buf_delete = function() end, - nvim_win_call = function(win, fn) - fn() - end, - }, - keymap = { - set = function() end, - }, - schedule = function(fn) - fn() - end, - log = { - levels = { - ERROR = 1, - WARN = 2, - INFO = 3, - }, - }, - tbl_contains = function(tbl, val) - for _, v in ipairs(tbl) do - if v == val then - return true - end - end - return false - end, - tbl_map = function(fn, tbl) - local result = {} - for i, v in ipairs(tbl) do - result[i] = fn(v) - end - return result - end, - tbl_filter = function(fn, tbl) - local result = {} - for _, v in ipairs(tbl) do - if fn(v) then - table.insert(result, v) - end - end - return result - end, - split = function(str, sep) - local result = {} - for token in string.gmatch(str, '[^' .. sep .. ']+') do - table.insert(result, token) - end - return result - end, - } - - package.loaded['cp.log'] = mock_logger - package.loaded['cp.cache'] = mock_cache - package.loaded['cp.scrape'] = mock_scrape - package.loaded['cp.problem'] = mock_problem - package.loaded['cp.snippets'] = mock_snippets - package.loaded['cp.run'] = mock_run - package.loaded['cp.execute'] = mock_execute - package.loaded['cp.run_render'] = mock_run_render - - _G.vim = mock_vim - - mock_vim.cmd = function(cmd_str) - table.insert(mock_vim_cmd_calls, cmd_str) - if cmd_str == 'silent only' then - -- Simulate closing all windows - end - end - - cp = require('cp') - cp.setup({ - contests = { - atcoder = { - default_language = 'cpp', - cpp = { extension = 'cpp' }, - }, - }, - scrapers = {}, -- Disable scrapers for testing - run_panel = { - diff_mode = 'vim', - toggle_diff_key = 'd', - next_test_key = 'j', - prev_test_key = 'k', - ansi = false, - }, - }) - end) - - after_each(function() - package.loaded['cp.log'] = nil - package.loaded['cp.cache'] = nil - package.loaded['cp.scrape'] = nil - package.loaded['cp.problem'] = nil - package.loaded['cp.snippets'] = nil - package.loaded['cp.run'] = nil - package.loaded['cp.execute'] = nil - package.loaded['cp.run_render'] = nil - _G.vim = nil - end) - - describe('run panel state bug fix', function() - it('properly resets run panel state after navigation', function() - -- Setup: configure a contest with problems - cp.handle_command({ fargs = { 'atcoder', 'abc123', 'a' } }) - - -- Clear previous messages - logged_messages = {} - mock_vim_cmd_calls = {} - - -- Step 1: Run cp run to open the test panel - cp.handle_command({ fargs = { 'run' } }) - - -- Verify panel would be opened (no test cases means it logs a warning but state is set) - local panel_opened = false - for _, log in ipairs(logged_messages) do - if log.msg:match('no test cases found') then - panel_opened = true -- Panel was attempted to open - break - end - end - assert.is_true(panel_opened, 'First run command should attempt to open panel') - - -- Clear messages and cmd calls for next step - logged_messages = {} - mock_vim_cmd_calls = {} - - -- Step 2: Run cp next to navigate to next problem - cp.handle_command({ fargs = { 'next' } }) - - -- Verify that 'silent only' was called (which closes windows) - local silent_only_called = false - for _, cmd in ipairs(mock_vim_cmd_calls) do - if cmd == 'silent only' then - silent_only_called = true - break - end - end - assert.is_true(silent_only_called, 'Navigation should close all windows') - - -- Clear messages for final step - logged_messages = {} - - -- Step 3: Run cp run again - this should try to OPEN the panel, not close it - cp.handle_command({ fargs = { 'run' } }) - - -- Verify panel would be opened again (not closed) - local panel_opened_again = false - for _, log in ipairs(logged_messages) do - if log.msg:match('no test cases found') then - panel_opened_again = true -- Panel was attempted to open again - break - end - end - assert.is_true( - panel_opened_again, - 'Second run command should attempt to open panel, not close it' - ) - - -- Verify no "test panel closed" message - local panel_closed = false - for _, log in ipairs(logged_messages) do - if log.msg:match('test panel closed') then - panel_closed = true - break - end - end - assert.is_false(panel_closed, 'Second run command should not close panel') - end) - - it('handles navigation to previous problem correctly', function() - -- Setup: configure a contest starting with problem b - cp.handle_command({ fargs = { 'atcoder', 'abc123', 'b' } }) - - -- Clear messages - logged_messages = {} - mock_vim_cmd_calls = {} - - -- Open test panel - cp.handle_command({ fargs = { 'run' } }) - - -- Navigate to previous problem (should go to 'a') - logged_messages = {} - mock_vim_cmd_calls = {} - cp.handle_command({ fargs = { 'prev' } }) - - -- Verify 'silent only' was called - local silent_only_called = false - for _, cmd in ipairs(mock_vim_cmd_calls) do - if cmd == 'silent only' then - silent_only_called = true - break - end - end - assert.is_true(silent_only_called, 'Previous navigation should also close all windows') - - -- Run again - should open panel - logged_messages = {} - cp.handle_command({ fargs = { 'run' } }) - - local panel_opened = false - for _, log in ipairs(logged_messages) do - if log.msg:match('no test cases found') then - panel_opened = true - break - end - end - assert.is_true(panel_opened, 'After previous navigation, run should open panel') - end) - end) -end) From 847307bd1f044325b03ea105695bdb0bbdf0c21d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 22:30:21 -0400 Subject: [PATCH 04/17] fix(cache): actually use the cache --- lua/cp/init.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 15b2478..1e7ef46 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -77,9 +77,8 @@ local function setup_problem(contest_id, problem_id, language) local cached_test_cases = cache.get_test_cases(state.platform, contest_id, problem_id) if cached_test_cases then state.test_cases = cached_test_cases - end - - if vim.tbl_contains(config.scrapers, state.platform) then + logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) + elseif vim.tbl_contains(config.scrapers, state.platform) then local scrape_result = scrape.scrape_problem(ctx) if not scrape_result.success then From 7b8aae7921bc9f57a14ee050b6f4568b26c3f484 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 23:52:32 -0400 Subject: [PATCH 05/17] fix(ci): move imports --- lua/cp/init.lua | 131 +++++++++++++++++------ lua/cp/scrape.lua | 124 ++++++++++++++++++++++ scrapers/atcoder.py | 232 +++++++++++++++++++++++++++++++++-------- scrapers/codeforces.py | 38 +++++-- scrapers/cses.py | 45 ++++++-- 5 files changed, 475 insertions(+), 95 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 1e7ef46..c5d5beb 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -145,6 +145,42 @@ local function setup_problem(contest_id, problem_id, language) logger.log(('switched to problem %s'):format(ctx.problem_name)) end +local function ensure_io_directory() + vim.fn.mkdir('io', 'p') +end + +local function scrape_missing_problems(contest_id, missing_problems) + ensure_io_directory() + + logger.log(('scraping %d uncached problems...'):format(#missing_problems)) + + local results = + scrape.scrape_problems_parallel(state.platform, 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 + local function get_current_problem() local filename = vim.fn.expand('%:t:r') if filename == '' then @@ -557,6 +593,62 @@ end ---@param delta number 1 for next, -1 for prev ---@param language? string +local function setup_contest(contest_id, language) + if not state.platform then + logger.log('no platform set', vim.log.levels.ERROR) + return false + end + + if not vim.tbl_contains(config.scrapers, state.platform) then + logger.log('scraping disabled for ' .. state.platform, vim.log.levels.WARN) + return false + end + + logger.log(('setting up contest %s %s'):format(state.platform, contest_id)) + + local metadata_result = scrape.scrape_contest_metadata(state.platform, 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 _, problem in ipairs(problems) do + local cached_tests = cache.get_test_cases(state.platform, contest_id, problem.id) + if not cached_tests then + table.insert(missing_problems, problem) + end + end + + if #missing_problems > 0 then + logger.log(('scraping %d uncached problems...'):format(#missing_problems)) + scrape_missing_problems(contest_id, missing_problems) + else + logger.log('all problems already cached') + end + + state.contest_id = contest_id + if state.platform == 'cses' then + setup_problem(problems[1].id, nil, language) + else + setup_problem(contest_id, problems[1].id, language) + end + + return true +end + local function navigate_problem(delta, language) if not state.platform or not state.contest_id then logger.log('no contest set. run :CP first', vim.log.levels.ERROR) @@ -701,20 +793,12 @@ local function parse_command(args) language = language, } elseif #filtered_args == 2 then - if first == 'cses' then - logger.log( - 'CSES requires both category and problem ID. Usage: :CP cses ', - vim.log.levels.ERROR - ) - return { type = 'error' } - else - return { - type = 'contest_setup', - platform = first, - contest = filtered_args[2], - language = language, - } - end + return { + type = 'contest_setup', + platform = first, + contest = filtered_args[2], + language = language, + } elseif #filtered_args == 3 then return { type = 'full_setup', @@ -779,24 +863,7 @@ function M.handle_command(opts) if cmd.type == 'contest_setup' then if set_platform(cmd.platform) then - state.contest_id = cmd.contest - 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.WARN - ) - else - logger.log( - ('loaded %d problems for %s %s'):format( - #metadata_result.problems, - cmd.platform, - cmd.contest - ) - ) - end - end + setup_contest(cmd.contest, cmd.language) end return end diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index d01bbb6..bd763b8 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -14,6 +14,7 @@ local M = {} local cache = require('cp.cache') local logger = require('cp.log') +local problem = require('cp.problem') local function get_plugin_path() local plugin_path = debug.getinfo(1, 'S').source:sub(2) @@ -294,4 +295,127 @@ function M.scrape_problem(ctx) } 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 setup_python_env() then + return {} + end + + local plugin_path = get_plugin_path() + local jobs = {} + + for _, problem in ipairs(problems) do + local args + if platform == 'cses' then + args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'tests', + problem.id, + } + else + args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'tests', + contest_id, + problem.id, + } + end + + local job = vim.system(args, { + cwd = plugin_path, + text = true, + timeout = 30000, + }) + + jobs[problem.id] = { + job = job, + problem = problem, + } + 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/scrapers/atcoder.py b/scrapers/atcoder.py index 02beda8..1088979 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -168,70 +168,210 @@ def scrape(url: str) -> list[TestCase]: def scrape_contests() -> list[ContestSummary]: - contests = [] - max_pages = 15 + import concurrent.futures + import random - for page in range(1, max_pages + 1): + def get_max_pages() -> int: try: headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } - url = f"https://atcoder.jp/contests/archive?page={page}" - response = requests.get(url, headers=headers, timeout=10) + response = requests.get( + "https://atcoder.jp/contests/archive", headers=headers, timeout=10 + ) response.raise_for_status() soup = BeautifulSoup(response.text, "html.parser") - table = soup.find("table", class_="table") - if not table: - break + pagination = soup.find("ul", class_="pagination") + if not pagination or not isinstance(pagination, Tag): + return 15 - tbody = table.find("tbody") - if not tbody or not isinstance(tbody, Tag): - break + lis = pagination.find_all("li") + if lis and isinstance(lis[-1], Tag): + last_li_text = lis[-1].get_text().strip() + try: + return int(last_li_text) + except ValueError: + return 15 + return 15 + except Exception: + return 15 - rows = tbody.find_all("tr") - if not rows: - break + def scrape_page_with_retry(page: int, max_retries: int = 3) -> list[ContestSummary]: + for attempt in range(max_retries): + try: + headers = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + url = f"https://atcoder.jp/contests/archive?page={page}" + response = requests.get(url, headers=headers, timeout=10) - for row in rows: - cells = row.find_all("td") - if len(cells) < 2: + if response.status_code == 429: + backoff_time = (2**attempt) + random.uniform(0, 1) + print( + f"Rate limited on page {page}, retrying in {backoff_time:.1f}s", + file=sys.stderr, + ) + time.sleep(backoff_time) continue - contest_cell = cells[1] - link = contest_cell.find("a") - if not link or not link.get("href"): + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + table = soup.find("table", class_="table") + if not table: + return [] + + tbody = table.find("tbody") + if not tbody or not isinstance(tbody, Tag): + return [] + + rows = tbody.find_all("tr") + if not rows: + return [] + + contests = [] + for row in rows: + cells = row.find_all("td") + if len(cells) < 2: + continue + + contest_cell = cells[1] + link = contest_cell.find("a") + if not link or not link.get("href"): + continue + + href = link.get("href") + contest_id = href.split("/")[-1] + name = link.get_text().strip() + + try: + name = name.encode().decode("unicode_escape") + except: + pass + + name = ( + name.replace("\uff08", "(") + .replace("\uff09", ")") + .replace("\u3000", " ") + ) + name = re.sub( + r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name + ) + + def generate_display_name_from_id(contest_id: str) -> str: + parts = contest_id.replace("-", " ").replace("_", " ") + + parts = re.sub( + r"\b(jsc|JSC)\b", + "Japanese Student Championship", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(wtf|WTF)\b", + "World Tour Finals", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(ahc)(\d+)\b", + r"Heuristic Contest \2 (AHC)", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(arc)(\d+)\b", + r"Regular Contest \2 (ARC)", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(abc)(\d+)\b", + r"Beginner Contest \2 (ABC)", + parts, + flags=re.IGNORECASE, + ) + parts = re.sub( + r"\b(agc)(\d+)\b", + r"Grand Contest \2 (AGC)", + parts, + flags=re.IGNORECASE, + ) + + return parts.title() + + english_chars = sum(1 for c in name if c.isascii() and c.isalpha()) + total_chars = len(re.sub(r"\s+", "", name)) + + if total_chars > 0 and english_chars / total_chars < 0.3: + display_name = generate_display_name_from_id(contest_id) + else: + display_name = name + if "AtCoder Beginner Contest" in name: + match = re.search(r"AtCoder Beginner Contest (\d+)", name) + if match: + display_name = ( + f"Beginner Contest {match.group(1)} (ABC)" + ) + elif "AtCoder Regular Contest" in name: + match = re.search(r"AtCoder Regular Contest (\d+)", name) + if match: + display_name = f"Regular Contest {match.group(1)} (ARC)" + elif "AtCoder Grand Contest" in name: + match = re.search(r"AtCoder Grand Contest (\d+)", name) + if match: + display_name = f"Grand Contest {match.group(1)} (AGC)" + elif "AtCoder Heuristic Contest" in name: + match = re.search(r"AtCoder Heuristic Contest (\d+)", name) + if match: + display_name = ( + f"Heuristic Contest {match.group(1)} (AHC)" + ) + + contests.append( + ContestSummary( + id=contest_id, name=name, display_name=display_name + ) + ) + + return contests + + except requests.exceptions.RequestException as e: + if response.status_code == 429: continue - - href = link.get("href") - contest_id = href.split("/")[-1] - name = link.get_text().strip() - - display_name = name - if "AtCoder Beginner Contest" in name: - match = re.search(r"AtCoder Beginner Contest (\d+)", name) - if match: - display_name = f"Beginner Contest {match.group(1)} (ABC)" - elif "AtCoder Regular Contest" in name: - match = re.search(r"AtCoder Regular Contest (\d+)", name) - if match: - display_name = f"Regular Contest {match.group(1)} (ARC)" - elif "AtCoder Grand Contest" in name: - match = re.search(r"AtCoder Grand Contest (\d+)", name) - if match: - display_name = f"Grand Contest {match.group(1)} (AGC)" - - contests.append( - ContestSummary(id=contest_id, name=name, display_name=display_name) + print( + f"Failed to scrape page {page} (attempt {attempt + 1}): {e}", + file=sys.stderr, ) + if attempt == max_retries - 1: + return [] + except Exception as e: + print(f"Unexpected error on page {page}: {e}", file=sys.stderr) + return [] - time.sleep(0.5) + return [] - except Exception as e: - print(f"Failed to scrape page {page}: {e}", file=sys.stderr) - continue + max_pages = get_max_pages() + page_results = {} - return contests + with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + future_to_page = { + executor.submit(scrape_page_with_retry, page): page + for page in range(1, max_pages + 1) + } + + for future in concurrent.futures.as_completed(future_to_page): + page = future_to_page[future] + page_contests = future.result() + page_results[page] = page_contests + + # Sort by page number to maintain order + all_contests = [] + for page in sorted(page_results.keys()): + all_contests.extend(page_results[page]) + + return all_contests def main() -> None: diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index b4f6409..a85d26e 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import json +import re import sys from dataclasses import asdict @@ -148,8 +149,6 @@ def parse_problem_url(contest_id: str, problem_letter: str) -> str: def extract_problem_limits(soup: BeautifulSoup) -> tuple[int, float]: - import re - timeout_ms = None memory_mb = None @@ -240,22 +239,43 @@ def scrape_contests() -> list[ContestSummary]: contest_id = str(contest["id"]) name = contest["name"] - # Clean up contest names for display display_name = name if "Educational Codeforces Round" in name: - import re - match = re.search(r"Educational Codeforces Round (\d+)", name) if match: display_name = f"Educational Round {match.group(1)}" - elif "Codeforces Round" in name and "Div" in name: - match = re.search(r"Codeforces Round (\d+) \(Div\. (\d+)\)", name) - if match: - display_name = f"Round {match.group(1)} (Div. {match.group(2)})" elif "Codeforces Global Round" in name: match = re.search(r"Codeforces Global Round (\d+)", name) if match: display_name = f"Global Round {match.group(1)}" + elif "Codeforces Round" in name: + # Handle various Div patterns + div_match = re.search(r"Codeforces Round (\d+) \(Div\. (\d+)\)", name) + if div_match: + display_name = ( + f"Round {div_match.group(1)} (Div. {div_match.group(2)})" + ) + else: + # Handle combined divs like "Div. 1 + Div. 2" + combined_match = re.search( + r"Codeforces Round (\d+) \(Div\. 1 \+ Div\. 2\)", name + ) + if combined_match: + display_name = ( + f"Round {combined_match.group(1)} (Div. 1 + Div. 2)" + ) + else: + # Handle single div like "Div. 1" + single_div_match = re.search( + r"Codeforces Round (\d+) \(Div\. 1\)", name + ) + if single_div_match: + display_name = f"Round {single_div_match.group(1)} (Div. 1)" + else: + # Fallback: extract just the round number + round_match = re.search(r"Codeforces Round (\d+)", name) + if round_match: + display_name = f"Round {round_match.group(1)}" contests.append( ContestSummary(id=contest_id, name=name, display_name=display_name) diff --git a/scrapers/cses.py b/scrapers/cses.py index 8edaef8..f032c7a 100755 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 import json +import random import re import sys +import time from dataclasses import asdict import requests @@ -39,6 +41,38 @@ def denormalize_category_name(category_id: str) -> str: return category_map.get(category_id, category_id.replace("_", " ").title()) +def request_with_retry( + url: str, headers: dict, max_retries: int = 3 +) -> requests.Response: + for attempt in range(max_retries): + try: + delay = 0.5 + random.uniform(0, 0.3) + time.sleep(delay) + + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 429: + backoff = (2**attempt) + random.uniform(0, 1) + print(f"Rate limited, retrying in {backoff:.1f}s", file=sys.stderr) + time.sleep(backoff) + continue + + response.raise_for_status() + return response + + except requests.exceptions.RequestException as e: + if attempt == max_retries - 1: + raise + backoff = 2**attempt + print( + f"Request failed (attempt {attempt + 1}), retrying in {backoff}s: {e}", + file=sys.stderr, + ) + time.sleep(backoff) + + raise Exception("All retry attempts failed") + + def scrape_category_problems(category_id: str) -> list[ProblemSummary]: category_name = denormalize_category_name(category_id) @@ -48,8 +82,7 @@ def scrape_category_problems(category_id: str) -> list[ProblemSummary]: "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } - response = requests.get(problemset_url, headers=headers, timeout=10) - response.raise_for_status() + response = request_with_retry(problemset_url, headers) soup = BeautifulSoup(response.text, "html.parser") @@ -143,10 +176,7 @@ def scrape_categories() -> list[ContestSummary]: headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } - response = requests.get( - "https://cses.fi/problemset/", headers=headers, timeout=10 - ) - response.raise_for_status() + response = request_with_retry("https://cses.fi/problemset/", headers) soup = BeautifulSoup(response.text, "html.parser") categories = [] @@ -293,8 +323,7 @@ def scrape(url: str) -> list[TestCase]: "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } - response = requests.get(url, headers=headers, timeout=10) - response.raise_for_status() + response = request_with_retry(url, headers) soup = BeautifulSoup(response.text, "html.parser") From 5bf9ae731f859f63446d466307b6330584be4c04 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 23:58:26 -0400 Subject: [PATCH 06/17] fix(ci): inline functions --- lua/cp/execute.lua | 6 +----- lua/cp/init.lua | 9 ++------- lua/cp/scrape.lua | 17 +++++++++++------ 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index a56bc62..1c433d6 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -63,10 +63,6 @@ local function build_command(cmd_template, executable, substitutions) return cmd end -local function ensure_directories() - vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() -end - ---@param language_config table ---@param substitutions table ---@return {code: integer, stdout: string, stderr: string} @@ -252,7 +248,7 @@ function M.run_problem(ctx, contest_config, is_debug) is_debug = { is_debug, 'boolean' }, }) - ensure_directories() + vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() local language = get_language_from_file(ctx.source_file, contest_config) local language_config = contest_config[language] diff --git a/lua/cp/init.lua b/lua/cp/init.lua index c5d5beb..9f93ecf 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -45,8 +45,7 @@ local function set_platform(platform) end state.platform = platform - vim.fn.mkdir('build', 'p') - vim.fn.mkdir('io', 'p') + vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() return true end @@ -145,12 +144,8 @@ local function setup_problem(contest_id, problem_id, language) logger.log(('switched to problem %s'):format(ctx.problem_name)) end -local function ensure_io_directory() - vim.fn.mkdir('io', 'p') -end - local function scrape_missing_problems(contest_id, missing_problems) - ensure_io_directory() + vim.fn.mkdir('io', 'p') logger.log(('scraping %d uncached problems...'):format(#missing_problems)) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index bd763b8..31955df 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -21,10 +21,6 @@ local function get_plugin_path() return vim.fn.fnamemodify(plugin_path, ':h:h:h') end -local function ensure_io_directory() - vim.fn.mkdir('io', 'p') -end - local function check_internet_connectivity() local result = vim.system({ 'ping', '-c', '1', '-W', '3', '8.8.8.8' }, { text = true }):wait() return result.code == 0 @@ -144,7 +140,7 @@ function M.scrape_problem(ctx) ctx = { ctx, 'table' }, }) - ensure_io_directory() + 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') @@ -373,7 +369,16 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) scrape_result = data if data.tests and #data.tests > 0 then - local ctx = problem.create_context(platform, contest_id, problem_id, config) + local ctx_contest_id, ctx_problem_id + if platform == 'cses' then + ctx_contest_id = problem_id + ctx_problem_id = nil + else + ctx_contest_id = contest_id + ctx_problem_id = problem_id + end + + local ctx = problem.create_context(platform, ctx_contest_id, ctx_problem_id, config) local base_name = vim.fn.fnamemodify(ctx.input_file, ':r') for i, test_case in ipairs(data.tests) do From a8984d013a4cffdeb78bf29dca734fde5ffd520e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:06:52 -0400 Subject: [PATCH 07/17] fix(cses): handle problem id uniquely --- lua/cp/init.lua | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 9f93ecf..df56538 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -64,16 +64,25 @@ local function setup_problem(contest_id, problem_id, language) local ctx = problem.create_context(state.platform, contest_id, problem_id, config, language) if vim.tbl_contains(config.scrapers, state.platform) then - local metadata_result = scrape.scrape_contest_metadata(state.platform, 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 - ) + cache.load() + local existing_contest_data = state.platform == 'cses' + and cache.get_contest_data(state.platform, state.contest_id) + or cache.get_contest_data(state.platform, contest_id) + + if not existing_contest_data then + local metadata_result = scrape.scrape_contest_metadata(state.platform, 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.platform, contest_id, problem_id) + local cache_contest_id = state.platform == 'cses' and state.contest_id or contest_id + local cache_problem_id = state.platform == 'cses' and contest_id or problem_id + local cached_test_cases = cache.get_test_cases(state.platform, cache_contest_id, cache_problem_id) if cached_test_cases then state.test_cases = cached_test_cases logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) From 9deedec15a7363738eecebf7b912b3c2f17c7939 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:10:10 -0400 Subject: [PATCH 08/17] fix(scraper): comments --- lua/cp/init.lua | 2 ++ scrapers/atcoder.py | 1 - scrapers/codeforces.py | 3 --- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index df56538..74da04b 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -80,6 +80,8 @@ local function setup_problem(contest_id, problem_id, language) end end + -- NOTE: CSES uses different cache key structure: (platform, category, problem_id) + -- vs other platforms: (platform, contest_id, problem_letter) local cache_contest_id = state.platform == 'cses' and state.contest_id or contest_id local cache_problem_id = state.platform == 'cses' and contest_id or problem_id local cached_test_cases = cache.get_test_cases(state.platform, cache_contest_id, cache_problem_id) diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 1088979..1451bc2 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -366,7 +366,6 @@ def scrape_contests() -> list[ContestSummary]: page_contests = future.result() page_results[page] = page_contests - # Sort by page number to maintain order all_contests = [] for page in sorted(page_results.keys()): all_contests.extend(page_results[page]) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index a85d26e..efaf0e1 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -249,14 +249,12 @@ def scrape_contests() -> list[ContestSummary]: if match: display_name = f"Global Round {match.group(1)}" elif "Codeforces Round" in name: - # Handle various Div patterns div_match = re.search(r"Codeforces Round (\d+) \(Div\. (\d+)\)", name) if div_match: display_name = ( f"Round {div_match.group(1)} (Div. {div_match.group(2)})" ) else: - # Handle combined divs like "Div. 1 + Div. 2" combined_match = re.search( r"Codeforces Round (\d+) \(Div\. 1 \+ Div\. 2\)", name ) @@ -265,7 +263,6 @@ def scrape_contests() -> list[ContestSummary]: f"Round {combined_match.group(1)} (Div. 1 + Div. 2)" ) else: - # Handle single div like "Div. 1" single_div_match = re.search( r"Codeforces Round (\d+) \(Div\. 1\)", name ) From 7a027c7379445f7428b22a1a37532496638d84b7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:15:23 -0400 Subject: [PATCH 09/17] fix(ci): typing --- lua/cp/init.lua | 5 ++++- scrapers/atcoder.py | 2 +- scrapers/cses.py | 11 ++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 74da04b..e295360 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -518,6 +518,7 @@ local function toggle_run_panel(is_debug) update_diff_panes() end + ---@param delta number 1 for next, -1 for prev local function navigate_test_case(delta) local test_state = run.get_run_panel_state() if #test_state.test_cases == 0 then @@ -597,7 +598,7 @@ local function toggle_run_panel(is_debug) logger.log(string.format('test panel opened (%d test cases)', #test_state.test_cases)) end ----@param delta number 1 for next, -1 for prev +---@param contest_id string ---@param language? string local function setup_contest(contest_id, language) if not state.platform then @@ -655,6 +656,8 @@ local function setup_contest(contest_id, language) return true end +---@param delta number 1 for next, -1 for prev +---@param language? string local function navigate_problem(delta, language) if not state.platform or not state.contest_id then logger.log('no contest set. run :CP first', vim.log.levels.ERROR) diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 1451bc2..3dc1d16 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -247,7 +247,7 @@ def scrape_contests() -> list[ContestSummary]: try: name = name.encode().decode("unicode_escape") - except: + except (UnicodeDecodeError, UnicodeEncodeError): pass name = ( diff --git a/scrapers/cses.py b/scrapers/cses.py index f032c7a..3018645 100755 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -343,7 +343,7 @@ def main() -> None: if len(sys.argv) < 2: result = MetadataResult( success=False, - error="Usage: cses.py metadata OR cses.py tests OR cses.py contests", + error="Usage: cses.py metadata OR cses.py tests OR cses.py contests", ) print(json.dumps(asdict(result))) sys.exit(1) @@ -374,10 +374,10 @@ def main() -> None: print(json.dumps(asdict(result))) elif mode == "tests": - if len(sys.argv) != 3: + if len(sys.argv) != 4: tests_result = TestsResult( success=False, - error="Usage: cses.py tests ", + error="Usage: cses.py tests ", problem_id="", url="", tests=[], @@ -387,7 +387,8 @@ def main() -> None: print(json.dumps(asdict(tests_result))) sys.exit(1) - problem_input: str = sys.argv[2] + category: str = sys.argv[2] + problem_input: str = sys.argv[3] url: str | None = parse_problem_url(problem_input) if not url: @@ -475,7 +476,7 @@ def main() -> None: else: result = MetadataResult( success=False, - error=f"Unknown mode: {mode}. Use 'metadata', 'tests', or 'contests'", + error=f"Unknown mode: {mode}. Use 'metadata ', 'tests ', or 'contests'", ) print(json.dumps(asdict(result))) sys.exit(1) From 3821174c6e08eb5c5f94b7d0ee502164b8e5e606 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:15:51 -0400 Subject: [PATCH 10/17] fix(ci): typing --- lua/cp/init.lua | 6 +----- lua/cp/scrape.lua | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index e295360..5dff8f6 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -647,11 +647,7 @@ local function setup_contest(contest_id, language) end state.contest_id = contest_id - if state.platform == 'cses' then - setup_problem(problems[1].id, nil, language) - else - setup_problem(contest_id, problems[1].id, language) - end + setup_problem(contest_id, problems[1].id, language) return true end diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 31955df..dad5500 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -315,7 +315,7 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) local plugin_path = get_plugin_path() local jobs = {} - for _, problem in ipairs(problems) do + for _, prob in ipairs(problems) do local args if platform == 'cses' then args = { From 18939a9d5fb84adb4be9a8b07b0065e54e8fa752 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:15:53 -0400 Subject: [PATCH 11/17] fix(ci): typing --- lua/cp/scrape.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index dad5500..c50b300 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -326,7 +326,7 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) '-m', 'scrapers.' .. platform, 'tests', - problem.id, + prob.id, } else args = { @@ -338,7 +338,7 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) 'scrapers.' .. platform, 'tests', contest_id, - problem.id, + prob.id, } end From 98aa3edd41356f767b50a74b89ae637208bc8c5a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:16:06 -0400 Subject: [PATCH 12/17] fix(ci): typing --- lua/cp/init.lua | 2 +- lua/cp/scrape.lua | 36 +++++++++++------------------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 5dff8f6..eb9c17d 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -632,7 +632,7 @@ local function setup_contest(contest_id, language) cache.load() local missing_problems = {} - for _, problem in ipairs(problems) do + for _, prob in ipairs(problems) do local cached_tests = cache.get_test_cases(state.platform, contest_id, problem.id) if not cached_tests then table.insert(missing_problems, problem) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index c50b300..c9d1d13 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -316,31 +316,17 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) local jobs = {} for _, prob in ipairs(problems) do - local args - if platform == 'cses' then - args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'tests', - prob.id, - } - else - args = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'tests', - contest_id, - prob.id, - } - end + local args = { + 'uv', + 'run', + '--directory', + plugin_path, + '-m', + 'scrapers.' .. platform, + 'tests', + contest_id, + prob.id, + } local job = vim.system(args, { cwd = plugin_path, From 03bb0bda3342cfae7ef883f6c35ba61438843c42 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:16:14 -0400 Subject: [PATCH 13/17] fix(ci): typing --- lua/cp/init.lua | 4 ++-- lua/cp/scrape.lua | 11 +---------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index eb9c17d..fa68375 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -633,9 +633,9 @@ local function setup_contest(contest_id, language) cache.load() local missing_problems = {} for _, prob in ipairs(problems) do - local cached_tests = cache.get_test_cases(state.platform, contest_id, problem.id) + local cached_tests = cache.get_test_cases(state.platform, contest_id, prob.id) if not cached_tests then - table.insert(missing_problems, problem) + table.insert(missing_problems, prob) end end diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index c9d1d13..8c6a4a1 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -355,16 +355,7 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) scrape_result = data if data.tests and #data.tests > 0 then - local ctx_contest_id, ctx_problem_id - if platform == 'cses' then - ctx_contest_id = problem_id - ctx_problem_id = nil - else - ctx_contest_id = contest_id - ctx_problem_id = problem_id - end - - local ctx = problem.create_context(platform, ctx_contest_id, ctx_problem_id, config) + 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 From d827b6dd0b537fb71dcb2aa23ea8506473503485 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:19:01 -0400 Subject: [PATCH 14/17] feat(cese): normalize cses handling --- lua/cp/init.lua | 40 ++++++++-------------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index fa68375..36c2faf 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -58,16 +58,14 @@ local function setup_problem(contest_id, problem_id, language) return end - local problem_name = state.platform == 'cses' and contest_id or (contest_id .. (problem_id or '')) + local problem_name = contest_id .. (problem_id or '') logger.log(('setting up problem: %s'):format(problem_name)) local ctx = problem.create_context(state.platform, contest_id, problem_id, config, language) if vim.tbl_contains(config.scrapers, state.platform) then cache.load() - local existing_contest_data = state.platform == 'cses' - and cache.get_contest_data(state.platform, state.contest_id) - or cache.get_contest_data(state.platform, contest_id) + local existing_contest_data = cache.get_contest_data(state.platform, contest_id) if not existing_contest_data then local metadata_result = scrape.scrape_contest_metadata(state.platform, contest_id) @@ -80,11 +78,7 @@ local function setup_problem(contest_id, problem_id, language) end end - -- NOTE: CSES uses different cache key structure: (platform, category, problem_id) - -- vs other platforms: (platform, contest_id, problem_letter) - local cache_contest_id = state.platform == 'cses' and state.contest_id or contest_id - local cache_problem_id = state.platform == 'cses' and contest_id or problem_id - local cached_test_cases = cache.get_test_cases(state.platform, cache_contest_id, cache_problem_id) + local cached_test_cases = cache.get_test_cases(state.platform, contest_id, problem_id) if cached_test_cases then state.test_cases = cached_test_cases logger.log(('using cached test cases (%d)'):format(#cached_test_cases)) @@ -671,13 +665,7 @@ local function navigate_problem(delta, language) end local problems = contest_data.problems - local current_problem_id - - if state.platform == 'cses' then - current_problem_id = state.contest_id - else - current_problem_id = state.problem_id - end + local current_problem_id = state.problem_id if not current_problem_id then logger.log('no current problem set', vim.log.levels.ERROR) @@ -707,11 +695,7 @@ local function navigate_problem(delta, language) local new_problem = problems[new_index] - if state.platform == 'cses' then - setup_problem(new_problem.id, nil, language) - else - setup_problem(state.contest_id, new_problem.id, language) - end + setup_problem(state.contest_id, new_problem.id, language) end local function restore_from_current_file() @@ -735,7 +719,7 @@ local function restore_from_current_file() ('Restoring from cached state: %s %s %s'):format( file_state.platform, file_state.contest_id, - file_state.problem_id or 'CSES' + file_state.problem_id or 'N/A' ) ) @@ -746,11 +730,7 @@ local function restore_from_current_file() state.contest_id = file_state.contest_id state.problem_id = file_state.problem_id - if file_state.platform == 'cses' then - setup_problem(file_state.contest_id, nil, file_state.language) - else - setup_problem(file_state.contest_id, file_state.problem_id, file_state.language) - end + setup_problem(file_state.contest_id, file_state.problem_id, file_state.language) return true end @@ -925,11 +905,7 @@ function M.handle_command(opts) end if cmd.type == 'problem_switch' then - if state.platform == 'cses' then - setup_problem(cmd.problem, nil, cmd.language) - else - setup_problem(state.contest_id, cmd.problem, cmd.language) - end + setup_problem(state.contest_id, cmd.problem, cmd.language) return end end From df1b4c200934be8f0390207d1599c134656cd537 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:25:55 -0400 Subject: [PATCH 15/17] fix(scrape): proper vars --- lua/cp/scrape.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/cp/scrape.lua b/lua/cp/scrape.lua index 8c6a4a1..f8a5e31 100644 --- a/lua/cp/scrape.lua +++ b/lua/cp/scrape.lua @@ -334,9 +334,9 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config) timeout = 30000, }) - jobs[problem.id] = { + jobs[prob.id] = { job = job, - problem = problem, + problem = prob, } end From 1f38dba57f2fc81269f55bc46ec7586756a5a5d5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:31:10 -0400 Subject: [PATCH 16/17] fix(scrape): proper vars --- lua/cp/diff.lua | 27 +++++---------------------- scrapers/codeforces.py | 3 +-- tests/scrapers/test_atcoder.py | 28 ++++++++++++++++------------ 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua index e576b8c..9295c63 100644 --- a/lua/cp/diff.lua +++ b/lua/cp/diff.lua @@ -9,7 +9,6 @@ local M = {} ----Vim's built-in diff backend using diffthis ---@type DiffBackend local vim_backend = { name = 'vim', @@ -18,17 +17,15 @@ local vim_backend = { return { content = actual_lines, - highlights = nil, -- diffthis handles highlighting + highlights = nil, } end, } ----Git word-diff backend for character-level precision ---@type DiffBackend local git_backend = { name = 'git', render = function(expected, actual) - -- Create temporary files for git diff local tmp_expected = vim.fn.tempname() local tmp_actual = vim.fn.tempname() @@ -48,7 +45,6 @@ local git_backend = { local result = vim.system(cmd, { text = true }):wait() - -- Clean up temp files vim.fn.delete(tmp_expected) vim.fn.delete(tmp_actual) @@ -58,25 +54,21 @@ local git_backend = { highlights = {}, } else - -- Parse git diff output to extract content and highlights local diff_content = result.stdout or '' local lines = {} local highlights = {} local line_num = 0 - -- Extract content lines that start with space, +, or - for line in diff_content:gmatch('[^\n]*') do if line:match('^[%s%+%-]') or (not line:match('^[@%-+]') and not line:match('^index') and not line:match('^diff')) then - -- This is content, not metadata local clean_line = line if line:match('^[%+%-]') then - clean_line = line:sub(2) -- Remove +/- prefix + clean_line = line:sub(2) end - -- Parse diff markers in the line local col_pos = 0 local processed_line = '' local i = 1 @@ -97,28 +89,26 @@ local git_backend = { end if next_marker_start then - -- Add text before marker if next_marker_start > i then local before_text = clean_line:sub(i, next_marker_start - 1) processed_line = processed_line .. before_text col_pos = col_pos + #before_text end - -- Extract and add marker content with highlighting local marker_end = (marker_type == 'removed') and removed_end or added_end local marker_text = clean_line:sub(next_marker_start, marker_end) local content_text if marker_type == 'removed' then - content_text = marker_text:sub(3, -3) -- Remove [- and -] + content_text = marker_text:sub(3, -3) table.insert(highlights, { line = line_num, col_start = col_pos, col_end = col_pos + #content_text, highlight_group = 'DiffDelete', }) - else -- added - content_text = marker_text:sub(3, -3) -- Remove {+ and +} + else + content_text = marker_text:sub(3, -3) table.insert(highlights, { line = line_num, col_start = col_pos, @@ -131,7 +121,6 @@ local git_backend = { col_pos = col_pos + #content_text i = marker_end + 1 else - -- No more markers, add rest of line local rest = clean_line:sub(i) processed_line = processed_line .. rest break @@ -152,34 +141,29 @@ local git_backend = { end, } ----Available diff backends ---@type table local backends = { vim = vim_backend, git = git_backend, } ----Get available backend names ---@return string[] function M.get_available_backends() return vim.tbl_keys(backends) end ----Get a diff backend by name ---@param name string ---@return DiffBackend? function M.get_backend(name) return backends[name] end ----Check if git backend is available ---@return boolean function M.is_git_available() local result = vim.system({ 'git', '--version' }, { text = true }):wait() return result.code == 0 end ----Get the best available backend based on config and system availability ---@param preferred_backend? string ---@return DiffBackend function M.get_best_backend(preferred_backend) @@ -193,7 +177,6 @@ function M.get_best_backend(preferred_backend) return backends.vim end ----Render diff using specified backend ---@param expected string ---@param actual string ---@param backend_name? string diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index efaf0e1..0aa7d07 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -269,7 +269,6 @@ def scrape_contests() -> list[ContestSummary]: if single_div_match: display_name = f"Round {single_div_match.group(1)} (Div. 1)" else: - # Fallback: extract just the round number round_match = re.search(r"Codeforces Round (\d+)", name) if round_match: display_name = f"Round {round_match.group(1)}" @@ -278,7 +277,7 @@ def scrape_contests() -> list[ContestSummary]: ContestSummary(id=contest_id, name=name, display_name=display_name) ) - return contests[:100] # Limit to recent 100 contests + return contests[:100] except Exception as e: print(f"Failed to fetch contests: {e}", file=sys.stderr) diff --git a/tests/scrapers/test_atcoder.py b/tests/scrapers/test_atcoder.py index 0474c6a..5ff91d9 100644 --- a/tests/scrapers/test_atcoder.py +++ b/tests/scrapers/test_atcoder.py @@ -54,18 +54,22 @@ def test_scrape_network_error(mocker): def test_scrape_contests_success(mocker): def mock_get_side_effect(url, **kwargs): - if "page=1" in url: + 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 = """ +
Start TimeContest NameDurationRated Range
@@ -84,9 +88,9 @@ def test_scrape_contests_success(mocker): """ return mock_response else: - # Return empty page for all other pages mock_response = Mock() - mock_response.text = "No table found" + 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) From 56c7cf00a513e8a52a96bb5f25c464e421f937e2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 21 Sep 2025 00:33:35 -0400 Subject: [PATCH 17/17] fix(ci): cses --- scrapers/cses.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scrapers/cses.py b/scrapers/cses.py index 3018645..b2f1733 100755 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -387,7 +387,6 @@ def main() -> None: print(json.dumps(asdict(tests_result))) sys.exit(1) - category: str = sys.argv[2] problem_input: str = sys.argv[3] url: str | None = parse_problem_url(problem_input)
2025-01-15 21:00:00+0900