Merge pull request #121 from barrett-ruth/refactor/picker-fixes

Refactor/picker fixes
This commit is contained in:
Barrett Ruth 2025-10-02 16:40:18 +02:00 committed by GitHub
commit 35ccd6e217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 466 additions and 4186 deletions

View file

@ -1,7 +1,5 @@
# cp.nvim # 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** **The definitive competitive programming environment for Neovim**
Scrape problems, run tests, and debug solutions across multiple platforms with zero configuration. 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 ### Basic Usage
1. **Find a contest or problem** on the judge website 1. **Find a contest or problem** on the judge website
2. **Set up locally** with `:CP <platform> <contest> [<problem>]` 2. **Set up locally** with `:CP <platform> <contest> [--{lang=<lang>,debug}]`
``` ```
:CP codeforces 1848 :CP codeforces 1848

View file

@ -56,13 +56,6 @@ COMMANDS *cp-commands*
:CP {platform} Platform setup: set platform only. :CP {platform} Platform setup: set platform only.
Example: > Example: >
:CP cses :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 ~ Action Commands ~
:CP run [--debug] Toggle run panel for individual test case :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. :CP prev Navigate to previous problem in current contest.
Stops at first problem (no wrapping). 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 ~ Command Flags ~
*cp-flags* *cp-flags*
Flags can be used with setup and action commands: 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', diff_mode = 'vim',
next_test_key = '<c-n>', next_test_key = '<c-n>',
prev_test_key = '<c-p>', prev_test_key = '<c-p>',
toggle_diff_key = '<c-q>',
max_output_lines = 50, max_output_lines = 50,
}, },
diff = { diff = {
@ -229,7 +231,6 @@ is required:
{next_test_key} (string, default: "<c-n>") Key to navigate to next test case. {next_test_key} (string, default: "<c-n>") Key to navigate to next test case.
{prev_test_key} (string, default: "<c-p>") Key to navigate to previous test case. {prev_test_key} (string, default: "<c-p>") Key to navigate to previous test case.
{toggle_diff_key} (string, default: "<c-t>") Key to cycle through diff modes. {toggle_diff_key} (string, default: "<c-t>") Key to cycle through diff modes.
{close_key} (string, default: "<c-q>") Close the run panel/interactive terminal
{max_output_lines} (number, default: 50) Maximum lines of test output. {max_output_lines} (number, default: 50) Maximum lines of test output.
*cp.DiffConfig* *cp.DiffConfig*
@ -285,7 +286,6 @@ URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a
Usage examples: > Usage examples: >
:CP atcoder abc324 a " Full setup: problem A from contest ABC324 :CP atcoder abc324 a " Full setup: problem A from contest ABC324
:CP atcoder abc324 " Contest setup: load contest metadata only :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 :CP next " Navigate to next problem in contest
< <
Note: AtCoder template includes optimizations Note: AtCoder template includes optimizations
@ -303,7 +303,6 @@ URL format: https://codeforces.com/contest/1234/problem/A
Usage examples: > Usage examples: >
:CP codeforces 1934 a " Full setup: problem A from contest 1934 :CP codeforces 1934 a " Full setup: problem A from contest 1934
:CP codeforces 1934 " Contest setup: load contest metadata only :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 :CP prev " Navigate to previous problem in contest
< <
Note: Problem IDs are automatically converted Note: Problem IDs are automatically converted
@ -535,9 +534,9 @@ RUN PANEL KEYMAPS *cp-test-keys*
run_panel.next_test_key) run_panel.next_test_key)
<c-p> Navigate to previous test case (configurable via <c-p> Navigate to previous test case (configurable via
run_panel.prev_test_key) run_panel.prev_test_key)
<c-t> Cycle through diff modes: none → git → vim (configurable t Cycle through diff modes: none → git → vim
via run_panel.toggle_diff_key) q Exit run panel and restore layout
<c-q> Exit run panel/interactive terminal and restore layout <c-q> Exit interactive terminal and restore layout
Diff Modes ~ Diff Modes ~

View file

@ -11,13 +11,10 @@
---@class ContestListData ---@class ContestListData
---@field contests table[] ---@field contests table[]
---@field cached_at number
---@class ContestData ---@class ContestData
---@field problems Problem[] ---@field problems Problem[]
---@field scraped_at string
---@field test_cases? CachedTestCase[] ---@field test_cases? CachedTestCase[]
---@field test_cases_cached_at? number
---@field timeout_ms? number ---@field timeout_ms? number
---@field memory_mb? number ---@field memory_mb? number
---@field interactive? boolean ---@field interactive? boolean
@ -34,6 +31,7 @@
local M = {} local M = {}
local logger = require('cp.log')
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
local cache_data = {} local cache_data = {}
local loaded = false local loaded = false
@ -44,7 +42,7 @@ function M.load()
end end
if vim.fn.filereadable(cache_file) == 0 then if vim.fn.filereadable(cache_file) == 0 then
cache_data = {} vim.fn.writefile({}, cache_file)
loaded = true loaded = true
return return
end end
@ -60,28 +58,19 @@ function M.load()
if ok then if ok then
cache_data = decoded cache_data = decoded
else else
cache_data = {} logger.log('Could not decode json in cache file', vim.log.levels.ERROR)
end end
loaded = true loaded = true
end end
function M.save() function M.save()
local ok, _ = pcall(vim.fn.mkdir, vim.fn.fnamemodify(cache_file, ':h'), 'p') vim.schedule(function()
if not ok then vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
vim.schedule(function()
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
end)
return
end
local encoded = vim.json.encode(cache_data) local encoded = vim.json.encode(cache_data)
local lines = vim.split(encoded, '\n') local lines = vim.split(encoded, '\n')
local write_ok, _ = pcall(vim.fn.writefile, lines, cache_file) vim.fn.writefile(lines, cache_file)
if not write_ok then end)
vim.schedule(function()
vim.fn.writefile(lines, cache_file)
end)
end
end end
---@param platform string ---@param platform string
@ -98,7 +87,7 @@ function M.get_contest_data(platform, contest_id)
end end
local contest_data = cache_data[platform][contest_id] 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 return nil
end end
@ -115,15 +104,37 @@ function M.set_contest_data(platform, contest_id, problems)
problems = { problems, 'table' }, problems = { problems, 'table' },
}) })
if not cache_data[platform] then cache_data[platform] = cache_data[platform] or {}
cache_data[platform] = {} 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 end
cache_data[platform][contest_id] = { local merged = {}
problems = problems, for _, p in ipairs(problems) do
scraped_at = os.date('%Y-%m-%d'), 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() M.save()
end end
@ -152,16 +163,23 @@ function M.get_test_cases(platform, contest_id, problem_id)
problem_id = { problem_id, { 'string', 'nil' }, true }, problem_id = { problem_id, { 'string', 'nil' }, true },
}) })
local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id if
if not cache_data[platform] or not cache_data[platform][problem_key] then 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 return nil
end 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 end
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@param problem_id? string ---@param problem_id string
---@param test_cases CachedTestCase[] ---@param test_cases CachedTestCase[]
---@param timeout_ms? number ---@param timeout_ms? number
---@param memory_mb? number ---@param memory_mb? number
@ -185,22 +203,12 @@ function M.set_test_cases(
interactive = { interactive, { 'boolean', 'nil' }, true }, interactive = { interactive, { 'boolean', 'nil' }, true },
}) })
local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id local index = cache_data[platform][contest_id].index_map[problem_id]
if not cache_data[platform] then
cache_data[platform] = {} cache_data[platform][contest_id].problems[index].test_cases = test_cases
end cache_data[platform][contest_id].problems[index].timeout_ms = timeout_ms or 0
if not cache_data[platform][problem_key] then cache_data[platform][contest_id].problems[index].memory_mb = memory_mb or 0
cache_data[platform][problem_key] = {}
end
cache_data[platform][problem_key].test_cases = test_cases
cache_data[platform][problem_key].test_cases_cached_at = os.time()
if timeout_ms then
cache_data[platform][problem_key].timeout_ms = timeout_ms
end
if memory_mb then
cache_data[platform][problem_key].memory_mb = memory_mb
end
M.save() M.save()
end end
@ -215,12 +223,9 @@ function M.get_constraints(platform, contest_id, problem_id)
problem_id = { problem_id, { 'string', 'nil' }, true }, problem_id = { problem_id, { 'string', 'nil' }, true },
}) })
local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id local index = cache_data[platform][contest_id].index_map[problem_id]
if not cache_data[platform] or not cache_data[platform][problem_key] then
return nil, nil
end
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 return problem_data.timeout_ms, problem_data.memory_mb
end end
@ -255,43 +260,34 @@ function M.set_file_state(file_path, platform, contest_id, problem_id, language)
end end
---@param platform string ---@param platform string
---@return table[]? ---@return table[]
function M.get_contest_list(platform) function M.get_contest_list(platform)
if not cache_data.contest_lists or not cache_data.contest_lists[platform] then local contest_list = {}
return nil 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 end
return contest_list
return cache_data.contest_lists[platform].contests
end end
---@param platform string ---@param platform string
---@param contests table[] ---@param contests table[]
function M.set_contest_list(platform, contests) function M.set_contest_list(platform, contests)
if not cache_data.contest_lists then cache_data[platform] = cache_data[platform] or {}
cache_data.contest_lists = {} 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 end
cache_data.contest_lists[platform] = {
contests = contests,
cached_at = os.time(),
}
M.save() M.save()
end 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() function M.clear_all()
cache_data = { cache_data = {}
file_states = {},
contest_lists = {},
}
M.save() M.save()
end end
@ -300,10 +296,15 @@ function M.clear_platform(platform)
if cache_data[platform] then if cache_data[platform] then
cache_data[platform] = nil cache_data[platform] = nil
end end
if cache_data.contest_lists and cache_data.contest_lists[platform] then
cache_data.contest_lists[platform] = nil
end
M.save() M.save()
end end
---@return string
function M.get_data_pretty()
M.load()
return vim.inspect(cache_data)
end
return M return M

View file

