feat: race countdown support and language version selection (#346)
## Problem Two gaps in the plugin: (1) contest pickers have no way to know whether a contest supports countdown (race), so the UI can't surface that affordance; (2) `:CP submit` hardcodes a single language ID per platform with no way to choose C++ standard or override the platform ID. ## Solution **Race countdown** (`4e709c8`): Add `supports_countdown` boolean to `ContestListResult` and wire it through CSES/USACO scrapers, cache, race module, and pickers. **Language version selection** (`b90ac67`): Add `LANGUAGE_VERSIONS` and `DEFAULT_VERSIONS` tables in `constants.lua`. Config gains `version` and `submit_id` fields validated at setup time. `submit.lua` resolves the effective config to a platform ID (priority: `submit_id` > `version` > default). Helpdocs add `*cp-submit-language*` section. Tests cover `LANGUAGE_IDS` completeness.
This commit is contained in:
parent
592f977296
commit
58b6840815
13 changed files with 265 additions and 46 deletions
|
|
@ -24,15 +24,16 @@ CONTENTS *cp-contents*
|
|||
16. Race .......................................................... |cp-race|
|
||||
17. Credentials ............................................ |cp-credentials|
|
||||
18. Submit ...................................................... |cp-submit|
|
||||
19. Open ......................................................... |cp-open|
|
||||
20. ANSI Colors ................................................... |cp-ansi|
|
||||
21. Highlight Groups ........................................ |cp-highlights|
|
||||
22. Terminal Colors .................................... |cp-terminal-colors|
|
||||
23. Highlight Customization .......................... |cp-highlight-custom|
|
||||
24. Helpers .................................................... |cp-helpers|
|
||||
25. Statusline Integration .................................. |cp-statusline|
|
||||
26. Panel Keymaps .......................................... |cp-panel-keys|
|
||||
27. File Structure ................................................ |cp-files|
|
||||
19. Submit Language Versions ......................... |cp-submit-language|
|
||||
20. Open ......................................................... |cp-open|
|
||||
21. ANSI Colors ................................................... |cp-ansi|
|
||||
22. Highlight Groups ........................................ |cp-highlights|
|
||||
23. Terminal Colors .................................... |cp-terminal-colors|
|
||||
24. Highlight Customization .......................... |cp-highlight-custom|
|
||||
25. Helpers .................................................... |cp-helpers|
|
||||
26. Statusline Integration .................................. |cp-statusline|
|
||||
27. Panel Keymaps .......................................... |cp-panel-keys|
|
||||
28. File Structure ................................................ |cp-files|
|
||||
28. Health Check ................................................ |cp-health|
|
||||
|
||||
==============================================================================
|
||||
|
|
@ -1032,7 +1033,58 @@ Submit the current solution to the online judge.
|
|||
|
||||
Platform support:
|
||||
AtCoder Fully implemented.
|
||||
Others Not yet implemented.
|
||||
Codeforces Fully implemented.
|
||||
CSES Fully implemented.
|
||||
Kattis Fully implemented.
|
||||
USACO Fully implemented.
|
||||
CodeChef Not yet implemented.
|
||||
|
||||
See |cp-submit-language| for configuring the language version
|
||||
(e.g. C++20 instead of C++17).
|
||||
|
||||
==============================================================================
|
||||
SUBMIT LANGUAGE VERSIONS *cp-submit-language*
|
||||
|
||||
When submitting, cp.nvim selects a language version for the platform.
|
||||
The default is C++17 for cpp and Python 3 for python.
|
||||
|
||||
Configuring a version ~
|
||||
|
||||
Set {version} globally or per-platform:
|
||||
>lua
|
||||
languages = {
|
||||
cpp = { version = "c++20", ... },
|
||||
},
|
||||
platforms = {
|
||||
atcoder = {
|
||||
overrides = { cpp = { version = "c++23" } },
|
||||
},
|
||||
},
|
||||
<
|
||||
Available versions per platform ~
|
||||
|
||||
Platform cpp python
|
||||
AtCoder c++23 python3
|
||||
Codeforces c++17 python3
|
||||
CSES c++17 python3
|
||||
Kattis c++17/20/23 python3
|
||||
USACO c++17/20/23 python3
|
||||
CodeChef c++17 python3
|
||||
|
||||
Using a raw platform ID ~
|
||||
|
||||
If your preferred version is not listed, you can bypass version
|
||||
lookup by setting {submit_id} to the raw platform language ID:
|
||||
>lua
|
||||
platforms = {
|
||||
codeforces = {
|
||||
overrides = { cpp = { submit_id = "91" } },
|
||||
},
|
||||
},
|
||||
<
|
||||
To find the raw ID, open the platform's submit page in your browser,
|
||||
inspect the language dropdown, and copy the <option value="..."> for
|
||||
your desired language.
|
||||
|
||||
==============================================================================
|
||||
OPEN *cp-open*
|
||||
|
|
|
|||
|
|
@ -392,7 +392,8 @@ end
|
|||
|
||||
---@param platform string
|
||||
---@param contests ContestSummary[]
|
||||
function M.set_contest_summaries(platform, contests)
|
||||
---@param opts? { supports_countdown?: boolean }
|
||||
function M.set_contest_summaries(platform, contests, opts)
|
||||
cache_data[platform] = cache_data[platform] or {}
|
||||
for _, contest in ipairs(contests) do
|
||||
cache_data[platform][contest.id] = cache_data[platform][contest.id] or {}
|
||||
|
|
@ -405,9 +406,22 @@ function M.set_contest_summaries(platform, contests)
|
|||
end
|
||||
end
|
||||
|
||||
if opts and opts.supports_countdown ~= nil then
|
||||
cache_data[platform].supports_countdown = opts.supports_countdown
|
||||
end
|
||||
|
||||
M.save()
|
||||
end
|
||||
|
||||
---@param platform string
|
||||
---@return boolean?
|
||||
function M.get_supports_countdown(platform)
|
||||
if not cache_data[platform] then
|
||||
return nil
|
||||
end
|
||||
return cache_data[platform].supports_countdown
|
||||
end
|
||||
|
||||
---@param platform string
|
||||
---@param contest_id string
|
||||
---@return integer?
|
||||
|
|
@ -418,6 +432,16 @@ function M.get_contest_start_time(platform, contest_id)
|
|||
return cache_data[platform][contest_id].start_time
|
||||
end
|
||||
|
||||
---@param platform string
|
||||
---@param contest_id string
|
||||
---@return string?
|
||||
function M.get_contest_display_name(platform, contest_id)
|
||||
if not cache_data[platform] or not cache_data[platform][contest_id] then
|
||||
return nil
|
||||
end
|
||||
return cache_data[platform][contest_id].display_name
|
||||
end
|
||||
|
||||
---@param platform string
|
||||
---@return table?
|
||||
function M.get_credentials(platform)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
---@field extension string
|
||||
---@field commands CpLangCommands
|
||||
---@field template? string
|
||||
---@field version? string
|
||||
---@field submit_id? string
|
||||
|
||||
---@class CpTemplatesConfig
|
||||
---@field cursor_marker? string
|
||||
|
|
@ -16,6 +18,8 @@
|
|||
---@field extension? string
|
||||
---@field commands? CpLangCommands
|
||||
---@field template? string
|
||||
---@field version? string
|
||||
---@field submit_id? string
|
||||
|
||||
---@class CpPlatform
|
||||
---@field enabled_languages string[]
|
||||
|
|
@ -293,6 +297,12 @@ local function merge_lang(base, ov)
|
|||
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
|
||||
|
||||
|
|
@ -323,6 +333,23 @@ local function build_runtime(cfg)
|
|||
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
|
||||
|
|
|
|||
|
|
@ -74,4 +74,21 @@ M.signal_codes = {
|
|||
[143] = 'SIGTERM',
|
||||
}
|
||||
|
||||
M.LANGUAGE_VERSIONS = {
|
||||
atcoder = { cpp = { ['c++23'] = '6017' }, python = { python3 = '6082' } },
|
||||
codeforces = { cpp = { ['c++17'] = '89' }, python = { python3 = '70' } },
|
||||
cses = { cpp = { ['c++17'] = 'C++17' }, python = { python3 = 'Python3' } },
|
||||
kattis = {
|
||||
cpp = { ['c++17'] = 'C++', ['c++20'] = 'C++', ['c++23'] = 'C++' },
|
||||
python = { python3 = 'Python 3' },
|
||||
},
|
||||
usaco = {
|
||||
cpp = { ['c++17'] = 'cpp', ['c++20'] = 'cpp', ['c++23'] = 'cpp' },
|
||||
python = { python3 = 'python' },
|
||||
},
|
||||
codechef = { cpp = { ['c++17'] = 'C++ 17' }, python = { python3 = 'Python 3' } },
|
||||
}
|
||||
|
||||
M.DEFAULT_VERSIONS = { cpp = 'c++17', python = 'python3' }
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -48,8 +48,10 @@ function M.get_platform_contests(platform, refresh)
|
|||
{ level = vim.log.levels.INFO, override = true, sync = true }
|
||||
)
|
||||
|
||||
local contests = scraper.scrape_contest_list(platform)
|
||||
cache.set_contest_summaries(platform, contests)
|
||||
local result = scraper.scrape_contest_list(platform)
|
||||
local contests = result and result.contests or {}
|
||||
local sc = result and result.supports_countdown
|
||||
cache.set_contest_summaries(platform, contests, { supports_countdown = sc })
|
||||
picker_contests = cache.get_contest_summaries(platform)
|
||||
|
||||
logger.log(
|
||||
|
|
|
|||
|
|
@ -9,15 +9,24 @@ local race_state = {
|
|||
timer = nil,
|
||||
platform = nil,
|
||||
contest_id = nil,
|
||||
contest_name = nil,
|
||||
language = nil,
|
||||
start_time = nil,
|
||||
}
|
||||
|
||||
local function format_countdown(seconds)
|
||||
local h = math.floor(seconds / 3600)
|
||||
local d = math.floor(seconds / 86400)
|
||||
local h = math.floor((seconds % 86400) / 3600)
|
||||
local m = math.floor((seconds % 3600) / 60)
|
||||
local s = seconds % 60
|
||||
return string.format('%02d:%02d:%02d', h, m, s)
|
||||
if d > 0 then
|
||||
return string.format('%dd %dh %dm %ds', d, h, m, s)
|
||||
elseif h > 0 then
|
||||
return string.format('%dh %dm %ds', h, m, s)
|
||||
elseif m > 0 then
|
||||
return string.format('%dm %ds', m, s)
|
||||
end
|
||||
return string.format('%ds', s)
|
||||
end
|
||||
|
||||
function M.start(platform, contest_id, language)
|
||||
|
|
@ -35,20 +44,42 @@ function M.start(platform, contest_id, language)
|
|||
end
|
||||
|
||||
cache.load()
|
||||
|
||||
local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||
local cached_countdown = cache.get_supports_countdown(platform)
|
||||
if cached_countdown == false then
|
||||
logger.log(('%s does not support :CP race'):format(display), { level = vim.log.levels.ERROR })
|
||||
return
|
||||
end
|
||||
|
||||
local start_time = cache.get_contest_start_time(platform, contest_id)
|
||||
|
||||
if not start_time then
|
||||
logger.log('Fetching contest list...', { level = vim.log.levels.INFO, override = true })
|
||||
local contests = scraper.scrape_contest_list(platform)
|
||||
if contests and #contests > 0 then
|
||||
cache.set_contest_summaries(platform, contests)
|
||||
start_time = cache.get_contest_start_time(platform, contest_id)
|
||||
logger.log(
|
||||
'Fetching contest list...',
|
||||
{ level = vim.log.levels.INFO, override = true, sync = true }
|
||||
)
|
||||
local result = scraper.scrape_contest_list(platform)
|
||||
if result then
|
||||
local sc = result.supports_countdown
|
||||
if sc == false then
|
||||
cache.set_contest_summaries(platform, result.contests or {}, { supports_countdown = false })
|
||||
logger.log(
|
||||
('%s does not support :CP race'):format(display),
|
||||
{ level = vim.log.levels.ERROR }
|
||||
)
|
||||
return
|
||||
end
|
||||
if result.contests and #result.contests > 0 then
|
||||
cache.set_contest_summaries(platform, result.contests, { supports_countdown = sc })
|
||||
start_time = cache.get_contest_start_time(platform, contest_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not start_time then
|
||||
logger.log(
|
||||
('No start time found for %s contest %s'):format(
|
||||
('No start time found for %s contest "%s"'):format(
|
||||
constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
|
||||
contest_id
|
||||
),
|
||||
|
|
@ -69,18 +100,10 @@ function M.start(platform, contest_id, language)
|
|||
|
||||
race_state.platform = platform
|
||||
race_state.contest_id = contest_id
|
||||
race_state.contest_name = cache.get_contest_display_name(platform, contest_id) or contest_id
|
||||
race_state.language = language
|
||||
race_state.start_time = start_time
|
||||
|
||||
logger.log(
|
||||
('Race started for %s %s — %s remaining'):format(
|
||||
constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
|
||||
contest_id,
|
||||
format_countdown(remaining)
|
||||
),
|
||||
{ level = vim.log.levels.INFO, override = true }
|
||||
)
|
||||
|
||||
local timer = vim.uv.new_timer()
|
||||
race_state.timer = timer
|
||||
timer:start(
|
||||
|
|
@ -97,17 +120,14 @@ function M.start(platform, contest_id, language)
|
|||
local l = race_state.language
|
||||
race_state.platform = nil
|
||||
race_state.contest_id = nil
|
||||
race_state.contest_name = nil
|
||||
race_state.language = nil
|
||||
race_state.start_time = nil
|
||||
logger.log('Contest started!', { level = vim.log.levels.INFO, override = true })
|
||||
require('cp.setup').setup_contest(p, c, nil, l)
|
||||
else
|
||||
vim.notify(
|
||||
('[cp.nvim] %s %s — %s'):format(
|
||||
constants.PLATFORM_DISPLAY_NAMES[race_state.platform] or race_state.platform,
|
||||
race_state.contest_id,
|
||||
format_countdown(r)
|
||||
),
|
||||
('[cp.nvim] %s starts in %s'):format(race_state.contest_name, format_countdown(r)),
|
||||
vim.log.levels.INFO
|
||||
)
|
||||
end
|
||||
|
|
@ -126,6 +146,7 @@ function M.stop()
|
|||
race_state.timer = nil
|
||||
race_state.platform = nil
|
||||
race_state.contest_id = nil
|
||||
race_state.contest_name = nil
|
||||
race_state.language = nil
|
||||
race_state.start_time = nil
|
||||
logger.log('Race cancelled', { level = vim.log.levels.INFO, override = true })
|
||||
|
|
|
|||
|
|
@ -257,9 +257,12 @@ function M.scrape_contest_list(platform)
|
|||
),
|
||||
{ level = vim.log.levels.ERROR }
|
||||
)
|
||||
return {}
|
||||
return nil
|
||||
end
|
||||
return result.data.contests
|
||||
return {
|
||||
contests = result.data.contests,
|
||||
supports_countdown = result.data.supports_countdown ~= false,
|
||||
}
|
||||
end
|
||||
|
||||
---@param platform string
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
local M = {}
|
||||
|
||||
local cache = require('cp.cache')
|
||||
local config = require('cp.config')
|
||||
local constants = require('cp.constants')
|
||||
local logger = require('cp.log')
|
||||
local state = require('cp.state')
|
||||
|
||||
|
|
@ -56,6 +58,24 @@ function M.submit(opts)
|
|||
end
|
||||
source_file = vim.fn.fnamemodify(source_file, ':p')
|
||||
|
||||
local submit_language = language
|
||||
local cfg = config.get_config()
|
||||
local plat_effective = cfg.runtime and cfg.runtime.effective and cfg.runtime.effective[platform]
|
||||
local eff = plat_effective and plat_effective[language]
|
||||
if eff then
|
||||
if eff.submit_id then
|
||||
submit_language = eff.submit_id
|
||||
else
|
||||
local ver = eff.version or constants.DEFAULT_VERSIONS[language]
|
||||
if ver then
|
||||
local versions = (constants.LANGUAGE_VERSIONS[platform] or {})[language]
|
||||
if versions and versions[ver] then
|
||||
submit_language = versions[ver]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
prompt_credentials(platform, function(creds)
|
||||
vim.cmd.update()
|
||||
vim.notify('[cp.nvim] Submitting...', vim.log.levels.INFO)
|
||||
|
|
@ -64,7 +84,7 @@ function M.submit(opts)
|
|||
platform,
|
||||
contest_id,
|
||||
problem_id,
|
||||
language,
|
||||
submit_language,
|
||||
source_file,
|
||||
creds,
|
||||
function(ev)
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ end, {
|
|||
end
|
||||
return filter_candidates(candidates)
|
||||
elseif args[2] == 'race' then
|
||||
if require('cp.race').status().active then
|
||||
return filter_candidates({ 'stop' })
|
||||
end
|
||||
local candidates = { 'stop' }
|
||||
vim.list_extend(candidates, platforms)
|
||||
return filter_candidates(candidates)
|
||||
|
|
@ -126,7 +129,11 @@ end, {
|
|||
if args[2] == 'stress' then
|
||||
local utils = require('cp.utils')
|
||||
return filter_candidates(utils.cwd_executables())
|
||||
elseif args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
|
||||
elseif
|
||||
args[2] == 'race'
|
||||
and not require('cp.race').status().active
|
||||
and vim.tbl_contains(platforms, args[3])
|
||||
then
|
||||
local cache = require('cp.cache')
|
||||
cache.load()
|
||||
local contests = cache.get_cached_contest_ids(args[3])
|
||||
|
|
@ -153,7 +160,11 @@ end, {
|
|||
return filter_candidates(candidates)
|
||||
end
|
||||
elseif num_args == 5 then
|
||||
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
|
||||
if
|
||||
args[2] == 'race'
|
||||
and not require('cp.race').status().active
|
||||
and vim.tbl_contains(platforms, args[3])
|
||||
then
|
||||
return filter_candidates({ '--lang' })
|
||||
elseif args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
|
||||
local cache = require('cp.cache')
|
||||
|
|
@ -168,7 +179,12 @@ end, {
|
|||
end
|
||||
end
|
||||
elseif num_args == 6 then
|
||||
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) and args[5] == '--lang' then
|
||||
if
|
||||
args[2] == 'race'
|
||||
and not require('cp.race').status().active
|
||||
and vim.tbl_contains(platforms, args[3])
|
||||
and args[5] == '--lang'
|
||||
then
|
||||
return filter_candidates(get_enabled_languages(args[3]))
|
||||
elseif vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
|
||||
return filter_candidates(get_enabled_languages(args[2]))
|
||||
|
|
|
|||
|
|
@ -228,9 +228,13 @@ class CSESScraper(BaseScraper):
|
|||
cats = parse_categories(html)
|
||||
if not cats:
|
||||
return ContestListResult(
|
||||
success=False, error=f"{self.platform_name}: No contests found"
|
||||
success=False,
|
||||
error=f"{self.platform_name}: No contests found",
|
||||
supports_countdown=False,
|
||||
)
|
||||
return ContestListResult(success=True, error="", contests=cats)
|
||||
return ContestListResult(
|
||||
success=True, error="", contests=cats, supports_countdown=False
|
||||
)
|
||||
|
||||
async def login(self, credentials: dict[str, str]) -> LoginResult:
|
||||
username = credentials.get("username", "")
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class MetadataResult(ScrapingResult):
|
|||
|
||||
class ContestListResult(ScrapingResult):
|
||||
contests: list[ContestSummary] = Field(default_factory=list)
|
||||
supports_countdown: bool = True
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
|
|
|||
|
|
@ -337,10 +337,16 @@ class USACOScraper(BaseScraper):
|
|||
contests.extend(await coro)
|
||||
|
||||
if not contests:
|
||||
return self._contests_error("No contests found")
|
||||
return ContestListResult(success=True, error="", contests=contests)
|
||||
return ContestListResult(
|
||||
success=False, error="No contests found", supports_countdown=False
|
||||
)
|
||||
return ContestListResult(
|
||||
success=True, error="", contests=contests, supports_countdown=False
|
||||
)
|
||||
except Exception as e:
|
||||
return self._contests_error(str(e))
|
||||
return ContestListResult(
|
||||
success=False, error=str(e), supports_countdown=False
|
||||
)
|
||||
|
||||
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
||||
month_year, division = _parse_contest_id(category_id)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import pytest
|
||||
|
||||
from scrapers.language_ids import LANGUAGE_IDS, get_language_id
|
||||
from scrapers.models import (
|
||||
ContestListResult,
|
||||
MetadataResult,
|
||||
|
|
@ -137,3 +138,28 @@ def test_scraper_metadata_error(run_scraper_offline, scraper, contest_id):
|
|||
assert objs
|
||||
assert objs[-1].get("success") is False
|
||||
assert objs[-1].get("error")
|
||||
|
||||
|
||||
EXPECTED_PLATFORMS = {"atcoder", "codeforces", "cses", "usaco", "kattis", "codechef"}
|
||||
EXPECTED_LANGUAGES = {"cpp", "python"}
|
||||
|
||||
|
||||
def test_language_ids_coverage():
|
||||
assert set(LANGUAGE_IDS.keys()) == EXPECTED_PLATFORMS
|
||||
for platform, langs in LANGUAGE_IDS.items():
|
||||
assert set(langs.keys()) == EXPECTED_LANGUAGES, f"{platform} missing languages"
|
||||
for lang, lid in langs.items():
|
||||
assert isinstance(lid, str) and lid, f"{platform}/{lang} empty ID"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", EXPECTED_PLATFORMS)
|
||||
@pytest.mark.parametrize("language", EXPECTED_LANGUAGES)
|
||||
def test_get_language_id(platform, language):
|
||||
result = get_language_id(platform, language)
|
||||
assert result is not None, f"No ID for {platform}/{language}"
|
||||
assert result == LANGUAGE_IDS[platform][language]
|
||||
|
||||
|
||||
def test_get_language_id_unknown():
|
||||
assert get_language_id("nonexistent", "cpp") is None
|
||||
assert get_language_id("codeforces", "rust") is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue