cp.nvim/lua/cp/setup.lua
Barrett Ruth 2d50f0a52a
refactor: remove open_url config option in favour of :CP open (#322)
## Problem

`open_url` automatically opened the browser on contest load and problem
change, which is now redundant with `:CP open`.

## Solution

Remove the `open_url` field from `cp.Config`, its default, its
validation, and the call site in `setup_contest`. Remove documentation
from `cp.nvim.txt`.
2026-03-05 22:55:43 -05:00

524 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()
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)
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,
result.contest_url or '',
result.standings_url or ''
)
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