diff --git a/README.md b/README.md index 4fdca57..9249a17 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # cp.nvim -> ⚠️ **Warning**: as of 27/09/25, CodeForces upgraded their anti-scraping technology and support is thus (temporarily) broken. I am actively researching a way around this. - **The definitive competitive programming environment for Neovim** Scrape problems, run tests, and debug solutions across multiple platforms with zero configuration. @@ -29,7 +27,7 @@ cp.nvim follows a simple principle: **solve locally, submit remotely**. ### Basic Usage 1. **Find a contest or problem** on the judge website -2. **Set up locally** with `:CP []` +2. **Set up locally** with `:CP [--{lang=,debug}]` ``` :CP codeforces 1848 diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 07d4312..6b8097b 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -56,13 +56,6 @@ COMMANDS *cp-commands* :CP {platform} Platform setup: set platform only. Example: > :CP cses -< - :CP {problem_id} [--lang={language}] - Problem switch: switch to different problem - within current contest context. - Example: > - :CP b - :CP b --lang=python < Action Commands ~ :CP run [--debug] Toggle run panel for individual test case @@ -82,6 +75,16 @@ COMMANDS *cp-commands* :CP prev Navigate to previous problem in current contest. Stops at first problem (no wrapping). + Cache Commands ~ + :CP cache clear [contest] + Clear the cache data (contest list, problem + data, file states) for the specified contest, + or all contests if none specified + + :CP cache read + View the cache in a pretty-printed lua buffer. + Exit with q. + Command Flags ~ *cp-flags* Flags can be used with setup and action commands: @@ -151,7 +154,6 @@ Here's an example configuration with lazy.nvim: >lua diff_mode = 'vim', next_test_key = '', prev_test_key = '', - toggle_diff_key = '', max_output_lines = 50, }, diff = { @@ -229,7 +231,6 @@ is required: {next_test_key} (string, default: "") Key to navigate to next test case. {prev_test_key} (string, default: "") Key to navigate to previous test case. {toggle_diff_key} (string, default: "") Key to cycle through diff modes. - {close_key} (string, default: "") Close the run panel/interactive terminal {max_output_lines} (number, default: 50) Maximum lines of test output. *cp.DiffConfig* @@ -285,7 +286,6 @@ URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a Usage examples: > :CP atcoder abc324 a " Full setup: problem A from contest ABC324 :CP atcoder abc324 " Contest setup: load contest metadata only - :CP b " Switch to problem B (if contest loaded) :CP next " Navigate to next problem in contest < Note: AtCoder template includes optimizations @@ -303,7 +303,6 @@ URL format: https://codeforces.com/contest/1234/problem/A Usage examples: > :CP codeforces 1934 a " Full setup: problem A from contest 1934 :CP codeforces 1934 " Contest setup: load contest metadata only - :CP c " Switch to problem C (if contest loaded) :CP prev " Navigate to previous problem in contest < Note: Problem IDs are automatically converted @@ -535,9 +534,9 @@ RUN PANEL KEYMAPS *cp-test-keys* run_panel.next_test_key) Navigate to previous test case (configurable via run_panel.prev_test_key) - Cycle through diff modes: none → git → vim (configurable - via run_panel.toggle_diff_key) - Exit run panel/interactive terminal and restore layout +t Cycle through diff modes: none → git → vim +q Exit run panel and restore layout + Exit interactive terminal and restore layout Diff Modes ~ diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index c98fedb..588ac38 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 @@ -34,6 +31,7 @@ local M = {} +local logger = require('cp.log') local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_data = {} local loaded = false @@ -44,7 +42,7 @@ function M.load() end if vim.fn.filereadable(cache_file) == 0 then - cache_data = {} + vim.fn.writefile({}, cache_file) loaded = true return end @@ -60,28 +58,19 @@ function M.load() if ok then cache_data = decoded else - cache_data = {} + logger.log('Could not decode json in cache file', vim.log.levels.ERROR) end loaded = true end function M.save() - 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') - end) - return - end + vim.schedule(function() + vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') - local encoded = vim.json.encode(cache_data) - local lines = vim.split(encoded, '\n') - 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) - end + local encoded = vim.json.encode(cache_data) + local lines = vim.split(encoded, '\n') + vim.fn.writefile(lines, cache_file) + end) end ---@param platform string @@ -98,7 +87,7 @@ function M.get_contest_data(platform, contest_id) end local contest_data = cache_data[platform][contest_id] - if not contest_data then + if not contest_data or vim.tbl_isempty(contest_data) then return nil end @@ -115,15 +104,37 @@ function M.set_contest_data(platform, contest_id, problems) problems = { problems, 'table' }, }) - if not cache_data[platform] then - cache_data[platform] = {} + cache_data[platform] = cache_data[platform] or {} + local existing = cache_data[platform][contest_id] or {} + + local existing_by_id = {} + if existing.problems then + for _, p in ipairs(existing.problems) do + existing_by_id[p.id] = p + end end - cache_data[platform][contest_id] = { - problems = problems, - scraped_at = os.date('%Y-%m-%d'), - } + local merged = {} + for _, p in ipairs(problems) do + local prev = existing_by_id[p.id] or {} + local merged_p = { + id = p.id, + name = p.name or prev.name, + test_cases = prev.test_cases, + timeout_ms = prev.timeout_ms, + memory_mb = prev.memory_mb, + interactive = prev.interactive, + } + table.insert(merged, merged_p) + end + existing.problems = merged + existing.index_map = {} + for i, p in ipairs(merged) do + existing.index_map[p.id] = i + end + + cache_data[platform][contest_id] = existing M.save() end @@ -152,16 +163,23 @@ function M.get_test_cases(platform, contest_id, problem_id) problem_id = { problem_id, { 'string', 'nil' }, true }, }) - local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id - if not cache_data[platform] or not cache_data[platform][problem_key] then + if + not cache_data[platform] + or not cache_data[platform][contest_id] + or not cache_data[platform][contest_id].problems + or not cache_data[platform][contest_id].index_map + then + print('bad, failing') return nil end - return cache_data[platform][problem_key].test_cases + + local index = cache_data[platform][contest_id].index_map[problem_id] + return cache_data[platform][contest_id].problems[index].test_cases end ---@param platform string ---@param contest_id string ----@param problem_id? string +---@param problem_id string ---@param test_cases CachedTestCase[] ---@param timeout_ms? number ---@param memory_mb? number @@ -185,22 +203,12 @@ function M.set_test_cases( interactive = { interactive, { 'boolean', 'nil' }, true }, }) - local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id - if not cache_data[platform] then - cache_data[platform] = {} - end - if not cache_data[platform][problem_key] then - cache_data[platform][problem_key] = {} - end + local index = cache_data[platform][contest_id].index_map[problem_id] + + cache_data[platform][contest_id].problems[index].test_cases = test_cases + cache_data[platform][contest_id].problems[index].timeout_ms = timeout_ms or 0 + cache_data[platform][contest_id].problems[index].memory_mb = memory_mb or 0 - 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 - if memory_mb then - cache_data[platform][problem_key].memory_mb = memory_mb - end M.save() end @@ -215,12 +223,9 @@ function M.get_constraints(platform, contest_id, problem_id) problem_id = { problem_id, { 'string', 'nil' }, true }, }) - local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id - if not cache_data[platform] or not cache_data[platform][problem_key] then - return nil, nil - end + local index = cache_data[platform][contest_id].index_map[problem_id] - local problem_data = cache_data[platform][problem_key] + local problem_data = cache_data[platform][contest_id].problems[index] return problem_data.timeout_ms, problem_data.memory_mb end @@ -255,43 +260,34 @@ function M.set_file_state(file_path, platform, contest_id, problem_id, language) end ---@param platform string ----@return table[]? +---@return table[] function M.get_contest_list(platform) - if not cache_data.contest_lists or not cache_data.contest_lists[platform] then - return nil + local contest_list = {} + for contest_id, contest_data in pairs(cache_data[platform] or {}) do + table.insert(contest_list, { + id = contest_id, + name = contest_data.name, + display_name = contest_data.display_name, + }) end - - return cache_data.contest_lists[platform].contests + return contest_list end ---@param platform string ---@param contests table[] function M.set_contest_list(platform, contests) - if not cache_data.contest_lists then - cache_data.contest_lists = {} + cache_data[platform] = cache_data[platform] or {} + for _, contest in ipairs(contests) do + cache_data[platform][contest.id] = cache_data[platform][contest] or {} + cache_data[platform][contest.id].display_name = contest.display_name + cache_data[platform][contest.id].name = contest.name end - cache_data.contest_lists[platform] = { - contests = contests, - cached_at = os.time(), - } - M.save() end ----@param platform string -function M.clear_contest_list(platform) - if cache_data.contest_lists and cache_data.contest_lists[platform] then - cache_data.contest_lists[platform] = nil - M.save() - end -end - function M.clear_all() - cache_data = { - file_states = {}, - contest_lists = {}, - } + cache_data = {} M.save() end @@ -300,10 +296,15 @@ function M.clear_platform(platform) if cache_data[platform] then cache_data[platform] = nil end - if cache_data.contest_lists and cache_data.contest_lists[platform] then - cache_data.contest_lists[platform] = nil - end + M.save() end +---@return string +function M.get_data_pretty() + M.load() + + return vim.inspect(cache_data) +end + return M diff --git a/lua/cp/commands/cache.lua b/lua/cp/commands/cache.lua index 08f50de..8af5ff7 100644 --- a/lua/cp/commands/cache.lua +++ b/lua/cp/commands/cache.lua @@ -7,15 +7,46 @@ local logger = require('cp.log') local platforms = constants.PLATFORMS function M.handle_cache_command(cmd) - if cmd.subcommand == 'clear' then + if cmd.subcommand == 'read' then + local data = cache.get_data_pretty() + local name = 'cp.nvim://cache.lua' + + local existing = vim.fn.bufnr(name) + local buf + if existing ~= -1 then + buf = existing + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(data, '\n')) + else + buf = vim.api.nvim_create_buf(true, true) + vim.api.nvim_buf_set_name(buf, name) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(data, '\n')) + vim.bo[buf].filetype = 'lua' + vim.bo[buf].buftype = 'nofile' + vim.bo[buf].bufhidden = 'wipe' + vim.bo[buf].swapfile = false + vim.api.nvim_buf_set_keymap( + buf, + 'n', + 'q', + 'bd!', + { nowait = true, noremap = true, silent = true } + ) + end + + vim.api.nvim_set_current_buf(buf) + elseif cmd.subcommand == 'clear' then cache.load() if cmd.platform then if vim.tbl_contains(platforms, cmd.platform) then cache.clear_platform(cmd.platform) - logger.log(('cleared cache for %s'):format(cmd.platform), vim.log.levels.INFO, true) + logger.log( + ('Cache cleared for platform %s'):format(cmd.platform), + vim.log.levels.INFO, + true + ) else logger.log( - ('unknown platform: %s. Available: %s'):format( + ("Unknown platform: '%s'. Available: %s"):format( cmd.platform, table.concat(platforms, ', ') ), @@ -24,7 +55,7 @@ function M.handle_cache_command(cmd) end else cache.clear_all() - logger.log('cleared all cache', vim.log.levels.INFO, true) + logger.log('Cache cleared', vim.log.levels.INFO, true) end end end diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 88c1f24..b4ce499 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', } @@ -44,11 +44,11 @@ local function parse_command(args) if not subcommand then return { type = 'error', message = 'cache command requires subcommand: clear' } end - if subcommand == 'clear' then + if vim.tbl_contains({ 'clear', 'read' }, subcommand) then local platform = filtered_args[3] return { type = 'cache', - subcommand = 'clear', + subcommand = subcommand, platform = platform, } else @@ -62,9 +62,8 @@ local function parse_command(args) if vim.tbl_contains(platforms, first) then if #filtered_args == 1 then return { - type = 'platform_only', - platform = first, - language = language, + type = 'error', + message = 'Too few arguments - specify a contest.', } elseif #filtered_args == 2 then return { @@ -75,11 +74,8 @@ local function parse_command(args) } elseif #filtered_args == 3 then return { - type = 'full_setup', - platform = first, - contest = filtered_args[2], - problem = filtered_args[3], - language = language, + type = 'error', + message = 'Setup contests with :CP [--{lang=,debug}]', } else return { type = 'error', message = 'Too many arguments' } @@ -89,16 +85,6 @@ local function parse_command(args) if state.get_platform() and state.get_contest_id() then local cache = require('cp.cache') cache.load() - local contest_data = - cache.get_contest_data(state.get_platform() or '', state.get_contest_id() or '') - if contest_data and contest_data.problems then - local problem_ids = vim.tbl_map(function(prob) - return prob.id - end, contest_data.problems) - if vim.tbl_contains(problem_ids, first) then - return { type = 'problem_switch', problem = first, language = language } - end - end return { type = 'error', message = ("invalid subcommand '%s'"):format(first), @@ -147,33 +133,13 @@ function M.handle_command(opts) return end - if cmd.type == 'platform_only' then - local setup = require('cp.setup') - setup.set_platform(cmd.platform) - return - end - if cmd.type == 'contest_setup' then local setup = require('cp.setup') if setup.set_platform(cmd.platform) then - setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language) + setup.setup_contest(cmd.platform, cmd.contest, cmd.language, nil) end return end - - if cmd.type == 'full_setup' then - local setup = require('cp.setup') - if setup.set_platform(cmd.platform) then - setup.setup_contest(cmd.platform, cmd.contest, cmd.problem, cmd.language) - 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) - return - end end return M diff --git a/lua/cp/commands/picker.lua b/lua/cp/commands/picker.lua index 1c3ef6a..80d79be 100644 --- a/lua/cp/commands/picker.lua +++ b/lua/cp/commands/picker.lua @@ -8,7 +8,7 @@ function M.handle_pick_action() if not config.picker then logger.log( - 'No picker configured. Set picker = "telescope" or picker = "fzf-lua" in config', + 'No picker configured. Set picker = "{telescope,fzf-lua}" in your config.', vim.log.levels.ERROR ) return @@ -20,14 +20,14 @@ function M.handle_pick_action() local ok = pcall(require, 'telescope') if not ok then logger.log( - 'Telescope not available. Install telescope.nvim or change picker config', + 'telescope.nvim is not available. Install telescope.nvim xor change your picker config.', vim.log.levels.ERROR ) return end local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope') if not ok_cp then - logger.log('Failed to load telescope integration', vim.log.levels.ERROR) + logger.log('Failed to load telescope integration.', vim.log.levels.ERROR) return end @@ -36,14 +36,14 @@ function M.handle_pick_action() local ok, _ = pcall(require, 'fzf-lua') if not ok then logger.log( - 'fzf-lua not available. Install fzf-lua or change picker config', + 'fzf-lua is not available. Install fzf-lua xor change your picker config', vim.log.levels.ERROR ) return end local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua') if not ok_cp then - logger.log('Failed to load fzf-lua integration', vim.log.levels.ERROR) + logger.log('Failed to load fzf-lua integration.', vim.log.levels.ERROR) return end diff --git a/lua/cp/config.lua b/lua/cp/config.lua index e5f0963..6d96793 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -33,8 +33,6 @@ ---@field diff_mode "none"|"vim"|"git" Diff backend to use ---@field next_test_key string Key to navigate to next test case ---@field prev_test_key string Key to navigate to previous test case ----@field toggle_diff_key string Key to cycle through diff modes ----@field close_key string Key to close panel/interactive terminal ---@field max_output_lines number Maximum lines of test output to display ---@class DiffGitConfig @@ -104,8 +102,6 @@ M.defaults = { diff_mode = 'none', next_test_key = '', prev_test_key = '', - toggle_diff_key = '', - close_key = '', max_output_lines = 50, }, diff = { @@ -224,20 +220,6 @@ function M.setup(user_config) end, 'prev_test_key must be a non-empty string', }, - toggle_diff_key = { - config.run_panel.toggle_diff_key, - function(value) - return type(value) == 'string' and value ~= '' - end, - 'toggle_diff_key must be a non-empty string', - }, - close_key = { - config.run_panel.close_key, - function(value) - return type(value) == 'string' and value ~= '' - end, - 'close_key must be a non-empty string', - }, max_output_lines = { config.run_panel.max_output_lines, function(value) @@ -270,7 +252,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/init.lua b/lua/cp/init.lua index da82f9c..b2881a9 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -5,7 +5,7 @@ local logger = require('cp.log') local snippets = require('cp.snippets') if not vim.fn.has('nvim-0.10.0') then - logger.log('[cp.nvim]: requires nvim-0.10.0+', vim.log.levels.ERROR) + logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR) return {} end diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index 1573f4c..da29d74 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -2,13 +2,13 @@ local picker_utils = require('cp.pickers') local M = {} -local function contest_picker(platform) +local function contest_picker(platform, refresh) local constants = require('cp.constants') local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform local fzf = require('fzf-lua') - local contests = picker_utils.get_contests_for_platform(platform) + local contests = picker_utils.get_platform_contests(platform, refresh) - 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 @@ -48,7 +48,7 @@ local function contest_picker(platform) ['ctrl-r'] = function() local cache = require('cp.cache') cache.clear_contest_list(platform) - contest_picker(platform) + contest_picker(platform, true) 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..a3321a3 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -2,8 +2,9 @@ local M = {} local cache = require('cp.cache') local config = require('cp.config').get_config() +local constants = require('cp.constants') 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") @@ -21,7 +22,6 @@ local utils = require('cp.utils') ---@return cp.PlatformItem[] function M.get_platforms() - local constants = require('cp.constants') local result = {} for _, platform in ipairs(constants.PLATFORMS) do @@ -38,169 +38,35 @@ end ---Get list of contests for a specific platform ---@param platform string Platform identifier (e.g. "codeforces", "atcoder") +---@param refresh? boolean Whether to skip caching and append new contests ---@return cp.ContestItem[] -function M.get_contests_for_platform(platform) - logger.log('loading contests...', vim.log.levels.INFO, true) +function M.get_platform_contests(platform, refresh) + logger.log( + ('Loading %s contests...'):format(constants.PLATFORM_DISPLAY_NAMES[platform]), + vim.log.levels.INFO, + true + ) cache.load() - local cached_contests = cache.get_contest_list(platform) - if cached_contests then - return cached_contests + + local picker_contests = cache.get_contest_list(platform) + + if refresh or vim.tbl_isempty(picker_contests) then + logger.log(('Cache miss on %s contests'):format(platform)) + local contests = scraper.scrape_contest_list(platform) + + cache.set_contest_list(platform, contests) end - if not utils.setup_python_env() then - return {} - end + logger.log( + ('Loaded %s %s contests.'):format(#picker_contests, constants.PLATFORM_DISPLAY_NAMES[platform]), + vim.log.levels.INFO, + true + ) - local plugin_path = utils.get_plugin_path() - local cmd = { - 'uv', - 'run', - '--directory', - plugin_path, - '-m', - 'scrapers.' .. platform, - 'contests', - } + picker_contests = cache.get_contest_list(platform) - local result = vim - .system(cmd, { - cwd = plugin_path, - text = true, - timeout = 30000, - }) - :wait() - - 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, - }) - 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 -end - ----@param platform string Platform identifier ----@param contest_id string Contest identifier ----@param problem_id string Problem identifier -function M.setup_problem(platform, contest_id, problem_id) - vim.schedule(function() - local cp = require('cp') - cp.handle_command({ fargs = { platform, contest_id, problem_id } }) - end) + return picker_contests end return M diff --git a/lua/cp/pickers/telescope.lua b/lua/cp/pickers/telescope.lua index c1bb03c..9b3c0db 100644 --- a/lua/cp/pickers/telescope.lua +++ b/lua/cp/pickers/telescope.lua @@ -8,12 +8,12 @@ local picker_utils = require('cp.pickers') local M = {} -local function contest_picker(opts, platform) +local function contest_picker(opts, platform, refresh) 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) + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] + local contests = picker_utils.get_platform_contests(platform, refresh) - 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 @@ -48,10 +48,8 @@ local function contest_picker(opts, platform) end) map('i', '', function() - local cache = require('cp.cache') - cache.clear_contest_list(platform) actions.close(prompt_bufnr) - contest_picker(opts, platform) + contest_picker(opts, platform, true) end) return true diff --git a/lua/cp/restore.lua b/lua/cp/restore.lua index 60236b4..54ff8be 100644 --- a/lua/cp/restore.lua +++ b/lua/cp/restore.lua @@ -7,7 +7,7 @@ local state = require('cp.state') function M.restore_from_current_file() local current_file = vim.fn.expand('%:p') if current_file == '' then - logger.log('No file is currently open', vim.log.levels.ERROR) + logger.log('No file is currently open.', vim.log.levels.ERROR) return false end @@ -15,7 +15,7 @@ function M.restore_from_current_file() local file_state = cache.get_file_state(current_file) if not file_state then logger.log( - 'No cached state found for current file. Use :CP first.', + 'No cached state found for current file. Use :CP [--{lang=,debug}...] first.', vim.log.levels.ERROR ) return false @@ -25,7 +25,7 @@ function M.restore_from_current_file() ('Restoring from cached state: %s %s %s'):format( file_state.platform, file_state.contest_id, - file_state.problem_id or 'N/A' + file_state.problem_id ) ) @@ -37,7 +37,12 @@ function M.restore_from_current_file() state.set_contest_id(file_state.contest_id) state.set_problem_id(file_state.problem_id) - setup.setup_problem(file_state.contest_id, file_state.problem_id, file_state.language) + setup.setup_contest( + file_state.platform, + file_state.contest_id, + file_state.language, + file_state.problem_id + ) return true end diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 8bffa33..4fce9e2 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -52,7 +52,7 @@ end ---@return {code: integer, stdout: string, stderr: string} function M.compile_generic(language_config, substitutions) if not language_config.compile then - logger.log('no compilation step required') + logger.log('No compilation step required for language - skipping.') return { code = 0, stderr = '' } end @@ -73,9 +73,9 @@ function M.compile_generic(language_config, substitutions) result.stderr = ansi.bytes_to_string(result.stderr or '') if result.code == 0 then - logger.log(('compilation successful (%.1fms)'):format(compile_time), vim.log.levels.INFO) + logger.log(('Compilation successful in %.1fms.'):format(compile_time), vim.log.levels.INFO) else - logger.log(('compilation failed (%.1fms)'):format(compile_time)) + logger.log(('Compilation failed in %.1fms.'):format(compile_time)) end return result @@ -107,14 +107,14 @@ local function execute_command(cmd, input_data, timeout_ms) local actual_code = result.code or 0 if result.code == 124 then - logger.log(('execution timed out after %.1fms'):format(execution_time), vim.log.levels.WARN) + logger.log(('Execution timed out in %.1fms.'):format(execution_time), vim.log.levels.WARN) elseif actual_code ~= 0 then logger.log( - ('execution failed (exit code %d, %.1fms)'):format(actual_code, execution_time), + ('Execution failed in %.1fms (exit code %d).'):format(execution_time, actual_code), vim.log.levels.WARN ) else - logger.log(('execution successful (%.1fms)'):format(execution_time)) + logger.log(('Execution successful in %.1fms.'):format(execution_time)) end return { @@ -177,8 +177,8 @@ function M.compile_problem(contest_config, is_debug) local state = require('cp.state') local source_file = state.get_source_file() if not source_file then - logger.log('No source file found', vim.log.levels.ERROR) - return { success = false, output = 'No source file found' } + logger.log('No source file found.', vim.log.levels.ERROR) + return { success = false, output = 'No source file found.' } end local language = get_language_from_file(source_file, contest_config) @@ -186,7 +186,7 @@ function M.compile_problem(contest_config, is_debug) if not language_config then logger.log('No configuration for language: ' .. language, vim.log.levels.ERROR) - return { success = false, output = 'No configuration for language: ' .. language } + return { success = false, output = ('No configuration for language %s.'):format(language) } end local binary_file = state.get_binary_file() @@ -203,10 +203,6 @@ function M.compile_problem(contest_config, is_debug) if compile_result.code ~= 0 then return { success = false, output = compile_result.stdout or 'unknown error' } end - logger.log( - ('compilation successful (%s)'):format(is_debug and 'debug mode' or 'test mode'), - vim.log.levels.INFO - ) end return { success = true, output = nil } @@ -220,7 +216,10 @@ function M.run_problem(contest_config, is_debug) local output_file = state.get_output_file() if not source_file or not output_file then - logger.log('Missing required file paths', vim.log.levels.ERROR) + logger.log( + ('Missing required file paths %s and %s'):format(source_file, output_file), + vim.log.levels.ERROR + ) return end @@ -257,13 +256,14 @@ function M.run_problem(contest_config, is_debug) local cache = require('cp.cache') cache.load() + local platform = state.get_platform() local contest_id = state.get_contest_id() local problem_id = state.get_problem_id() local expected_file = state.get_expected_file() if not platform or not contest_id or not expected_file then - logger.log('configure a contest before running a problem', vim.log.levels.ERROR) + logger.log('Configure a contest before running a problem', vim.log.levels.ERROR) return end local timeout_ms, _ = cache.get_constraints(platform, contest_id, problem_id) diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 21cf19a..4462d0c 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 @@ -83,34 +83,6 @@ local function parse_test_cases_from_cache(platform, contest_id, problem_id) return test_cases end ----@param input_file string ----@return TestCase[] -local function parse_test_cases_from_files(input_file, _) - local base_name = vim.fn.fnamemodify(input_file, ':r') - local test_cases = {} - - local i = 1 - while true do - local individual_input_file = base_name .. '.' .. i .. '.cpin' - local individual_expected_file = base_name .. '.' .. i .. '.cpout' - - if - vim.fn.filereadable(individual_input_file) == 1 - and vim.fn.filereadable(individual_expected_file) == 1 - then - local input_content = table.concat(vim.fn.readfile(individual_input_file), '\n') - local expected_content = table.concat(vim.fn.readfile(individual_expected_file), '\n') - - table.insert(test_cases, create_test_case(i, input_content, expected_content)) - i = i + 1 - else - break - end - end - - return test_cases -end - ---@param platform string ---@param contest_id string ---@param problem_id string? @@ -136,28 +108,11 @@ end local function run_single_test_case(contest_config, cp_config, test_case) local state = require('cp.state') local source_file = state.get_source_file() - if not source_file then - return { - status = 'fail', - actual = '', - error = 'No source file found', - time_ms = 0, - } - end local language = vim.fn.fnamemodify(source_file, ':e') local language_name = constants.filetype_to_language[language] or contest_config.default_language local language_config = contest_config[language_name] - if not language_config then - return { - status = 'fail', - actual = '', - error = 'No language configuration', - time_ms = 0, - } - end - local function substitute_template(cmd_template, substitutions) local result = {} for _, arg in ipairs(cmd_template) do @@ -185,7 +140,7 @@ local function run_single_test_case(contest_config, cp_config, test_case) } if language_config.compile and binary_file and vim.fn.filereadable(binary_file) == 0 then - logger.log('binary not found, compiling first...') + logger.log('Binary not found - compiling first.') local compile_cmd = substitute_template(language_config.compile, substitutions) local redirected_cmd = vim.deepcopy(compile_cmd) redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1' @@ -208,6 +163,7 @@ local function run_single_test_case(contest_config, cp_config, test_case) ok = false, signal = nil, timed_out = false, + actual_highlights = {}, } end end @@ -219,9 +175,6 @@ local function run_single_test_case(contest_config, cp_config, test_case) local start_time = vim.uv.hrtime() local timeout_ms = run_panel_state.constraints and run_panel_state.constraints.timeout_ms or 2000 - if not run_panel_state.constraints then - logger.log('no problem constraints available, using default 2000ms timeout') - end local redirected_run_cmd = vim.deepcopy(run_cmd) redirected_run_cmd[#redirected_run_cmd] = redirected_run_cmd[#redirected_run_cmd] .. ' 2>&1' local result = vim @@ -299,13 +252,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 - 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) - end + -- TODO: re-fetch/cache-populating mechanism to ge the test cases if not in the cache run_panel_state.test_cases = test_cases run_panel_state.current_index = 1 @@ -315,14 +264,7 @@ function M.load_test_cases(state) state.get_problem_id() ) - local constraint_info = run_panel_state.constraints - and string.format( - ' with %dms/%dMB limits', - run_panel_state.constraints.timeout_ms, - run_panel_state.constraints.memory_mb - ) - or '' - logger.log(('loaded %d test case(s)%s'):format(#test_cases, constraint_info), vim.log.levels.INFO) + logger.log(('Loaded %d test case(s)'):format(#test_cases), vim.log.levels.INFO) return #test_cases > 0 end diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 4f70930..2a2f168 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.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,98 @@ 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 not result or not result.success then + logger.log( + ('Failed to scrape metadata for %s contest %s.'):format(platform, contest_id), + vim.log.levels.ERROR + ) + return + end + local data = result.data or {} + if not data.problems or #data.problems == 0 then + logger.log( + ('No problems returned for %s contest %s.'):format(platform, contest_id), + vim.log.levels.ERROR + ) + return + end + if type(callback) == 'function' then + callback(data) + end + end, + }) end -function M.scrape_contest_list(platform, 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), + vim.log.levels.ERROR + ) + 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 not result.success or not result.data.tests then + logger.log( + 'Failed to load tests: ' .. (result.msg or 'unknown error'), + vim.log.levels.ERROR + ) - 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', '') - - 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 - 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, - }) + return {} end - cache.set_test_cases( - platform, - contest_id, - problem_id, - cached_tests, - result.timeout_ms, - result.memory_mb - ) - end + vim.schedule(function() + vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() + local config = require('cp.config') + local base_name = config.default_filename(contest_id, problem_id) - callback(result) - end) + for i, test_case in ipairs(result.data.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', '') + + 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 + if type(callback) == 'function' then + callback(result.data) + end + end) + end, + }) end return M diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 629c3a2..4d0c402 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -28,196 +28,154 @@ function M.set_platform(platform) return true end -function M.setup_contest(platform, contest_id, problem_id, language) - if not state.get_platform() then - logger.log('No platform configured. Use :CP [...] first.') - +local function backfill_missing_tests(platform, contest_id, problems) + cache.load() + local missing = {} + for _, prob in ipairs(problems) do + if not cache.get_test_cases(platform, contest_id, prob.id) then + table.insert(missing, prob.id) + end + end + if #missing == 0 then + logger.log(('All problems already cached for %s contest %s.'):format(platform, contest_id)) 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('fetching contests problems...', vim.log.levels.INFO, true) - - 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)) - - state.set_contest_id(contest_id) - local target_problem = problem_id or problems[1].id - - if problem_id then - local problem_exists = false - for _, prob in ipairs(problems) do - if prob.id == problem_id then - problem_exists = true - break + for _, pid in ipairs(missing) do + local captured = pid + scraper.scrape_problem_tests(platform, contest_id, captured, function(result) + local cached_tests = {} + if result.tests then + for i, t in ipairs(result.tests) do + cached_tests[i] = { index = i, input = t.input, expected = t.expected } 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) - - M.scrape_remaining_problems(platform, contest_id, problems) - end) + cache.set_test_cases( + platform, + contest_id, + captured, + cached_tests, + result.timeout_ms, + result.memory_mb + ) + end) + 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) +function M.setup_contest(platform, contest_id, language, problem_id) + if not platform then + logger.log('No platform configured. Use :CP [--{lang=,debug} first.') 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 '')) + if not vim.tbl_contains(config.scrapers, platform) then + logger.log(('Scraping disabled for %s.'):format(platform), vim.log.levels.WARN) + return + end state.set_contest_id(contest_id) - state.set_problem_id(problem_id) - -- TODO: why comment this out - -- state.set_active_panel('run') + cache.load() + local contest_data = cache.get_contest_data(platform, contest_id) - vim.schedule(function() - local ok, err = pcall(function() - vim.cmd.only({ mods = { silent = true } }) - - local source_file = state.get_source_file(language) - if not source_file then - return - end - vim.cmd.e(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(state) - end - - cache.set_file_state(vim.fn.expand('%:p'), platform, contest_id, problem_id, language) - - logger.log(('ready - problem %s'):format(state.get_base_name())) - end) - - if not ok then - logger.log(('setup error: %s'):format(err), vim.log.levels.ERROR) - end - end) - - 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) - 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 not contest_data or not contest_data.problems then + logger.log('Fetching contests problems...', vim.log.levels.INFO, true) + scraper.scrape_contest_metadata(platform, contest_id, function(result) + local problems = result.problems or {} + cache.set_contest_data(platform, contest_id, problems) + logger.log(('Found %d problems for %s contest %s.'):format(#problems, platform, contest_id)) + local pid = problem_id or (problems[1] and problems[1].id) + if pid then + M.setup_problem(pid, language) end + backfill_missing_tests(platform, contest_id, problems) end) else - logger.log(('scraping disabled for %s'):format(platform)) - state.set_test_cases({}) + local problems = contest_data.problems + local pid = problem_id or (problems[1] and problems[1].id) + if pid then + M.setup_problem(pid, language) + end + backfill_missing_tests(platform, contest_id, problems) end end -function M.scrape_remaining_problems(platform, contest_id, problems) - cache.load() - 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') +---@param problem_id string +---@param language? string +function M.setup_problem(problem_id, language) + local platform = state.get_platform() + if not platform then + logger.log( + 'No platform set. run :CP [--{lang=,debug}]', + vim.log.levels.ERROR + ) return end - logger.log(('caching %d remaining problems...'):format(#missing_problems)) + state.set_problem_id(problem_id) - for _, prob in ipairs(missing_problems) do - scraper.scrape_problem_tests(platform, contest_id, prob.id, function(result) - if result.success then - logger.log(('background: scraped problem %s'):format(prob.id)) + local config = config_module.get_config() + + vim.schedule(function() + vim.cmd.only({ mods = { silent = true } }) + + local source_file = state.get_source_file(language) + if not source_file then + return + end + vim.cmd.e(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) end - end) - end + end + + if config.hooks and config.hooks.setup_code then + config.hooks.setup_code(state) + end + + cache.set_file_state( + vim.fn.expand('%:p'), + platform, + state.get_contest_id() or '', + state.get_problem_id(), + language + ) + end) end function M.navigate_problem(direction, language) + if direction == 0 then + return + end + direction = direction > 0 and 1 or -1 + 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 platform configured. Use :CP [...] first.', + 'No platform configured. Use :CP [--{lang=,debug}] first.', vim.log.levels.ERROR ) return @@ -226,32 +184,35 @@ function M.navigate_problem(direction, language) 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) + logger.log( + ('No data available for %s contest %s.'):format( + constants.PLATFORM_DISPLAY_NAMES[platform], + contest_id + ), + vim.log.levels.ERROR + ) return end local problems = contest_data.problems - local current_index = nil + local current_index 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) + M.setup_contest(platform, contest_id, language, problems[1].id) 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) + M.setup_contest(platform, contest_id, language, problems[new_index].id) end return M diff --git a/lua/cp/snippets.lua b/lua/cp/snippets.lua index ac43a65..9108286 100644 --- a/lua/cp/snippets.lua +++ b/lua/cp/snippets.lua @@ -4,7 +4,7 @@ local logger = require('cp.log') function M.setup(config) local ok, ls = pcall(require, 'luasnip') if not ok then - logger.log('LuaSnip not available - snippets disabled', vim.log.levels.INFO) + logger.log('LuaSnip not available - snippets are disabled.', vim.log.levels.INFO, true) return end diff --git a/lua/cp/state.lua b/lua/cp/state.lua index f827a50..497e5d5 100644 --- a/lua/cp/state.lua +++ b/lua/cp/state.lua @@ -7,8 +7,6 @@ ---@field set_problem_id fun(problem_id: string) ---@field get_active_panel fun(): string? ---@field set_active_panel fun(): string? ----@field get_test_cases fun(): table[]? ----@field set_test_cases fun(test_cases: table[]) ---@field get_saved_session fun(): table? ---@field set_saved_session fun(session: table) ---@field get_context fun(): {platform: string?, contest_id: string?, problem_id: string?} @@ -56,14 +54,6 @@ function M.set_problem_id(problem_id) state.problem_id = problem_id end -function M.get_test_cases() - return state.test_cases -end - -function M.set_test_cases(test_cases) - state.test_cases = test_cases -end - function M.get_saved_session() return state.saved_session end diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 7fb1984..60026b2 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -23,12 +23,12 @@ function M.toggle_interactive() state.saved_interactive_session = nil end state.set_active_panel(nil) - logger.log('interactive closed') + logger.log('Interactive panel closed.') return end if state.get_active_panel() then - logger.log('another panel is already active', vim.log.levels.ERROR) + logger.log('Another panel is already active.', vim.log.levels.WARN) return end @@ -36,7 +36,7 @@ function M.toggle_interactive() if not platform then logger.log( - 'No platform configured. Use :CP [...] first.', + 'No platform configured. Use :CP [--{lang=,debug}] first.', vim.log.levels.ERROR ) return @@ -44,7 +44,7 @@ function M.toggle_interactive() if not contest_id then logger.log( - ('No contest %s configured for platform %s. Use :CP to set up first.'):format( + ('No contest %s configured for platform %s. Use :CP [--{lang=,debug}] to set up first.'):format( contest_id, platform ), @@ -63,10 +63,7 @@ function M.toggle_interactive() cache.load() local contest_data = cache.get_contest_data(platform, contest_id) if contest_data and not contest_data.interactive then - logger.log( - 'This is NOT an interactive problem. Use :CP run instead - aborting.', - vim.log.levels.WARN - ) + logger.log('This is NOT an interactive problem. Use :CP run instead.', vim.log.levels.WARN) return end @@ -95,7 +92,7 @@ function M.toggle_interactive() vim.fn.chansend(vim.b.terminal_job_id, binary .. '\n') - vim.keymap.set('t', config.run_panel.close_key, function() + vim.keymap.set('t', '', function() M.toggle_interactive() end, { buffer = term_buf, silent = true }) @@ -139,7 +136,7 @@ function M.toggle_run_panel(is_debug) if not contest_id then logger.log( - ('No contest %s configured for platform %s. Use :CP to set up first.'):format( + ('No contest %s configured for platform %s. Use :CP [--{lang=,debug}] to set up first.'):format( contest_id, platform ), @@ -158,15 +155,12 @@ function M.toggle_run_panel(is_debug) cache.load() local contest_data = cache.get_contest_data(platform, contest_id) if contest_data and contest_data.interactive then - logger.log( - 'This is an interactive problem. Use :CP interact instead - aborting.', - vim.log.levels.WARN - ) + logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN) return end logger.log( - ('run panel: platform=%s, contest=%s, problem=%s'):format( + ('Run panel: platform=%s, contest=%s, problem=%s'):format( tostring(platform), tostring(contest_id), tostring(problem_id) @@ -235,18 +229,18 @@ 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 + test_state.current_index = (test_state.current_index + delta - 1) % #test_state.test_cases + 1 refresh_run_panel() end setup_keybindings_for_buffer = function(buf) - vim.keymap.set('n', config.run_panel.close_key, function() + vim.keymap.set('n', 'q', function() M.toggle_run_panel() end, { buffer = buf, silent = true }) - vim.keymap.set('n', config.run_panel.toggle_diff_key, function() + vim.keymap.set('n', 't', function() local modes = { 'none', 'git', 'vim' } local current_idx = nil for i, mode in ipairs(modes) do diff --git a/plugin/cp.lua b/plugin/cp.lua index 37c6f36..193beeb 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -46,7 +46,7 @@ end, { if args[2] == 'cache' then return vim.tbl_filter(function(cmd) return cmd:find(ArgLead, 1, true) == 1 - end, { 'clear' }) + end, { 'clear', 'read' }) end elseif num_args == 4 then if args[2] == 'cache' and args[3] == 'clear' then diff --git a/spec/ansi_spec.lua b/spec/ansi_spec.lua deleted file mode 100644 index af62c36..0000000 --- a/spec/ansi_spec.lua +++ /dev/null @@ -1,243 +0,0 @@ -describe('ansi parser', function() - local ansi = require('cp.ui.ansi') - - describe('bytes_to_string', function() - it('returns string as-is', function() - local input = 'hello world' - assert.equals('hello world', ansi.bytes_to_string(input)) - end) - - it('converts byte array to string', function() - local input = { 104, 101, 108, 108, 111 } - assert.equals('hello', ansi.bytes_to_string(input)) - end) - end) - - describe('parse_ansi_text', function() - it('strips ansi codes from simple text', function() - local input = 'Hello \027[31mworld\027[0m!' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello world!', table.concat(result.lines, '\n')) - end) - - it('handles text without ansi codes', function() - local input = 'Plain text' - local result = ansi.parse_ansi_text(input) - - assert.equals('Plain text', table.concat(result.lines, '\n')) - assert.equals(0, #result.highlights) - end) - - it('creates correct highlight for simple colored text', function() - local input = 'Hello \027[31mworld\027[0m!' - local result = ansi.parse_ansi_text(input) - - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals(0, highlight.line) - assert.equals(6, highlight.col_start) - assert.equals(11, highlight.col_end) - assert.equals('CpAnsiRed', highlight.highlight_group) - end) - - it('handles bold text', function() - local input = 'Hello \027[1mbold\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello bold world', table.concat(result.lines, '\n')) - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals('CpAnsiBold', highlight.highlight_group) - end) - - it('handles italic text', function() - local input = 'Hello \027[3mitalic\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello italic world', table.concat(result.lines, '\n')) - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals('CpAnsiItalic', highlight.highlight_group) - end) - - it('handles bold + color combination', function() - local input = 'Hello \027[1;31mbold red\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello bold red world', table.concat(result.lines, '\n')) - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals('CpAnsiBoldRed', highlight.highlight_group) - assert.equals(6, highlight.col_start) - assert.equals(14, highlight.col_end) - end) - - it('handles italic + color combination', function() - local input = 'Hello \027[3;32mitalic green\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello italic green world', table.concat(result.lines, '\n')) - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals('CpAnsiItalicGreen', highlight.highlight_group) - end) - - it('handles bold + italic + color combination', function() - local input = 'Hello \027[1;3;33mbold italic yellow\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello bold italic yellow world', table.concat(result.lines, '\n')) - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals('CpAnsiBoldItalicYellow', highlight.highlight_group) - end) - - it('handles sequential attribute setting', function() - local input = 'Hello \027[1m\027[31mbold red\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello bold red world', table.concat(result.lines, '\n')) - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals('CpAnsiBoldRed', highlight.highlight_group) - end) - - it('handles selective attribute reset', function() - local input = 'Hello \027[1;31mbold red\027[22mno longer bold\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals('Hello bold redno longer bold world', table.concat(result.lines, '\n')) - assert.equals(2, #result.highlights) - - local bold_red = result.highlights[1] - assert.equals('CpAnsiBoldRed', bold_red.highlight_group) - assert.equals(6, bold_red.col_start) - assert.equals(14, bold_red.col_end) - - local just_red = result.highlights[2] - assert.equals('CpAnsiRed', just_red.highlight_group) - assert.equals(14, just_red.col_start) - assert.equals(28, just_red.col_end) - end) - - it('handles bright colors', function() - local input = 'Hello \027[91mbright red\027[0m world' - local result = ansi.parse_ansi_text(input) - - assert.equals(1, #result.highlights) - local highlight = result.highlights[1] - assert.equals('CpAnsiBrightRed', highlight.highlight_group) - end) - - it('handles compiler-like output with complex formatting', function() - local input = - "error.cpp:10:5: \027[1m\027[31merror:\027[0m\027[1m 'undefined' was not declared\027[0m" - local result = ansi.parse_ansi_text(input) - - local clean_text = table.concat(result.lines, '\n') - assert.equals("error.cpp:10:5: error: 'undefined' was not declared", clean_text) - assert.equals(2, #result.highlights) - - local error_highlight = result.highlights[1] - assert.equals('CpAnsiBoldRed', error_highlight.highlight_group) - assert.equals(16, error_highlight.col_start) - assert.equals(22, error_highlight.col_end) - - local message_highlight = result.highlights[2] - assert.equals('CpAnsiBold', message_highlight.highlight_group) - assert.equals(22, message_highlight.col_start) - assert.equals(51, message_highlight.col_end) - end) - - it('handles multiline with persistent state', function() - local input = '\027[1;31mline1\nline2\nline3\027[0m' - local result = ansi.parse_ansi_text(input) - - assert.equals('line1\nline2\nline3', table.concat(result.lines, '\n')) - assert.equals(3, #result.highlights) - - for i, highlight in ipairs(result.highlights) do - assert.equals('CpAnsiBoldRed', highlight.highlight_group) - assert.equals(i - 1, highlight.line) - assert.equals(0, highlight.col_start) - assert.equals(5, highlight.col_end) - end - end) - end) - - describe('update_ansi_state', function() - it('resets all state on reset code', function() - local state = { bold = true, italic = true, foreground = 'Red' } - ansi.update_ansi_state(state, '0') - - assert.is_false(state.bold) - assert.is_false(state.italic) - assert.is_nil(state.foreground) - end) - - it('sets individual attributes', function() - local state = { bold = false, italic = false, foreground = nil } - - ansi.update_ansi_state(state, '1') - assert.is_true(state.bold) - - ansi.update_ansi_state(state, '3') - assert.is_true(state.italic) - - ansi.update_ansi_state(state, '31') - assert.equals('Red', state.foreground) - end) - - it('handles compound codes', function() - local state = { bold = false, italic = false, foreground = nil } - ansi.update_ansi_state(state, '1;3;31') - - assert.is_true(state.bold) - assert.is_true(state.italic) - assert.equals('Red', state.foreground) - end) - - it('handles selective resets', function() - local state = { bold = true, italic = true, foreground = 'Red' } - - ansi.update_ansi_state(state, '22') - assert.is_false(state.bold) - assert.is_true(state.italic) - assert.equals('Red', state.foreground) - - ansi.update_ansi_state(state, '39') - assert.is_false(state.bold) - assert.is_true(state.italic) - assert.is_nil(state.foreground) - end) - end) - - describe('setup_highlight_groups', function() - it('creates highlight groups with fallback colors when terminal colors are nil', function() - local original_colors = {} - for i = 0, 15 do - original_colors[i] = vim.g['terminal_color_' .. i] - vim.g['terminal_color_' .. i] = nil - end - - ansi.setup_highlight_groups() - - local highlight = vim.api.nvim_get_hl(0, { name = 'CpAnsiRed' }) - assert.is_nil(highlight.fg) - - for i = 0, 15 do - vim.g['terminal_color_' .. i] = original_colors[i] - end - end) - - it('creates highlight groups with proper colors when terminal colors are set', function() - vim.g.terminal_color_1 = '#ff0000' - - ansi.setup_highlight_groups() - - local highlight = vim.api.nvim_get_hl(0, { name = 'CpAnsiRed' }) - assert.equals(0xff0000, highlight.fg) - end) - end) -end) diff --git a/spec/cache_spec.lua b/spec/cache_spec.lua deleted file mode 100644 index 50f74a3..0000000 --- a/spec/cache_spec.lua +++ /dev/null @@ -1,205 +0,0 @@ -describe('cp.cache', function() - local cache - local spec_helper = require('spec.spec_helper') - - 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) - - after_each(function() - spec_helper.teardown() - cache.clear_contest_data('atcoder', 'test_contest') - cache.clear_contest_data('codeforces', 'test_contest') - cache.clear_contest_data('cses', 'test_contest') - end) - - describe('load and save', function() - it('loads without error when cache file exists', function() - assert.has_no_errors(function() - cache.load() - end) - end) - - it('saves and persists data', function() - local problems = { { id = 'A', name = 'Problem A' } } - - assert.has_no_errors(function() - cache.set_contest_data('atcoder', 'test_contest', problems) - end) - - local result = cache.get_contest_data('atcoder', 'test_contest') - assert.is_not_nil(result) - assert.equals('A', result.problems[1].id) - end) - end) - - describe('contest data', function() - it('stores and retrieves contest data', function() - local problems = { - { id = 'A', name = 'First Problem' }, - { id = 'B', name = 'Second Problem' }, - } - - cache.set_contest_data('codeforces', 'test_contest', problems) - local result = cache.get_contest_data('codeforces', 'test_contest') - - assert.is_not_nil(result) - assert.equals(2, #result.problems) - assert.equals('A', result.problems[1].id) - assert.equals('Second Problem', result.problems[2].name) - end) - - it('returns nil for missing contest', function() - local result = cache.get_contest_data('atcoder', 'nonexistent_contest') - assert.is_nil(result) - end) - - it('clears contest data', function() - local problems = { { id = 'A' } } - cache.set_contest_data('atcoder', 'test_contest', problems) - - cache.clear_contest_data('atcoder', 'test_contest') - local result = cache.get_contest_data('atcoder', 'test_contest') - - assert.is_nil(result) - end) - - it('handles cses expiry correctly', function() - local problems = { { id = 'A' } } - cache.set_contest_data('cses', 'test_contest', problems) - - local result = cache.get_contest_data('cses', 'test_contest') - assert.is_not_nil(result) - end) - end) - - describe('test cases', function() - it('stores and retrieves test cases', function() - local test_cases = { - { index = 1, input = '1 2', expected = '3' }, - { index = 2, input = '4 5', expected = '9' }, - } - - cache.set_test_cases('atcoder', 'test_contest', 'A', test_cases) - local result = cache.get_test_cases('atcoder', 'test_contest', 'A') - - assert.is_not_nil(result) - assert.equals(2, #result) - assert.equals('1 2', result[1].input) - assert.equals('9', result[2].expected) - end) - - it('handles contest-level test cases', function() - local test_cases = { { input = 'test', expected = 'output' } } - - cache.set_test_cases('cses', 'test_contest', nil, test_cases) - local result = cache.get_test_cases('cses', 'test_contest', nil) - - assert.is_not_nil(result) - assert.equals(1, #result) - assert.equals('test', result[1].input) - end) - - it('returns nil for missing test cases', function() - local result = cache.get_test_cases('atcoder', 'nonexistent', 'A') - assert.is_nil(result) - end) - end) - - describe('file state', function() - it('stores and retrieves file state', function() - local file_path = '/tmp/test.cpp' - - cache.set_file_state(file_path, 'atcoder', 'abc123', 'a', 'cpp') - local result = cache.get_file_state(file_path) - - assert.is_not_nil(result) - assert.equals('atcoder', result.platform) - assert.equals('abc123', result.contest_id) - assert.equals('a', result.problem_id) - assert.equals('cpp', result.language) - end) - - it('handles cses file state without problem_id', function() - local file_path = '/tmp/cses.py' - - cache.set_file_state(file_path, 'cses', '1068', nil, 'python') - local result = cache.get_file_state(file_path) - - assert.is_not_nil(result) - assert.equals('cses', result.platform) - assert.equals('1068', result.contest_id) - assert.is_nil(result.problem_id) - assert.equals('python', result.language) - end) - - it('returns nil for missing file state', function() - local result = cache.get_file_state('/nonexistent/file.cpp') - assert.is_nil(result) - end) - - it('overwrites existing file state', function() - local file_path = '/tmp/overwrite.cpp' - - cache.set_file_state(file_path, 'atcoder', 'abc123', 'a', 'cpp') - cache.set_file_state(file_path, 'codeforces', '1934', 'b', 'python') - - local result = cache.get_file_state(file_path) - - assert.is_not_nil(result) - assert.equals('codeforces', result.platform) - assert.equals('1934', result.contest_id) - assert.equals('b', result.problem_id) - assert.equals('python', result.language) - end) - end) - - describe('cache management', function() - it('clears all cache data', function() - cache.set_contest_data('atcoder', 'test_contest', { { id = 'A' } }) - cache.set_contest_data('codeforces', 'test_contest', { { id = 'B' } }) - cache.set_file_state('/tmp/test.cpp', 'atcoder', 'abc123', 'a', 'cpp') - - cache.clear_all() - - assert.is_nil(cache.get_contest_data('atcoder', 'test_contest')) - assert.is_nil(cache.get_contest_data('codeforces', 'test_contest')) - assert.is_nil(cache.get_file_state('/tmp/test.cpp')) - end) - - it('clears cache for specific platform', function() - cache.set_contest_data('atcoder', 'test_contest', { { id = 'A' } }) - cache.set_contest_data('codeforces', 'test_contest', { { id = 'B' } }) - cache.set_contest_list('atcoder', { { id = '123', name = 'Test' } }) - cache.set_contest_list('codeforces', { { id = '456', name = 'Test' } }) - - cache.clear_platform('atcoder') - - assert.is_nil(cache.get_contest_data('atcoder', 'test_contest')) - assert.is_nil(cache.get_contest_list('atcoder')) - assert.is_not_nil(cache.get_contest_data('codeforces', 'test_contest')) - assert.is_not_nil(cache.get_contest_list('codeforces')) - end) - - it('handles clear platform for non-existent platform', function() - assert.has_no_errors(function() - cache.clear_platform('nonexistent') - end) - end) - end) -end) diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua deleted file mode 100644 index c6114e3..0000000 --- a/spec/command_parsing_spec.lua +++ /dev/null @@ -1,730 +0,0 @@ -describe('cp command parsing', function() - local cp - 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 - - local mock_setup = { - set_platform = function() - return true - end, - setup_contest = function() end, - navigate_problem = function() end, - setup_problem = function() end, - scrape_remaining_problems = function() end, - } - package.loaded['cp.setup'] = mock_setup - - local mock_state = { - get_platform = function() - return 'atcoder' - end, - get_contest_id = function() - return 'abc123' - end, - get_problem_id = function() - return 'a' - end, - set_platform = function() end, - set_contest_id = function() end, - set_problem_id = function() end, - } - package.loaded['cp.state'] = mock_state - - local mock_ui_panel = { - toggle_run_panel = function() end, - toggle_interactive = function() end, - } - package.loaded['cp.ui.panel'] = mock_ui_panel - - local mock_cache = { - load = function() end, - get_contest_data = function() - 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() - logged_messages[#logged_messages + 1] = - { msg = 'No file is currently open', level = vim.log.levels.ERROR } - 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(cmd) - if cmd.subcommand == 'clear' then - if cmd.platform then - 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 - end - end, - } - package.loaded['cp.commands.cache'] = mock_cache_commands - - cp = require('cp') - cp.setup({ - contests = { - atcoder = { - default_language = 'cpp', - cpp = { extension = 'cpp' }, - }, - cses = { - default_language = 'cpp', - cpp = { extension = 'cpp' }, - }, - }, - }) - end) - - after_each(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 - package.loaded['cp'] = nil - package.loaded['cp.commands.init'] = nil - end) - - describe('empty arguments', function() - it('attempts file state restoration for no arguments', function() - local opts = { fargs = {} } - - cp.handle_command(opts) - - assert.is_true(#logged_messages > 0) - local error_logged = false - for _, log_entry in ipairs(logged_messages) do - if - log_entry.level == vim.log.levels.ERROR - and log_entry.msg:match('No file is currently open') - then - error_logged = true - break - end - end - assert.is_true(error_logged) - end) - end) - - describe('action commands', function() - it('handles test action without error', function() - local opts = { fargs = { 'run' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles next action without error', function() - local opts = { fargs = { 'next' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles prev action without error', function() - local opts = { fargs = { 'prev' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - end) - - describe('platform commands', function() - it('handles platform-only command', function() - local opts = { fargs = { 'atcoder' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles contest setup command', function() - local opts = { fargs = { 'atcoder', 'abc123' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles cses problem command', function() - local opts = { fargs = { 'cses', 'sorting_and_searching', '1234' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles full setup command', function() - local opts = { fargs = { 'atcoder', 'abc123', 'a' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('logs error for too many arguments', function() - local opts = { fargs = { 'atcoder', 'abc123', 'a', 'b', 'extra' } } - - 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('language flag parsing', function() - it('logs error for --lang flag missing value', function() - local opts = { fargs = { 'run', '--lang' } } - - cp.handle_command(opts) - - local error_logged = false - for _, log_entry in ipairs(logged_messages) do - if - log_entry.level == vim.log.levels.ERROR and log_entry.msg:match('--lang requires a value') - then - error_logged = true - break - end - end - assert.is_true(error_logged) - end) - - it('handles language with equals format', function() - local opts = { fargs = { 'atcoder', '--lang=python' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles language with space format', function() - local opts = { fargs = { 'atcoder', '--lang', 'cpp' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles contest with language flag', function() - local opts = { fargs = { 'atcoder', 'abc123', '--lang=python' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - end) - - describe('debug flag parsing', function() - it('handles debug flag without error', function() - local opts = { fargs = { 'run', '--debug' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles combined language and debug flags', function() - local opts = { fargs = { 'run', '--lang=cpp', '--debug' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - end) - - describe('restore from file', function() - it('returns restore_from_file type for empty args', function() - local opts = { fargs = {} } - local logged_error = false - - cp.handle_command(opts) - - for _, log in ipairs(logged_messages) do - if log.level == vim.log.levels.ERROR and log.msg:match('No file is currently open') then - logged_error = true - end - end - - assert.is_true(logged_error) - end) - end) - - describe('invalid commands', function() - it('logs error for invalid platform', function() - local opts = { fargs = { 'invalid_platform' } } - - 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('logs error for invalid action', function() - local opts = { fargs = { 'invalid_action' } } - - 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('edge cases', function() - it('handles empty string arguments', function() - local opts = { fargs = { '' } } - - 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 flag order variations', function() - local opts = { fargs = { '--debug', 'run', '--lang=python' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - - it('handles multiple language flags', function() - local opts = { fargs = { 'run', '--lang=cpp', '--lang=python' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end) - end) - - describe('command validation', function() - it('validates platform names against constants', function() - local constants = require('cp.constants') - - for _, platform in ipairs(constants.PLATFORMS) do - local opts = { fargs = { platform } } - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end - end) - - it('validates action names against constants', function() - local constants = require('cp.constants') - - for _, action in ipairs(constants.ACTIONS) do - local opts = { fargs = { action } } - assert.has_no_errors(function() - cp.handle_command(opts) - end) - end - end) - end) - - describe('cache commands', function() - it('handles cache clear without platform', function() - local opts = { fargs = { 'cache', 'clear' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - - local success_logged = false - for _, log_entry in ipairs(logged_messages) do - if log_entry.msg and log_entry.msg:match('cleared all cache') then - success_logged = true - break - end - end - assert.is_true(success_logged) - end) - - it('handles cache clear with valid platform', function() - local opts = { fargs = { 'cache', 'clear', 'atcoder' } } - - assert.has_no_errors(function() - cp.handle_command(opts) - end) - - local success_logged = false - for _, log_entry in ipairs(logged_messages) do - if log_entry.msg and log_entry.msg:match('cleared cache for atcoder') then - success_logged = true - break - end - end - assert.is_true(success_logged) - end) - - it('logs error for cache clear with invalid platform', function() - local opts = { fargs = { 'cache', 'clear', 'invalid_platform' } } - - cp.handle_command(opts) - - local error_logged = false - for _, log_entry in ipairs(logged_messages) do - if log_entry.level == vim.log.levels.ERROR and log_entry.msg:match('unknown platform') then - error_logged = true - break - end - end - assert.is_true(error_logged) - end) - - it('logs error for cache command without subcommand', function() - local opts = { fargs = { 'cache' } } - - cp.handle_command(opts) - - local error_logged = false - for _, log_entry in ipairs(logged_messages) do - if - log_entry.level == vim.log.levels.ERROR - and log_entry.msg:match('cache command requires subcommand') - then - error_logged = true - break - end - end - assert.is_true(error_logged) - end) - - it('logs error for invalid cache subcommand', function() - local opts = { fargs = { 'cache', 'invalid' } } - - cp.handle_command(opts) - - local error_logged = false - for _, log_entry in ipairs(logged_messages) do - if - log_entry.level == vim.log.levels.ERROR - and log_entry.msg:match('unknown cache subcommand') - then - error_logged = true - break - end - end - assert.is_true(error_logged) - end) - end) - - describe('CP command completion', function() - local complete_fn - - before_each(function() - package.loaded['cp'] = nil - package.loaded['cp.cache'] = nil - - complete_fn = function(ArgLead, CmdLine, _) - local constants = require('cp.constants') - local platforms = constants.PLATFORMS - local actions = constants.ACTIONS - - local args = vim.split(vim.trim(CmdLine), '%s+') - local num_args = #args - if CmdLine:sub(-1) == ' ' then - num_args = num_args + 1 - end - - if num_args == 2 then - local candidates = {} - 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(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) - end - end - else - vim.list_extend(candidates, platforms) - table.insert(candidates, 'cache') - table.insert(candidates, 'pick') - end - return vim.tbl_filter(function(cmd) - return cmd:find(ArgLead, 1, true) == 1 - end, candidates) - elseif num_args == 3 then - if args[2] == 'cache' then - return vim.tbl_filter(function(cmd) - return cmd:find(ArgLead, 1, true) == 1 - end, { 'clear' }) - end - elseif num_args == 4 then - if args[2] == 'cache' and args[3] == 'clear' then - return vim.tbl_filter(function(cmd) - return cmd:find(ArgLead, 1, true) == 1 - end, platforms) - elseif vim.tbl_contains(platforms, args[2]) then - local cache = require('cp.cache') - cache.load() - local contest_data = cache.get_contest_data(args[2], args[3]) - if contest_data and contest_data.problems then - local candidates = {} - for _, problem in ipairs(contest_data.problems) do - table.insert(candidates, problem.id) - end - return vim.tbl_filter(function(cmd) - return cmd:find(ArgLead, 1, true) == 1 - end, candidates) - end - end - end - return {} - end - - package.loaded['cp.state'] = { - get_platform = function() - return nil - end, - get_contest_id = function() - return nil - end, - } - - package.loaded['cp.cache'] = { - load = function() end, - get_contest_data = function() - return nil - end, - } - end) - - after_each(function() - package.loaded['cp'] = nil - package.loaded['cp.cache'] = nil - end) - - it('completes platforms and global actions without contest configuration', function() - local result = complete_fn('', 'CP ', 3) - - assert.is_table(result) - - local has_atcoder = false - local has_codeforces = false - local has_cses = false - local has_cache = false - local has_pick = false - local has_run = false - local has_next = false - local has_prev = false - - for _, item in ipairs(result) do - if item == 'atcoder' then - has_atcoder = true - end - if item == 'codeforces' then - has_codeforces = true - end - if item == 'cses' then - has_cses = true - end - if item == 'cache' then - has_cache = true - end - if item == 'pick' then - has_pick = true - end - if item == 'run' then - has_run = true - end - if item == 'next' then - has_next = true - end - if item == 'prev' then - has_prev = true - end - end - - assert.is_true(has_atcoder) - assert.is_true(has_codeforces) - assert.is_true(has_cses) - assert.is_true(has_cache) - assert.is_true(has_pick) - assert.is_false(has_run) - assert.is_false(has_next) - assert.is_false(has_prev) - end) - - it('completes all actions and problems when contest context exists', function() - package.loaded['cp.state'] = { - get_platform = function() - return 'atcoder' - end, - get_contest_id = function() - return 'abc350' - end, - } - package.loaded['cp.cache'] = { - load = function() end, - get_contest_data = function() - return { - problems = { - { id = 'a' }, - { id = 'b' }, - { id = 'c' }, - }, - } - end, - } - - local result = complete_fn('', 'CP ', 3) - - assert.is_table(result) - - local items = {} - for _, item in ipairs(result) do - items[item] = true - end - - assert.is_true(items['run']) - assert.is_true(items['next']) - assert.is_true(items['prev']) - assert.is_true(items['pick']) - assert.is_true(items['cache']) - - assert.is_true(items['a']) - assert.is_true(items['b']) - assert.is_true(items['c']) - end) - - it('completes cache subcommands', function() - local result = complete_fn('c', 'CP cache c', 10) - - assert.is_table(result) - assert.equals(1, #result) - assert.equals('clear', result[1]) - end) - - it('completes cache subcommands with exact match', function() - local result = complete_fn('clear', 'CP cache clear', 14) - - assert.is_table(result) - assert.equals(1, #result) - assert.equals('clear', result[1]) - end) - - it('completes platforms for cache clear', function() - local result = complete_fn('a', 'CP cache clear a', 16) - - assert.is_table(result) - - local has_atcoder = false - local has_cache = false - - for _, item in ipairs(result) do - if item == 'atcoder' then - has_atcoder = true - end - if item == 'cache' then - has_cache = true - end - end - - assert.is_true(has_atcoder) - assert.is_false(has_cache) - end) - - it('filters completions based on current input', function() - local result = complete_fn('at', 'CP at', 5) - - assert.is_table(result) - assert.equals(1, #result) - assert.equals('atcoder', result[1]) - end) - - it('returns empty array when no matches', function() - local result = complete_fn('xyz', 'CP xyz', 6) - - assert.is_table(result) - assert.equals(0, #result) - end) - - it('handles problem completion for platform contest', function() - package.loaded['cp.cache'] = { - load = function() end, - get_contest_data = function(platform, contest) - if platform == 'atcoder' and contest == 'abc350' then - return { - problems = { - { id = 'a' }, - { id = 'b' }, - }, - } - end - return nil - end, - } - - local result = complete_fn('a', 'CP atcoder abc350 a', 18) - - assert.is_table(result) - assert.equals(1, #result) - assert.equals('a', result[1]) - end) - end) -end) diff --git a/spec/config_spec.lua b/spec/config_spec.lua deleted file mode 100644 index 21723b1..0000000 --- a/spec/config_spec.lua +++ /dev/null @@ -1,334 +0,0 @@ -describe('cp.config', function() - local config - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - config = require('cp.config') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('setup', function() - it('returns defaults with nil input', function() - local result = config.setup() - - assert.equals('table', type(result.contests)) - assert.equals('table', type(result.snippets)) - assert.equals('table', type(result.hooks)) - assert.equals('table', type(result.scrapers)) - assert.is_false(result.debug) - assert.is_nil(result.filename) - end) - - it('merges user config with defaults', function() - local user_config = { - debug = true, - contests = { test_contest = { cpp = { extension = 'cpp' } } }, - } - - local result = config.setup(user_config) - - assert.is_true(result.debug) - assert.equals('table', type(result.contests.test_contest)) - assert.equals('table', type(result.scrapers)) - end) - - it('allows custom extensions', function() - local custom_config = { - contests = { - test_contest = { - cpp = { extension = 'custom' }, - }, - }, - } - - assert.has_no.errors(function() - config.setup(custom_config) - end) - end) - - it('validates scraper platforms', function() - local invalid_config = { - scrapers = { 'invalid_platform' }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - it('validates scraper values are strings', function() - local invalid_config = { - scrapers = { 123 }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - it('validates diff_mode values', function() - local valid_config = { - run_panel = { - diff_mode = 'none', - }, - } - - assert.has_no.errors(function() - config.setup(valid_config) - end) - - local invalid_config = { - run_panel = { - diff_mode = 'invalid_mode', - }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - it('validates hook functions', function() - local invalid_config = { - hooks = { before_run = 'not_a_function' }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - describe('run_panel config validation', function() - it('validates ansi is boolean', function() - local invalid_config = { - run_panel = { ansi = 'invalid' }, - } - - assert.has_error(function() - config.setup(invalid_config) - end, 'ansi: expected ansi color parsing must be enabled xor disabled, got string') - end) - - it('validates diff_mode values', function() - local invalid_config = { - run_panel = { diff_mode = 'invalid' }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - it('validates next_test_key is non-empty string', function() - local invalid_config = { - run_panel = { next_test_key = '' }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - it('validates prev_test_key is non-empty string', function() - local invalid_config = { - run_panel = { prev_test_key = '' }, - } - - assert.has_error(function() - config.setup(invalid_config) - end) - end) - - it('accepts valid run_panel config', function() - local valid_config = { - run_panel = { - ansi = false, - diff_mode = 'git', - next_test_key = 'j', - prev_test_key = 'k', - }, - } - - assert.has_no.errors(function() - config.setup(valid_config) - end) - end) - end) - - describe('auto-configuration', function() - it('sets default extensions for cpp and python', function() - local user_config = { - contests = { - test = { - cpp = { compile = { 'g++' } }, - python = { test = { 'python3' } }, - }, - }, - } - - local result = config.setup(user_config) - - assert.equals('cpp', result.contests.test.cpp.extension) - assert.equals('py', result.contests.test.python.extension) - end) - - it('sets default_language to cpp when available', function() - local user_config = { - contests = { - test = { - cpp = { compile = { 'g++' } }, - python = { test = { 'python3' } }, - }, - }, - } - - local result = config.setup(user_config) - - assert.equals('cpp', result.contests.test.default_language) - end) - - it('sets default_language to single available language when only one configured', function() - local user_config = { - contests = { - test = { - python = { test = { 'python3' } }, - }, - }, - } - - local result = config.setup(user_config) - - assert.equals('python', result.contests.test.default_language) - end) - - it('sets default_language to single available language even when not cpp', function() - local user_config = { - contests = { - test = { - rust = { - test = { './target/release/solution' }, - extension = 'rs', - }, - }, - }, - } - - local result = config.setup(user_config) - - assert.equals('rust', result.contests.test.default_language) - end) - - it('uses first available language when multiple configured', function() - local user_config = { - contests = { - test = { - python = { test = { 'python3' } }, - cpp = { compile = { 'g++' } }, - }, - }, - } - - local result = config.setup(user_config) - - assert.is_true(vim.tbl_contains({ 'cpp', 'python' }, result.contests.test.default_language)) - end) - - it('preserves explicit default_language', function() - local user_config = { - contests = { - test = { - cpp = { compile = { 'g++' } }, - python = { test = { 'python3' } }, - default_language = 'python', - }, - }, - } - - local result = config.setup(user_config) - - assert.equals('python', result.contests.test.default_language) - end) - - it('errors when no language configurations exist', function() - local invalid_config = { - contests = { - test = {}, - }, - } - - assert.has_error(function() - config.setup(invalid_config) - end, 'No language configurations found') - end) - - it('allows custom language names', function() - local user_config = { - contests = { - test = { - rust = { - compile = { 'rustc', '{source}', '-o', '{binary}' }, - test = { '{binary}' }, - extension = 'rs', - }, - cpp = { compile = { 'g++' } }, - }, - }, - } - - assert.has_no.errors(function() - local result = config.setup(user_config) - assert.equals('cpp', result.contests.test.default_language) - end) - end) - end) - - describe('picker validation', function() - it('validates picker is valid value', function() - local invalid_config = { - picker = 'invalid_picker', - } - - assert.has_error(function() - config.setup(invalid_config) - end, "Invalid picker 'invalid_picker'. Must be 'telescope' or 'fzf-lua'") - end) - - it('allows nil picker', function() - assert.has_no.errors(function() - local result = config.setup({ picker = nil }) - assert.is_nil(result.picker) - end) - end) - - it('allows telescope picker without checking availability', function() - assert.has_no.errors(function() - local result = config.setup({ picker = 'telescope' }) - assert.equals('telescope', result.picker) - end) - end) - - it('allows fzf-lua picker without checking availability', function() - assert.has_no.errors(function() - local result = config.setup({ picker = 'fzf-lua' }) - assert.equals('fzf-lua', result.picker) - end) - end) - end) - end) - - describe('default_filename', function() - it('generates lowercase contest filename', function() - local result = config.default_filename('ABC123') - assert.equals('abc123', result) - end) - - it('combines contest and problem ids', function() - local result = config.default_filename('ABC123', 'A') - assert.equals('abc123a', result) - end) - end) -end) diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua deleted file mode 100644 index 31fa395..0000000 --- a/spec/diff_spec.lua +++ /dev/null @@ -1,91 +0,0 @@ -describe('cp.diff', function() - local spec_helper = require('spec.spec_helper') - local diff - - before_each(function() - spec_helper.setup() - diff = require('cp.ui.diff') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('get_available_backends', function() - it('returns none, vim and git backends', function() - local backends = diff.get_available_backends() - table.sort(backends) - assert.same({ 'git', 'none', 'vim' }, backends) - end) - end) - - describe('get_backend', function() - it('returns vim backend by name', function() - local backend = diff.get_backend('vim') - assert.is_not_nil(backend) - assert.equals('vim', backend.name) - end) - - it('returns git backend by name', function() - local backend = diff.get_backend('git') - assert.is_not_nil(backend) - assert.equals('git', backend.name) - end) - - it('returns none backend by name', function() - local backend = diff.get_backend('none') - assert.is_not_nil(backend) - assert.equals('none', backend.name) - end) - - it('returns nil for invalid name', function() - local backend = diff.get_backend('invalid') - assert.is_nil(backend) - end) - end) - - describe('get_best_backend', function() - it('defaults to vim backend', function() - local backend = diff.get_best_backend() - assert.equals('vim', backend.name) - end) - end) - - describe('none backend', function() - it('returns both expected and actual content', function() - local backend = diff.get_backend('none') - local result = backend.render('expected\nline2', 'actual\nline2') - - assert.same({ - expected = { 'expected', 'line2' }, - actual = { 'actual', 'line2' }, - }, result.content) - assert.same({}, result.highlights) - end) - end) - - describe('vim backend', function() - it('returns content as-is', function() - local backend = diff.get_backend('vim') - local result = backend.render('expected', 'actual') - - assert.same({ 'actual' }, result.content) - assert.is_nil(result.highlights) - end) - end) - - describe('is_git_available', function() - it('returns boolean without errors', function() - local result = diff.is_git_available() - assert.equals('boolean', type(result)) - end) - end) - - describe('render_diff', function() - it('returns result without errors', function() - assert.has_no_errors(function() - diff.render_diff('expected', 'actual', 'vim') - end) - end) - end) -end) diff --git a/spec/error_boundaries_spec.lua b/spec/error_boundaries_spec.lua deleted file mode 100644 index 7d88a0d..0000000 --- a/spec/error_boundaries_spec.lua +++ /dev/null @@ -1,221 +0,0 @@ -describe('Error boundary handling', function() - local cp - local state - 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 - - package.loaded['cp.scraper'] = { - scrape_problem_tests = function(_, contest_id, problem_id, callback) - if contest_id == 'fail_scrape' then - callback({ - success = false, - error = 'Network error', - }) - return - end - callback({ - success = true, - problem_id = problem_id, - tests = { - { input = '1', expected = '2' }, - }, - }) - end, - scrape_contest_metadata = function(_, contest_id, callback) - if contest_id == 'fail_scrape' then - callback({ - success = false, - error = 'Network error', - }) - return - end - if contest_id == 'fail_metadata' then - callback({ - success = false, - error = 'Contest not found', - }) - return - end - callback({ - success = true, - problems = { - { id = 'a' }, - { id = 'b' }, - }, - }) - end, - } - - local cache = require('cp.cache') - cache.load = function() end - cache.set_test_cases = function() end - cache.set_file_state = function() end - cache.get_file_state = function() - return nil - end - cache.get_contest_data = function() - return nil - end - cache.get_test_cases = function() - return {} - end - - if not vim.fn then - vim.fn = {} - end - vim.fn.expand = vim.fn.expand or function() - return '/tmp/test.cpp' - end - vim.fn.mkdir = vim.fn.mkdir or function() end - if not vim.api then - vim.api = {} - end - vim.api.nvim_get_current_buf = vim.api.nvim_get_current_buf or function() - return 1 - end - vim.api.nvim_buf_get_lines = vim.api.nvim_buf_get_lines - or function() - return { '' } - end - if not vim.cmd then - vim.cmd = {} - end - vim.cmd.e = function() end - vim.cmd.only = function() end - if not vim.system then - vim.system = function(_) - return { - wait = function() - return { code = 0 } - end, - } - end - end - - state = require('cp.state') - state.reset() - - cp = require('cp') - cp.setup({ - contests = { - codeforces = { - default_language = 'cpp', - cpp = { extension = 'cpp', test = { 'echo', 'test' } }, - }, - }, - scrapers = { 'codeforces' }, - }) - end) - - after_each(function() - package.loaded['cp.log'] = nil - package.loaded['cp.scraper'] = nil - if state then - state.reset() - end - end) - - it('should handle scraping failures without state corruption', function() - cp.handle_command({ fargs = { 'codeforces', 'fail_scrape', 'a' } }) - - 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 - has_metadata_error = true - break - end - end - assert.is_true(has_metadata_error, 'Should log contest metadata failure') - - assert.equals('codeforces', state.get_platform()) - - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'run' } }) - end) - end) - - it('should handle missing contest data without crashing navigation', function() - state.set_platform('codeforces') - state.set_contest_id('nonexistent') - state.set_problem_id('a') - - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'next' } }) - end) - - local has_nav_error = false - for _, log_entry in ipairs(logged_messages) do - if log_entry.msg and log_entry.msg:match('no contest data available') then - has_nav_error = true - break - end - end - assert.is_true(has_nav_error, 'Should log navigation error') - end) - - it('should handle validation errors without crashing', function() - state.reset() - - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'next' } }) - end) - - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'prev' } }) - end) - - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'run' } }) - end) - - local has_validation_error = false - local has_appropriate_errors = 0 - for _, log_entry in ipairs(logged_messages) do - if log_entry.msg and log_entry.msg:match('expected string, got nil') then - has_validation_error = true - elseif - log_entry.msg - and (log_entry.msg:match('No platform ') or log_entry.msg:match('No contest ')) - then - has_appropriate_errors = has_appropriate_errors + 1 - end - end - - assert.is_false(has_validation_error, 'Should not have validation errors') - assert.is_true(has_appropriate_errors > 0, 'Should have user-facing errors') - end) - - it('should handle partial state gracefully', function() - state.set_platform('codeforces') - - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'run' } }) - end) - - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'next' } }) - end) - - local missing_contest_errors = 0 - for _, log_entry in ipairs(logged_messages) do - if - log_entry.msg - and (log_entry.msg:match('No problem found') or log_entry.msg:match('No contest')) - then - missing_contest_errors = missing_contest_errors + 1 - end - end - assert.is_true(missing_contest_errors > 0, 'Should report missing contest') - end) -end) diff --git a/spec/execute_spec.lua b/spec/execute_spec.lua index 81025ef..f401605 100644 --- a/spec/execute_spec.lua +++ b/spec/execute_spec.lua @@ -1,429 +1,11 @@ -describe('cp.execute', function() - local execute - local mock_system_calls - local temp_files - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - execute = require('cp.runner.execute') - mock_system_calls = {} - temp_files = {} - - vim.system = function(cmd, opts) - table.insert(mock_system_calls, { cmd = cmd, opts = opts }) - if not cmd or #cmd == 0 then - return { - wait = function() - return { code = 0, stdout = '', stderr = '' } - end, - } - end - - local result = { code = 0, stdout = '', stderr = '' } - - if cmd[1] == 'mkdir' then - result = { code = 0 } - elseif cmd[1] == 'g++' or cmd[1] == 'gcc' then - result = { code = 0, stderr = '' } - elseif cmd[1]:match('%.run$') or cmd[1] == 'python' then - result = { code = 0, stdout = '42\n', stderr = '' } - end - - return { - wait = function() - return result - end, - } - end - - local original_fn = vim.fn - vim.fn = vim.tbl_extend('force', vim.fn, { - filereadable = function(path) - return temp_files[path] and 1 or 0 - end, - readfile = function(path) - return temp_files[path] or {} - end, - fnamemodify = function(path, modifier) - if modifier == ':e' then - return path:match('%.([^.]+)$') or '' - end - return original_fn.fnamemodify(path, modifier) - end, - }) - - vim.uv = vim.tbl_extend('force', vim.uv or {}, { - hrtime = function() - return 1000000000 - end, - }) - end) - - after_each(function() - vim.system = vim.system_original or vim.system - spec_helper.teardown() - temp_files = {} - end) - - describe('template substitution', function() - it('substitutes placeholders correctly', function() - local language_config = { - compile = { 'g++', '{source_file}', '-o', '{binary_file}' }, - } - local substitutions = { - source_file = 'test.cpp', - binary_file = 'test.run', - } - - local result = execute.compile_generic(language_config, substitutions) - - assert.equals(0, result.code) - assert.is_true(#mock_system_calls > 0) - - local compile_call = mock_system_calls[1] - assert.equals('sh', compile_call.cmd[1]) - assert.equals('-c', compile_call.cmd[2]) - assert.is_not_nil(string.find(compile_call.cmd[3], 'g%+%+ test%.cpp %-o test%.run')) - assert.is_not_nil(string.find(compile_call.cmd[3], '2>&1')) - end) - - it('handles multiple substitutions in single argument', function() - local language_config = { - compile = { 'g++', '{source_file}', '-o{binary_file}' }, - } - local substitutions = { - source_file = 'main.cpp', - binary_file = 'main.out', - } - - execute.compile_generic(language_config, substitutions) - - local compile_call = mock_system_calls[1] - assert.is_not_nil(string.find(compile_call.cmd[3], '%-omain%.out')) - end) - end) - - describe('compilation', function() - it('skips compilation when not required', function() - local language_config = {} - local substitutions = {} - - local result = execute.compile_generic(language_config, substitutions) - - assert.equals(0, result.code) - assert.equals('', result.stderr) - assert.equals(0, #mock_system_calls) - end) - - it('compiles cpp files correctly', function() - local language_config = { - compile = { 'g++', '{source_file}', '-o', '{binary_file}', '-std=c++17' }, - } - local substitutions = { - source_file = 'solution.cpp', - binary_file = 'build/solution.run', - } - - local result = execute.compile_generic(language_config, substitutions) - - assert.equals(0, result.code) - assert.is_true(#mock_system_calls > 0) - - local compile_call = mock_system_calls[1] - assert.equals('sh', compile_call.cmd[1]) - assert.is_not_nil(string.find(compile_call.cmd[3], '%-std=c%+%+17')) - end) - - it('handles compilation errors gracefully', function() - vim.system = function() - return { - wait = function() - return { code = 1, stderr = 'error: undefined variable' } - end, - } - end - - local language_config = { - compile = { 'g++', '{source_file}', '-o', '{binary_file}' }, - } - local substitutions = { source_file = 'bad.cpp', binary_file = 'bad.run' } - - local result = execute.compile_generic(language_config, substitutions) - - assert.equals(1, result.code) - assert.is_not_nil(result.stderr:match('undefined variable')) - end) - - it('measures compilation time', function() - local start_time = 1000000000 - local end_time = 1500000000 - local call_count = 0 - - vim.uv.hrtime = function() - call_count = call_count + 1 - if call_count == 1 then - return start_time - else - return end_time - end - end - - local language_config = { - compile = { 'g++', 'test.cpp', '-o', 'test.run' }, - } - - execute.compile_generic(language_config, {}) - assert.is_true(call_count >= 2) - end) - end) - - describe('test execution', function() - it('executes commands with input data', function() - vim.system = function(cmd, opts) - table.insert(mock_system_calls, { cmd = cmd, opts = opts }) - return { - wait = function() - return { code = 0, stdout = '3\n', stderr = '' } - end, - } - end - - local language_config = { - run = { '{binary_file}' }, - } - - execute.compile_generic(language_config, { binary_file = './test.run' }) - end) - - it('handles command execution', function() - vim.system = function(_, opts) - if opts then - assert.equals(false, opts.text) - end - return { - wait = function() - return { code = 124, stdout = '', stderr = '' } - end, - } - end - - local language_config = { - compile = { 'timeout', '1', 'sleep', '2' }, - } - - local result = execute.compile_generic(language_config, {}) - assert.equals(124, result.code) - end) - - it('captures stderr output', function() - vim.system = function() - return { - wait = function() - return { code = 1, stdout = '', stderr = 'runtime error\n' } - end, - } - end - - local language_config = { - compile = { 'false' }, - } - - local result = execute.compile_generic(language_config, {}) - assert.equals(1, result.code) - assert.is_not_nil(result.stderr:match('runtime error')) - end) - end) - - describe('directory creation', function() - it('creates build and io directories', function() - local language_config = { - compile = { 'mkdir', '-p', 'build', 'io' }, - } - - execute.compile_generic(language_config, {}) - - local mkdir_call = mock_system_calls[1] - assert.equals('sh', mkdir_call.cmd[1]) - assert.is_not_nil(string.find(mkdir_call.cmd[3], 'mkdir')) - assert.is_not_nil(string.find(mkdir_call.cmd[3], 'build')) - assert.is_not_nil(string.find(mkdir_call.cmd[3], 'io')) - end) - end) - - describe('language detection', function() - it('detects cpp from extension', function() - vim.fn.fnamemodify = function() - return 'cpp' - end - - assert.has_no_errors(function() - execute.compile_generic({}, {}) - end) - end) - - it('falls back to default language', function() - vim.fn.fnamemodify = function(_, modifier) - if modifier == ':e' then - return 'unknown' - end - return '' - end - - assert.has_no_errors(function() - execute.compile_generic({}, {}) - end) - end) - end) - - describe('edge cases', function() - it('handles empty command templates', function() - local language_config = { - compile = {}, - } - - local result = execute.compile_generic(language_config, {}) - assert.equals(0, result.code) - end) - - it('handles commands with no substitutions needed', function() - local language_config = { - compile = { 'echo', 'hello' }, - } - - local result = execute.compile_generic(language_config, {}) - assert.equals(0, result.code) - - local echo_call = mock_system_calls[1] - assert.equals('sh', echo_call.cmd[1]) - assert.is_not_nil(string.find(echo_call.cmd[3], 'echo hello')) - end) - - it('handles multiple consecutive substitutions', function() - local language_config = { - compile = { '{compiler}{compiler}', '{file}{file}' }, - } - local substitutions = { - compiler = 'g++', - file = 'test.cpp', - } - - execute.compile_generic(language_config, substitutions) - - local call = mock_system_calls[1] - assert.equals('sh', call.cmd[1]) - assert.is_not_nil(string.find(call.cmd[3], 'g%+%+g%+%+ test%.cpptest%.cpp')) - end) - end) - - describe('stderr/stdout redirection', function() - it('should use stderr redirection (2>&1)', function() - local original_system = vim.system - local captured_command = nil - - vim.system = function(cmd, _) - captured_command = cmd - return { - wait = function() - return { code = 0, stdout = '', stderr = '' } - end, - } - end - - local language_config = { - compile = { 'g++', '-std=c++17', '-o', '{binary}', '{source}' }, - } - local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' } - execute.compile_generic(language_config, substitutions) - - assert.is_not_nil(captured_command) - assert.equals('sh', captured_command[1]) - assert.equals('-c', captured_command[2]) - assert.is_not_nil( - string.find(captured_command[3], '2>&1'), - 'Command should contain 2>&1 redirection' - ) - - vim.system = original_system - end) - - it('should return combined stdout+stderr in result', function() - local original_system = vim.system - local test_output = 'STDOUT: Hello\nSTDERR: Error message\n' - - vim.system = function(_, _) - return { - wait = function() - return { code = 1, stdout = test_output, stderr = '' } - end, - } - end - - local language_config = { - compile = { 'g++', '-std=c++17', '-o', '{binary}', '{source}' }, - } - local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' } - local result = execute.compile_generic(language_config, substitutions) - - assert.equals(1, result.code) - assert.equals(test_output, result.stdout) - - vim.system = original_system - end) - end) - - describe('integration with execute_command function', function() - it('tests the full execute_command flow with stderr/stdout combination', function() - local cmd = { 'echo', 'test output' } - local input_data = 'test input' - local timeout_ms = 1000 - - local original_system = vim.system - vim.system = function(shell_cmd, opts) - assert.equals('sh', shell_cmd[1]) - assert.equals('-c', shell_cmd[2]) - assert.is_not_nil(string.find(shell_cmd[3], '2>&1')) - assert.equals(input_data, opts.stdin) - assert.equals(timeout_ms, opts.timeout) - assert.is_true(opts.text) - - return { - wait = function() - return { code = 0, stdout = 'combined output from stdout and stderr', stderr = '' } - end, - } - end - - local execute_command = require('cp.runner.execute').execute_command - or function(command, stdin_data, timeout) - local redirected_cmd = vim.deepcopy(command) - if #redirected_cmd > 0 then - redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1' - end - - local result = vim - .system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, { - stdin = stdin_data, - timeout = timeout, - text = true, - }) - :wait() - - return { - stdout = result.stdout or '', - stderr = result.stderr or '', - code = result.code or 0, - time_ms = 0, - timed_out = result.code == 124, - } - end - - local result = execute_command(cmd, input_data, timeout_ms) - - assert.equals(0, result.code) - assert.equals('combined output from stdout and stderr', result.stdout) - - vim.system = original_system +describe('run module', function() + local run = require('cp.runner.run') + + describe('basic functionality', function() + it('can get panel state', function() + local state = run.get_run_panel_state() + assert.is_table(state) + assert.is_table(state.test_cases) end) end) end) diff --git a/spec/fzf_lua_spec.lua b/spec/fzf_lua_spec.lua deleted file mode 100644 index a134fcb..0000000 --- a/spec/fzf_lua_spec.lua +++ /dev/null @@ -1,40 +0,0 @@ -describe('cp.fzf_lua', function() - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - - package.preload['fzf-lua'] = function() - return { - fzf_exec = function(_, _) end, - } - end - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('module loading', function() - it('loads fzf-lua integration without error', function() - assert.has_no_errors(function() - require('cp.pickers.fzf_lua') - end) - end) - - it('returns module with picker function', function() - local fzf_lua_cp = require('cp.pickers.fzf_lua') - assert.is_table(fzf_lua_cp) - assert.is_function(fzf_lua_cp.pick) - end) - end) - - describe('basic running', function() - it('can run and open the picker with :CP pick', function() - local cp = require('cp') - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'pick' } }) - end) - end) - end) -end) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua deleted file mode 100644 index 8897cc4..0000000 --- a/spec/highlight_spec.lua +++ /dev/null @@ -1,107 +0,0 @@ -describe('cp.highlight', function() - local spec_helper = require('spec.spec_helper') - local highlight - - before_each(function() - spec_helper.setup() - highlight = require('cp.ui.highlight') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('parse_git_diff', function() - it('skips git diff headers', function() - local diff_output = [[diff --git a/test b/test -index 1234567..abcdefg 100644 ---- a/test -+++ b/test -@@ -1,3 +1,3 @@ - hello -+world --goodbye]] - local result = highlight.parse_git_diff(diff_output) - assert.same({ 'hello', 'world' }, result.content) - end) - - it('processes added lines', function() - local diff_output = '+hello w{+o+}rld' - local result = highlight.parse_git_diff(diff_output) - assert.same({ 'hello world' }, result.content) - assert.equals(1, #result.highlights) - assert.equals('CpDiffAdded', result.highlights[1].highlight_group) - end) - - it('ignores removed lines', function() - local diff_output = 'hello\n-removed line\n+kept line' - local result = highlight.parse_git_diff(diff_output) - assert.same({ 'hello', 'kept line' }, result.content) - end) - - it('handles unchanged lines', function() - local diff_output = 'unchanged line\n+added line' - local result = highlight.parse_git_diff(diff_output) - assert.same({ 'unchanged line', 'added line' }, result.content) - end) - - it('sets correct line numbers', function() - local diff_output = '+first {+added+}\n+second {+text+}' - local result = highlight.parse_git_diff(diff_output) - assert.equals(0, result.highlights[1].line) - assert.equals(1, result.highlights[2].line) - end) - - it('handles empty diff output', function() - local result = highlight.parse_git_diff('') - assert.same({}, result.content) - assert.same({}, result.highlights) - end) - end) - - describe('apply_highlights', function() - it('handles empty highlights without errors', function() - local namespace = highlight.create_namespace() - assert.has_no_errors(function() - highlight.apply_highlights(1, {}, namespace) - end) - end) - - it('handles valid highlight data without errors', function() - vim.api.nvim_buf_set_lines(1, 0, -1, false, { 'hello world test line' }) - local highlights = { - { - line = 0, - col_start = 5, - col_end = 10, - highlight_group = 'CpDiffAdded', - }, - } - local namespace = highlight.create_namespace() - assert.has_no_errors(function() - highlight.apply_highlights(1, highlights, namespace) - end) - end) - end) - - describe('create_namespace', function() - it('returns a number', function() - local result = highlight.create_namespace() - assert.equals('number', type(result)) - end) - end) - - describe('parse_and_apply_diff', function() - it('returns content lines', function() - local namespace = highlight.create_namespace() - local result = highlight.parse_and_apply_diff(1, '+first\n+second', namespace) - assert.same({ 'first', 'second' }, result) - end) - - it('handles empty diff', function() - local namespace = highlight.create_namespace() - local result = highlight.parse_and_apply_diff(1, '', namespace) - assert.same({}, result) - end) - end) -end) diff --git a/spec/panel_spec.lua b/spec/panel_spec.lua deleted file mode 100644 index b5fdfe2..0000000 --- a/spec/panel_spec.lua +++ /dev/null @@ -1,109 +0,0 @@ -describe('Panel integration', function() - local spec_helper = require('spec.spec_helper') - local cp - local state - - before_each(function() - spec_helper.setup_full() - spec_helper.mock_scraper_success() - - 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, - setup_contest = function(platform, contest, problem, _) - 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 - - cp = require('cp') - cp.setup({ - contests = { - codeforces = { - default_language = 'cpp', - cpp = { extension = 'cpp', test = { 'echo', 'test' } }, - }, - }, - scrapers = { 'codeforces' }, - }) - end) - - after_each(function() - spec_helper.teardown() - if state then - state.reset() - end - end) - - it('should handle run command with properly set contest context', function() - cp.handle_command({ fargs = { 'codeforces', '2146', 'b' } }) - - 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' } }) - end) - - local has_validation_error = false - for _, log_entry in ipairs(spec_helper.logged_messages) do - if - log_entry.level == vim.log.levels.ERROR - and log_entry.msg:match('expected string, got nil') - then - has_validation_error = true - break - end - end - assert.is_false(has_validation_error) - end) - - it('should handle state module interface correctly', function() - local run = require('cp.runner.run') - - state.set_platform('codeforces') - state.set_contest_id('2146') - state.set_problem_id('b') - - local config_module = require('cp.config') - config_module.setup({ - contests = { codeforces = { cpp = { extension = 'cpp' } } }, - }) - local cp_state = require('cp.state') - cp_state.set_platform('codeforces') - cp_state.set_contest_id('2146') - cp_state.set_problem_id('b') - - assert.has_no_errors(function() - run.load_test_cases(state) - end) - - local fake_state_data = { platform = 'codeforces', contest_id = '2146', problem_id = 'b' } - assert.has_errors(function() - run.load_test_cases(fake_state_data) - end) - end) -end) diff --git a/spec/picker_spec.lua b/spec/picker_spec.lua deleted file mode 100644 index a007a8a..0000000 --- a/spec/picker_spec.lua +++ /dev/null @@ -1,190 +0,0 @@ -describe('cp.picker', function() - local picker - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - picker = require('cp.pickers') - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('get_contests_for_platform', function() - it('returns empty list when scraper fails', function() - vim.system = function(_, _) - return { - wait = function() - return { code = 1, stderr = 'test error' } - end, - } - end - - local contests = picker.get_contests_for_platform('test_platform') - assert.is_table(contests) - assert.equals(0, #contests) - end) - - it('returns empty list when JSON is invalid', function() - vim.system = function(_, _) - return { - wait = function() - return { code = 0, stdout = 'invalid json' } - end, - } - end - - local contests = picker.get_contests_for_platform('test_platform') - assert.is_table(contests) - assert.equals(0, #contests) - end) - - it('returns contest list when scraper succeeds', function() - local cache = require('cp.cache') - local utils = require('cp.utils') - - cache.load = function() end - cache.get_contest_list = function() - return nil - end - cache.set_contest_list = function() end - - utils.setup_python_env = function() - return true - end - utils.get_plugin_path = function() - return '/test/path' - end - - vim.system = function(_, _) - return { - wait = function() - return { - code = 0, - stdout = vim.json.encode({ - success = true, - contests = { - { - id = 'abc123', - name = 'AtCoder Beginner Contest 123', - display_name = 'Beginner Contest 123 (ABC)', - }, - { - id = '1951', - name = 'Educational Round 168', - display_name = 'Educational Round 168', - }, - }, - }), - } - end, - } - end - - local contests = picker.get_contests_for_platform('test_platform') - assert.is_table(contests) - assert.equals(2, #contests) - assert.equals('abc123', contests[1].id) - assert.equals('AtCoder Beginner Contest 123', contests[1].name) - assert.equals('Beginner Contest 123 (ABC)', contests[1].display_name) - end) - end) - - describe('get_problems_for_contest', function() - it('returns problems from cache when available', function() - local cache = require('cp.cache') - cache.load = function() end - cache.get_contest_data = function(_, _) - 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) diff --git a/spec/run_render_spec.lua b/spec/run_render_spec.lua deleted file mode 100644 index 72f58c4..0000000 --- a/spec/run_render_spec.lua +++ /dev/null @@ -1,200 +0,0 @@ -describe('cp.run_render', function() - local run_render = require('cp.runner.run_render') - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('get_status_info', function() - it('returns AC for pass status', function() - local test_case = { status = 'pass' } - local result = run_render.get_status_info(test_case) - assert.equals('AC', result.text) - assert.equals('CpTestAC', result.highlight_group) - end) - - it('returns WA for fail status with normal exit codes', function() - local test_case = { status = 'fail', code = 1 } - local result = run_render.get_status_info(test_case) - assert.equals('WA', result.text) - assert.equals('CpTestWA', result.highlight_group) - end) - - it('returns TLE for timeout status', function() - local test_case = { status = 'timeout' } - local result = run_render.get_status_info(test_case) - assert.equals('TLE', result.text) - assert.equals('CpTestTLE', result.highlight_group) - end) - - it('returns TLE for timed out fail status', function() - local test_case = { status = 'fail', timed_out = true } - local result = run_render.get_status_info(test_case) - assert.equals('TLE', result.text) - assert.equals('CpTestTLE', result.highlight_group) - end) - - it('returns RTE for fail with signal codes (>= 128)', function() - local test_case = { status = 'fail', code = 139 } - local result = run_render.get_status_info(test_case) - assert.equals('RTE', result.text) - assert.equals('CpTestRTE', result.highlight_group) - end) - - it('returns empty for pending status', function() - local test_case = { status = 'pending' } - local result = run_render.get_status_info(test_case) - assert.equals('', result.text) - assert.equals('CpTestPending', result.highlight_group) - end) - - it('returns running indicator for running status', function() - local test_case = { status = 'running' } - local result = run_render.get_status_info(test_case) - assert.equals('...', result.text) - assert.equals('CpTestPending', result.highlight_group) - end) - end) - - describe('render_test_list', function() - it('renders table with headers and borders', function() - local test_state = { - test_cases = { - { status = 'pass', input = '5' }, - { status = 'fail', code = 1, input = '3' }, - }, - current_index = 1, - } - local result = run_render.render_test_list(test_state) - assert.is_true(result[1]:find('^┌') ~= nil) - assert.is_true(result[2]:find('│.*#.*│.*Status.*│.*Time.*│.*Exit Code.*│') ~= nil) - assert.is_true(result[3]:find('^├') ~= nil) - end) - - it('shows current test with > prefix in table', function() - local test_state = { - test_cases = { - { status = 'pass', input = '' }, - { status = 'pass', input = '' }, - }, - current_index = 2, - } - local result = run_render.render_test_list(test_state) - local found_current = false - for _, line in ipairs(result) do - if line:match('│.*> 2.*│') then - found_current = true - break - end - end - assert.is_true(found_current) - end) - - it('displays input only for current test', function() - local test_state = { - test_cases = { - { status = 'pass', input = '5 3' }, - { status = 'pass', input = '2 4' }, - }, - current_index = 1, - } - local result = run_render.render_test_list(test_state) - local found_input = false - for _, line in ipairs(result) do - if line:match('│5 3') then - found_input = true - break - end - end - assert.is_true(found_input) - end) - - it('handles empty test cases', function() - local test_state = { test_cases = {}, current_index = 1 } - local result = run_render.render_test_list(test_state) - assert.equals(3, #result) - end) - - it('preserves input line breaks', function() - local test_state = { - test_cases = { - { status = 'pass', input = '5\n3\n1' }, - }, - current_index = 1, - } - local result = run_render.render_test_list(test_state) - local input_lines = {} - for _, line in ipairs(result) do - if line:match('^│[531]') then - table.insert(input_lines, line:match('│([531])')) - end - end - assert.same({ '5', '3', '1' }, input_lines) - end) - end) - - describe('render_status_bar', function() - it('formats time and exit code', function() - local test_case = { time_ms = 45.7, code = 0 } - local result = run_render.render_status_bar(test_case) - assert.equals('45.70ms │ Exit: 0', result) - end) - - it('handles missing time', function() - local test_case = { code = 0 } - local result = run_render.render_status_bar(test_case) - assert.equals('Exit: 0', result) - end) - - it('handles missing exit code', function() - local test_case = { time_ms = 123 } - local result = run_render.render_status_bar(test_case) - assert.equals('123.00ms', result) - end) - - it('returns empty for nil test case', function() - local result = run_render.render_status_bar(nil) - assert.equals('', result) - end) - end) - - describe('setup_highlights', function() - it('runs without errors', function() - assert.has_no_errors(function() - run_render.setup_highlights() - end) - end) - end) - - describe('highlight positioning', function() - it('generates correct highlight positions for status text', function() - local test_state = { - test_cases = { - { status = 'pass', input = '' }, - { status = 'fail', code = 1, input = '' }, - }, - current_index = 1, - } - local lines, highlights = run_render.render_test_list(test_state) - - assert.equals(2, #highlights) - - for _, hl in ipairs(highlights) do - assert.is_not_nil(hl.line) - assert.is_not_nil(hl.col_start) - assert.is_not_nil(hl.col_end) - assert.is_not_nil(hl.highlight_group) - assert.is_true(hl.col_end > hl.col_start) - - local line_content = lines[hl.line + 1] - local highlighted_text = line_content:sub(hl.col_start + 1, hl.col_end) - assert.is_true(highlighted_text == 'AC' or highlighted_text == 'WA') - end - end) - end) -end) diff --git a/spec/run_spec.lua b/spec/run_spec.lua deleted file mode 100644 index f7eb772..0000000 --- a/spec/run_spec.lua +++ /dev/null @@ -1,27 +0,0 @@ -describe('run module', function() - local run = require('cp.runner.run') - - describe('basic functionality', function() - it('has required functions', function() - assert.is_function(run.load_test_cases) - assert.is_function(run.run_test_case) - assert.is_function(run.run_all_test_cases) - assert.is_function(run.get_run_panel_state) - assert.is_function(run.handle_compilation_failure) - end) - - it('can get panel state', function() - local state = run.get_run_panel_state() - assert.is_table(state) - assert.is_table(state.test_cases) - end) - - it('handles compilation failure', function() - local compilation_output = 'error.cpp:1:1: error: undefined variable' - - assert.does_not_error(function() - run.handle_compilation_failure(compilation_output) - end) - end) - end) -end) diff --git a/spec/snippets_spec.lua b/spec/snippets_spec.lua deleted file mode 100644 index 944e0d9..0000000 --- a/spec/snippets_spec.lua +++ /dev/null @@ -1,261 +0,0 @@ -describe('cp.snippets', function() - local snippets - local mock_luasnip - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - snippets = spec_helper.fresh_require('cp.snippets') - mock_luasnip = { - snippet = function(trigger, body) - return { trigger = trigger, body = body } - end, - insert_node = function(pos) - return { type = 'insert', pos = pos } - end, - add_snippets = function(filetype, snippet_list) - mock_luasnip.added = mock_luasnip.added or {} - mock_luasnip.added[filetype] = snippet_list - end, - added = {}, - } - - mock_luasnip.extras = { - fmt = { - fmt = function(template, nodes) - return { template = template, nodes = nodes } - end, - }, - } - - package.loaded['luasnip'] = mock_luasnip - package.loaded['luasnip.extras.fmt'] = mock_luasnip.extras.fmt - end) - - after_each(function() - spec_helper.teardown() - package.loaded['cp.snippets'] = nil - package.loaded['luasnip'] = nil - package.loaded['luasnip.extras.fmt'] = nil - end) - - describe('setup without luasnip', function() - it('handles missing luasnip gracefully', function() - package.loaded['luasnip'] = nil - - assert.has_no_errors(function() - snippets.setup({}) - end) - end) - end) - - describe('setup with luasnip available', function() - it('sets up default cpp snippets for all contests', function() - local config = { snippets = {} } - - snippets.setup(config) - - assert.is_not_nil(mock_luasnip.added.cpp) - assert.is_true(#mock_luasnip.added.cpp >= 3) - - local triggers = {} - for _, snippet in ipairs(mock_luasnip.added.cpp) do - table.insert(triggers, snippet.trigger) - end - - assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/codeforces.cpp')) - assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/atcoder.cpp')) - assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/cses.cpp')) - end) - - it('sets up default python snippets for all contests', function() - local config = { snippets = {} } - - snippets.setup(config) - - assert.is_not_nil(mock_luasnip.added.python) - assert.is_true(#mock_luasnip.added.python >= 3) - - local triggers = {} - for _, snippet in ipairs(mock_luasnip.added.python) do - table.insert(triggers, snippet.trigger) - end - - assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/codeforces.python')) - assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/atcoder.python')) - assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/cses.python')) - end) - - it('includes template content with placeholders', function() - local config = { snippets = {} } - - snippets.setup(config) - - local cpp_snippets = mock_luasnip.added.cpp or {} - local codeforces_snippet = nil - for _, snippet in ipairs(cpp_snippets) do - if snippet.trigger == 'cp.nvim/codeforces.cpp' then - codeforces_snippet = snippet - break - end - end - - assert.is_not_nil(codeforces_snippet) - assert.is_not_nil(codeforces_snippet.body) - assert.equals('table', type(codeforces_snippet.body)) - assert.is_not_nil(codeforces_snippet.body.template:match('#include')) - assert.is_not_nil(codeforces_snippet.body.template:match('void solve')) - end) - - it('respects user snippet overrides', function() - local custom_snippet = { - trigger = 'cp.nvim/custom.cpp', - body = 'custom template', - } - local config = { - snippets = { custom_snippet }, - } - - snippets.setup(config) - - local cpp_snippets = mock_luasnip.added.cpp or {} - local found_custom = false - for _, snippet in ipairs(cpp_snippets) do - if snippet.trigger == 'cp.nvim/custom.cpp' then - found_custom = true - assert.equals('custom template', snippet.body) - break - end - end - assert.is_true(found_custom) - end) - - it('filters user snippets by language', function() - local cpp_snippet = { - trigger = 'cp.nvim/custom.cpp', - body = 'cpp template', - } - local python_snippet = { - trigger = 'cp.nvim/custom.python', - body = 'python template', - } - local config = { - snippets = { cpp_snippet, python_snippet }, - } - - snippets.setup(config) - - local cpp_snippets = mock_luasnip.added.cpp or {} - local python_snippets = mock_luasnip.added.python or {} - - local cpp_has_custom = false - for _, snippet in ipairs(cpp_snippets) do - if snippet.trigger == 'cp.nvim/custom.cpp' then - cpp_has_custom = true - break - end - end - - local python_has_custom = false - for _, snippet in ipairs(python_snippets) do - if snippet.trigger == 'cp.nvim/custom.python' then - python_has_custom = true - break - end - end - - assert.is_true(cpp_has_custom) - assert.is_true(python_has_custom) - end) - - it('handles empty config gracefully', function() - assert.has_no_errors(function() - snippets.setup({}) - end) - - assert.is_not_nil(mock_luasnip.added.cpp) - assert.is_not_nil(mock_luasnip.added.python) - end) - - it('handles empty config gracefully', function() - assert.has_no_errors(function() - snippets.setup({ snippets = {} }) - end) - end) - - it('creates templates for correct filetypes', function() - local config = { snippets = {} } - - snippets.setup(config) - - assert.is_not_nil(mock_luasnip.added.cpp) - assert.is_not_nil(mock_luasnip.added.python) - assert.is_nil(mock_luasnip.added.c) - assert.is_nil(mock_luasnip.added.py) - end) - - it('excludes overridden default snippets', function() - local override_snippet = { - trigger = 'cp.nvim/codeforces.cpp', - body = 'overridden template', - } - local config = { - snippets = { override_snippet }, - } - - snippets.setup(config) - - local cpp_snippets = mock_luasnip.added.cpp or {} - local codeforces_count = 0 - for _, snippet in ipairs(cpp_snippets) do - if snippet.trigger == 'cp.nvim/codeforces.cpp' then - codeforces_count = codeforces_count + 1 - end - end - - assert.equals(1, codeforces_count) - end) - - it('handles case-insensitive snippet triggers', function() - local mixed_case_snippet = { - trigger = 'cp.nvim/CodeForces.cpp', - body = 'mixed case template', - } - local upper_case_snippet = { - trigger = 'cp.nvim/ATCODER.cpp', - body = 'upper case template', - } - local config = { - snippets = { mixed_case_snippet, upper_case_snippet }, - } - - snippets.setup(config) - - local cpp_snippets = mock_luasnip.added.cpp or {} - - local has_mixed_case = false - local has_upper_case = false - local default_codeforces_count = 0 - local default_atcoder_count = 0 - - for _, snippet in ipairs(cpp_snippets) do - if snippet.trigger == 'cp.nvim/CodeForces.cpp' then - has_mixed_case = true - assert.equals('mixed case template', snippet.body) - elseif snippet.trigger == 'cp.nvim/ATCODER.cpp' then - has_upper_case = true - assert.equals('upper case template', snippet.body) - elseif snippet.trigger == 'cp.nvim/codeforces.cpp' then - default_codeforces_count = default_codeforces_count + 1 - elseif snippet.trigger == 'cp.nvim/atcoder.cpp' then - default_atcoder_count = default_atcoder_count + 1 - end - end - - assert.is_true(has_mixed_case) - assert.is_true(has_upper_case) - assert.equals(0, default_codeforces_count, 'Default codeforces snippet should be overridden') - assert.equals(0, default_atcoder_count, 'Default atcoder snippet should be overridden') - end) - end) -end) diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua deleted file mode 100644 index acbdf62..0000000 --- a/spec/spec_helper.lua +++ /dev/null @@ -1,205 +0,0 @@ -local M = {} - -M.logged_messages = {} - -local mock_logger = { - log = function(msg, level) - table.insert(M.logged_messages, { msg = msg, level = level }) - end, - set_config = function() end, -} - -local function setup_vim_mocks() - if not vim.fn then - vim.fn = {} - end - vim.fn.expand = vim.fn.expand or function() - return '/tmp/test.cpp' - end - vim.fn.mkdir = vim.fn.mkdir or function() end - vim.fn.fnamemodify = vim.fn.fnamemodify or function(path) - return path - end - vim.fn.tempname = vim.fn.tempname or function() - return '/tmp/session' - end - if not vim.api then - vim.api = {} - end - vim.api.nvim_get_current_buf = vim.api.nvim_get_current_buf or function() - return 1 - end - vim.api.nvim_buf_get_lines = vim.api.nvim_buf_get_lines or function() - return { '' } - end - if not vim.cmd then - vim.cmd = {} - 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 { - wait = function() - return { code = 0 } - end, - } - end - end -end - -function M.setup() - M.logged_messages = {} - package.loaded['cp.log'] = mock_logger -end - -function M.setup_full() - M.setup() - setup_vim_mocks() - - local cache = require('cp.cache') - cache.load = function() end - cache.set_test_cases = function() end - cache.set_file_state = function() end - cache.get_file_state = function() - return nil - end - cache.get_contest_data = function() - return nil - end - cache.get_test_cases = function() - return {} - end -end - -function M.mock_scraper_success() - package.loaded['cp.scrape'] = { - scrape_problem = function() - local state = require('cp.state') - return { - success = true, - problem_id = state.get_problem_id(), - test_cases = { - { input = '1 2', expected = '3' }, - { input = '3 4', expected = '7' }, - }, - test_count = 2, - } - end, - scrape_contest_metadata = function(_, _) - return { - success = true, - problems = { - { id = 'a' }, - { id = 'b' }, - { id = 'c' }, - }, - } - end, - scrape_problems_parallel = function() - return {} - end, - } -end - -function M.mock_async_scraper_success() - package.loaded['cp.async.scraper'] = { - scrape_contest_metadata_async = function(_, _, callback) - vim.schedule(function() - callback({ - success = true, - problems = { - { id = 'a' }, - { id = 'b' }, - { id = 'c' }, - }, - }) - end) - end, - scrape_problem_async = function(_, _, 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(_, _, callback) - vim.schedule(function() - callback({ - success = false, - error = 'mock network error', - }) - end) - end, - scrape_problem_async = function(_, _, 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 - return true - end - end - return false -end - -function M.find_logged_message(pattern) - for _, log_entry in ipairs(M.logged_messages) do - if log_entry.msg and log_entry.msg:match(pattern) then - return log_entry - end - end - return nil -end - -function M.fresh_require(module_name, additional_clears) - additional_clears = additional_clears or {} - - for _, clear_module in ipairs(additional_clears) do - package.loaded[clear_module] = nil - end - package.loaded[module_name] = nil - - return require(module_name) -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 - -return M diff --git a/spec/telescope_spec.lua b/spec/telescope_spec.lua deleted file mode 100644 index bc4876a..0000000 --- a/spec/telescope_spec.lua +++ /dev/null @@ -1,87 +0,0 @@ -describe('cp.telescope', function() - local spec_helper = require('spec.spec_helper') - - before_each(function() - spec_helper.setup() - - package.preload['telescope'] = function() - return { - register_extension = function(ext_config) - return ext_config - end, - } - end - - package.preload['telescope.pickers'] = function() - return { - new = function(_, _) - return { - find = function() end, - } - end, - } - end - - package.preload['telescope.finders'] = function() - return { - new_table = function(opts) - return opts - end, - } - end - - package.preload['telescope.config'] = function() - return { - values = { - generic_sorter = function() - return {} - end, - }, - } - end - - package.preload['telescope.actions'] = function() - return { - select_default = { - replace = function() end, - }, - close = function() end, - } - end - - package.preload['telescope.actions.state'] = function() - return { - get_selected_entry = function() - return nil - end, - } - end - end) - - after_each(function() - spec_helper.teardown() - end) - - describe('module loading', function() - it('registers telescope extension without error', function() - assert.has_no_errors(function() - require('cp.pickers.telescope') - end) - end) - - it('returns module with picker function', function() - local telescope_cp = require('cp.pickers.telescope') - assert.is_table(telescope_cp) - assert.is_function(telescope_cp.pick) - end) - end) - - describe('basic running', function() - it('can run and open the picker with :CP pick', function() - local cp = require('cp') - assert.has_no_errors(function() - cp.handle_command({ fargs = { 'pick' } }) - end) - end) - end) -end)