## Problem The `CONTEST_ACTIONS` list in `handle_command` was a manually maintained parallel of `ACTIONS` that had already drifted (`pick` was incorrectly included, breaking `:CP pick` cold). `navigate_problem` only cancelled the `'run'` panel on `next`/`prev`, leaving interactive and stress terminals alive and in-flight `run_io_view` callbacks unguarded. `pick` and `cache` also appeared twice in tab-completion when a contest was active. ## Solution Replace `CONTEST_ACTIONS` with a `requires_context` field on `ParsedCommand`, set at each parse-site in `parse_command` so the semantics live with the action definition. Expand `navigate_problem` to call `cancel_io_view()` and dispatch `cancel_interactive`/`stress.cancel` for all panel types, mirroring the contest-switch logic. Filter already-present `pick` and `cache` from the context-conditional completion candidates.
525 lines
16 KiB
Lua
525 lines
16 KiB
Lua
local M = {}
|
|
|
|
local cache = require('cp.cache')
|
|
local config_module = require('cp.config')
|
|
local constants = require('cp.constants')
|
|
local helpers = require('cp.helpers')
|
|
local logger = require('cp.log')
|
|
local scraper = require('cp.scraper')
|
|
local state = require('cp.state')
|
|
|
|
local function apply_template(bufnr, lang_id, platform)
|
|
local config = config_module.get_config()
|
|
local eff = config.runtime.effective[platform] and config.runtime.effective[platform][lang_id]
|
|
if not eff or not eff.template then
|
|
return
|
|
end
|
|
local path = vim.fn.expand(eff.template)
|
|
if vim.fn.filereadable(path) ~= 1 then
|
|
logger.log(
|
|
('[cp.nvim] template not readable: %s'):format(path),
|
|
{ level = vim.log.levels.WARN }
|
|
)
|
|
return
|
|
end
|
|
local lines = vim.fn.readfile(path)
|
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
|
local marker = config.templates and config.templates.cursor_marker
|
|
if marker then
|
|
for lnum, line in ipairs(lines) do
|
|
local col = line:find(marker, 1, true)
|
|
if col then
|
|
local new_line = line:sub(1, col - 1) .. line:sub(col + #marker)
|
|
vim.api.nvim_buf_set_lines(bufnr, lnum - 1, lnum, false, { new_line })
|
|
local winid = vim.fn.bufwinid(bufnr)
|
|
if winid ~= -1 then
|
|
vim.api.nvim_win_set_cursor(winid, { lnum, col - 1 })
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
---Get the language of the current file from cache
|
|
---@return string?
|
|
local function get_current_file_language()
|
|
local current_file = vim.fn.expand('%:p')
|
|
if current_file == '' then
|
|
return nil
|
|
end
|
|
cache.load()
|
|
local file_state = cache.get_file_state(current_file)
|
|
return file_state and file_state.language or nil
|
|
end
|
|
|
|
---Check if a problem file exists for any enabled language
|
|
---@param platform string
|
|
---@param contest_id string
|
|
---@param problem_id string
|
|
---@return string?
|
|
local function get_existing_problem_language(platform, contest_id, problem_id)
|
|
local config = config_module.get_config()
|
|
local platform_config = config.platforms[platform]
|
|
if not platform_config then
|
|
return nil
|
|
end
|
|
|
|
for _, lang_id in ipairs(platform_config.enabled_languages) do
|
|
local effective = config.runtime.effective[platform][lang_id]
|
|
if effective and effective.extension then
|
|
local basename = config.filename
|
|
and config.filename(platform, contest_id, problem_id, config, lang_id)
|
|
or config_module.default_filename(contest_id, problem_id)
|
|
local filepath = basename .. '.' .. effective.extension
|
|
if vim.fn.filereadable(filepath) == 1 then
|
|
return lang_id
|
|
end
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
---@class TestCaseLite
|
|
---@field input string
|
|
---@field expected string
|
|
|
|
---@class ScrapeEvent
|
|
---@field problem_id string
|
|
---@field tests TestCaseLite[]|nil
|
|
---@field timeout_ms integer|nil
|
|
---@field memory_mb integer|nil
|
|
---@field interactive boolean|nil
|
|
---@field error string|nil
|
|
---@field done boolean|nil
|
|
---@field succeeded integer|nil
|
|
---@field failed integer|nil
|
|
|
|
---@param cd table|nil
|
|
---@return boolean
|
|
local function is_metadata_ready(cd)
|
|
return cd
|
|
and type(cd.problems) == 'table'
|
|
and #cd.problems > 0
|
|
and type(cd.index_map) == 'table'
|
|
and next(cd.index_map) ~= nil
|
|
or false
|
|
end
|
|
|
|
---@param platform string
|
|
---@param contest_id string
|
|
---@param problems table
|
|
local function start_tests(platform, contest_id, problems)
|
|
local cached_len = #vim.tbl_filter(function(p)
|
|
return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id))
|
|
end, problems)
|
|
if cached_len ~= #problems then
|
|
local to_fetch = #problems - cached_len
|
|
logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems))
|
|
scraper.scrape_all_tests(platform, contest_id, function(ev)
|
|
local cached_tests = {}
|
|
if not ev.interactive and vim.tbl_isempty(ev.tests) then
|
|
logger.log(
|
|
("No tests found for problem '%s'."):format(ev.problem_id),
|
|
{ level = vim.log.levels.WARN }
|
|
)
|
|
end
|
|
for i, t in ipairs(ev.tests) do
|
|
cached_tests[i] = { index = i, input = t.input, expected = t.expected }
|
|
end
|
|
cache.set_test_cases(
|
|
platform,
|
|
contest_id,
|
|
ev.problem_id,
|
|
ev.combined,
|
|
cached_tests,
|
|
ev.timeout_ms or 0,
|
|
ev.memory_mb or 0,
|
|
ev.interactive,
|
|
ev.multi_test,
|
|
ev.precision
|
|
)
|
|
|
|
local io_state = state.get_io_view_state()
|
|
if io_state then
|
|
local combined_test = cache.get_combined_test(platform, contest_id, state.get_problem_id())
|
|
if combined_test then
|
|
local input_lines = vim.split(combined_test.input, '\n')
|
|
require('cp.utils').update_buffer_content(io_state.input_buf, input_lines, nil, nil)
|
|
end
|
|
end
|
|
end, function()
|
|
logger.log(
|
|
('Loaded %d test%s.'):format(to_fetch, to_fetch == 1 and '' or 's'),
|
|
{ level = vim.log.levels.INFO, override = true }
|
|
)
|
|
end)
|
|
end
|
|
end
|
|
|
|
---@param platform string
|
|
---@param contest_id string
|
|
---@param problem_id? string
|
|
---@param language? string
|
|
function M.setup_contest(platform, contest_id, problem_id, language)
|
|
local old_platform, old_contest_id = state.get_platform(), state.get_contest_id()
|
|
local old_problem_id = state.get_problem_id()
|
|
|
|
state.set_platform(platform)
|
|
state.set_contest_id(contest_id)
|
|
|
|
if language then
|
|
local lang_result = config_module.get_language_for_platform(platform, language)
|
|
if not lang_result.valid then
|
|
logger.log(lang_result.error, { level = vim.log.levels.ERROR })
|
|
return
|
|
end
|
|
end
|
|
|
|
local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id
|
|
|
|
if is_new_contest then
|
|
local views = require('cp.ui.views')
|
|
views.cancel_io_view()
|
|
local active = state.get_active_panel()
|
|
if active == 'interactive' then
|
|
views.cancel_interactive()
|
|
elseif active == 'stress' then
|
|
require('cp.stress').cancel()
|
|
elseif active == 'run' then
|
|
views.disable()
|
|
end
|
|
end
|
|
|
|
cache.load()
|
|
|
|
local function proceed(contest_data)
|
|
if is_new_contest then
|
|
local io_state = state.get_io_view_state()
|
|
if io_state and io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
|
require('cp.utils').update_buffer_content(io_state.output_buf, {}, nil, nil)
|
|
end
|
|
end
|
|
local problems = contest_data.problems
|
|
local pid = problem_id and problem_id or problems[1].id
|
|
M.setup_problem(pid, language)
|
|
start_tests(platform, contest_id, problems)
|
|
|
|
local is_new_problem = old_problem_id ~= pid
|
|
local should_open_url = config_module.get_config().open_url
|
|
and (is_new_contest or is_new_problem)
|
|
if should_open_url and contest_data.url then
|
|
vim.ui.open(contest_data.url:format(pid))
|
|
end
|
|
end
|
|
|
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
|
if not is_metadata_ready(contest_data) then
|
|
local cfg = config_module.get_config()
|
|
local lang = language or (cfg.platforms[platform] and cfg.platforms[platform].default_language)
|
|
|
|
vim.cmd.only({ mods = { silent = true } })
|
|
local bufnr = vim.api.nvim_create_buf(true, false)
|
|
vim.api.nvim_win_set_buf(0, bufnr)
|
|
vim.bo[bufnr].filetype = lang or ''
|
|
vim.bo[bufnr].buftype = ''
|
|
vim.bo[bufnr].swapfile = false
|
|
|
|
state.set_language(lang)
|
|
|
|
state.set_provisional({
|
|
bufnr = bufnr,
|
|
platform = platform,
|
|
contest_id = contest_id,
|
|
language = lang,
|
|
requested_problem_id = problem_id,
|
|
token = vim.uv.hrtime(),
|
|
})
|
|
|
|
logger.log('Fetching contests problems...', { level = vim.log.levels.INFO, override = true })
|
|
scraper.scrape_contest_metadata(
|
|
platform,
|
|
contest_id,
|
|
vim.schedule_wrap(function(result)
|
|
local problems = result.problems or {}
|
|
cache.set_contest_data(platform, contest_id, problems, result.url)
|
|
local prov = state.get_provisional()
|
|
if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then
|
|
return
|
|
end
|
|
local cd = cache.get_contest_data(platform, contest_id)
|
|
if not is_metadata_ready(cd) then
|
|
return
|
|
end
|
|
local pid = prov.requested_problem_id
|
|
if not pid or not cd.index_map or not cd.index_map[pid] then
|
|
pid = cd.problems[1] and cd.problems[1].id or nil
|
|
end
|
|
if not pid then
|
|
return
|
|
end
|
|
proceed(cd)
|
|
end)
|
|
)
|
|
return
|
|
end
|
|
|
|
proceed(contest_data)
|
|
end
|
|
|
|
---@param problem_id string
|
|
---@param language? string
|
|
function M.setup_problem(problem_id, language)
|
|
local platform = state.get_platform()
|
|
if not platform then
|
|
logger.log('No platform/contest/problem configured.', { level = vim.log.levels.ERROR })
|
|
return
|
|
end
|
|
|
|
local old_problem_id = state.get_problem_id()
|
|
state.set_problem_id(problem_id)
|
|
|
|
if old_problem_id ~= problem_id then
|
|
local io_state = state.get_io_view_state()
|
|
if io_state and io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
|
local utils = require('cp.utils')
|
|
utils.update_buffer_content(io_state.output_buf, {}, nil, nil)
|
|
end
|
|
end
|
|
local config = config_module.get_config()
|
|
local lang = language
|
|
or (config.platforms[platform] and config.platforms[platform].default_language)
|
|
|
|
if language then
|
|
local lang_result = config_module.get_language_for_platform(platform, language)
|
|
if not lang_result.valid then
|
|
logger.log(lang_result.error, { level = vim.log.levels.ERROR })
|
|
return
|
|
end
|
|
end
|
|
|
|
state.set_language(lang)
|
|
|
|
local source_file = state.get_source_file(lang)
|
|
if not source_file then
|
|
return
|
|
end
|
|
|
|
if vim.fn.filereadable(source_file) == 1 then
|
|
local existing = cache.get_file_state(vim.fn.fnamemodify(source_file, ':p'))
|
|
if
|
|
existing
|
|
and (
|
|
existing.platform ~= platform
|
|
or existing.contest_id ~= (state.get_contest_id() or '')
|
|
or existing.problem_id ~= problem_id
|
|
)
|
|
then
|
|
logger.log(
|
|
('File %q already exists for %s/%s %s.'):format(
|
|
source_file,
|
|
existing.platform,
|
|
existing.contest_id,
|
|
existing.problem_id
|
|
),
|
|
{ level = vim.log.levels.ERROR }
|
|
)
|
|
return
|
|
end
|
|
end
|
|
|
|
local contest_dir = vim.fn.fnamemodify(source_file, ':h')
|
|
local is_new_dir = vim.fn.isdirectory(contest_dir) == 0
|
|
vim.fn.mkdir(contest_dir, 'p')
|
|
if is_new_dir then
|
|
local s = config.hooks and config.hooks.setup
|
|
if s and s.contest then
|
|
pcall(s.contest, state)
|
|
end
|
|
end
|
|
|
|
local prov = state.get_provisional()
|
|
if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then
|
|
if vim.api.nvim_buf_is_valid(prov.bufnr) then
|
|
local existing_bufnr = vim.fn.bufnr(source_file)
|
|
if existing_bufnr ~= -1 then
|
|
vim.api.nvim_buf_delete(prov.bufnr, { force = true })
|
|
state.set_provisional(nil)
|
|
else
|
|
vim.api.nvim_buf_set_name(prov.bufnr, source_file)
|
|
-- selene: allow(mixed_table)
|
|
vim.cmd.write({
|
|
vim.fn.fnameescape(source_file),
|
|
bang = true,
|
|
mods = { silent = true, noautocmd = true, keepalt = true },
|
|
})
|
|
state.set_solution_win(vim.api.nvim_get_current_win())
|
|
if not vim.b[prov.bufnr].cp_setup_done then
|
|
apply_template(prov.bufnr, lang, platform)
|
|
local s = config.hooks and config.hooks.setup
|
|
if s and s.code then
|
|
local ok = pcall(s.code, state)
|
|
if ok then
|
|
vim.b[prov.bufnr].cp_setup_done = true
|
|
end
|
|
else
|
|
helpers.clearcol(prov.bufnr)
|
|
vim.b[prov.bufnr].cp_setup_done = true
|
|
end
|
|
local o = config.hooks and config.hooks.on
|
|
if o and o.enter then
|
|
local bufnr = prov.bufnr
|
|
vim.api.nvim_create_autocmd('BufEnter', {
|
|
buffer = bufnr,
|
|
callback = function()
|
|
pcall(o.enter, state)
|
|
end,
|
|
})
|
|
pcall(o.enter, state)
|
|
end
|
|
end
|
|
cache.set_file_state(
|
|
vim.fn.fnamemodify(source_file, ':p'),
|
|
platform,
|
|
state.get_contest_id() or '',
|
|
state.get_problem_id() or '',
|
|
lang
|
|
)
|
|
require('cp.ui.views').ensure_io_view()
|
|
state.set_provisional(nil)
|
|
return
|
|
end
|
|
else
|
|
state.set_provisional(nil)
|
|
end
|
|
end
|
|
|
|
vim.cmd.only({ mods = { silent = true } })
|
|
local current_file = vim.fn.expand('%:p')
|
|
if current_file ~= vim.fn.fnamemodify(source_file, ':p') then
|
|
vim.cmd.e(source_file)
|
|
end
|
|
local bufnr = vim.api.nvim_get_current_buf()
|
|
state.set_solution_win(vim.api.nvim_get_current_win())
|
|
require('cp.ui.views').ensure_io_view()
|
|
if not vim.b[bufnr].cp_setup_done then
|
|
local is_new = vim.api.nvim_buf_line_count(bufnr) == 1
|
|
and vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] == ''
|
|
if is_new then
|
|
apply_template(bufnr, lang, platform)
|
|
end
|
|
local s = config.hooks and config.hooks.setup
|
|
if s and s.code then
|
|
local ok = pcall(s.code, state)
|
|
if ok then
|
|
vim.b[bufnr].cp_setup_done = true
|
|
end
|
|
else
|
|
helpers.clearcol(bufnr)
|
|
vim.b[bufnr].cp_setup_done = true
|
|
end
|
|
local o = config.hooks and config.hooks.on
|
|
if o and o.enter then
|
|
vim.api.nvim_create_autocmd('BufEnter', {
|
|
buffer = bufnr,
|
|
callback = function()
|
|
pcall(o.enter, state)
|
|
end,
|
|
})
|
|
pcall(o.enter, state)
|
|
end
|
|
end
|
|
cache.set_file_state(
|
|
vim.fn.expand('%:p'),
|
|
platform,
|
|
state.get_contest_id() or '',
|
|
state.get_problem_id() or '',
|
|
lang
|
|
)
|
|
end
|
|
|
|
---@param direction integer
|
|
---@param language? string
|
|
function M.navigate_problem(direction, language)
|
|
if direction == 0 then
|
|
return
|
|
end
|
|
direction = direction > 0 and 1 or -1
|
|
|
|
local platform = state.get_platform()
|
|
local contest_id = state.get_contest_id()
|
|
local current_problem_id = state.get_problem_id()
|
|
if not platform or not contest_id or not current_problem_id then
|
|
logger.log('No platform configured.', { level = vim.log.levels.ERROR })
|
|
return
|
|
end
|
|
|
|
cache.load()
|
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
|
if not is_metadata_ready(contest_data) then
|
|
logger.log(
|
|
('No data available for %s contest %s.'):format(
|
|
constants.PLATFORM_DISPLAY_NAMES[platform],
|
|
contest_id
|
|
),
|
|
{ level = vim.log.levels.ERROR }
|
|
)
|
|
return
|
|
end
|
|
|
|
local problems = contest_data.problems
|
|
local index = contest_data.index_map[current_problem_id]
|
|
local new_index = index + direction
|
|
if new_index < 1 or new_index > #problems then
|
|
return
|
|
end
|
|
|
|
logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id))
|
|
|
|
local views = require('cp.ui.views')
|
|
views.cancel_io_view()
|
|
local active_panel = state.get_active_panel()
|
|
if active_panel == 'run' then
|
|
views.disable()
|
|
elseif active_panel == 'interactive' then
|
|
views.cancel_interactive()
|
|
elseif active_panel == 'stress' then
|
|
require('cp.stress').cancel()
|
|
end
|
|
|
|
local lang = nil
|
|
|
|
if language then
|
|
local lang_result = config_module.get_language_for_platform(platform, language)
|
|
if not lang_result.valid then
|
|
logger.log(lang_result.error, { level = vim.log.levels.ERROR })
|
|
return
|
|
end
|
|
lang = language
|
|
else
|
|
local existing_lang =
|
|
get_existing_problem_language(platform, contest_id, problems[new_index].id)
|
|
if existing_lang then
|
|
lang = existing_lang
|
|
else
|
|
lang = get_current_file_language()
|
|
if lang then
|
|
local lang_result = config_module.get_language_for_platform(platform, lang)
|
|
if not lang_result.valid then
|
|
lang = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local io_state = state.get_io_view_state()
|
|
if io_state and io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
|
local utils = require('cp.utils')
|
|
utils.update_buffer_content(io_state.output_buf, {}, nil, nil)
|
|
end
|
|
|
|
M.setup_contest(platform, contest_id, problems[new_index].id, lang)
|
|
end
|
|
|
|
return M
|