diff --git a/README.md b/README.md index dbbafe4..c2da671 100644 --- a/README.md +++ b/README.md @@ -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 [--{lang=,debug}]` +2. **Set up locally** with `:CP ` ``` :CP codeforces 1848 diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 56802bf..0dfc82d 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -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 = '', - prev_test_key = '', - 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) Contest configurations. + {languages} (table) Global language registry. + Each language provides an {extension} and {commands}. + {platforms} (table) 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) 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, 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: "") Key to navigate to next test case. - {prev_test_key} (string, default: "") Key to navigate to previous test case. - {toggle_diff_key} (string, default: "") 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* - Navigate to next test case (configurable via - run_panel.next_test_key) - Navigate to previous test case (configurable via - run_panel.prev_test_key) + Navigate to next test case + Navigate to previous test case t Cycle through diff modes: none → git → vim q Exit run panel and restore layout Exit interactive terminal and restore layout diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 11471db..06c4c0d 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -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 diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 6ad48c2..6923a0f 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -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 [--{lang=,debug}]', + message = 'Setup contests with :CP ', } 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 diff --git a/lua/cp/commands/picker.lua b/lua/cp/commands/picker.lua index f41c9b3..a733b58 100644 --- a/lua/cp/commands/picker.lua +++ b/lua/cp/commands/picker.lua @@ -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( diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 912dd39..f58c369 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -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 + +---@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 ----@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 ----@field snippets? any[] ----@field hooks? Hooks ----@field debug? boolean ----@field scrapers? string[] +---@class cp.Config +---@field languages table +---@field platforms table +---@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> } -- 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 = '', - prev_test_key = '', - 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 diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 7d81242..dce8751 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -24,6 +24,12 @@ M.canonical_filetypes = { [M.PYTHON] = 'python', } +---@type table +M.canonical_filetype_to_extension = { + [M.CPP] = 'cc', + [M.PYTHON] = 'py', +} + ---@type table M.signal_codes = { [128] = 'SIGILL', diff --git a/lua/cp/init.lua b/lua/cp/init.lua index a6f70a1..88467ee 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -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 diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index 2e7598b..c634c64 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -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 diff --git a/lua/cp/restore.lua b/lua/cp/restore.lua index 54ff8be..875e733 100644 --- a/lua/cp/restore.lua +++ b/lua/cp/restore.lua @@ -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 [--{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 diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 9a70343..bfe0178 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -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' } diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index dbfb52b..16ae696 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -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 +---@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 diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 0d334d6..c877f9a 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -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 diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 98fe0a3..0e7c8f4 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -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 [--{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 [--{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 diff --git a/lua/cp/state.lua b/lua/cp/state.lua index deef248..c90396c 100644 --- a/lua/cp/state.lua +++ b/lua/cp/state.lua @@ -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() diff --git a/lua/cp/ui/ansi.lua b/lua/cp/ui/ansi.lua index facfd1f..8bc8581 100644 --- a/lua/cp/ui/ansi.lua +++ b/lua/cp/ui/ansi.lua @@ -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 diff --git a/lua/cp/ui/layouts.lua b/lua/cp/ui/layouts.lua index 3d12a21..c2613a5 100644 --- a/lua/cp/ui/layouts.lua +++ b/lua/cp/ui/layouts.lua @@ -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') diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 1c6e414..4467f0e 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -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', '', 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', '', 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 diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 2aab23c..0dc9dce 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -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)