@ -7,15 +7,46 @@ local logger = require('cp.log')
local platforms = constants.PLATFORMS local platforms = constants.PLATFORMS
function M.handle_cache_command(cmd) 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',
'<cmd>bd!<cr>',
{ nowait = true, noremap = true, silent = true }
)
end
vim.api.nvim_set_current_buf(buf)
elseif cmd.subcommand == 'clear' then
cache.load() cache.load()
if cmd.platform then if cmd.platform then
if vim.tbl_contains(platforms, cmd.platform) then if vim.tbl_contains(platforms, cmd.platform) then
cache.clear_platform(cmd.platform) 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 else
logger.log( logger.log(
('unknown platform: %s. Available: %s'):format( ("Unknown platform: '%s'. Available: %s"):format(
cmd.platform, cmd.platform,
table.concat(platforms, ', ') table.concat(platforms, ', ')
), ),
@ -24,7 +55,7 @@ function M.handle_cache_command(cmd)
end end
else else
cache.clear_all() 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 end
end end

View file

@ -8,7 +8,7 @@ local platforms = constants.PLATFORMS
local actions = constants.ACTIONS local actions = constants.ACTIONS
local function parse_command(args) local function parse_command(args)
if #args == 0 then if vim.tbl_isempty(args) then
return { return {
type = 'restore_from_file', type = 'restore_from_file',
} }
@ -44,11 +44,11 @@ local function parse_command(args)
if not subcommand then if not subcommand then
return { type = 'error', message = 'cache command requires subcommand: clear' } return { type = 'error', message = 'cache command requires subcommand: clear' }
end end
if subcommand == 'clear' then if vim.tbl_contains({ 'clear', 'read' }, subcommand) then
local platform = filtered_args[3] local platform = filtered_args[3]
return { return {
type = 'cache', type = 'cache',
subcommand = 'clear', subcommand = subcommand,
platform = platform, platform = platform,
} }
else else
@ -62,9 +62,8 @@ local function parse_command(args)
if vim.tbl_contains(platforms, first) then if vim.tbl_contains(platforms, first) then
if #filtered_args == 1 then if #filtered_args == 1 then
return { return {
type = 'platform_only', type = 'error',
platform = first, message = 'Too few arguments - specify a contest.',
language = language,
} }
elseif #filtered_args == 2 then elseif #filtered_args == 2 then
return { return {
@ -75,11 +74,8 @@ local function parse_command(args)
} }
elseif #filtered_args == 3 then elseif #filtered_args == 3 then
return { return {
type = 'full_setup', type = 'error',
platform = first, message = 'Setup contests with :CP <platform> <contest_id> [--{lang=<lang>,debug}]',
contest = filtered_args[2],
problem = filtered_args[3],
language = language,
} }
else else
return { type = 'error', message = 'Too many arguments' } 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 if state.get_platform() and state.get_contest_id() then
local cache = require('cp.cache') local cache = require('cp.cache')
cache.load() 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 { return {
type = 'error', type = 'error',
message = ("invalid subcommand '%s'"):format(first), message = ("invalid subcommand '%s'"):format(first),
@ -147,33 +133,13 @@ function M.handle_command(opts)
return return
end 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 if cmd.type == 'contest_setup' then
local setup = require('cp.setup') local setup = require('cp.setup')
if setup.set_platform(cmd.platform) then 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 end
return return
end 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 end
return M return M

View file

@ -8,7 +8,7 @@ function M.handle_pick_action()
if not config.picker then if not config.picker then
logger.log( 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 vim.log.levels.ERROR
) )
return return
@ -20,14 +20,14 @@ function M.handle_pick_action()
local ok = pcall(require, 'telescope') local ok = pcall(require, 'telescope')
if not ok then if not ok then
logger.log( 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 vim.log.levels.ERROR
) )
return return
end end
local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope') local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope')
if not ok_cp then 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 return
end end
@ -36,14 +36,14 @@ function M.handle_pick_action()
local ok, _ = pcall(require, 'fzf-lua') local ok, _ = pcall(require, 'fzf-lua')
if not ok then if not ok then
logger.log( 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 vim.log.levels.ERROR
) )
return return
end end
local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua') local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua')
if not ok_cp then 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 return
end end

View file

@ -33,8 +33,6 @@
---@field diff_mode "none"|"vim"|"git" Diff backend to use ---@field diff_mode "none"|"vim"|"git" Diff backend to use
---@field next_test_key string Key to navigate to next test case ---@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 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 ---@field max_output_lines number Maximum lines of test output to display
---@class DiffGitConfig ---@class DiffGitConfig
@ -104,8 +102,6 @@ M.defaults = {
diff_mode = 'none', diff_mode = 'none',
next_test_key = '<c-n>', next_test_key = '<c-n>',
prev_test_key = '<c-p>', prev_test_key = '<c-p>',
toggle_diff_key = '<c-t>',
close_key = '<c-q>',
max_output_lines = 50, max_output_lines = 50,
}, },
diff = { diff = {
@ -224,20 +220,6 @@ function M.setup(user_config)
end, end,
'prev_test_key must be a non-empty string', '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 = { max_output_lines = {
config.run_panel.max_output_lines, config.run_panel.max_output_lines,
function(value) function(value)
@ -270,7 +252,7 @@ function M.setup(user_config)
end end
end end
if #available_langs == 0 then if vim.tbl_isemtpy(available_langs) then
error('No language configurations found') error('No language configurations found')
end end

View file

@ -5,7 +5,7 @@ local logger = require('cp.log')
local snippets = require('cp.snippets') local snippets = require('cp.snippets')
if not vim.fn.has('nvim-0.10.0') then 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 {} return {}
end end

View file

@ -2,13 +2,13 @@ local picker_utils = require('cp.pickers')
local M = {} local M = {}
local function contest_picker(platform) local function contest_picker(platform, refresh)
local constants = require('cp.constants') local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
local fzf = require('fzf-lua') 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( vim.notify(
('No contests found for platform: %s'):format(platform_display_name), ('No contests found for platform: %s'):format(platform_display_name),
vim.log.levels.WARN vim.log.levels.WARN
@ -27,7 +27,7 @@ local function contest_picker(platform)
}, },
actions = { actions = {
['default'] = function(selected) ['default'] = function(selected)
if not selected or #selected == 0 then if vim.tbl_isempty(selected) then
return return
end end
@ -48,7 +48,7 @@ local function contest_picker(platform)
['ctrl-r'] = function() ['ctrl-r'] = function()
local cache = require('cp.cache') local cache = require('cp.cache')
cache.clear_contest_list(platform) cache.clear_contest_list(platform)
contest_picker(platform) contest_picker(platform, true)
end, end,
}, },
}) })
@ -65,7 +65,7 @@ function M.pick()
prompt = 'Select Platform> ', prompt = 'Select Platform> ',
actions = { actions = {
['default'] = function(selected) ['default'] = function(selected)
if not selected or #selected == 0 then if vim.tbl_isempty(selected) then
return return
end end

View file

@ -2,8 +2,9 @@ local M = {}
local cache = require('cp.cache') local cache = require('cp.cache')
local config = require('cp.config').get_config() local config = require('cp.config').get_config()
local constants = require('cp.constants')
local logger = require('cp.log') local logger = require('cp.log')
local utils = require('cp.utils') local scraper = require('cp.scraper')
---@class cp.PlatformItem ---@class cp.PlatformItem
---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses") ---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses")
@ -21,7 +22,6 @@ local utils = require('cp.utils')
---@return cp.PlatformItem[] ---@return cp.PlatformItem[]
function M.get_platforms() function M.get_platforms()
local constants = require('cp.constants')
local result = {} local result = {}
for _, platform in ipairs(constants.PLATFORMS) do for _, platform in ipairs(constants.PLATFORMS) do
@ -38,169 +38,35 @@ end
---Get list of contests for a specific platform ---Get list of contests for a specific platform
---@param platform string Platform identifier (e.g. "codeforces", "atcoder") ---@param platform string Platform identifier (e.g. "codeforces", "atcoder")
---@param refresh? boolean Whether to skip caching and append new contests
---@return cp.ContestItem[] ---@return cp.ContestItem[]
function M.get_contests_for_platform(platform) function M.get_platform_contests(platform, refresh)
logger.log('loading contests...', vim.log.levels.INFO, true) logger.log(
('Loading %s contests...'):format(constants.PLATFORM_DISPLAY_NAMES[platform]),
vim.log.levels.INFO,
true
)
cache.load() cache.load()
local cached_contests = cache.get_contest_list(platform)
if cached_contests then local picker_contests = cache.get_contest_list(platform)
return cached_contests
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 end
if not utils.setup_python_env() then logger.log(
return {} ('Loaded %s %s contests.'):format(#picker_contests, constants.PLATFORM_DISPLAY_NAMES[platform]),
end vim.log.levels.INFO,
true
)
local plugin_path = utils.get_plugin_path() picker_contests = cache.get_contest_list(platform)
local cmd = {
'uv',
'run',
'--directory',
plugin_path,
'-m',
'scrapers.' .. platform,
'contests',
}
local result = vim return picker_contests
.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)
end end
return M return M

View file

@ -8,12 +8,12 @@ local picker_utils = require('cp.pickers')
local M = {} local M = {}
local function contest_picker(opts, platform) local function contest_picker(opts, platform, refresh)
local constants = require('cp.constants') local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
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( vim.notify(
('No contests found for platform: %s'):format(platform_display_name), ('No contests found for platform: %s'):format(platform_display_name),
vim.log.levels.WARN vim.log.levels.WARN
@ -48,10 +48,8 @@ local function contest_picker(opts, platform)
end) end)
map('i', '<c-r>', function() map('i', '<c-r>', function()
local cache = require('cp.cache')
cache.clear_contest_list(platform)
actions.close(prompt_bufnr) actions.close(prompt_bufnr)
contest_picker(opts, platform) contest_picker(opts, platform, true)
end) end)
return true return true

View file

@ -7,7 +7,7 @@ local state = require('cp.state')
function M.restore_from_current_file() function M.restore_from_current_file()
local current_file = vim.fn.expand('%:p') local current_file = vim.fn.expand('%:p')
if current_file == '' then 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 return false
end end
@ -15,7 +15,7 @@ function M.restore_from_current_file()
local file_state = cache.get_file_state(current_file) local file_state = cache.get_file_state(current_file)
if not file_state then if not file_state then
logger.log( logger.log(
'No cached state found for current file. Use :CP <platform> <contest> <problem> first.', 'No cached state found for current file. Use :CP <platform> <contest> [--{lang=<lang>,debug}...] first.',
vim.log.levels.ERROR vim.log.levels.ERROR
) )
return false return false
@ -25,7 +25,7 @@ function M.restore_from_current_file()
('Restoring from cached state: %s %s %s'):format( ('Restoring from cached state: %s %s %s'):format(
file_state.platform, file_state.platform,
file_state.contest_id, 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_contest_id(file_state.contest_id)
state.set_problem_id(file_state.problem_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 return true
end end

View file

@ -52,7 +52,7 @@ end
---@return {code: integer, stdout: string, stderr: string} ---@return {code: integer, stdout: string, stderr: string}
function M.compile_generic(language_config, substitutions) function M.compile_generic(language_config, substitutions)
if not language_config.compile then 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 = '' } return { code = 0, stderr = '' }
end end
@ -73,9 +73,9 @@ function M.compile_generic(language_config, substitutions)
result.stderr = ansi.bytes_to_string(result.stderr or '') result.stderr = ansi.bytes_to_string(result.stderr or '')
if result.code == 0 then 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 else
logger.log(('compilation failed (%.1fms)'):format(compile_time)) logger.log(('Compilation failed in %.1fms.'):format(compile_time))
end end
return result return result
@ -107,14 +107,14 @@ local function execute_command(cmd, input_data, timeout_ms)
local actual_code = result.code or 0 local actual_code = result.code or 0
if result.code == 124 then 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 elseif actual_code ~= 0 then
logger.log( 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 vim.log.levels.WARN
) )
else else
logger.log(('execution successful (%.1fms)'):format(execution_time)) logger.log(('Execution successful in %.1fms.'):format(execution_time))
end end
return { return {
@ -177,8 +177,8 @@ function M.compile_problem(contest_config, is_debug)
local state = require('cp.state') local state = require('cp.state')
local source_file = state.get_source_file() local source_file = state.get_source_file()
if not source_file then if not source_file then
logger.log('No source file found', vim.log.levels.ERROR) logger.log('No source file found.', vim.log.levels.ERROR)
return { success = false, output = 'No source file found' } return { success = false, output = 'No source file found.' }
end end
local language = get_language_from_file(source_file, contest_config) 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 if not language_config then
logger.log('No configuration for language: ' .. language, vim.log.levels.ERROR) 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 end
local binary_file = state.get_binary_file() 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 if compile_result.code ~= 0 then
return { success = false, output = compile_result.stdout or 'unknown error' } return { success = false, output = compile_result.stdout or 'unknown error' }
end end
logger.log(
('compilation successful (%s)'):format(is_debug and 'debug mode' or 'test mode'),
vim.log.levels.INFO
)
end end
return { success = true, output = nil } return { success = true, output = nil }
@ -220,7 +216,10 @@ function M.run_problem(contest_config, is_debug)
local output_file = state.get_output_file() local output_file = state.get_output_file()
if not source_file or not output_file then 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 return
end end
@ -257,13 +256,14 @@ function M.run_problem(contest_config, is_debug)
local cache = require('cp.cache') local cache = require('cp.cache')
cache.load() cache.load()
local platform = state.get_platform() local platform = state.get_platform()
local contest_id = state.get_contest_id() local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id() local problem_id = state.get_problem_id()
local expected_file = state.get_expected_file() local expected_file = state.get_expected_file()
if not platform or not contest_id or not expected_file then 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 return
end end
local timeout_ms, _ = cache.get_constraints(platform, contest_id, problem_id) local timeout_ms, _ = cache.get_constraints(platform, contest_id, problem_id)

View file

@ -66,9 +66,9 @@ end
local function parse_test_cases_from_cache(platform, contest_id, problem_id) local function parse_test_cases_from_cache(platform, contest_id, problem_id)
local cache = require('cp.cache') local cache = require('cp.cache')
cache.load() 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 {} return {}
end end
@ -83,34 +83,6 @@ local function parse_test_cases_from_cache(platform, contest_id, problem_id)
return test_cases return test_cases
end 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 platform string
---@param contest_id string ---@param contest_id string
---@param problem_id string? ---@param problem_id string?
@ -136,28 +108,11 @@ end
local function run_single_test_case(contest_config, cp_config, test_case) local function run_single_test_case(contest_config, cp_config, test_case)
local state = require('cp.state') local state = require('cp.state')
local source_file = state.get_source_file() 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 = vim.fn.fnamemodify(source_file, ':e')
local language_name = constants.filetype_to_language[language] or contest_config.default_language local language_name = constants.filetype_to_language[language] or contest_config.default_language
local language_config = contest_config[language_name] 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 function substitute_template(cmd_template, substitutions)
local result = {} local result = {}
for _, arg in ipairs(cmd_template) do 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 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 compile_cmd = substitute_template(language_config.compile, substitutions)
local redirected_cmd = vim.deepcopy(compile_cmd) local redirected_cmd = vim.deepcopy(compile_cmd)
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1' 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, ok = false,
signal = nil, signal = nil,
timed_out = false, timed_out = false,
actual_highlights = {},
} }
end end
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 start_time = vim.uv.hrtime()
local timeout_ms = run_panel_state.constraints and run_panel_state.constraints.timeout_ms or 2000 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) local redirected_run_cmd = vim.deepcopy(run_cmd)
redirected_run_cmd[#redirected_run_cmd] = redirected_run_cmd[#redirected_run_cmd] .. ' 2>&1' redirected_run_cmd[#redirected_run_cmd] = redirected_run_cmd[#redirected_run_cmd] .. ' 2>&1'
local result = vim local result = vim
@ -299,13 +252,9 @@ function M.load_test_cases(state)
state.get_platform() or '', state.get_platform() or '',
state.get_contest_id() or '', state.get_contest_id() or '',
state.get_problem_id() state.get_problem_id()
) ) or {}
if #test_cases == 0 then -- TODO: re-fetch/cache-populating mechanism to ge the test cases if not in the cache
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
run_panel_state.test_cases = test_cases run_panel_state.test_cases = test_cases
run_panel_state.current_index = 1 run_panel_state.current_index = 1
@ -315,14 +264,7 @@ function M.load_test_cases(state)
state.get_problem_id() state.get_problem_id()
) )
local constraint_info = run_panel_state.constraints logger.log(('Loaded %d test case(s)'):format(#test_cases), vim.log.levels.INFO)
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)
return #test_cases > 0 return #test_cases > 0
end end

View file

@ -1,11 +1,42 @@
local M = {} local M = {}
local cache = require('cp.cache')
local utils = require('cp.utils') 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 if not utils.setup_python_env() then
callback({ success = false, error = 'Python environment setup failed' }) local msg = 'Python environment setup failed'
return logger.log(msg, vim.log.levels.ERROR)
return {
success = false,
message = msg,
}
end end
local plugin_path = utils.get_plugin_path() local plugin_path = utils.get_plugin_path()
@ -18,114 +49,98 @@ local function run_scraper(platform, subcommand, args, callback)
'scrapers.' .. platform, 'scrapers.' .. platform,
subcommand, subcommand,
} }
vim.list_extend(cmd, args)
for _, arg in ipairs(args or {}) do local sysopts = {
table.insert(cmd, arg)
end
vim.system(cmd, {
cwd = plugin_path,
text = true, text = true,
timeout = 30000, 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 opts.sync then
if not ok then local result = vim.system(cmd, sysopts):wait()
callback({ return syshandle(result)
success = false, else
error = 'Failed to parse scraper output: ' .. tostring(data), vim.system(cmd, sysopts, function(result)
}) return opts.on_exit(syshandle(result))
return end)
end end
callback(data)
end)
end end
function M.scrape_contest_metadata(platform, contest_id, callback) function M.scrape_contest_metadata(platform, contest_id, callback)
cache.load() run_scraper(platform, 'metadata', { contest_id }, {
on_exit = function(result)
local cached = cache.get_contest_data(platform, contest_id) if not result or not result.success then
if cached then logger.log(
callback({ success = true, problems = cached.problems }) ('Failed to scrape metadata for %s contest %s.'):format(platform, contest_id),
return vim.log.levels.ERROR
end )
return
run_scraper(platform, 'metadata', { contest_id }, function(result) end
if result.success and result.problems then local data = result.data or {}
cache.set_contest_data(platform, contest_id, result.problems) if not data.problems or #data.problems == 0 then
end logger.log(
callback(result) ('No problems returned for %s contest %s.'):format(platform, contest_id),
end) vim.log.levels.ERROR
)
return
end
if type(callback) == 'function' then
callback(data)
end
end,
})
end end
function M.scrape_contest_list(platform, callback) function M.scrape_contest_list(platform)
cache.load() local result = run_scraper(platform, 'contests', {}, { sync = true })
if not result.success or not result.data.contests then
local cached = cache.get_contest_list(platform) logger.log(
if cached then ('Could not scrape contests list for platform %s: %s'):format(platform, result.msg),
callback({ success = true, contests = cached }) vim.log.levels.ERROR
return )
return {}
end end
run_scraper(platform, 'contests', {}, function(result) return result.data.contests
if result.success and result.contests then
cache.set_contest_list(platform, result.contests)
end
callback(result)
end)
end end
function M.scrape_problem_tests(platform, contest_id, problem_id, callback) function M.scrape_problem_tests(platform, contest_id, problem_id, callback)
run_scraper(platform, 'tests', { contest_id, problem_id }, function(result) run_scraper(platform, 'tests', { contest_id, problem_id }, {
if result.success and result.tests then on_exit = function(result)
vim.schedule(function() if not result.success or not result.data.tests then
local mkdir_ok = pcall(vim.fn.mkdir, 'io', 'p') logger.log(
if mkdir_ok then 'Failed to load tests: ' .. (result.msg or 'unknown error'),
local config = require('cp.config') vim.log.levels.ERROR
local base_name = config.default_filename(contest_id, problem_id) )
for i, test_case in ipairs(result.tests) do return {}
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,
})
end end
cache.set_test_cases( vim.schedule(function()
platform, vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
contest_id, local config = require('cp.config')
problem_id, local base_name = config.default_filename(contest_id, problem_id)
cached_tests,
result.timeout_ms,
result.memory_mb
)
end
callback(result) for i, test_case in ipairs(result.data.tests) do
end) 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 end
return M return M

View file

@ -28,196 +28,154 @@ function M.set_platform(platform)
return true return true
end end
function M.setup_contest(platform, contest_id, problem_id, language) local function backfill_missing_tests(platform, contest_id, problems)
if not state.get_platform() then cache.load()
logger.log('No platform configured. Use :CP <platform> <contest> [...] first.') 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 return
end end
for _, pid in ipairs(missing) do
local config = config_module.get_config() local captured = pid
scraper.scrape_problem_tests(platform, contest_id, captured, function(result)
if not vim.tbl_contains(config.scrapers, platform) then local cached_tests = {}
logger.log('scraping disabled for ' .. platform, vim.log.levels.WARN) if result.tests then
return for i, t in ipairs(result.tests) do
end cached_tests[i] = { index = i, input = t.input, expected = t.expected }
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
end end
end end
if not problem_exists then cache.set_test_cases(
logger.log( platform,
('invalid problem %s for contest %s'):format(problem_id, contest_id), contest_id,
vim.log.levels.ERROR captured,
) cached_tests,
return result.timeout_ms,
end result.memory_mb
end )
end)
M.setup_problem(contest_id, target_problem, language) end
M.scrape_remaining_problems(platform, contest_id, problems)
end)
end end
function M.setup_problem(contest_id, problem_id, language) function M.setup_contest(platform, contest_id, language, problem_id)
if not state.get_platform() then if not platform then
logger.log('no platform set. run :CP <platform> <contest> first', vim.log.levels.ERROR) logger.log('No platform configured. Use :CP <platform> <contest> [--{lang=<lang>,debug} first.')
return return
end end
local config = config_module.get_config() local config = config_module.get_config()
local platform = state.get_platform() or '' if not vim.tbl_contains(config.scrapers, platform) then
logger.log(('Scraping disabled for %s.'):format(platform), vim.log.levels.WARN)
logger.log(('setting up problem %s%s...'):format(contest_id, problem_id or '')) return
end
state.set_contest_id(contest_id) state.set_contest_id(contest_id)
state.set_problem_id(problem_id) cache.load()
-- TODO: why comment this out local contest_data = cache.get_contest_data(platform, contest_id)
-- state.set_active_panel('run')
vim.schedule(function() if not contest_data or not contest_data.problems then
local ok, err = pcall(function() logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
vim.cmd.only({ mods = { silent = true } }) scraper.scrape_contest_metadata(platform, contest_id, function(result)
local problems = result.problems or {}
local source_file = state.get_source_file(language) cache.set_contest_data(platform, contest_id, problems)
if not source_file then logger.log(('Found %d problems for %s contest %s.'):format(#problems, platform, contest_id))
return local pid = problem_id or (problems[1] and problems[1].id)
end if pid then
vim.cmd.e(source_file) M.setup_problem(pid, language)
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<c-space><esc>'):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
end end
backfill_missing_tests(platform, contest_id, problems)
end) end)
else else
logger.log(('scraping disabled for %s'):format(platform)) local problems = contest_data.problems
state.set_test_cases({}) 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
end end
function M.scrape_remaining_problems(platform, contest_id, problems) ---@param problem_id string
cache.load() ---@param language? string
local missing_problems = {} function M.setup_problem(problem_id, language)
local platform = state.get_platform()
for _, prob in ipairs(problems) do if not platform then
local cached_tests = cache.get_test_cases(platform, contest_id, prob.id) logger.log(
if not cached_tests then 'No platform set. run :CP <platform> <contest> [--{lang=<lang>,debug}]',
table.insert(missing_problems, prob) vim.log.levels.ERROR
end )
end
if #missing_problems == 0 then
logger.log('all problems already cached')
return return
end end
logger.log(('caching %d remaining problems...'):format(#missing_problems)) state.set_problem_id(problem_id)
for _, prob in ipairs(missing_problems) do local config = config_module.get_config()
scraper.scrape_problem_tests(platform, contest_id, prob.id, function(result)
if result.success then vim.schedule(function()
logger.log(('background: scraped problem %s'):format(prob.id)) 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
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 end
function M.navigate_problem(direction, language) 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 platform = state.get_platform()
local contest_id = state.get_contest_id() local contest_id = state.get_contest_id()
local current_problem_id = state.get_problem_id() local current_problem_id = state.get_problem_id()
if not platform or not contest_id or not current_problem_id then if not platform or not contest_id or not current_problem_id then
logger.log( logger.log(
'No platform configured. Use :CP <platform> <contest> [...] first.', 'No platform configured. Use :CP <platform> <contest> [--{lang=<lang>,debug}] first.',
vim.log.levels.ERROR vim.log.levels.ERROR
) )
return return
@ -226,32 +184,35 @@ function M.navigate_problem(direction, language)
cache.load() cache.load()
local contest_data = cache.get_contest_data(platform, contest_id) local contest_data = cache.get_contest_data(platform, contest_id)
if not contest_data or not contest_data.problems then 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 return
end end
local problems = contest_data.problems local problems = contest_data.problems
local current_index = nil local current_index
for i, prob in ipairs(problems) do for i, prob in ipairs(problems) do
if prob.id == current_problem_id then if prob.id == current_problem_id then
current_index = i current_index = i
break break
end end
end end
if not current_index then 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 return
end end
local new_index = current_index + direction local new_index = current_index + direction
if new_index < 1 or new_index > #problems then if new_index < 1 or new_index > #problems then
logger.log('no more problems in that direction', vim.log.levels.WARN)
return return
end end
local new_problem = problems[new_index] M.setup_contest(platform, contest_id, language, problems[new_index].id)
M.setup_problem(contest_id, new_problem.id, language)
end end
return M return M

View file

@ -4,7 +4,7 @@ local logger = require('cp.log')
function M.setup(config) function M.setup(config)
local ok, ls = pcall(require, 'luasnip') local ok, ls = pcall(require, 'luasnip')
if not ok then 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 return
end end

View file

@ -7,8 +7,6 @@
---@field set_problem_id fun(problem_id: string) ---@field set_problem_id fun(problem_id: string)
---@field get_active_panel fun(): string? ---@field get_active_panel fun(): string?
---@field set_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 get_saved_session fun(): table?
---@field set_saved_session fun(session: table) ---@field set_saved_session fun(session: table)
---@field get_context fun(): {platform: string?, contest_id: string?, problem_id: string?} ---@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 state.problem_id = problem_id
end 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() function M.get_saved_session()
return state.saved_session return state.saved_session
end end

View file

@ -23,12 +23,12 @@ function M.toggle_interactive()
state.saved_interactive_session = nil state.saved_interactive_session = nil
end end
state.set_active_panel(nil) state.set_active_panel(nil)
logger.log('interactive closed') logger.log('Interactive panel closed.')
return return
end end
if state.get_active_panel() then 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 return
end end
@ -36,7 +36,7 @@ function M.toggle_interactive()
if not platform then if not platform then
logger.log( logger.log(
'No platform configured. Use :CP <platform> <contest> [...] first.', 'No platform configured. Use :CP <platform> <contest> [--{lang=<lang>,debug}] first.',
vim.log.levels.ERROR vim.log.levels.ERROR
) )
return return
@ -44,7 +44,7 @@ function M.toggle_interactive()
if not contest_id then if not contest_id then
logger.log( logger.log(
('No contest %s configured for platform %s. Use :CP <platform> <contest> <problem> to set up first.'):format( ('No contest %s configured for platform %s. Use :CP <platform> <contest> [--{lang=<lang>,debug}] to set up first.'):format(
contest_id, contest_id,
platform platform
), ),
@ -63,10 +63,7 @@ function M.toggle_interactive()
cache.load() cache.load()
local contest_data = cache.get_contest_data(platform, contest_id) local contest_data = cache.get_contest_data(platform, contest_id)
if contest_data and not contest_data.interactive then if contest_data and not contest_data.interactive then
logger.log( logger.log('This is NOT an interactive problem. Use :CP run instead.', vim.log.levels.WARN)
'This is NOT an interactive problem. Use :CP run instead - aborting.',
vim.log.levels.WARN
)
return return
end end
@ -95,7 +92,7 @@ function M.toggle_interactive()
vim.fn.chansend(vim.b.terminal_job_id, binary .. '\n') vim.fn.chansend(vim.b.terminal_job_id, binary .. '\n')
vim.keymap.set('t', config.run_panel.close_key, function() vim.keymap.set('t', '<c-q>', function()
M.toggle_interactive() M.toggle_interactive()
end, { buffer = term_buf, silent = true }) end, { buffer = term_buf, silent = true })
@ -139,7 +136,7 @@ function M.toggle_run_panel(is_debug)
if not contest_id then if not contest_id then
logger.log( logger.log(
('No contest %s configured for platform %s. Use :CP <platform> <contest> <problem> to set up first.'):format( ('No contest %s configured for platform %s. Use :CP <platform> <contest> [--{lang=<lang>,debug}] to set up first.'):format(
contest_id, contest_id,
platform platform
), ),
@ -158,15 +155,12 @@ function M.toggle_run_panel(is_debug)
cache.load() cache.load()
local contest_data = cache.get_contest_data(platform, contest_id) local contest_data = cache.get_contest_data(platform, contest_id)
if contest_data and contest_data.interactive then if contest_data and contest_data.interactive then
logger.log( logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN)
'This is an interactive problem. Use :CP interact instead - aborting.',
vim.log.levels.WARN
)
return return
end end
logger.log( logger.log(
('run panel: platform=%s, contest=%s, problem=%s'):format( ('Run panel: platform=%s, contest=%s, problem=%s'):format(
tostring(platform), tostring(platform),
tostring(contest_id), tostring(contest_id),
tostring(problem_id) tostring(problem_id)
@ -235,18 +229,18 @@ function M.toggle_run_panel(is_debug)
local function navigate_test_case(delta) local function navigate_test_case(delta)
local test_state = run.get_run_panel_state() 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 return
end 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() refresh_run_panel()
end end
setup_keybindings_for_buffer = function(buf) 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() M.toggle_run_panel()
end, { buffer = buf, silent = true }) 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 modes = { 'none', 'git', 'vim' }
local current_idx = nil local current_idx = nil
for i, mode in ipairs(modes) do for i, mode in ipairs(modes) do

View file

@ -46,7 +46,7 @@ end, {
if args[2] == 'cache' then if args[2] == 'cache' then
return vim.tbl_filter(function(cmd) return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1 return cmd:find(ArgLead, 1, true) == 1
end, { 'clear' }) end, { 'clear', 'read' })
end end
elseif num_args == 4 then elseif num_args == 4 then
if args[2] == 'cache' and args[3] == 'clear' then if args[2] == 'cache' and args[3] == 'clear' then

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -1,429 +1,11 @@
describe('cp.execute', function() describe('run module', function()
local execute local run = require('cp.runner.run')
local mock_system_calls
local temp_files describe('basic functionality', function()
local spec_helper = require('spec.spec_helper') it('can get panel state', function()
local state = run.get_run_panel_state()
before_each(function() assert.is_table(state)
spec_helper.setup() assert.is_table(state.test_cases)
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
end) end)
end) end)
end) end)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)