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
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

View file

@ -32,16 +32,13 @@ COMMANDS *cp-commands*
Automatically detects platform, contest, problem,
and language from cached state. Use this after
switching files to restore your CP environment.
Requires previous setup with full :CP command.
Setup Commands ~
:CP {platform} {contest_id} {problem_id} [--lang={language}]
Full setup: set platform, load contest metadata,
and set up specific problem. Scrapes test cases
and creates source file.
:CP {platform} {contest_id}
Full setup: set platform and load contest metadata.
Scrapes test cases and creates source file.
Example: >
:CP codeforces 1933 a
:CP codeforces 1933 a --lang=python
:CP codeforces 1933
<
:CP {platform} {contest_id}
Contest setup: set platform, load contest metadata,
@ -52,10 +49,6 @@ COMMANDS *cp-commands*
Example: >
:CP atcoder abc324
:CP codeforces 1951
<
:CP {platform} Platform setup: set platform only.
Example: >
:CP cses
<
Action Commands ~
:CP run [--debug] Toggle run panel for individual test case
@ -65,12 +58,13 @@ COMMANDS *cp-commands*
Requires contest setup first.
:CP pick Launch configured picker for interactive
platform/contest/problem selection.
platform/contest selection.
Navigation Commands ~
:CP next Navigate to next problem in current contest.
Stops at last problem (no wrapping).
Navigation Commands ~
:CP prev Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
@ -79,7 +73,7 @@ COMMANDS *cp-commands*
:CP cache clear [contest]
Clear the cache data (contest list, problem
data, file states) for the specified contest,
or all contests if none specified
or all contests if none specified.
:CP cache read
View the cache in a pretty-printed lua buffer.
@ -89,22 +83,15 @@ Command Flags ~
*cp-flags*
Flags can be used with setup and action commands:
--lang={language} Specify language for the problem.
--lang {language} Alternative syntax for language specification.
Supported languages: cpp, python
Example: >
:CP atcoder abc324 a --lang=python
:CP b --lang cpp
<
--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.
--debug Use the debug command template.
For compiled languages, this selects
`commands.debug` (a debug *build*) instead of
`commands.build`. For interpreted languages,
this selects `commands.debug` in place of
`commands.run`.
Example: >
:CP run --debug
<
Note: Debug compilation may be slower but provides
better error reporting for runtime issues.
Template Variables ~
*cp-template-vars*
@ -116,7 +103,7 @@ Template Variables ~
• {problem} Problem identifier (e.g. "a", "b")
Example template: >
compile = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
< Would expand to: >
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
{
'barrett-ruth/cp.nvim',
cmd = 'CP',
opts = {
contests = {
default = {
cpp = {
compile = { 'g++', '{source}', '-o', '{binary}',
'-std=c++17', '-fdiagnostic-colors=always' },
test = { '{binary}' },
debug = { 'g++', '{source}', '-o', '{binary}',
'-std=c++17', '-g',
'-fdiagnostic-colors=always'
'-fsanitize=address,undefined' },
},
python = {
test = { 'python3', '{source}' },
},
},
'barrett-ruth/cp.nvim',
cmd = 'CP',
build = 'uv sync',
opts = {
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
},
snippets = {},
debug = false,
scrapers = { 'atcoder', 'codeforces', 'cses' },
run_panel = {
ansi = true,
diff_mode = 'vim',
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
max_output_lines = 50,
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
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
17. Python is also configured with the system executable python as a non-default option. Consult lua/cp/config.lua for
more information.
By default, C++ (g++ with ISO C++17) and Python are preconfigured under
`languages`. Platforms select which languages are enabled and which one is
the default; per-platform overrides can tweak `extension` or `commands`.
For example, to run CodeForces contests with Python, only the following config
is required:
For example, to run CodeForces contests with Python by default:
>lua
{
contests = {
codeforces = {
default_langauge = 'python'
}
}
platforms = {
codeforces = {
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*
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.
{snippets} (table[]) LuaSnip snippet definitions.
{debug} (boolean, default: false) Show info messages
during operation.
{scrapers} (table<string>) List of enabled scrapers.
Default: all scrapers enabled
{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)
{debug} (boolean, default: false) Show info messages.
{scrapers} (string[]) Supported platform ids.
{filename} (function, optional)
function(contest, contest_id, problem_id, config, language): string
Should return full filename with extension.
(default: concatenates contest_id and problem_id, lowercased)
{ui} (|CpUI|) UI settings: run panel, diff backend, picker.
*cp.ContestConfig*
Fields: ~
{cpp} (|LanguageConfig|) C++ language configuration.
{python} (|LanguageConfig|) Python language configuration.
{default_language} (string, default: "cpp") Default language when
--lang not specified.
*cp.PlatformConfig*
Replaced by |CpPlatform|. Platforms no longer inline language tables.
*cp.LanguageConfig*
*CpPlatform*
Fields: ~
{compile} (string[], optional) Compile command template with
{source}, {binary} placeholders.
{test} (string[]) Test execution command template.
{debug} (string[], optional) Debug compile command template.
{extension} (string) File extension (e.g. "cc", "py").
{executable} (string, optional) Executable name for interpreted languages.
{enabled_languages} (string[]) Language ids enabled on this platform.
{default_language} (string) One of {enabled_languages}.
{overrides} (table<string,|CpPlatformOverrides|>, optional)
Per-language overrides of {extension} and/or {commands}.
*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*
Fields: ~
{ansi} (boolean, default: true) Enable ANSI color parsing and
highlighting. When true, compiler output and test results
display with colored syntax highlighting. When false,
ANSI escape codes are stripped for plain text display.
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.
{ansi} (boolean, default: true) Enable ANSI color parsing
and highlighting.
{diff_mode} (string, default: "none") Diff backend: "none",
"vim", or "git".
{max_output_lines} (number, default: 50) Maximum lines of test output.
*cp.DiffConfig*
@ -251,10 +289,9 @@ is required:
Fields: ~
{before_run} (function, optional) Called before test panel opens.
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)
{setup_code} (function, optional) Called after source file is opened.
Good for configuring buffer settings.
function(state: cp.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
Usage examples: >
:CP atcoder abc324 a " Full setup: problem A from contest ABC324
: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 ~
*cp-codeforces*
URL format: https://codeforces.com/contest/1234/problem/A
Usage examples: >
:CP codeforces 1934 a " Full setup: problem A from contest 1934
: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 ~
*cp-cses*
URL format: https://cses.fi/problemset/task/1068
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
<
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*
<c-n> Navigate to next test case (configurable via
run_panel.next_test_key)
<c-p> Navigate to previous test case (configurable via
run_panel.prev_test_key)
<c-n> Navigate to next test case
<c-p> Navigate to previous test case
t Cycle through diff modes: none → git → vim
q Exit run panel 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' },
})
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
---@param platform string
@ -105,7 +107,7 @@ function M.set_contest_data(platform, contest_id, problems)
local out = {
name = prev.name,
display_name = prev.display_name,
problems = vim.deepcopy(problems),
problems = problems,
index_map = {},
}
for i, p in ipairs(out.problems) do
@ -207,32 +209,27 @@ function M.get_constraints(platform, contest_id, problem_id)
end
---@param file_path string
---@return FileState?
---@return FileState|nil
function M.get_file_state(file_path)
if not cache_data.file_states then
return nil
end
M.load()
cache_data.file_states = cache_data.file_states or {}
return cache_data.file_states[file_path]
end
---@param file_path string
---@param path string
---@param platform string
---@param contest_id string
---@param problem_id? string
---@param language? string
function M.set_file_state(file_path, platform, contest_id, problem_id, language)
if not cache_data.file_states then
cache_data.file_states = {}
end
cache_data.file_states[file_path] = {
---@param problem_id string
---@param language string|nil
function M.set_file_state(path, platform, contest_id, problem_id, language)
M.load()
cache_data.file_states = cache_data.file_states or {}
cache_data.file_states[path] = {
platform = platform,
contest_id = contest_id,
problem_id = problem_id,
language = language,
}
M.save()
end
@ -255,7 +252,7 @@ end
function M.set_contest_summaries(platform, contests)
cache_data[platform] = cache_data[platform] or {}
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].name = contest.name
end
@ -284,4 +281,6 @@ function M.get_data_pretty()
return vim.inspect(cache_data)
end
M._cache = cache_data
return M

View file

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

View file

@ -8,9 +8,9 @@ local logger = require('cp.log')
function M.handle_pick_action()
local config = config_module.get_config()
if not config.picker then
if not (config.ui and config.ui.picker) then
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
)
return
@ -18,7 +18,8 @@ function M.handle_pick_action()
local picker
if config.picker == 'telescope' then
local picker_name = config.ui.picker
if picker_name == 'telescope' then
local ok = pcall(require, 'telescope')
if not ok then
logger.log(
@ -34,7 +35,7 @@ function M.handle_pick_action()
end
picker = telescope_picker
elseif config.picker == 'fzf-lua' then
elseif picker_name == 'fzf-lua' then
local ok, _ = pcall(require, 'fzf-lua')
if not ok then
logger.log(

View file

@ -1,262 +1,279 @@
---@class LanguageConfig
---@field compile? string[] Compile command template
---@field test string[] Test execution command template
---@field debug? string[] Debug command template
---@field executable? string Executable name
---@field version? number Language version
---@field extension? string File extension
-- lua/cp/config.lua
---@class CpLangCommands
---@field build? string[]
---@field run? string[]
---@field debug? string[]
---@class ContestConfig
---@field cpp LanguageConfig
---@field python LanguageConfig
---@field default_language? string
---@class CpLanguage
---@field extension string
---@field commands CpLangCommands
---@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
---@field before_run? fun(state: cp.State)
---@field before_debug? fun(state: cp.State)
---@field setup_code? fun(state: cp.State)
---@class RunPanelConfig
---@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
---@class CpUI
---@field run_panel RunPanelConfig
---@field diff DiffConfig
---@field picker string|nil
---@class cp.PartialConfig
---@field contests? table<string, ContestConfig>
---@field snippets? any[]
---@field hooks? Hooks
---@field debug? boolean
---@field scrapers? string[]
---@class cp.Config
---@field languages table<string, CpLanguage>
---@field platforms table<string, CpPlatform>
---@field hooks Hooks
---@field snippets any[]
---@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 diff? DiffConfig
---@field picker? string|nil
---@field ui CpUI
---@field runtime { effective: table<string, table<string, CpLanguage>> } -- computed
---@class cp.PartialConfig: cp.Config
local M = {}
local constants = require('cp.constants')
local utils = require('cp.utils')
local default_contest_config = {
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',
}
-- defaults per the new single schema
---@type cp.Config
M.defaults = {
contests = {
codeforces = default_contest_config,
atcoder = default_contest_config,
cses = default_contest_config,
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
run = { '{binary}' },
debug = {
'g++',
'-std=c++17',
'-fsanitize=address,undefined',
'{source}',
'-o',
'{binary}',
},
},
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
},
},
platforms = {
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
-- example override, safe to keep empty initially
},
},
atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
snippets = {},
hooks = {
before_run = nil,
before_debug = nil,
setup_code = nil,
},
hooks = { before_run = nil, before_debug = nil, setup_code = nil },
debug = false,
scrapers = constants.PLATFORMS,
filename = nil,
run_panel = {
ansi = true,
diff_mode = 'none',
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' },
ui = {
run_panel = { ansi = true, diff_mode = 'none', 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
---@return cp.Config
function M.setup(user_config)
vim.validate({
user_config = { user_config, { 'table', 'nil' }, true },
})
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({ user_config = { user_config, { 'table', 'nil' }, true } })
local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
vim.validate({
before_run = {
config.hooks.before_run,
{ 'function', 'nil' },
true,
},
before_debug = {
config.hooks.before_debug,
{ 'function', 'nil' },
true,
},
setup_code = {
config.hooks.setup_code,
{ 'function', 'nil' },
true,
},
hooks = { cfg.hooks, { 'table' } },
ui = { cfg.ui, { 'table' } },
})
vim.validate({
ansi = {
config.run_panel.ansi,
'boolean',
'ansi color parsing must be enabled xor disabled',
},
before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
})
vim.validate({
ansi = { cfg.ui.run_panel.ansi, 'boolean' },
diff_mode = {
config.run_panel.diff_mode,
function(value)
return vim.tbl_contains({ 'none', 'vim', 'git' }, value)
cfg.ui.run_panel.diff_mode,
function(v)
return vim.tbl_contains({ 'none', 'vim', 'git' }, v)
end,
"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 = {
config.run_panel.max_output_lines,
function(value)
return type(value) == 'number' and value > 0 and value == math.floor(value)
cfg.ui.run_panel.max_output_lines,
function(v)
return type(v) == 'number' and v > 0 and v == math.floor(v)
end,
'max_output_lines must be a positive integer',
'positive integer',
},
git = { cfg.ui.diff.git, { 'table' } },
})
vim.validate({
git = { config.diff.git, { 'table', 'nil' }, true },
})
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
for id, lang in pairs(cfg.languages) do
validate_language(id, lang)
end
build_runtime(cfg)
local ok, err = utils.check_required_runtime()
if not ok then
error('[cp.nvim] ' .. err)
end
return 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
---@param contest_id string
@ -265,25 +282,9 @@ end
local function default_filename(contest_id, problem_id)
if problem_id then
return (contest_id .. problem_id):lower()
else
return contest_id:lower()
end
return contest_id:lower()
end
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

View file

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

View file

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

View file

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

View file

@ -4,44 +4,26 @@ local cache = require('cp.cache')
local logger = require('cp.log')
local state = require('cp.state')
---@return boolean
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()
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)
if not file_state then
logger.log(
'No cached state found for current file. Use :CP <platform> <contest> [--{lang=<lang>,debug}...] first.',
vim.log.levels.ERROR
)
logger.log('No cached state found for current file.', vim.log.levels.ERROR)
return false
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')
if not setup.set_platform(file_state.platform) then
return false
end
setup.set_platform(file_state.platform)
state.set_contest_id(file_state.contest_id)
state.set_problem_id(file_state.problem_id)
setup.setup_contest(
file_state.platform,
file_state.contest_id,
file_state.language,
file_state.problem_id
file_state.problem_id,
file_state.language
)
return true

View file

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

View file

@ -31,8 +31,11 @@
local M = {}
local cache = require('cp.cache')
local config = require('cp.config').get_config()
local constants = require('cp.constants')
local execute = require('cp.runner.execute')
local logger = require('cp.log')
local state = require('cp.state')
---@type RunPanelState
local run_panel_state = {
@ -90,42 +93,36 @@ local function create_sentinal_panel_data(test_cases)
return out
end
---@param language_config LanguageConfig
---@param substitutions table<string, string>
---@param cmd string[]
---@return string[]
local function build_command(language_config, substitutions)
local execute = require('cp.runner.execute')
return execute.build_command(language_config.test, language_config.executable, substitutions)
local function build_command(cmd, substitutions)
return execute.build_command(cmd, substitutions)
end
---@param contest_config ContestConfig
---@param cp_config cp.Config
---@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 }
local function run_single_test_case(contest_config, cp_config, test_case)
local state = require('cp.state')
local exec = require('cp.runner.execute')
local function run_single_test_case(test_case)
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 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 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 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 out = r.stdout or ''
local highlights = {}
if out ~= '' then
if cp_config.run_panel.ansi then
if config.ui.run_panel.ansi then
local parsed = ansi.parse_ansi_text(out)
out = table.concat(parsed.lines, '\n')
highlights = parsed.highlights
@ -134,7 +131,7 @@ local function run_single_test_case(contest_config, cp_config, test_case)
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')
if #lines > max_lines then
local trimmed = {}
@ -180,9 +177,8 @@ local function run_single_test_case(contest_config, cp_config, test_case)
}
end
---@param state table
---@return boolean
function M.load_test_cases(state)
function M.load_test_cases()
local tcs = cache.get_test_cases(
state.get_platform() or '',
state.get_contest_id() or '',
@ -201,18 +197,16 @@ function M.load_test_cases(state)
return #tcs > 0
end
---@param contest_config ContestConfig
---@param cp_config cp.Config
---@param index number
---@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]
if not tc then
return false
end
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.actual = r.actual
@ -230,13 +224,11 @@ function M.run_test_case(contest_config, cp_config, index)
return true
end
---@param contest_config ContestConfig
---@param cp_config cp.Config
---@return RanTestCase[]
function M.run_all_test_cases(contest_config, cp_config)
function M.run_all_test_cases()
local results = {}
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]
end
return results
@ -251,12 +243,11 @@ end
---@return nil
function M.handle_compilation_failure(output)
local ansi = require('cp.ui.ansi')
local config = require('cp.config').setup()
local txt
local hl = {}
if config.run_panel.ansi then
if config.ui.run_panel.ansi then
local p = ansi.parse_ansi_text(output or '')
txt = table.concat(p.lines, '\n')
hl = p.highlights

View file

@ -5,7 +5,6 @@ local utils = require('cp.utils')
local function syshandle(result)
if result.code ~= 0 then
local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error')
logger.log(msg, vim.log.levels.ERROR)
return { success = false, error = msg }
end
@ -114,7 +113,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
on_exit = function(result)
if not result or not result.success then
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
)
return
@ -122,7 +121,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
local data = result.data or {}
if not data.problems or #data.problems == 0 then
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
)
return
@ -161,7 +160,7 @@ function M.scrape_all_tests(platform, contest_id, callback)
end
if ev.error and ev.problem_id then
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
)
return

View file

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

View file

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

View file

@ -12,13 +12,81 @@ local M = {}
local logger = require('cp.log')
---@param raw_output string|table
local dyn_hl_cache = {}
---@param s string|table
---@return string
function M.bytes_to_string(raw_output)
if type(raw_output) == 'string' then
return raw_output
function M.bytes_to_string(s)
if type(s) == 'string' then
return s
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
---@param text string
@ -38,22 +106,7 @@ function M.parse_ansi_text(text)
}
local function get_highlight_group()
if not ansi_state.bold and not ansi_state.italic and not ansi_state.foreground then
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)
return ensure_hl_for(ansi_state.foreground, ansi_state.bold, ansi_state.italic)
end
local function apply_highlight(start_line, start_col, end_col)
@ -137,6 +190,7 @@ end
---@param ansi_state table
---@param code_string string
---@return nil
function M.update_ansi_state(ansi_state, code_string)
if code_string == '' or code_string == '0' then
ansi_state.bold = false
@ -146,40 +200,60 @@ function M.update_ansi_state(ansi_state, code_string)
end
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
local num = tonumber(code)
if num then
if num == 1 then
ansi_state.bold = true
elseif num == 3 then
ansi_state.italic = true
elseif num == 22 then
ansi_state.bold = false
elseif num == 23 then
ansi_state.italic = false
elseif num >= 30 and num <= 37 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.foreground = colors[num - 29]
elseif num >= 90 and num <= 97 then
local colors = {
'BrightBlack',
'BrightRed',
'BrightGreen',
'BrightYellow',
'BrightBlue',
'BrightMagenta',
'BrightCyan',
'BrightWhite',
}
ansi_state.foreground = colors[num - 89]
elseif num == 39 then
ansi_state.foreground = nil
if num == 1 then
ansi_state.bold = true
elseif num == 3 then
ansi_state.italic = true
elseif num == 22 then
ansi_state.bold = false
elseif num == 23 then
ansi_state.italic = false
elseif num and num >= 30 and num <= 37 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.foreground = { kind = 'named', name = colors[num - 29] }
elseif num and num >= 90 and num <= 97 then
local colors = {
'BrightBlack',
'BrightRed',
'BrightGreen',
'BrightYellow',
'BrightBlue',
'BrightMagenta',
'BrightCyan',
'BrightWhite',
}
ansi_state.foreground = { kind = 'named', name = colors[num - 89] }
elseif num == 39 then
ansi_state.foreground = nil
elseif num == 38 or num == 48 then
local is_fg = (num == 38)
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
idx = idx + 1
end
end
---@return nil
function M.setup_highlight_groups()
local color_map = {
Black = vim.g.terminal_color_0,
@ -202,7 +276,7 @@ function M.setup_highlight_groups()
if vim.tbl_count(color_map) < 16 then
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
)
end
@ -218,7 +292,6 @@ function M.setup_highlight_groups()
for color_name, terminal_color in pairs(color_map) do
local parts = { 'CpAnsi' }
local opts = { fg = terminal_color or 'NONE' }
if combo.bold then
table.insert(parts, 'Bold')
opts.bold = true
@ -228,7 +301,6 @@ function M.setup_highlight_groups()
opts.italic = true
end
table.insert(parts, color_name)
local hl_name = table.concat(parts)
vim.api.nvim_set_hl(0, hl_name, opts)
end
@ -239,4 +311,30 @@ function M.setup_highlight_groups()
vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true })
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

