Merge pull request #145 from barrett-ruth/feat/cli-enhancements

Misc CLI/Config Enhancements
This commit is contained in:
Barrett Ruth 2025-10-05 04:48:07 +02:00 committed by GitHub
commit 45d21be879
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 677 additions and 643 deletions

View file

@ -28,7 +28,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> [--{lang=<lang>,debug}]` 2. **Set up locally** with `:CP <platform> <contest>`
``` ```
:CP codeforces 1848 :CP codeforces 1848

View file

@ -32,16 +32,13 @@ COMMANDS *cp-commands*
Automatically detects platform, contest, problem, Automatically detects platform, contest, problem,
and language from cached state. Use this after and language from cached state. Use this after
switching files to restore your CP environment. switching files to restore your CP environment.
Requires previous setup with full :CP command.
Setup Commands ~ Setup Commands ~
:CP {platform} {contest_id} {problem_id} [--lang={language}] :CP {platform} {contest_id}
Full setup: set platform, load contest metadata, Full setup: set platform and load contest metadata.
and set up specific problem. Scrapes test cases Scrapes test cases and creates source file.
and creates source file.
Example: > Example: >
:CP codeforces 1933 a :CP codeforces 1933
:CP codeforces 1933 a --lang=python
< <
:CP {platform} {contest_id} :CP {platform} {contest_id}
Contest setup: set platform, load contest metadata, Contest setup: set platform, load contest metadata,
@ -52,10 +49,6 @@ COMMANDS *cp-commands*
Example: > Example: >
:CP atcoder abc324 :CP atcoder abc324
:CP codeforces 1951 :CP codeforces 1951
<
:CP {platform} Platform setup: set platform only.
Example: >
:CP cses
< <
Action Commands ~ Action Commands ~
:CP run [--debug] Toggle run panel for individual test case :CP run [--debug] Toggle run panel for individual test case
@ -65,12 +58,13 @@ COMMANDS *cp-commands*
Requires contest setup first. Requires contest setup first.
:CP pick Launch configured picker for interactive :CP pick Launch configured picker for interactive
platform/contest/problem selection. platform/contest selection.
Navigation Commands ~ Navigation Commands ~
:CP next Navigate to next problem in current contest. :CP next Navigate to next problem in current contest.
Stops at last problem (no wrapping). Stops at last problem (no wrapping).
Navigation Commands ~ Navigation 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).
@ -79,7 +73,7 @@ COMMANDS *cp-commands*
:CP cache clear [contest] :CP cache clear [contest]
Clear the cache data (contest list, problem Clear the cache data (contest list, problem
data, file states) for the specified contest, data, file states) for the specified contest,
or all contests if none specified or all contests if none specified.
:CP cache read :CP cache read
View the cache in a pretty-printed lua buffer. View the cache in a pretty-printed lua buffer.
@ -89,22 +83,15 @@ Command Flags ~
*cp-flags* *cp-flags*
Flags can be used with setup and action commands: Flags can be used with setup and action commands:
--lang={language} Specify language for the problem. --debug Use the debug command template.
--lang {language} Alternative syntax for language specification. For compiled languages, this selects
Supported languages: cpp, python `commands.debug` (a debug *build*) instead of
Example: > `commands.build`. For interpreted languages,
:CP atcoder abc324 a --lang=python this selects `commands.debug` in place of
:CP b --lang cpp `commands.run`.
<
--debug Enable debug compilation with additional flags.
Uses the `debug` command template instead of
`compile`. Typically includes debug symbols and
sanitizers for memory error detection.
Example: > Example: >
:CP run --debug :CP run --debug
< <
Note: Debug compilation may be slower but provides
better error reporting for runtime issues.
Template Variables ~ Template Variables ~
*cp-template-vars* *cp-template-vars*
@ -116,7 +103,7 @@ Template Variables ~
• {problem} Problem identifier (e.g. "a", "b") • {problem} Problem identifier (e.g. "a", "b")
Example template: > Example template: >
compile = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' } build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
< Would expand to: > < Would expand to: >
g++ abc324a.cpp -o build/abc324a.run -std=c++17 g++ abc324a.cpp -o build/abc324a.run -std=c++17
< <
@ -127,110 +114,161 @@ CONFIGURATION *cp-config*
Here's an example configuration with lazy.nvim: >lua Here's an example configuration with lazy.nvim: >lua
{ {
'barrett-ruth/cp.nvim', 'barrett-ruth/cp.nvim',
cmd = 'CP', cmd = 'CP',
opts = { build = 'uv sync',
contests = { opts = {
default = { languages = {
cpp = { cpp = {
compile = { 'g++', '{source}', '-o', '{binary}', extension = 'cc',
'-std=c++17', '-fdiagnostic-colors=always' }, commands = {
test = { '{binary}' }, build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
debug = { 'g++', '{source}', '-o', '{binary}', run = { '{binary}' },
'-std=c++17', '-g', debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'-fdiagnostic-colors=always' '{source}', '-o', '{binary}' },
'-fsanitize=address,undefined' },
},
python = {
test = { 'python3', '{source}' },
},
},
}, },
snippets = {}, },
debug = false, python = {
scrapers = { 'atcoder', 'codeforces', 'cses' }, extension = 'py',
run_panel = { commands = {
ansi = true, run = { 'python', '{source}' },
diff_mode = 'vim', debug = { 'python', '{source}' },
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
max_output_lines = 50,
}, },
diff = { },
git = { },
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' }, platforms = {
}, cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
cpp = { extension = 'cpp', commands = { build = { ... } } }
}, },
picker = 'telescope', -- 'telescope', 'fzf-lua', or nil (disabled) },
} atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
snippets = {},
debug = false,
ui = {
run_panel = {
ansi = true,
diff_mode = 'vim',
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' },
},
},
picker = 'telescope',
},
}
} }
< <
By default, all contests are configured to use C++ with the g++ compiler and ISO standard By default, C++ (g++ with ISO C++17) and Python are preconfigured under
17. Python is also configured with the system executable python as a non-default option. Consult lua/cp/config.lua for `languages`. Platforms select which languages are enabled and which one is
more information. the default; per-platform overrides can tweak `extension` or `commands`.
For example, to run CodeForces contests with Python, only the following config For example, to run CodeForces contests with Python by default:
is required:
>lua
{ {
contests = { platforms = {
codeforces = { codeforces = {
default_langauge = 'python' enabled_languages = { 'cpp', 'python' },
} default_language = 'python',
} },
},
} }
<
Any language is supported provided the proper configuration. For example, to
run CSES problems with Rust using the single schema:
>lua
{
languages = {
rust = {
extension = 'rs',
commands = {
build = { 'rustc', '{source}', '-o', '{binary}' },
run = { '{binary}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python', 'rust' },
default_language = 'rust',
},
},
}
<
*cp.Config* *cp.Config*
Fields: ~ Fields: ~
{contests} (table<string,ContestConfig>) Contest configurations. {languages} (table<string,|CpLanguage|>) Global language registry.
Each language provides an {extension} and {commands}.
{platforms} (table<string,|CpPlatform|>) Per-platform enablement,
default language, and optional overrides.
{hooks} (|cp.Hooks|) Hook functions called at various stages. {hooks} (|cp.Hooks|) Hook functions called at various stages.
{snippets} (table[]) LuaSnip snippet definitions. {snippets} (table[]) LuaSnip snippet definitions.
{debug} (boolean, default: false) Show info messages {debug} (boolean, default: false) Show info messages.
during operation. {scrapers} (string[]) Supported platform ids.
{scrapers} (table<string>) List of enabled scrapers. {filename} (function, optional)
Default: all scrapers enabled function(contest, contest_id, problem_id, config, language): string
{run_panel} (|RunPanelConfig|) Test panel behavior configuration.
{diff} (|DiffConfig|) Diff backend configuration.
{picker} (string, optional) Picker integration: "telescope",
"fzf-lua", or nil to disable. When enabled, provides
:CP pick for interactive platform/contest/problem selection.
{filename} (function, optional) Custom filename generation.
function(contest, contest_id, problem_id, config, language)
Should return full filename with extension. Should return full filename with extension.
(default: concatenates contest_id and problem_id, lowercased) (default: concatenates contest_id and problem_id, lowercased)
{ui} (|CpUI|) UI settings: run panel, diff backend, picker.
*cp.ContestConfig* *cp.PlatformConfig*
Fields: ~ Replaced by |CpPlatform|. Platforms no longer inline language tables.
{cpp} (|LanguageConfig|) C++ language configuration.
{python} (|LanguageConfig|) Python language configuration.
{default_language} (string, default: "cpp") Default language when
--lang not specified.
*cp.LanguageConfig* *CpPlatform*
Fields: ~ Fields: ~
{compile} (string[], optional) Compile command template with {enabled_languages} (string[]) Language ids enabled on this platform.
{source}, {binary} placeholders. {default_language} (string) One of {enabled_languages}.
{test} (string[]) Test execution command template. {overrides} (table<string,|CpPlatformOverrides|>, optional)
{debug} (string[], optional) Debug compile command template. Per-language overrides of {extension} and/or {commands}.
{extension} (string) File extension (e.g. "cc", "py").
{executable} (string, optional) Executable name for interpreted languages. *CpLanguage*
Fields: ~
{extension} (string) File extension without leading dot.
{commands} (|CpLangCommands|) Command templates.
*CpLangCommands*
Fields: ~
{build} (string[], optional) For compiled languages.
Must include {source} and {binary}.
{run} (string[], optional) Runtime command.
Compiled: must include {binary}.
Interpreted: must include {source}.
{debug} (string[], optional) Debug variant; same token rules
as {build} (compiled) or {run} (interpreted).
*CpUI*
Fields: ~
{run_panel} (|RunPanelConfig|) Test panel behavior configuration.
{diff} (|DiffConfig|) Diff backend configuration.
{picker} (string|nil) 'telescope', 'fzf-lua', or nil.
*cp.RunPanelConfig* *cp.RunPanelConfig*
Fields: ~ Fields: ~
{ansi} (boolean, default: true) Enable ANSI color parsing and {ansi} (boolean, default: true) Enable ANSI color parsing
highlighting. When true, compiler output and test results and highlighting.
display with colored syntax highlighting. When false, {diff_mode} (string, default: "none") Diff backend: "none",
ANSI escape codes are stripped for plain text display. "vim", or "git".
Requires vim.g.terminal_color_* to be configured for
proper color display.
{diff_mode} (string, default: "none") Diff backend: "none", "vim", or "git".
"none" displays plain buffers without highlighting,
"vim" uses built-in diff, "git" provides character-level precision.
{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.
{toggle_diff_key} (string, default: "<c-t>") Key to cycle through diff modes.
{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*
@ -251,10 +289,9 @@ is required:
Fields: ~ Fields: ~
{before_run} (function, optional) Called before test panel opens. {before_run} (function, optional) Called before test panel opens.
function(state: cp.State) function(state: cp.State)
{before_debug} (function, optional) Called before debug compilation. {before_debug} (function, optional) Called before debug build/run.
function(state: cp.State) function(state: cp.State)
{setup_code} (function, optional) Called after source file is opened. {setup_code} (function, optional) Called after source file is opened.
Good for configuring buffer settings.
function(state: cp.State) function(state: cp.State)
Hook functions receive the cp.nvim state object (cp.State). See the state Hook functions receive the cp.nvim state object (cp.State). See the state
@ -284,41 +321,21 @@ AtCoder ~
URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a 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 " Contest setup: load contest metadata only :CP atcoder abc324 " Contest setup: load contest metadata only
:CP next " Navigate to next problem in contest
<
Note: AtCoder template includes optimizations
for multi-test case problems commonly found
in contests.
AtCoder Heuristic Contests (AHC) are excluded
from the contest list as they don't have
standard sample test cases.
Codeforces ~ Codeforces ~
*cp-codeforces* *cp-codeforces*
URL format: https://codeforces.com/contest/1234/problem/A 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 " Contest setup: load contest metadata only :CP codeforces 1934 " Contest setup: load contest metadata only
:CP prev " Navigate to previous problem in contest
<
Note: Problem IDs are automatically converted
to lowercase for consistency.
CSES ~ CSES ~
*cp-cses* *cp-cses*
URL format: https://cses.fi/problemset/task/1068 URL format: https://cses.fi/problemset/task/1068
Usage examples: > Usage examples: >
:CP cses dynamic_programming 1633 " Set up problem 1633 from DP category
:CP cses dynamic_programming " Set up ALL problems from DP category :CP cses dynamic_programming " Set up ALL problems from DP category
<
Note: Category name is always required. For bulk
setup, omit the problem ID to scrape all problems
in the category.
============================================================================== ==============================================================================
@ -533,10 +550,8 @@ prevent them from being overridden: >lua
============================================================================== ==============================================================================
RUN PANEL KEYMAPS *cp-test-keys* RUN PANEL KEYMAPS *cp-test-keys*
<c-n> Navigate to next test case (configurable via <c-n> Navigate to next test case
run_panel.next_test_key) <c-p> Navigate to previous test case
<c-p> Navigate to previous test case (configurable via
run_panel.prev_test_key)
t Cycle through diff modes: none → git → vim t Cycle through diff modes: none → git → vim
q Exit run panel and restore layout q Exit run panel and restore layout
<c-q> Exit interactive terminal and restore layout <c-q> Exit interactive terminal and restore layout

View file

@ -86,7 +86,9 @@ function M.get_contest_data(platform, contest_id)
contest_id = { contest_id, 'string' }, contest_id = { contest_id, 'string' },
}) })
return cache_data[platform][contest_id] or {} cache_data[platform] = cache_data[platform] or {}
cache_data[platform][contest_id] = cache_data[platform][contest_id] or {}
return cache_data[platform][contest_id]
end end
---@param platform string ---@param platform string
@ -105,7 +107,7 @@ function M.set_contest_data(platform, contest_id, problems)
local out = { local out = {
name = prev.name, name = prev.name,
display_name = prev.display_name, display_name = prev.display_name,
problems = vim.deepcopy(problems), problems = problems,
index_map = {}, index_map = {},
} }
for i, p in ipairs(out.problems) do for i, p in ipairs(out.problems) do
@ -207,32 +209,27 @@ function M.get_constraints(platform, contest_id, problem_id)
end end
---@param file_path string ---@param file_path string
---@return FileState? ---@return FileState|nil
function M.get_file_state(file_path) function M.get_file_state(file_path)
if not cache_data.file_states then M.load()
return nil cache_data.file_states = cache_data.file_states or {}
end
return cache_data.file_states[file_path] return cache_data.file_states[file_path]
end end
---@param file_path string ---@param path string
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@param problem_id? string ---@param problem_id string
---@param language? string ---@param language string|nil
function M.set_file_state(file_path, platform, contest_id, problem_id, language) function M.set_file_state(path, platform, contest_id, problem_id, language)
if not cache_data.file_states then M.load()
cache_data.file_states = {} cache_data.file_states = cache_data.file_states or {}
end cache_data.file_states[path] = {
cache_data.file_states[file_path] = {
platform = platform, platform = platform,
contest_id = contest_id, contest_id = contest_id,
problem_id = problem_id, problem_id = problem_id,
language = language, language = language,
} }
M.save() M.save()
end end
@ -255,7 +252,7 @@ end
function M.set_contest_summaries(platform, contests) function M.set_contest_summaries(platform, contests)
cache_data[platform] = cache_data[platform] or {} cache_data[platform] = cache_data[platform] or {}
for _, contest in ipairs(contests) do for _, contest in ipairs(contests) do
cache_data[platform][contest.id] = cache_data[platform][contest] or {} cache_data[platform][contest.id] = cache_data[platform][contest.id] or {}
cache_data[platform][contest.id].display_name = contest.display_name cache_data[platform][contest.id].display_name = contest.display_name
cache_data[platform][contest.id].name = contest.name cache_data[platform][contest.id].name = contest.name
end end
@ -284,4 +281,6 @@ function M.get_data_pretty()
return vim.inspect(cache_data) return vim.inspect(cache_data)
end end
M._cache = cache_data
return M return M

View file

@ -10,7 +10,6 @@ local actions = constants.ACTIONS
---@class ParsedCommand ---@class ParsedCommand
---@field type string ---@field type string
---@field error string? ---@field error string?
---@field language? string
---@field debug? boolean ---@field debug? boolean
---@field action? string ---@field action? string
---@field message? string ---@field message? string
@ -27,26 +26,10 @@ local function parse_command(args)
} }
end end
local language = nil local debug = vim.tbl_contains(args, '--debug')
local debug = false
for i, arg in ipairs(args) do
local lang_match = arg:match('^--lang=(.+)$')
if lang_match then
language = lang_match
elseif arg == '--lang' then
if i + 1 <= #args then
language = args[i + 1]
else
return { type = 'error', message = '--lang requires a value' }
end
elseif arg == '--debug' then
debug = true
end
end
local filtered_args = vim.tbl_filter(function(arg) local filtered_args = vim.tbl_filter(function(arg)
return not (arg:match('^--lang') or arg == language or arg == '--debug') return arg ~= '--debug'
end, args) end, args)
local first = filtered_args[1] local first = filtered_args[1]
@ -68,7 +51,7 @@ local function parse_command(args)
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand } return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
end end
else else
return { type = 'action', action = first, language = language, debug = debug } return { type = 'action', action = first, debug = debug }
end end
end end
@ -83,12 +66,11 @@ local function parse_command(args)
type = 'contest_setup', type = 'contest_setup',
platform = first, platform = first,
contest = filtered_args[2], contest = filtered_args[2],
language = language,
} }
elseif #filtered_args == 3 then elseif #filtered_args == 3 then
return { return {
type = 'error', type = 'error',
message = 'Setup contests with :CP <platform> <contest_id> [--{lang=<lang>,debug}]', message = 'Setup contests with :CP <platform> <contest_id>',
} }
else else
return { type = 'error', message = 'Too many arguments' } return { type = 'error', message = 'Too many arguments' }
@ -129,9 +111,9 @@ function M.handle_command(opts)
elseif cmd.action == 'run' then elseif cmd.action == 'run' then
ui.toggle_run_panel(cmd.debug) ui.toggle_run_panel(cmd.debug)
elseif cmd.action == 'next' then elseif cmd.action == 'next' then
setup.navigate_problem(1, cmd.language) setup.navigate_problem(1)
elseif cmd.action == 'prev' then elseif cmd.action == 'prev' then
setup.navigate_problem(-1, cmd.language) setup.navigate_problem(-1)
elseif cmd.action == 'pick' then elseif cmd.action == 'pick' then
local picker = require('cp.commands.picker') local picker = require('cp.commands.picker')
picker.handle_pick_action() picker.handle_pick_action()
@ -142,7 +124,7 @@ function M.handle_command(opts)
elseif cmd.type == 'contest_setup' then elseif 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, cmd.language, nil) setup.setup_contest(cmd.platform, cmd.contest, nil)
end end
return return
end end

View file

@ -8,9 +8,9 @@ local logger = require('cp.log')
function M.handle_pick_action() function M.handle_pick_action()
local config = config_module.get_config() local config = config_module.get_config()
if not config.picker then if not (config.ui and config.ui.picker) then
logger.log( logger.log(
'No picker configured. Set picker = "{telescope,fzf-lua}" in your config.', 'No picker configured. Set ui.picker = "{telescope,fzf-lua}" in your config.',
vim.log.levels.ERROR vim.log.levels.ERROR
) )
return return
@ -18,7 +18,8 @@ function M.handle_pick_action()
local picker local picker
if config.picker == 'telescope' then local picker_name = config.ui.picker
if picker_name == 'telescope' then
local ok = pcall(require, 'telescope') local ok = pcall(require, 'telescope')
if not ok then if not ok then
logger.log( logger.log(
@ -34,7 +35,7 @@ function M.handle_pick_action()
end end
picker = telescope_picker picker = telescope_picker
elseif config.picker == 'fzf-lua' then elseif picker_name == 'fzf-lua' then
local ok, _ = pcall(require, 'fzf-lua') local ok, _ = pcall(require, 'fzf-lua')
if not ok then if not ok then
logger.log( logger.log(

View file

@ -1,262 +1,279 @@
---@class LanguageConfig -- lua/cp/config.lua
---@field compile? string[] Compile command template ---@class CpLangCommands
---@field test string[] Test execution command template ---@field build? string[]
---@field debug? string[] Debug command template ---@field run? string[]
---@field executable? string Executable name ---@field debug? string[]
---@field version? number Language version
---@field extension? string File extension
---@class ContestConfig ---@class CpLanguage
---@field cpp LanguageConfig ---@field extension string
---@field python LanguageConfig ---@field commands CpLangCommands
---@field default_language? string
---@class CpPlatformOverrides
---@field extension? string
---@field commands? CpLangCommands
---@class CpPlatform
---@field enabled_languages string[]
---@field default_language string
---@field overrides? table<string, CpPlatformOverrides>
---@class RunPanelConfig
---@field ansi boolean
---@field diff_mode "none"|"vim"|"git"
---@field max_output_lines integer
---@class DiffGitConfig
---@field args string[]
---@class DiffConfig
---@field git DiffGitConfig
---@class Hooks ---@class Hooks
---@field before_run? fun(state: cp.State) ---@field before_run? fun(state: cp.State)
---@field before_debug? fun(state: cp.State) ---@field before_debug? fun(state: cp.State)
---@field setup_code? fun(state: cp.State) ---@field setup_code? fun(state: cp.State)
---@class RunPanelConfig ---@class CpUI
---@field ansi boolean Enable ANSI color parsing and highlighting
---@field diff_mode "none"|"vim"|"git" Diff backend to use
---@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 max_output_lines number Maximum lines of test output to display
---@class DiffGitConfig
---@field args string[] Git diff arguments
---@class DiffConfig
---@field git DiffGitConfig
---@class cp.Config
---@field contests table<string, ContestConfig>
---@field snippets any[]
---@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 run_panel RunPanelConfig ---@field run_panel RunPanelConfig
---@field diff DiffConfig ---@field diff DiffConfig
---@field picker string|nil ---@field picker string|nil
---@class cp.PartialConfig ---@class cp.Config
---@field contests? table<string, ContestConfig> ---@field languages table<string, CpLanguage>
---@field snippets? any[] ---@field platforms table<string, CpPlatform>
---@field hooks? Hooks ---@field hooks Hooks
---@field debug? boolean ---@field snippets any[]
---@field scrapers? string[] ---@field debug boolean
---@field scrapers string[]
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
---@field run_panel? RunPanelConfig ---@field ui CpUI
---@field diff? DiffConfig ---@field runtime { effective: table<string, table<string, CpLanguage>> } -- computed
---@field picker? string|nil
---@class cp.PartialConfig: cp.Config
local M = {} local M = {}
local constants = require('cp.constants') local constants = require('cp.constants')
local utils = require('cp.utils') local utils = require('cp.utils')
local default_contest_config = { -- defaults per the new single schema
cpp = {
compile = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined', '{source}', '-o', '{binary}' },
test = { '{binary}' },
},
python = {
test = { '{source}' },
debug = { '{source}' },
executable = 'python',
extension = 'py',
},
default_language = 'cpp',
}
---@type cp.Config ---@type cp.Config
M.defaults = { M.defaults = {
contests = { languages = {
codeforces = default_contest_config, cpp = {
atcoder = default_contest_config, extension = 'cc',
cses = default_contest_config, 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',
},
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
}, },
snippets = {}, snippets = {},
hooks = { hooks = { before_run = nil, before_debug = nil, setup_code = nil },
before_run = nil,
before_debug = nil,
setup_code = nil,
},
debug = false, debug = false,
scrapers = constants.PLATFORMS, scrapers = constants.PLATFORMS,
filename = nil, filename = nil,
run_panel = { ui = {
ansi = true, run_panel = { ansi = true, diff_mode = 'none', max_output_lines = 50 },
diff_mode = 'none', diff = {
next_test_key = '<c-n>', git = {
prev_test_key = '<c-p>', args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
max_output_lines = 50, },
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
}, },
picker = nil,
}, },
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.commands.build ~= nil then
vim.validate({ build = { lang.commands.build, { 'table' } } })
if not has_tokens(lang.commands.build, { '{source}', '{binary}' }) then
error(('[cp.nvim] languages.%s.commands.build must include {source} and {binary}'):format(id))
end
for _, k in ipairs({ 'run', 'debug' }) do
if lang.commands[k] then
if not has_tokens(lang.commands[k], { '{binary}' }) then
error(('[cp.nvim] languages.%s.commands.%s must include {binary}'):format(id, k))
end
end
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
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)
cfg.runtime.effective[plat][lid] = eff
end
end
end
---@param user_config cp.PartialConfig|nil ---@param user_config cp.PartialConfig|nil
---@return cp.Config ---@return cp.Config
function M.setup(user_config) function M.setup(user_config)
vim.validate({ vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
user_config = { user_config, { 'table', 'nil' }, true }, local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
})
if user_config then
vim.validate({
contests = { user_config.contests, { 'table', 'nil' }, true },
snippets = { user_config.snippets, { 'table', 'nil' }, true },
hooks = { user_config.hooks, { 'table', 'nil' }, true },
debug = { user_config.debug, { 'boolean', 'nil' }, true },
scrapers = { user_config.scrapers, { 'table', 'nil' }, true },
filename = { user_config.filename, { 'function', 'nil' }, true },
run_panel = { user_config.run_panel, { 'table', 'nil' }, true },
diff = { user_config.diff, { 'table', 'nil' }, true },
picker = { user_config.picker, { 'string', 'nil' }, true },
})
if user_config.contests then
for contest_name, contest_config in pairs(user_config.contests) do
vim.validate({
[contest_name] = {
contest_config,
function(config)
if type(config) ~= 'table' then
return false
end
return true
end,
'contest configuration',
},
})
end
end
if user_config.scrapers then
for _, platform_name in ipairs(user_config.scrapers) do
if type(platform_name) ~= 'string' then
error(('Invalid scraper value type. Expected string, got %s'):format(type(platform_name)))
end
if not vim.tbl_contains(constants.PLATFORMS, platform_name) then
error(
("Invalid platform '%s' in scrapers config. Valid platforms: %s"):format(
platform_name,
table.concat(constants.PLATFORMS, ', ')
)
)
end
end
end
if user_config.picker then
if not vim.tbl_contains({ 'telescope', 'fzf-lua' }, user_config.picker) then
error(("Invalid picker '%s'. Must be 'telescope' or 'fzf-lua'"):format(user_config.picker))
end
end
end
local config = vim.tbl_deep_extend('force', M.defaults, user_config or {})
vim.validate({ vim.validate({
before_run = { hooks = { cfg.hooks, { 'table' } },
config.hooks.before_run, ui = { cfg.ui, { 'table' } },
{ 'function', 'nil' },
true,
},
before_debug = {
config.hooks.before_debug,
{ 'function', 'nil' },
true,
},
setup_code = {
config.hooks.setup_code,
{ 'function', 'nil' },
true,
},
}) })
vim.validate({ vim.validate({
ansi = { before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
config.run_panel.ansi, before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
'boolean', setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
'ansi color parsing must be enabled xor disabled', })
},
vim.validate({
ansi = { cfg.ui.run_panel.ansi, 'boolean' },
diff_mode = { diff_mode = {
config.run_panel.diff_mode, cfg.ui.run_panel.diff_mode,
function(value) function(v)
return vim.tbl_contains({ 'none', 'vim', 'git' }, value) return vim.tbl_contains({ 'none', 'vim', 'git' }, v)
end, end,
"diff_mode must be 'none', 'vim', or 'git'", "diff_mode must be 'none', 'vim', or 'git'",
}, },
next_test_key = {
config.run_panel.next_test_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'next_test_key must be a non-empty string',
},
prev_test_key = {
config.run_panel.prev_test_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'prev_test_key must be a non-empty string',
},
max_output_lines = { max_output_lines = {
config.run_panel.max_output_lines, cfg.ui.run_panel.max_output_lines,
function(value) function(v)
return type(value) == 'number' and value > 0 and value == math.floor(value) return type(v) == 'number' and v > 0 and v == math.floor(v)
end, end,
'max_output_lines must be a positive integer', 'positive integer',
}, },
git = { cfg.ui.diff.git, { 'table' } },
}) })
vim.validate({ for id, lang in pairs(cfg.languages) do
git = { config.diff.git, { 'table', 'nil' }, true }, validate_language(id, lang)
})
for _, contest_config in pairs(config.contests) do
for lang_name, lang_config in pairs(contest_config) do
if type(lang_config) == 'table' and not lang_config.extension then
if lang_name == 'cpp' then
lang_config.extension = 'cpp'
elseif lang_name == 'python' then
lang_config.extension = 'py'
end
end
end
if not contest_config.default_language then
local available_langs = {}
for lang_name, lang_config in pairs(contest_config) do
if type(lang_config) == 'table' and lang_name ~= 'default_language' then
table.insert(available_langs, lang_name)
end
end
if vim.tbl_isemtpy(available_langs) then
error('No language configurations found')
end
table.sort(available_langs)
contest_config.default_language = available_langs[1]
end
end end
build_runtime(cfg)
local ok, err = utils.check_required_runtime() local ok, err = utils.check_required_runtime()
if not ok then if not ok then
error('[cp.nvim] ' .. err) error('[cp.nvim] ' .. err)
end end
return config return cfg
end
local current_config = nil
function M.set_current_config(config)
current_config = config
end
function M.get_config()
return current_config or M.defaults
end end
---@param contest_id string ---@param contest_id string
@ -265,25 +282,9 @@ end
local function default_filename(contest_id, problem_id) local function default_filename(contest_id, problem_id)
if problem_id then if problem_id then
return (contest_id .. problem_id):lower() return (contest_id .. problem_id):lower()
else
return contest_id:lower()
end end
return contest_id:lower()
end end
M.default_filename = default_filename M.default_filename = default_filename
local current_config = nil
--- Set the config
---@return nil
function M.set_current_config(config)
current_config = config
end
--- Get the config
---@return cp.Config
function M.get_config()
return current_config or M.defaults
end
return M return M

View file

@ -24,6 +24,12 @@ M.canonical_filetypes = {
[M.PYTHON] = 'python', [M.PYTHON] = 'python',
} }
---@type table<string, string>
M.canonical_filetype_to_extension = {
[M.CPP] = 'cc',
[M.PYTHON] = 'py',
}
---@type table<number, string> ---@type table<number, string>
M.signal_codes = { M.signal_codes = {
[128] = 'SIGILL', [128] = 'SIGILL',

View file

@ -4,14 +4,15 @@ local config_module = require('cp.config')
local logger = require('cp.log') 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 vim.fn.has('nvim-0.10.0') == 0 then
logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR) logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR)
return {} return {}
end end
local user_config = {} local user_config = {}
local config = config_module.setup(user_config) local config = nil
local snippets_initialized = false local snippets_initialized = false
local initialized = false
--- Root handler for all `:CP ...` commands --- Root handler for all `:CP ...` commands
---@return nil ---@return nil
@ -30,10 +31,11 @@ function M.setup(opts)
snippets.setup(config) snippets.setup(config)
snippets_initialized = true snippets_initialized = true
end end
initialized = true
end end
function M.is_initialized() function M.is_initialized()
return true return initialized
end end
return M return M

View file

@ -1,7 +1,6 @@
local M = {} local M = {}
local cache = require('cp.cache') local cache = require('cp.cache')
local config = require('cp.config').get_config()
local constants = require('cp.constants') local constants = require('cp.constants')
local logger = require('cp.log') local logger = require('cp.log')
local scraper = require('cp.scraper') local scraper = require('cp.scraper')
@ -22,17 +21,16 @@ local scraper = require('cp.scraper')
---@return cp.PlatformItem[] ---@return cp.PlatformItem[]
function M.get_platforms() function M.get_platforms()
local config = require('cp.config').get_config()
local result = {} local result = {}
for _, platform in ipairs(constants.PLATFORMS) do for _, platform in ipairs(constants.PLATFORMS) do
if config.contests[platform] then if config.platforms[platform] then
table.insert(result, { table.insert(result, {
id = platform, id = platform,
display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform, display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
}) })
end end
end end
return result return result
end end

View file

@ -4,44 +4,26 @@ local cache = require('cp.cache')
local logger = require('cp.log') local logger = require('cp.log')
local state = require('cp.state') local state = require('cp.state')
---@return boolean
function M.restore_from_current_file() function M.restore_from_current_file()
local current_file = vim.fn.expand('%:p')
if current_file == '' then
logger.log('No file is currently open.', vim.log.levels.ERROR)
return false
end
cache.load() cache.load()
local current_file = (vim.uv.fs_realpath(vim.fn.expand('%:p')) or vim.fn.expand('%:p'))
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.', vim.log.levels.ERROR)
'No cached state found for current file. Use :CP <platform> <contest> [--{lang=<lang>,debug}...] first.',
vim.log.levels.ERROR
)
return false return false
end end
logger.log(
('Restoring from cached state: %s %s %s'):format(
file_state.platform,
file_state.contest_id,
file_state.problem_id
)
)
local setup = require('cp.setup') local setup = require('cp.setup')
if not setup.set_platform(file_state.platform) then setup.set_platform(file_state.platform)
return false
end
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_contest( setup.setup_contest(
file_state.platform, file_state.platform,
file_state.contest_id, file_state.contest_id,
file_state.language, file_state.problem_id,
file_state.problem_id file_state.language
) )
return true return true

View file

@ -7,44 +7,40 @@
---@field peak_mb number ---@field peak_mb number
---@field signal string|nil ---@field signal string|nil
---@class SubstitutableCommand
---@field source string substituted via '{source}'
---@field binary string substitued via '{binary}'
local M = {} local M = {}
local constants = require('cp.constants') local constants = require('cp.constants')
local logger = require('cp.log') local logger = require('cp.log')
local utils = require('cp.utils') local utils = require('cp.utils')
local filetype_to_language = constants.filetype_to_language ---@param cmd_template string[]
---@param substitutions SubstitutableCommand
local function get_language_from_file(source_file, contest_config) ---@return string[] string normalized with substitutions
local ext = vim.fn.fnamemodify(source_file, ':e')
return filetype_to_language[ext] or contest_config.default_language
end
local function substitute_template(cmd_template, substitutions) local function substitute_template(cmd_template, substitutions)
local out = {} local out = {}
for _, a in ipairs(cmd_template) do for _, arg in ipairs(cmd_template) do
local s = a if arg == '{source}' and substitutions.source then
for k, v in pairs(substitutions) do table.insert(out, substitutions.source)
s = s:gsub('{' .. k .. '}', v) elseif arg == '{binary}' and substitutions.binary then
table.insert(out, substitutions.binary)
else
table.insert(out, arg)
end end
table.insert(out, s)
end end
return out return out
end end
function M.build_command(cmd_template, executable, substitutions) function M.build_command(cmd_template, substitutions)
local cmd = substitute_template(cmd_template, substitutions) return substitute_template(cmd_template, substitutions)
if executable then
table.insert(cmd, 1, executable)
end
return cmd
end end
function M.compile(language_config, substitutions) ---@param compile_cmd string[]
if not language_config.compile then ---@param substitutions SubstitutableCommand
return { code = 0, stdout = '' } function M.compile(compile_cmd, substitutions)
end local cmd = substitute_template(compile_cmd, substitutions)
local cmd = substitute_template(language_config.compile, substitutions)
local sh = table.concat(cmd, ' ') .. ' 2>&1' local sh = table.concat(cmd, ' ') .. ' 2>&1'
local t0 = vim.uv.hrtime() local t0 = vim.uv.hrtime()
@ -164,32 +160,20 @@ function M.run(cmd, stdin, timeout_ms, memory_mb)
} }
end end
function M.compile_problem(contest_config, is_debug) function M.compile_problem()
local state = require('cp.state') local state = require('cp.state')
local source_file = state.get_source_file() local config = require('cp.config').get_config()
if not source_file then local platform = state.get_platform() or ''
return { success = false, output = 'No source file found.' } local language = config.platforms[platform].default_language
end local eff = config.runtime.effective[platform][language]
local compile_config = eff and eff.commands and eff.commands.build
local language = get_language_from_file(source_file, contest_config) if not compile_config then
local language_config = contest_config[language]
if not language_config then
return { success = false, output = ('No configuration for language %s.'):format(language) }
end
local binary_file = state.get_binary_file()
local substitutions = { source = source_file, binary = binary_file }
local chosen = (is_debug and language_config.debug) and language_config.debug
or language_config.compile
if not chosen then
return { success = true, output = nil } return { success = true, output = nil }
end end
local saved = language_config.compile local substitutions = { source = state.get_source_file(), binary = state.get_binary_file() }
language_config.compile = chosen local r = M.compile(compile_config, substitutions)
local r = M.compile(language_config, substitutions)
language_config.compile = saved
if r.code ~= 0 then if r.code ~= 0 then
return { success = false, output = r.stdout or 'unknown error' } return { success = false, output = r.stdout or 'unknown error' }

View file

@ -31,8 +31,11 @@
local M = {} local M = {}
local cache = require('cp.cache') local cache = require('cp.cache')
local config = require('cp.config').get_config()
local constants = require('cp.constants') local constants = require('cp.constants')
local execute = require('cp.runner.execute')
local logger = require('cp.log') local logger = require('cp.log')
local state = require('cp.state')
---@type RunPanelState ---@type RunPanelState
local run_panel_state = { local run_panel_state = {
@ -90,42 +93,36 @@ local function create_sentinal_panel_data(test_cases)
return out return out
end end
---@param language_config LanguageConfig ---@param cmd string[]
---@param substitutions table<string, string>
---@return string[] ---@return string[]
local function build_command(language_config, substitutions) local function build_command(cmd, substitutions)
local execute = require('cp.runner.execute') return execute.build_command(cmd, substitutions)
return execute.build_command(language_config.test, language_config.executable, substitutions)
end end
---@param contest_config ContestConfig
---@param cp_config cp.Config
---@param test_case RanTestCase ---@param test_case RanTestCase
---@return { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string, tled: boolean, mled: boolean, rss_mb: number } ---@return { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string, tled: boolean, mled: boolean, rss_mb: number }
local function run_single_test_case(contest_config, cp_config, test_case) local function run_single_test_case(test_case)
local state = require('cp.state')
local exec = require('cp.runner.execute')
local source_file = state.get_source_file() local source_file = state.get_source_file()
local ext = vim.fn.fnamemodify(source_file or '', ':e')
local lang_name = constants.filetype_to_language[ext] or contest_config.default_language
local language_config = contest_config[lang_name]
local binary_file = state.get_binary_file() local binary_file = state.get_binary_file()
local substitutions = { source = source_file, binary = binary_file } local substitutions = { source = source_file, binary = binary_file }
local cmd = build_command(language_config, substitutions) local platform_config = config.platforms[state.get_platform() or '']
local language = platform_config.default_language
local eff = config.runtime.effective[state.get_platform() or ''][language]
local run_template = eff and eff.commands and eff.commands.run or {}
local cmd = build_command(run_template, substitutions)
local stdin_content = (test_case.input or '') .. '\n' local stdin_content = (test_case.input or '') .. '\n'
local timeout_ms = (run_panel_state.constraints and run_panel_state.constraints.timeout_ms) or 0 local timeout_ms = (run_panel_state.constraints and run_panel_state.constraints.timeout_ms) or 0
local memory_mb = run_panel_state.constraints and run_panel_state.constraints.memory_mb or 0 local memory_mb = run_panel_state.constraints and run_panel_state.constraints.memory_mb or 0
local r = exec.run(cmd, stdin_content, timeout_ms, memory_mb) local r = execute.run(cmd, stdin_content, timeout_ms, memory_mb)
local ansi = require('cp.ui.ansi') local ansi = require('cp.ui.ansi')
local out = r.stdout or '' local out = r.stdout or ''
local highlights = {} local highlights = {}
if out ~= '' then if out ~= '' then
if cp_config.run_panel.ansi then if config.ui.run_panel.ansi then
local parsed = ansi.parse_ansi_text(out) local parsed = ansi.parse_ansi_text(out)
out = table.concat(parsed.lines, '\n') out = table.concat(parsed.lines, '\n')
highlights = parsed.highlights highlights = parsed.highlights
@ -134,7 +131,7 @@ local function run_single_test_case(contest_config, cp_config, test_case)
end end
end end
local max_lines = cp_config.run_panel.max_output_lines local max_lines = config.ui.run_panel.max_output_lines
local lines = vim.split(out, '\n') local lines = vim.split(out, '\n')
if #lines > max_lines then if #lines > max_lines then
local trimmed = {} local trimmed = {}
@ -180,9 +177,8 @@ local function run_single_test_case(contest_config, cp_config, test_case)
} }
end end
---@param state table
---@return boolean ---@return boolean
function M.load_test_cases(state) function M.load_test_cases()
local tcs = cache.get_test_cases( local tcs = cache.get_test_cases(
state.get_platform() or '', state.get_platform() or '',
state.get_contest_id() or '', state.get_contest_id() or '',
@ -201,18 +197,16 @@ function M.load_test_cases(state)
return #tcs > 0 return #tcs > 0
end end
---@param contest_config ContestConfig
---@param cp_config cp.Config
---@param index number ---@param index number
---@return boolean ---@return boolean
function M.run_test_case(contest_config, cp_config, index) function M.run_test_case(index)
local tc = run_panel_state.test_cases[index] local tc = run_panel_state.test_cases[index]
if not tc then if not tc then
return false return false
end end
tc.status = 'running' tc.status = 'running'
local r = run_single_test_case(contest_config, cp_config, tc) local r = run_single_test_case(tc)
tc.status = r.status tc.status = r.status
tc.actual = r.actual tc.actual = r.actual
@ -230,13 +224,11 @@ function M.run_test_case(contest_config, cp_config, index)
return true return true
end end
---@param contest_config ContestConfig
---@param cp_config cp.Config
---@return RanTestCase[] ---@return RanTestCase[]
function M.run_all_test_cases(contest_config, cp_config) function M.run_all_test_cases()
local results = {} local results = {}
for i = 1, #run_panel_state.test_cases do for i = 1, #run_panel_state.test_cases do
M.run_test_case(contest_config, cp_config, i) M.run_test_case(i)
results[i] = run_panel_state.test_cases[i] results[i] = run_panel_state.test_cases[i]
end end
return results return results
@ -251,12 +243,11 @@ end
---@return nil ---@return nil
function M.handle_compilation_failure(output) function M.handle_compilation_failure(output)
local ansi = require('cp.ui.ansi') local ansi = require('cp.ui.ansi')
local config = require('cp.config').setup()
local txt local txt
local hl = {} local hl = {}
if config.run_panel.ansi then if config.ui.run_panel.ansi then
local p = ansi.parse_ansi_text(output or '') local p = ansi.parse_ansi_text(output or '')
txt = table.concat(p.lines, '\n') txt = table.concat(p.lines, '\n')
hl = p.highlights hl = p.highlights

View file

@ -5,7 +5,6 @@ local utils = require('cp.utils')
local function syshandle(result) local function syshandle(result)
if result.code ~= 0 then if result.code ~= 0 then
local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error') local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error')
logger.log(msg, vim.log.levels.ERROR)
return { success = false, error = msg } return { success = false, error = msg }
end end
@ -114,7 +113,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
on_exit = function(result) on_exit = function(result)
if not result or not result.success then if not result or not result.success then
logger.log( logger.log(
('Failed to scrape metadata for %s contest %s.'):format(platform, contest_id), ("Failed to scrape metadata for %s contest '%s'."):format(platform, contest_id),
vim.log.levels.ERROR vim.log.levels.ERROR
) )
return return
@ -122,7 +121,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
local data = result.data or {} local data = result.data or {}
if not data.problems or #data.problems == 0 then if not data.problems or #data.problems == 0 then
logger.log( logger.log(
('No problems returned for %s contest %s.'):format(platform, contest_id), ("No problems returned for %s contest '%s'."):format(platform, contest_id),
vim.log.levels.ERROR vim.log.levels.ERROR
) )
return return
@ -161,7 +160,7 @@ function M.scrape_all_tests(platform, contest_id, callback)
end end
if ev.error and ev.problem_id then if ev.error and ev.problem_id then
logger.log( logger.log(
('Failed to load tests for %s/%s: %s'):format(contest_id, ev.problem_id, ev.error), ("Failed to load tests for problem '%s': %s"):format(contest_id, ev.problem_id, ev.error),
vim.log.levels.WARN vim.log.levels.WARN
) )
return return

View file

@ -17,14 +17,7 @@ function M.set_platform(platform)
) )
return false return false
end end
state.set_platform(platform)
if state.get_platform() == platform then
logger.log(('platform already set to %s'):format(platform))
else
state.set_platform(platform)
logger.log(('platform set to %s'):format(platform))
end
return true return true
end end
@ -45,15 +38,9 @@ end
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@param language string|nil
---@param problem_id string|nil ---@param problem_id string|nil
function M.setup_contest(platform, contest_id, language, problem_id) ---@param language? string|nil
local config = config_module.get_config() function M.setup_contest(platform, contest_id, problem_id, language)
if not vim.tbl_contains(config.scrapers, platform) then
logger.log(('Scraping disabled for %s.'):format(platform), vim.log.levels.WARN)
return
end
state.set_contest_id(contest_id) state.set_contest_id(contest_id)
cache.load() cache.load()
@ -106,10 +93,7 @@ end
function M.setup_problem(problem_id, language) function M.setup_problem(problem_id, language)
local platform = state.get_platform() local platform = state.get_platform()
if not platform then if not platform then
logger.log( logger.log('No platform set.', vim.log.levels.ERROR)
'No platform set. run :CP <platform> <contest> [--{lang=<lang>,debug}]',
vim.log.levels.ERROR
)
return return
end end
@ -120,25 +104,18 @@ function M.setup_problem(problem_id, language)
vim.schedule(function() vim.schedule(function()
vim.cmd.only({ mods = { silent = true } }) vim.cmd.only({ mods = { silent = true } })
local source_file = state.get_source_file(language) local lang = language or config.platforms[platform].default_language
if not source_file then local source_file = state.get_source_file(lang)
return
end
vim.cmd.e(source_file) vim.cmd.e(source_file)
local source_buf = vim.api.nvim_get_current_buf() local source_buf = vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then
local has_luasnip, luasnip = pcall(require, 'luasnip') local ok, luasnip = pcall(require, 'luasnip')
if has_luasnip then if ok then
local filetype = vim.api.nvim_get_option_value('filetype', { buf = source_buf }) local trigger = ('cp.nvim/%s.%s'):format(platform, lang)
local language_name = constants.filetype_to_language[filetype] vim.api.nvim_buf_set_lines(0, 0, -1, false, { trigger })
local canonical_language = constants.canonical_filetypes[language_name] or language_name vim.api.nvim_win_set_cursor(0, { 1, #trigger })
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.cmd.startinsert({ bang = true })
vim.schedule(function() vim.schedule(function()
if luasnip.expandable() then if luasnip.expandable() then
luasnip.expand() luasnip.expand()
@ -159,13 +136,13 @@ function M.setup_problem(problem_id, language)
vim.fn.expand('%:p'), vim.fn.expand('%:p'),
platform, platform,
state.get_contest_id() or '', state.get_contest_id() or '',
state.get_problem_id(), state.get_problem_id() or '',
language lang
) )
end) end)
end end
function M.navigate_problem(direction, language) function M.navigate_problem(direction)
if direction == 0 then if direction == 0 then
return return
end end
@ -176,10 +153,7 @@ function M.navigate_problem(direction, language)
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.', vim.log.levels.ERROR)
'No platform configured. Use :CP <platform> <contest> [--{lang=<lang>,debug}] first.',
vim.log.levels.ERROR
)
return return
end end
@ -198,14 +172,13 @@ function M.navigate_problem(direction, language)
local problems = contest_data.problems local problems = contest_data.problems
local index = contest_data.index_map[current_problem_id] local index = contest_data.index_map[current_problem_id]
local new_index = index + direction local new_index = index + direction
if new_index < 1 or new_index > #problems then if new_index < 1 or new_index > #problems then
return return
end end
require('cp.ui.panel').disable() require('cp.ui.panel').disable()
M.setup_contest(platform, contest_id, language, problems[new_index].id) M.setup_contest(platform, contest_id, problems[new_index].id)
end end
return M return M

View file

@ -72,18 +72,18 @@ function M.get_source_file(language)
end end
local config = require('cp.config').get_config() local config = require('cp.config').get_config()
local contest_config = config.contests[M.get_platform()] local plat = M.get_platform()
if not contest_config then local platform_cfg = config.platforms[plat]
if not platform_cfg then
return nil return nil
end end
local target_language = language or platform_cfg.default_language
local target_language = language or contest_config.default_language local eff = config.runtime.effective[plat] and config.runtime.effective[plat][target_language]
local language_config = contest_config[target_language] or nil
if not language_config or not language_config.extension then if not eff or not eff.extension then
return nil return nil
end end
return base_name .. '.' .. eff.extension
return base_name .. '.' .. language_config.extension
end end
function M.get_binary_file() function M.get_binary_file()

View file

@ -12,13 +12,81 @@ local M = {}
local logger = require('cp.log') local logger = require('cp.log')
---@param raw_output string|table local dyn_hl_cache = {}
---@param s string|table
---@return string ---@return string
function M.bytes_to_string(raw_output) function M.bytes_to_string(s)
if type(raw_output) == 'string' then if type(s) == 'string' then
return raw_output return s
end end
return table.concat(vim.tbl_map(string.char, raw_output)) return table.concat(vim.tbl_map(string.char, s))
end
---@param fg table|nil
---@param bold boolean
---@param italic boolean
---@return string|nil
local function ensure_hl_for(fg, bold, italic)
if not fg and not bold and not italic then
return nil
end
local base = 'CpAnsi'
local suffix
local opts = {}
if fg and fg.kind == 'named' then
suffix = fg.name
elseif fg and fg.kind == 'xterm' then
suffix = ('X%03d'):format(fg.idx)
local function xterm_to_hex(n)
if n >= 0 and n <= 15 then
local key = 'terminal_color_' .. n
return vim.g[key]
end
if n >= 16 and n <= 231 then
local c = n - 16
local r = math.floor(c / 36) % 6
local g = math.floor(c / 6) % 6
local b = c % 6
local function level(x)
return x == 0 and 0 or 55 + 40 * x
end
return ('#%02x%02x%02x'):format(level(r), level(g), level(b))
end
local l = 8 + 10 * (n - 232)
return ('#%02x%02x%02x'):format(l, l, l)
end
opts.fg = xterm_to_hex(fg.idx) or 'NONE'
elseif fg and fg.kind == 'rgb' then
suffix = ('Rgb%02x%02x%02x'):format(fg.r, fg.g, fg.b)
opts.fg = ('#%02x%02x%02x'):format(fg.r, fg.g, fg.b)
end
local parts = { base }
if bold then
table.insert(parts, 'Bold')
end
if italic then
table.insert(parts, 'Italic')
end
if suffix then
table.insert(parts, suffix)
end
local name = table.concat(parts)
if not dyn_hl_cache[name] then
if bold then
opts.bold = true
end
if italic then
opts.italic = true
end
vim.api.nvim_set_hl(0, name, opts)
dyn_hl_cache[name] = true
end
return name
end end
---@param text string ---@param text string
@ -38,22 +106,7 @@ function M.parse_ansi_text(text)
} }
local function get_highlight_group() local function get_highlight_group()
if not ansi_state.bold and not ansi_state.italic and not ansi_state.foreground then return ensure_hl_for(ansi_state.foreground, ansi_state.bold, ansi_state.italic)
return nil
end
local parts = { 'CpAnsi' }
if ansi_state.bold then
table.insert(parts, 'Bold')
end
if ansi_state.italic then
table.insert(parts, 'Italic')
end
if ansi_state.foreground then
table.insert(parts, ansi_state.foreground)
end
return table.concat(parts)
end end
local function apply_highlight(start_line, start_col, end_col) local function apply_highlight(start_line, start_col, end_col)
@ -137,6 +190,7 @@ end
---@param ansi_state table ---@param ansi_state table
---@param code_string string ---@param code_string string
---@return nil
function M.update_ansi_state(ansi_state, code_string) function M.update_ansi_state(ansi_state, code_string)
if code_string == '' or code_string == '0' then if code_string == '' or code_string == '0' then
ansi_state.bold = false ansi_state.bold = false
@ -146,40 +200,60 @@ function M.update_ansi_state(ansi_state, code_string)
end end
local codes = vim.split(code_string, ';', { plain = true }) local codes = vim.split(code_string, ';', { plain = true })
local idx = 1
while idx <= #codes do
local num = tonumber(codes[idx])
for _, code in ipairs(codes) do if num == 1 then
local num = tonumber(code) ansi_state.bold = true
if num then elseif num == 3 then
if num == 1 then ansi_state.italic = true
ansi_state.bold = true elseif num == 22 then
elseif num == 3 then ansi_state.bold = false
ansi_state.italic = true elseif num == 23 then
elseif num == 22 then ansi_state.italic = false
ansi_state.bold = false elseif num and num >= 30 and num <= 37 then
elseif num == 23 then local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.italic = false ansi_state.foreground = { kind = 'named', name = colors[num - 29] }
elseif num >= 30 and num <= 37 then elseif num and num >= 90 and num <= 97 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' } local colors = {
ansi_state.foreground = colors[num - 29] 'BrightBlack',
elseif num >= 90 and num <= 97 then 'BrightRed',
local colors = { 'BrightGreen',
'BrightBlack', 'BrightYellow',
'BrightRed', 'BrightBlue',
'BrightGreen', 'BrightMagenta',
'BrightYellow', 'BrightCyan',
'BrightBlue', 'BrightWhite',
'BrightMagenta', }
'BrightCyan', ansi_state.foreground = { kind = 'named', name = colors[num - 89] }
'BrightWhite', elseif num == 39 then
} ansi_state.foreground = nil
ansi_state.foreground = colors[num - 89] elseif num == 38 or num == 48 then
elseif num == 39 then local is_fg = (num == 38)
ansi_state.foreground = nil local mode = tonumber(codes[idx + 1] or '')
if mode == 5 and codes[idx + 2] then
local pal = tonumber(codes[idx + 2]) or 0
if is_fg then
ansi_state.foreground = { kind = 'xterm', idx = pal }
end
idx = idx + 2
elseif mode == 2 and codes[idx + 2] and codes[idx + 3] and codes[idx + 4] then
local r = tonumber(codes[idx + 2]) or 0
local g = tonumber(codes[idx + 3]) or 0
local b = tonumber(codes[idx + 4]) or 0
if is_fg then
ansi_state.foreground = { kind = 'rgb', r = r, g = g, b = b }
end
idx = idx + 4
end end
end end
idx = idx + 1
end end
end end
---@return nil
function M.setup_highlight_groups() function M.setup_highlight_groups()
local color_map = { local color_map = {
Black = vim.g.terminal_color_0, Black = vim.g.terminal_color_0,
@ -202,7 +276,7 @@ function M.setup_highlight_groups()
if vim.tbl_count(color_map) < 16 then if vim.tbl_count(color_map) < 16 then
logger.log( logger.log(
'ansi terminal colors (vim.g.terminal_color_*) not configured. ANSI colors will not display properly. ', 'ansi terminal colors (vim.g.terminal_color_*) not configured. ANSI colors will not display properly.',
vim.log.levels.WARN vim.log.levels.WARN
) )
end end
@ -218,7 +292,6 @@ function M.setup_highlight_groups()
for color_name, terminal_color in pairs(color_map) do for color_name, terminal_color in pairs(color_map) do
local parts = { 'CpAnsi' } local parts = { 'CpAnsi' }
local opts = { fg = terminal_color or 'NONE' } local opts = { fg = terminal_color or 'NONE' }
if combo.bold then if combo.bold then
table.insert(parts, 'Bold') table.insert(parts, 'Bold')
opts.bold = true opts.bold = true
@ -228,7 +301,6 @@ function M.setup_highlight_groups()
opts.italic = true opts.italic = true
end end
table.insert(parts, color_name) table.insert(parts, color_name)
local hl_name = table.concat(parts) local hl_name = table.concat(parts)
vim.api.nvim_set_hl(0, hl_name, opts) vim.api.nvim_set_hl(0, hl_name, opts)
end end
@ -239,4 +311,30 @@ function M.setup_highlight_groups()
vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true }) vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true })
end end
---@param text string
---@return string[]
function M.debug_ansi_tokens(text)
local out = {}
local i = 1
while true do
local s, e, codes, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i)
if not s then
break
end
table.insert(out, ('ESC[%s%s'):format(codes, cmd))
i = e + 1
end
return out
end
---@param s string
---@return string
function M.hex_dump(s)
local t = {}
for i = 1, #s do
t[#t + 1] = ('%02X'):format(s:byte(i))
end
return table.concat(t, ' ')
end
return M return M

View file

@ -185,7 +185,7 @@ function M.update_diff_panes(
actual_content = actual_content actual_content = actual_content
end end
local desired_mode = is_compilation_failure and 'single' or config.run_panel.diff_mode local desired_mode = is_compilation_failure and 'single' or config.ui.run_panel.diff_mode
local highlight = require('cp.ui.highlight') local highlight = require('cp.ui.highlight')
local diff_namespace = highlight.create_namespace() local diff_namespace = highlight.create_namespace()
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights') local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')

View file

@ -90,10 +90,8 @@ function M.toggle_interactive()
vim.cmd(('mksession! %s'):format(state.saved_interactive_session)) vim.cmd(('mksession! %s'):format(state.saved_interactive_session))
vim.cmd('silent only') vim.cmd('silent only')
local config = config_module.get_config()
local contest_config = config.contests[state.get_platform() or '']
local execute = require('cp.runner.execute') local execute = require('cp.runner.execute')
local compile_result = execute.compile_problem(contest_config, false) local compile_result = execute.compile_problem()
if not compile_result.success then if not compile_result.success then
require('cp.runner.run').handle_compilation_failure(compile_result.output) require('cp.runner.run').handle_compilation_failure(compile_result.output)
return return
@ -120,7 +118,8 @@ function M.toggle_interactive()
state.set_active_panel('interactive') state.set_active_panel('interactive')
end end
function M.toggle_run_panel(is_debug) ---@param debug? boolean
function M.toggle_run_panel(debug)
if state.get_active_panel() == 'run' then if state.get_active_panel() == 'run' then
if current_diff_layout then if current_diff_layout then
current_diff_layout.cleanup() current_diff_layout.cleanup()
@ -191,7 +190,7 @@ function M.toggle_run_panel(is_debug)
if config.hooks and config.hooks.before_run then if config.hooks and config.hooks.before_run then
config.hooks.before_run(state) config.hooks.before_run(state)
end end
if is_debug and config.hooks and config.hooks.before_debug then if debug and config.hooks and config.hooks.before_debug then
config.hooks.before_debug(state) config.hooks.before_debug(state)
end end
@ -199,7 +198,7 @@ function M.toggle_run_panel(is_debug)
local input_file = state.get_input_file() local input_file = state.get_input_file()
logger.log(('run panel: checking test cases for %s'):format(input_file or 'none')) logger.log(('run panel: checking test cases for %s'):format(input_file or 'none'))
if not run.load_test_cases(state) then if not run.load_test_cases() then
logger.log('no test cases found', vim.log.levels.WARN) logger.log('no test cases found', vim.log.levels.WARN)
return return
end end
@ -264,37 +263,29 @@ function M.toggle_run_panel(is_debug)
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
if config.run_panel.diff_mode == mode then if config.ui.run_panel.diff_mode == mode then
current_idx = i current_idx = i
break break
end end
end end
current_idx = current_idx or 1 current_idx = current_idx or 1
config.run_panel.diff_mode = modes[(current_idx % #modes) + 1] config.ui.run_panel.diff_mode = modes[(current_idx % #modes) + 1]
refresh_run_panel() refresh_run_panel()
end, { buffer = buf, silent = true }) end, { buffer = buf, silent = true })
vim.keymap.set('n', config.run_panel.next_test_key, function() vim.keymap.set('n', '<c-n>', function()
navigate_test_case(1) navigate_test_case(1)
end, { buffer = buf, silent = true }) end, { buffer = buf, silent = true })
vim.keymap.set('n', config.run_panel.prev_test_key, function() vim.keymap.set('n', '<c-p>', function()
navigate_test_case(-1) navigate_test_case(-1)
end, { buffer = buf, silent = true }) end, { buffer = buf, silent = true })
end end
vim.keymap.set('n', config.run_panel.next_test_key, function()
navigate_test_case(1)
end, { buffer = test_buffers.tab_buf, silent = true })
vim.keymap.set('n', config.run_panel.prev_test_key, function()
navigate_test_case(-1)
end, { buffer = test_buffers.tab_buf, silent = true })
setup_keybindings_for_buffer(test_buffers.tab_buf) setup_keybindings_for_buffer(test_buffers.tab_buf)
local execute = require('cp.runner.execute') local execute = require('cp.runner.execute')
local contest_config = config.contests[state.get_platform() or ''] local compile_result = execute.compile_problem()
local compile_result = execute.compile_problem(contest_config, is_debug)
if compile_result.success then if compile_result.success then
run.run_all_test_cases(contest_config, config) run.run_all_test_cases()
else else
run.handle_compilation_failure(compile_result.output) run.handle_compilation_failure(compile_result.output)
end end
@ -302,7 +293,7 @@ function M.toggle_run_panel(is_debug)
refresh_run_panel() refresh_run_panel()
vim.schedule(function() vim.schedule(function()
if config.run_panel.ansi then if config.ui.run_panel.ansi then
local ansi = require('cp.ui.ansi') local ansi = require('cp.ui.ansi')
ansi.setup_highlight_groups() ansi.setup_highlight_groups()
end end

View file

@ -265,14 +265,26 @@ class AtcoderScraper(BaseScraper):
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
async def impl(cid: str) -> MetadataResult: async def impl(cid: str) -> MetadataResult:
rows = await asyncio.to_thread(_scrape_tasks_sync, cid) try:
rows = await asyncio.to_thread(_scrape_tasks_sync, cid)
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
return self._create_metadata_error(
f"No problems found for contest {cid}", cid
)
raise
problems = _to_problem_summaries(rows) problems = _to_problem_summaries(rows)
if not problems: if not problems:
return self._create_metadata_error( return self._create_metadata_error(
f"No problems found for contest {cid}", cid f"No problems found for contest {cid}", cid
) )
return MetadataResult( return MetadataResult(
success=True, error="", contest_id=cid, problems=problems success=True,
error="",
contest_id=cid,
problems=problems,
) )
return await self._safe_execute("metadata", impl, contest_id) return await self._safe_execute("metadata", impl, contest_id)