diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index c98fedb..18ce0d3 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -11,13 +11,10 @@ ---@class ContestListData ---@field contests table[] ----@field cached_at number ---@class ContestData ---@field problems Problem[] ----@field scraped_at string ---@field test_cases? CachedTestCase[] ----@field test_cases_cached_at? number ---@field timeout_ms? number ---@field memory_mb? number ---@field interactive? boolean @@ -121,7 +118,6 @@ function M.set_contest_data(platform, contest_id, problems) cache_data[platform][contest_id] = { problems = problems, - scraped_at = os.date('%Y-%m-%d'), } M.save() @@ -194,7 +190,6 @@ function M.set_test_cases( end cache_data[platform][problem_key].test_cases = test_cases - cache_data[platform][problem_key].test_cases_cached_at = os.time() if timeout_ms then cache_data[platform][problem_key].timeout_ms = timeout_ms end @@ -273,7 +268,6 @@ function M.set_contest_list(platform, contests) cache_data.contest_lists[platform] = { contests = contests, - cached_at = os.time(), } M.save() diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 88c1f24..31d2771 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -8,7 +8,7 @@ local platforms = constants.PLATFORMS local actions = constants.ACTIONS local function parse_command(args) - if #args == 0 then + if vim.tbl_isempty(args) then return { type = 'restore_from_file', } diff --git a/lua/cp/config.lua b/lua/cp/config.lua index e5f0963..f59058c 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -270,7 +270,7 @@ function M.setup(user_config) end end - if #available_langs == 0 then + if vim.tbl_isemtpy(available_langs) then error('No language configurations found') end diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index 1573f4c..a8310af 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -8,7 +8,7 @@ local function contest_picker(platform) local fzf = require('fzf-lua') local contests = picker_utils.get_contests_for_platform(platform) - if #contests == 0 then + if vim.tbl_isempty(contests) then vim.notify( ('No contests found for platform: %s'):format(platform_display_name), vim.log.levels.WARN @@ -27,7 +27,7 @@ local function contest_picker(platform) }, actions = { ['default'] = function(selected) - if not selected or #selected == 0 then + if vim.tbl_isempty(selected) then return end @@ -65,7 +65,7 @@ function M.pick() prompt = 'Select Platform> ', actions = { ['default'] = function(selected) - if not selected or #selected == 0 then + if vim.tbl_isempty(selected) then return end diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index ee6bbb9..e6317bc 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -3,7 +3,7 @@ local M = {} local cache = require('cp.cache') local config = require('cp.config').get_config() local logger = require('cp.log') -local utils = require('cp.utils') +local scraper = require('cp.scraper') ---@class cp.PlatformItem ---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses") @@ -40,157 +40,33 @@ end ---@param platform string Platform identifier (e.g. "codeforces", "atcoder") ---@return cp.ContestItem[] function M.get_contests_for_platform(platform) - logger.log('loading contests...', vim.log.levels.INFO, true) + logger.log(('Loading %s contests..'):format(platform), vim.log.levels.INFO, true) 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 {} - end + local picker_contests = cache.get_contest_list(platform) or {} - local plugin_path = utils.get_plugin_path() - local cmd = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'contests', - } + if vim.tbl_isempty(picker_contests) then + logger.log(('Cache miss on %s contests'):format(platform)) + local contests = scraper.scrape_contest_list(platform) - local result = vim - .system(cmd, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - :wait() + cache.set_contest_list(platform, contests) - logger.log(('exit code: %d, stdout length: %d'):format(result.code, #(result.stdout or ''))) - if result.stderr and #result.stderr > 0 then - logger.log(('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'), - vim.log.levels.ERROR - ) - return {} - end - - logger.log(('stdout preview: %s'):format(result.stdout:sub(1, 100))) - - local ok, data = pcall(vim.json.decode, result.stdout) - 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 - - 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 - - logger.log(('loaded %d contests'):format(#contests)) - return contests -end - ----@param platform string Platform identifier ----@param contest_id string Contest identifier ----@return cp.ProblemItem[] -function M.get_problems_for_contest(platform, contest_id) - logger.log('loading contest problems...', vim.log.levels.INFO, true) - - local problems = {} - - cache.load() - local contest_data = cache.get_contest_data(platform, contest_id) - if contest_data and contest_data.problems then - for _, problem in ipairs(contest_data.problems) do - table.insert(problems, { - id = problem.id, - name = problem.name, - display_name = problem.name, + for _, contest in ipairs(contests or {}) do + table.insert(picker_contests, { + id = contest.id, + name = contest.name, + display_name = contest.display_name, }) end - return problems end - if not utils.setup_python_env() then - return problems - end - - 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( - ('Failed to scrape contest: %s'):format(result.stderr or 'unknown error'), - vim.log.levels.ERROR - ) - return problems - end - - local ok, data = pcall(vim.json.decode, result.stdout) - 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) - 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, - display_name = problem.name, - }) - end - - return problems + logger.log( + ('Loaded %d %s contests.'):format(#picker_contests, platform), + vim.log.levels.INFO, + true + ) + return picker_contests end ---@param platform string Platform identifier diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index c1bb03c..496b056 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -13,7 +13,7 @@ local function contest_picker(opts, platform) local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform local contests = picker_utils.get_contests_for_platform(platform) - if #contests == 0 then + if vim.tbl_isempty(contests) then vim.notify( ('No contests found for platform: %s'):format(platform_display_name), vim.log.levels.WARN diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 21cf19a..300ed1c 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -66,9 +66,9 @@ end local function parse_test_cases_from_cache(platform, contest_id, problem_id) local cache = require('cp.cache') cache.load() - local cached_test_cases = cache.get_test_cases(platform, contest_id, problem_id) + local cached_test_cases = cache.get_test_cases(platform, contest_id, problem_id) or {} - if not cached_test_cases or #cached_test_cases == 0 then + if vim.tbl_isempty(cached_test_cases) then return {} end @@ -299,9 +299,9 @@ function M.load_test_cases(state) state.get_platform() or '', state.get_contest_id() or '', state.get_problem_id() - ) + ) or {} - if #test_cases == 0 then + if vim.tbl_isempty(test_cases) then local input_file = state.get_input_file() local expected_file = state.get_expected_file() test_cases = parse_test_cases_from_files(input_file, expected_file) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 4f70930..2ad974f 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -1,11 +1,42 @@ local M = {} -local cache = require('cp.cache') local utils = require('cp.utils') -local function run_scraper(platform, subcommand, args, callback) +local logger = require('cp.log') + +local function syshandle(result) + if result.code ~= 0 then + local msg = 'Scraper failed: ' .. (result.error or result.stderr or 'Unknown error') + logger.log(msg, vim.log.levels.ERROR) + return { + success = false, + error = msg, + } + end + + local ok, data = pcall(vim.json.decode, result.stdout) + if not ok then + local msg = 'Failed to parse scraper output: ' .. tostring(data) + logger.log(msg, vim.log.levels.ERROR) + return { + success = false, + error = msg, + } + end + + return { + success = true, + data = data, + } +end + +local function run_scraper(platform, subcommand, args, opts) if not utils.setup_python_env() then - callback({ success = false, error = 'Python environment setup failed' }) - return + local msg = 'Python environment setup failed' + logger.log(msg, vim.log.levels.ERROR) + return { + success = false, + message = msg, + } end local plugin_path = utils.get_plugin_path() @@ -18,114 +49,96 @@ local function run_scraper(platform, subcommand, args, callback) 'scrapers.' .. platform, subcommand, } + vim.list_extend(cmd, args) - for _, arg in ipairs(args or {}) do - table.insert(cmd, arg) - end - - vim.system(cmd, { - cwd = plugin_path, + local sysopts = { 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) + if opts.sync then + local result = vim.system(cmd, sysopts):wait() + return syshandle(result) + else + vim.system(cmd, sysopts, function(result) + return opts.on_exit(syshandle(result)) + end) + 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) + run_scraper(platform, 'metadata', { contest_id }, { + on_exit = function(result) + if result.success and result.data.problems then + callback(result.data.problems) + end + 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 +function M.scrape_contest_list(platform) + local result = run_scraper(platform, 'contests', {}, { sync = true }) + if not result.success or not result.data.contests then + logger.log(('Could not scrape contests list for platform %s: %s'):format(platform, result.msg)) + 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) + return result.data.contests 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 - 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) + run_scraper(platform, 'tests', { contest_id, problem_id }, { + on_exit = function(result) + if result.success and result.data.tests then + 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) - 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' + 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_content = test_case.input:gsub('\r', '') + local expected_content = test_case.expected:gsub('\r', '') - pcall(vim.fn.writefile, vim.split(input_content, '\n', true), input_file) - pcall(vim.fn.writefile, vim.split(expected_content, '\n', true), expected_file) + pcall( + vim.fn.writefile, + vim.split(input_content, '\n', { trimempty = true }), + input_file + ) + pcall( + vim.fn.writefile, + vim.split(expected_content, '\n', { trimempty = true }), + expected_file + ) + end end - end - end) + end) - 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, - }) + 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 - cache.set_test_cases( - platform, - contest_id, - problem_id, - cached_tests, - result.timeout_ms, - result.memory_mb - ) - end - - callback(result) - end) + callback(result) + end, + }) end return M diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 629c3a2..292258e 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -28,10 +28,10 @@ function M.set_platform(platform) return true end +-- NOTE: this is backwards function M.setup_contest(platform, contest_id, problem_id, language) if not state.get_platform() then logger.log('No platform configured. Use :CP [...] first.') - return end @@ -42,6 +42,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) return end + state.set_contest_id(contest_id) logger.log('fetching contests problems...', vim.log.levels.INFO, true) scraper.scrape_contest_metadata(platform, contest_id, function(result) @@ -54,14 +55,13 @@ function M.setup_contest(platform, contest_id, problem_id, language) end local problems = result.problems - if not problems or #problems == 0 then + if vim.tbl_isempty(problems) then logger.log('no problems found in contest', vim.log.levels.ERROR) return end logger.log(('found %d problems'):format(#problems)) - state.set_contest_id(contest_id) local target_problem = problem_id or problems[1].id if problem_id then @@ -81,6 +81,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) end end + -- NOTE: should setup buffer without a name, then save it with proper name later for immediate editing M.setup_problem(contest_id, target_problem, language) M.scrape_remaining_problems(platform, contest_id, problems) @@ -161,6 +162,7 @@ function M.setup_problem(contest_id, problem_id, language) elseif vim.tbl_contains(config.scrapers, platform) then logger.log('loading test cases...') + -- TODO: caching should be here, not in scrpaer.lua scraper.scrape_problem_tests(platform, contest_id, problem_id, function(result) if result.success then logger.log(('loaded %d test cases for %s'):format(#(result.tests or {}), problem_id)) @@ -194,7 +196,7 @@ function M.scrape_remaining_problems(platform, contest_id, problems) end end - if #missing_problems == 0 then + if vim.tbl_isempty(missing_problems) then logger.log('all problems already cached') return end diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 7fb1984..4d742ae 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -235,7 +235,7 @@ function M.toggle_run_panel(is_debug) local function navigate_test_case(delta) local test_state = run.get_run_panel_state() - if #test_state.test_cases == 0 then + if vim.tbl_isempty(test_state.test_cases) then return end test_state.current_index = (test_state.current_index + delta) % #test_state.test_cases diff --git a/spec/execute_spec.lua b/spec/execute_spec.lua index 81025ef..e221bd4 100644 --- a/spec/execute_spec.lua +++ b/spec/execute_spec.lua @@ -12,7 +12,7 @@ describe('cp.execute', function() vim.system = function(cmd, opts) table.insert(mock_system_calls, { cmd = cmd, opts = opts }) - if not cmd or #cmd == 0 then + if vim.tbl_isempty(cmd) then return { wait = function() return { code = 0, stdout = '', stderr = '' } diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua index a007a8a..2af76f7 100644 --- a/spec/picker_spec.lua +++ b/spec/picker_spec.lua @@ -90,101 +90,4 @@ describe('cp.picker', function() assert.equals('Beginner Contest 123 (ABC)', contests[1].display_name) end) end) - - describe('get_problems_for_contest', function() - it('returns problems from cache when available', function() - local cache = require('cp.cache') - cache.load = function() end - cache.get_contest_data = function(_, _) - return { - problems = { - { id = 'a', name = 'Problem A' }, - { id = 'b', name = 'Problem B' }, - }, - } - end - - local problems = picker.get_problems_for_contest('test_platform', 'test_contest') - assert.is_table(problems) - assert.equals(2, #problems) - assert.equals('a', problems[1].id) - assert.equals('Problem A', problems[1].name) - assert.equals('Problem A', problems[1].display_name) - end) - - 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 - - utils.setup_python_env = function() - return true - end - utils.get_plugin_path = function() - return '/tmp' - end - - vim.system = function() - 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' }) - - local problems = picker.get_problems_for_contest('test_platform', 'test_contest') - assert.is_table(problems) - assert.equals(1, #problems) - assert.equals('x', problems[1].id) - end) - - it('returns empty list when scraping fails', function() - local cache = require('cp.cache') - local 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') - assert.is_table(problems) - assert.equals(0, #problems) - end) - end) end)