View file

@ -185,7 +185,7 @@ function M.update_diff_panes(
actual_content = actual_content
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 diff_namespace = highlight.create_namespace()
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('silent only')
local config = config_module.get_config()
local contest_config = config.contests[state.get_platform() or '']
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
require('cp.runner.run').handle_compilation_failure(compile_result.output)
return
@ -120,7 +118,8 @@ function M.toggle_interactive()
state.set_active_panel('interactive')
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 current_diff_layout then
current_diff_layout.cleanup()
@ -191,7 +190,7 @@ function M.toggle_run_panel(is_debug)
if config.hooks and config.hooks.before_run then
config.hooks.before_run(state)
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)
end
@ -199,7 +198,7 @@ function M.toggle_run_panel(is_debug)
local input_file = state.get_input_file()
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)
return
end
@ -264,37 +263,29 @@ function M.toggle_run_panel(is_debug)
local modes = { 'none', 'git', 'vim' }
local current_idx = nil
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
break
end
end
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()
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)
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)
end, { buffer = buf, silent = true })
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)
local execute = require('cp.runner.execute')
local contest_config = config.contests[state.get_platform() or '']
local compile_result = execute.compile_problem(contest_config, is_debug)
local compile_result = execute.compile_problem()
if compile_result.success then
run.run_all_test_cases(contest_config, config)
run.run_all_test_cases()
else
run.handle_compilation_failure(compile_result.output)
end
@ -302,7 +293,7 @@ function M.toggle_run_panel(is_debug)
refresh_run_panel()
vim.schedule(function()
if config.run_panel.ansi then
if config.ui.run_panel.ansi then
local ansi = require('cp.ui.ansi')
ansi.setup_highlight_groups()
end

View file

@ -265,14 +265,26 @@ class AtcoderScraper(BaseScraper):
async def scrape_contest_metadata(self, contest_id: 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)
if not problems:
return self._create_metadata_error(
f"No problems found for contest {cid}", cid
)
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)