## Problem httpx scrapers (CSES, Kattis, USACO) always ran full login flows even with valid cached sessions. \`credentials.lua\` always prompted before trying cached credentials. \`default_filename\` doubled the slug for Kattis single-problem mode (e.g. \`addtwonumbersaddtwonumbers.cc\`). ## Solution Added token/cookie fast paths to CSES \`login()\` and USACO \`login()\`/\`submit()\`. Hardened Kattis reactive re-auth trigger to check status code first. Refactored \`credentials.lua\` to try cached credentials before prompting. Fixed \`default_filename\` to not concatenate when \`contest_id == problem_id\`.
615 lines
16 KiB
Lua
615 lines
16 KiB
Lua
-- lua/cp/config.lua
|
|
---@class CpLangCommands
|
|
---@field build? string[]
|
|
---@field run? string[]
|
|
---@field debug? string[]
|
|
|
|
---@class CpLanguage
|
|
---@field extension string
|
|
---@field commands CpLangCommands
|
|
---@field template? string
|
|
---@field version? string
|
|
---@field submit_id? string
|
|
|
|
---@class CpTemplatesConfig
|
|
---@field cursor_marker? string
|
|
|
|
---@class CpPlatformOverrides
|
|
---@field extension? string
|
|
---@field commands? CpLangCommands
|
|
---@field template? string
|
|
---@field version? string
|
|
---@field submit_id? string
|
|
|
|
---@class CpPlatform
|
|
---@field enabled_languages string[]
|
|
---@field default_language string
|
|
---@field overrides? table<string, CpPlatformOverrides>
|
|
|
|
---@class PanelConfig
|
|
---@field diff_modes string[]
|
|
---@field max_output_lines integer
|
|
---@field precision number?
|
|
|
|
---@class DiffGitConfig
|
|
---@field args string[]
|
|
|
|
---@class DiffConfig
|
|
---@field git DiffGitConfig
|
|
|
|
---@class CpSetupIOHooks
|
|
---@field input? fun(bufnr: integer, state: cp.State)
|
|
---@field output? fun(bufnr: integer, state: cp.State)
|
|
|
|
---@class CpSetupHooks
|
|
---@field contest? fun(state: cp.State)
|
|
---@field code? fun(state: cp.State)
|
|
---@field io? CpSetupIOHooks
|
|
|
|
---@class CpOnHooks
|
|
---@field enter? fun(state: cp.State)
|
|
---@field run? fun(state: cp.State)
|
|
---@field debug? fun(state: cp.State)
|
|
|
|
---@class Hooks
|
|
---@field setup? CpSetupHooks
|
|
---@field on? CpOnHooks
|
|
|
|
---@class VerdictFormatData
|
|
---@field index integer
|
|
---@field status { text: string, highlight_group: string }
|
|
---@field time_ms number
|
|
---@field time_limit_ms number
|
|
---@field memory_mb number
|
|
---@field memory_limit_mb number
|
|
---@field exit_code integer
|
|
---@field signal string|nil
|
|
---@field time_actual_width? integer
|
|
---@field time_limit_width? integer
|
|
---@field mem_actual_width? integer
|
|
---@field mem_limit_width? integer
|
|
|
|
---@class VerdictHighlight
|
|
---@field col_start integer
|
|
---@field col_end integer
|
|
---@field group string
|
|
|
|
---@class VerdictFormatResult
|
|
---@field line string
|
|
---@field highlights? VerdictHighlight[]
|
|
|
|
---@alias VerdictFormatter fun(data: VerdictFormatData): VerdictFormatResult
|
|
|
|
---@class RunConfig
|
|
---@field width number
|
|
---@field format_verdict VerdictFormatter
|
|
|
|
---@class EditConfig
|
|
---@field next_test_key string|nil
|
|
---@field prev_test_key string|nil
|
|
---@field delete_test_key string|nil
|
|
---@field add_test_key string|nil
|
|
---@field save_and_exit_key string|nil
|
|
|
|
---@class CpUI
|
|
---@field ansi boolean
|
|
---@field run RunConfig
|
|
---@field edit EditConfig
|
|
---@field panel PanelConfig
|
|
---@field diff DiffConfig
|
|
---@field picker string|nil
|
|
|
|
---@class cp.Config
|
|
---@field languages table<string, CpLanguage>
|
|
---@field platforms table<string, CpPlatform>
|
|
---@field templates? CpTemplatesConfig
|
|
---@field hooks Hooks
|
|
---@field debug boolean
|
|
---@field scrapers string[]
|
|
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
|
---@field ui CpUI
|
|
---@field runtime { effective: table<string, table<string, CpLanguage>> } -- computed
|
|
|
|
---@class cp.PartialConfig: cp.Config
|
|
---@field platforms? table<string, CpPlatform|false>
|
|
|
|
local M = {}
|
|
|
|
local constants = require('cp.constants')
|
|
local helpers = require('cp.helpers')
|
|
local utils = require('cp.utils')
|
|
|
|
-- defaults per the new single schema
|
|
---@type cp.Config
|
|
M.defaults = {
|
|
languages = {
|
|
cpp = {
|
|
extension = 'cc',
|
|
commands = {
|
|
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
|
|
run = { '{binary}' },
|
|
debug = {
|
|
'g++',
|
|
'-std=c++17',
|
|
'-fsanitize=address,undefined',
|
|
'{source}',
|
|
'-o',
|
|
'{binary}',
|
|
},
|
|
},
|
|
},
|
|
python = {
|
|
extension = 'py',
|
|
commands = {
|
|
run = { 'python', '{source}' },
|
|
debug = { 'python', '{source}' },
|
|
},
|
|
},
|
|
},
|
|
platforms = {
|
|
codeforces = {
|
|
enabled_languages = { 'cpp', 'python' },
|
|
default_language = 'cpp',
|
|
overrides = {
|
|
-- example override, safe to keep empty initially
|
|
},
|
|
},
|
|
atcoder = {
|
|
enabled_languages = { 'cpp', 'python' },
|
|
default_language = 'cpp',
|
|
},
|
|
codechef = {
|
|
enabled_languages = { 'cpp', 'python' },
|
|
default_language = 'cpp',
|
|
},
|
|
cses = {
|
|
enabled_languages = { 'cpp', 'python' },
|
|
default_language = 'cpp',
|
|
},
|
|
kattis = {
|
|
enabled_languages = { 'cpp', 'python' },
|
|
default_language = 'cpp',
|
|
},
|
|
usaco = {
|
|
enabled_languages = { 'cpp', 'python' },
|
|
default_language = 'cpp',
|
|
},
|
|
},
|
|
hooks = {
|
|
setup = {
|
|
contest = nil,
|
|
code = nil,
|
|
io = {
|
|
input = helpers.clearcol,
|
|
output = helpers.clearcol,
|
|
},
|
|
},
|
|
on = {
|
|
enter = nil,
|
|
run = nil,
|
|
debug = nil,
|
|
},
|
|
},
|
|
debug = false,
|
|
scrapers = constants.PLATFORMS,
|
|
filename = nil,
|
|
ui = {
|
|
ansi = true,
|
|
run = {
|
|
width = 0.3,
|
|
format_verdict = helpers.default_verdict_formatter,
|
|
},
|
|
edit = {
|
|
next_test_key = ']t',
|
|
prev_test_key = '[t',
|
|
delete_test_key = 'gd',
|
|
add_test_key = 'ga',
|
|
save_and_exit_key = 'q',
|
|
},
|
|
panel = {
|
|
diff_modes = { 'side-by-side', 'git', 'vim' },
|
|
max_output_lines = 50,
|
|
precision = nil,
|
|
},
|
|
diff = {
|
|
git = {
|
|
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
|
|
},
|
|
},
|
|
picker = nil,
|
|
},
|
|
runtime = { effective = {} },
|
|
}
|
|
|
|
local function is_string_list(t)
|
|
if type(t) ~= 'table' then
|
|
return false
|
|
end
|
|
for _, v in ipairs(t) do
|
|
if type(v) ~= 'string' then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function has_tokens(cmd, required)
|
|
if type(cmd) ~= 'table' then
|
|
return false
|
|
end
|
|
local s = table.concat(cmd, ' ')
|
|
for _, tok in ipairs(required) do
|
|
if not s:find(vim.pesc(tok), 1, true) then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function validate_language(id, lang)
|
|
vim.validate({
|
|
extension = { lang.extension, 'string' },
|
|
commands = { lang.commands, { 'table' } },
|
|
})
|
|
|
|
if lang.template ~= nil then
|
|
vim.validate({ template = { lang.template, 'string' } })
|
|
end
|
|
|
|
if not lang.commands.run then
|
|
error(('[cp.nvim] languages.%s.commands.run is required'):format(id))
|
|
end
|
|
|
|
if lang.commands.build ~= nil then
|
|
vim.validate({ build = { lang.commands.build, { 'table' } } })
|
|
if not has_tokens(lang.commands.build, { '{source}' }) then
|
|
error(('[cp.nvim] languages.%s.commands.build must include {source}'):format(id))
|
|
end
|
|
else
|
|
for _, k in ipairs({ 'run', 'debug' }) do
|
|
if lang.commands[k] then
|
|
if not has_tokens(lang.commands[k], { '{source}' }) then
|
|
error(('[cp.nvim] languages.%s.commands.%s must include {source}'):format(id, k))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function merge_lang(base, ov)
|
|
if not ov then
|
|
return base
|
|
end
|
|
local out = vim.deepcopy(base)
|
|
if ov.extension then
|
|
out.extension = ov.extension
|
|
end
|
|
if ov.commands then
|
|
out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {})
|
|
end
|
|
if ov.template then
|
|
out.template = ov.template
|
|
end
|
|
if ov.version then
|
|
out.version = ov.version
|
|
end
|
|
if ov.submit_id then
|
|
out.submit_id = ov.submit_id
|
|
end
|
|
return out
|
|
end
|
|
|
|
---@param cfg cp.Config
|
|
local function build_runtime(cfg)
|
|
cfg.runtime = cfg.runtime or { effective = {} }
|
|
for plat, p in pairs(cfg.platforms) do
|
|
vim.validate({
|
|
enabled_languages = { p.enabled_languages, is_string_list, 'string[]' },
|
|
default_language = { p.default_language, 'string' },
|
|
})
|
|
for _, lid in ipairs(p.enabled_languages) do
|
|
if not cfg.languages[lid] then
|
|
error(("[cp.nvim] platform %s references unknown language '%s'"):format(plat, lid))
|
|
end
|
|
end
|
|
if not vim.tbl_contains(p.enabled_languages, p.default_language) then
|
|
error(
|
|
("[cp.nvim] platform %s default_language '%s' not in enabled_languages"):format(
|
|
plat,
|
|
p.default_language
|
|
)
|
|
)
|
|
end
|
|
cfg.runtime.effective[plat] = {}
|
|
for _, lid in ipairs(p.enabled_languages) do
|
|
local base = cfg.languages[lid]
|
|
validate_language(lid, base)
|
|
local eff = merge_lang(base, p.overrides and p.overrides[lid] or nil)
|
|
validate_language(lid, eff)
|
|
if eff.version then
|
|
local normalized = eff.version:lower():gsub('%s+', '')
|
|
local versions = (constants.LANGUAGE_VERSIONS[plat] or {})[lid]
|
|
if not versions or not versions[normalized] then
|
|
local avail = versions and vim.tbl_keys(versions) or {}
|
|
table.sort(avail)
|
|
error(
|
|
("[cp.nvim] Unknown version '%s' for %s on %s. Available: [%s]. See :help cp-submit-language"):format(
|
|
eff.version,
|
|
lid,
|
|
plat,
|
|
table.concat(avail, ', ')
|
|
)
|
|
)
|
|
end
|
|
eff.version = normalized
|
|
end
|
|
cfg.runtime.effective[plat][lid] = eff
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param user_config cp.PartialConfig|nil
|
|
---@return cp.Config
|
|
function M.setup(user_config)
|
|
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
|
|
local defaults = vim.deepcopy(M.defaults)
|
|
if user_config and user_config.platforms then
|
|
for plat, v in pairs(user_config.platforms) do
|
|
if v == false then
|
|
defaults.platforms[plat] = nil
|
|
end
|
|
end
|
|
end
|
|
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
|
|
for plat, v in pairs(cfg.platforms) do
|
|
if v == false then
|
|
cfg.platforms[plat] = nil
|
|
end
|
|
end
|
|
|
|
if not next(cfg.languages) then
|
|
error('[cp.nvim] At least one language must be configured')
|
|
end
|
|
|
|
if not next(cfg.platforms) then
|
|
error('[cp.nvim] At least one platform must be configured')
|
|
end
|
|
|
|
if cfg.templates ~= nil then
|
|
vim.validate({ templates = { cfg.templates, 'table' } })
|
|
if cfg.templates.cursor_marker ~= nil then
|
|
vim.validate({ cursor_marker = { cfg.templates.cursor_marker, 'string' } })
|
|
end
|
|
end
|
|
|
|
vim.validate({
|
|
hooks = { cfg.hooks, { 'table' } },
|
|
ui = { cfg.ui, { 'table' } },
|
|
debug = { cfg.debug, { 'boolean', 'nil' }, true },
|
|
filename = { cfg.filename, { 'function', 'nil' }, true },
|
|
scrapers = {
|
|
cfg.scrapers,
|
|
function(v)
|
|
if type(v) ~= 'table' then
|
|
return false
|
|
end
|
|
for _, s in ipairs(v) do
|
|
if not vim.tbl_contains(constants.PLATFORMS, s) then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end,
|
|
('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')),
|
|
},
|
|
})
|
|
if cfg.hooks.setup ~= nil then
|
|
vim.validate({ setup = { cfg.hooks.setup, 'table' } })
|
|
vim.validate({
|
|
contest = { cfg.hooks.setup.contest, { 'function', 'nil' }, true },
|
|
code = { cfg.hooks.setup.code, { 'function', 'nil' }, true },
|
|
})
|
|
if cfg.hooks.setup.io ~= nil then
|
|
vim.validate({ io = { cfg.hooks.setup.io, 'table' } })
|
|
vim.validate({
|
|
input = { cfg.hooks.setup.io.input, { 'function', 'nil' }, true },
|
|
output = { cfg.hooks.setup.io.output, { 'function', 'nil' }, true },
|
|
})
|
|
end
|
|
end
|
|
if cfg.hooks.on ~= nil then
|
|
vim.validate({ on = { cfg.hooks.on, 'table' } })
|
|
vim.validate({
|
|
enter = { cfg.hooks.on.enter, { 'function', 'nil' }, true },
|
|
run = { cfg.hooks.on.run, { 'function', 'nil' }, true },
|
|
debug = { cfg.hooks.on.debug, { 'function', 'nil' }, true },
|
|
})
|
|
end
|
|
|
|
local layouts = require('cp.ui.layouts')
|
|
vim.validate({
|
|
ansi = { cfg.ui.ansi, 'boolean' },
|
|
diff_modes = {
|
|
cfg.ui.panel.diff_modes,
|
|
function(v)
|
|
if type(v) ~= 'table' then
|
|
return false
|
|
end
|
|
for _, mode in ipairs(v) do
|
|
if not layouts.DIFF_MODES[mode] then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end,
|
|
('one of {%s}'):format(table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',')),
|
|
},
|
|
max_output_lines = {
|
|
cfg.ui.panel.max_output_lines,
|
|
function(v)
|
|
return type(v) == 'number' and v > 0 and v == math.floor(v)
|
|
end,
|
|
'positive integer',
|
|
},
|
|
precision = {
|
|
cfg.ui.panel.precision,
|
|
function(v)
|
|
return v == nil or (type(v) == 'number' and v >= 0)
|
|
end,
|
|
'nil or non-negative number',
|
|
},
|
|
git = { cfg.ui.diff.git, { 'table' } },
|
|
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
|
|
width = {
|
|
cfg.ui.run.width,
|
|
function(v)
|
|
return type(v) == 'number' and v > 0 and v <= 1
|
|
end,
|
|
'decimal between 0 and 1',
|
|
},
|
|
format_verdict = {
|
|
cfg.ui.run.format_verdict,
|
|
'function',
|
|
},
|
|
edit_next_test_key = {
|
|
cfg.ui.edit.next_test_key,
|
|
function(v)
|
|
return v == nil or (type(v) == 'string' and #v > 0)
|
|
end,
|
|
'nil or non-empty string',
|
|
},
|
|
edit_prev_test_key = {
|
|
cfg.ui.edit.prev_test_key,
|
|
function(v)
|
|
return v == nil or (type(v) == 'string' and #v > 0)
|
|
end,
|
|
'nil or non-empty string',
|
|
},
|
|
delete_test_key = {
|
|
cfg.ui.edit.delete_test_key,
|
|
function(v)
|
|
return v == nil or (type(v) == 'string' and #v > 0)
|
|
end,
|
|
'nil or non-empty string',
|
|
},
|
|
add_test_key = {
|
|
cfg.ui.edit.add_test_key,
|
|
function(v)
|
|
return v == nil or (type(v) == 'string' and #v > 0)
|
|
end,
|
|
'nil or non-empty string',
|
|
},
|
|
save_and_exit_key = {
|
|
cfg.ui.edit.save_and_exit_key,
|
|
function(v)
|
|
return v == nil or (type(v) == 'string' and #v > 0)
|
|
end,
|
|
'nil or non-empty string',
|
|
},
|
|
picker = {
|
|
cfg.ui.picker,
|
|
function(v)
|
|
return v == nil or v == 'telescope' or v == 'fzf-lua'
|
|
end,
|
|
"nil, 'telescope', or 'fzf-lua'",
|
|
},
|
|
})
|
|
|
|
for id, lang in pairs(cfg.languages) do
|
|
validate_language(id, lang)
|
|
end
|
|
|
|
build_runtime(cfg)
|
|
|
|
local ok, err = utils.check_required_runtime()
|
|
if not ok then
|
|
error('[cp.nvim] ' .. err)
|
|
end
|
|
|
|
return cfg
|
|
end
|
|
|
|
local current_config = nil
|
|
|
|
---@param config cp.Config
|
|
function M.set_current_config(config)
|
|
current_config = config
|
|
end
|
|
|
|
---@return cp.Config
|
|
function M.get_config()
|
|
return current_config or M.defaults
|
|
end
|
|
|
|
---Validate and get effective language config for a platform
|
|
---@param platform_id string
|
|
---@param language_id string
|
|
---@return { valid: boolean, effective?: CpLanguage, extension?: string, error?: string }
|
|
function M.get_language_for_platform(platform_id, language_id)
|
|
local cfg = M.get_config()
|
|
|
|
if not cfg.platforms[platform_id] then
|
|
return { valid = false, error = string.format("Unknown platform '%s'", platform_id) }
|
|
end
|
|
|
|
local platform = cfg.platforms[platform_id]
|
|
|
|
if not cfg.languages[language_id] then
|
|
local available = table.concat(platform.enabled_languages, ', ')
|
|
return {
|
|
valid = false,
|
|
error = string.format("Unknown language '%s'. Available: [%s]", language_id, available),
|
|
}
|
|
end
|
|
|
|
if not vim.tbl_contains(platform.enabled_languages, language_id) then
|
|
local available = table.concat(platform.enabled_languages, ', ')
|
|
return {
|
|
valid = false,
|
|
error = string.format(
|
|
"Language '%s' not enabled for %s. Available: [%s]",
|
|
language_id,
|
|
platform_id,
|
|
available
|
|
),
|
|
}
|
|
end
|
|
|
|
local platform_effective = cfg.runtime.effective[platform_id]
|
|
if not platform_effective then
|
|
return {
|
|
valid = false,
|
|
error = string.format(
|
|
'No runtime config for platform %s (plugin not initialized)',
|
|
platform_id
|
|
),
|
|
}
|
|
end
|
|
|
|
local effective = platform_effective[language_id]
|
|
if not effective then
|
|
return {
|
|
valid = false,
|
|
error = string.format('No effective config for %s/%s', platform_id, language_id),
|
|
}
|
|
end
|
|
|
|
return {
|
|
valid = true,
|
|
effective = effective,
|
|
extension = effective.extension,
|
|
}
|
|
end
|
|
|
|
---@param contest_id string
|
|
---@param problem_id? string
|
|
---@return string
|
|
local function default_filename(contest_id, problem_id)
|
|
if problem_id and problem_id ~= contest_id then
|
|
return (contest_id .. problem_id):lower()
|
|
end
|
|
return (problem_id or contest_id):lower()
|
|
end
|
|
M.default_filename = default_filename
|
|
|
|
return M
|