From 69fc2ecdbb5ce052c0cdf525339a108e436749b8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 22:45:36 -0400 Subject: [PATCH 1/5] feat(config): more sophisticated param validation --- doc/cp.txt | 30 +++++++---- lua/cp/config.lua | 118 ++++++++++++++++++++++++++++++++++-------- spec/config_spec.lua | 105 +++++++++++++++++++++++++++++++++++++ spec/execute_spec.lua | 10 ---- spec/scraper_spec.lua | 1 - 5 files changed, 221 insertions(+), 43 deletions(-) diff --git a/doc/cp.txt b/doc/cp.txt index de21cfb..1e9439e 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -72,7 +72,18 @@ Here's an example configuration with lazy.nvim: > 'barrett-ruth/cp.nvim', cmd = 'CP', opts = { - contests = {}, + contests = { + default = { + cpp = { + compile = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }, + test = { '{binary}' }, + debug = { 'g++', '{source}', '-o', '{binary}', '-std=c++17', '-g', '-fsanitize=address,undefined' }, + }, + python = { + test = { 'python3', '{source}' }, + }, + }, + }, snippets = {}, hooks = { before_run = nil, @@ -80,8 +91,8 @@ Here's an example configuration with lazy.nvim: > setup_code = nil, }, debug = false, - scrapers = { atcoder = true, codeforces = true, cses = true }, - filename = nil, + scrapers = { ... }, -- all scrapers enabled by default + filename = default_filename, run_panel = { diff_mode = 'vim', next_test_key = '', @@ -91,7 +102,6 @@ Here's an example configuration with lazy.nvim: > }, diff = { git = { - command = 'git', args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, }, }, @@ -107,14 +117,14 @@ Here's an example configuration with lazy.nvim: > - {snippets} (`table[]`) LuaSnip snippet definitions. - {debug} (`boolean`, default: `false`) Show info messages during operation. - - {scrapers} (`table`) Per-platform scraper control. - Default enables all platforms. + - {scrapers} (`table`) List of enabled scrapers. + Default: all scrapers enabled - {run_panel} (`RunPanelConfig`) Test panel behavior configuration. - {diff} (`DiffConfig`) Diff backend configuration. - {filename}? (`function`) Custom filename generation function. `function(contest, contest_id, problem_id, config, language)` Should return full filename with extension. - (default: concats contest_id and problem id) + (default: `default_filename` - concatenates contest_id and problem_id, lowercased) *cp.ContestConfig* @@ -236,7 +246,7 @@ Example: Setting up and solving AtCoder contest ABC324 1. Browse to https://atcoder.jp/contests/abc324 2. Set up contest and load metadata: > :CP atcoder abc324 -< This caches all problems (A, B, C, D, E, F, G) for navigation +< This caches all problems (A, B, ...) for navigation 3. Start with problem A: > :CP a @@ -298,8 +308,8 @@ characters below) > ┌──────────────────────────────────────────────────────────────────┐ │Expected vs Actual │ │4[-2-]{+3+} │ - │ 100 │ - │ hello w[-o-]r{+o+}ld │ + │100 │ + │hello w[-o-]r{+o+}ld │ └──────────────────────────────────────────────────────────────────┘ < diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 35a523f..2da2ee0 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -37,8 +37,7 @@ ---@field max_output_lines number Maximum lines of test output to display ---@class DiffGitConfig ----@field command string Git executable name ----@field args string[] Additional git diff arguments +---@field args string[] Git diff arguments ---@class DiffConfig ---@field git DiffGitConfig @@ -68,7 +67,26 @@ local constants = require('cp.constants') ---@type cp.Config M.defaults = { - contests = {}, + contests = { + default = { + cpp = { + compile = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }, + test = { '{binary}' }, + debug = { + 'g++', + '{source}', + '-o', + '{binary}', + '-std=c++17', + '-g', + '-fsanitize=address,undefined', + }, + }, + python = { + test = { 'python3', '{source}' }, + }, + }, + }, snippets = {}, hooks = { before_run = nil, @@ -87,7 +105,6 @@ M.defaults = { }, diff = { git = { - command = 'git', args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, }, }, @@ -114,25 +131,62 @@ function M.setup(user_config) if user_config.contests then for contest_name, contest_config in pairs(user_config.contests) do - for lang_name, lang_config in pairs(contest_config) do - if type(lang_config) == 'table' and lang_config.extension then - if - not vim.tbl_contains( - vim.tbl_keys(constants.filetype_to_language), - lang_config.extension - ) - then - error( - ("Invalid extension '%s' for language '%s' in contest '%s'. Valid extensions: %s"):format( - lang_config.extension, - lang_name, - contest_name, - table.concat(vim.tbl_keys(constants.filetype_to_language), ', ') + vim.validate({ + [contest_name] = { + contest_config, + function(config) + if type(config) ~= 'table' then + return false + end + + for lang_name, lang_config in pairs(config) do + if type(lang_config) == 'table' then + if + lang_name ~= 'default_language' + and not vim.tbl_contains(vim.tbl_keys(constants.canonical_filetypes), lang_name) + then + return false, + ("Invalid language '%s'. Valid languages: %s"):format( + lang_name, + table.concat(vim.tbl_keys(constants.canonical_filetypes), ', ') + ) + end + + if + lang_config.extension + and not vim.tbl_contains( + vim.tbl_keys(constants.filetype_to_language), + lang_config.extension + ) + then + return false, + ("Invalid extension '%s'. Valid extensions: %s"):format( + lang_config.extension, + table.concat(vim.tbl_keys(constants.filetype_to_language), ', ') + ) + end + end + end + + if + config.default_language + and not vim.tbl_contains( + vim.tbl_keys(constants.canonical_filetypes), + config.default_language ) - ) - end - end - end + then + return false, + ("Invalid default_language '%s'. Valid languages: %s"):format( + config.default_language, + table.concat(vim.tbl_keys(constants.canonical_filetypes), ', ') + ) + end + + return true + end, + 'contest configuration', + }, + }) end end @@ -226,6 +280,26 @@ function M.setup(user_config) 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 #available_langs == 0 then + error('No language configurations found') + end + + if vim.tbl_contains(available_langs, 'cpp') then + contest_config.default_language = 'cpp' + else + table.sort(available_langs) + contest_config.default_language = available_langs[1] + end + end end return config diff --git a/spec/config_spec.lua b/spec/config_spec.lua index 5b41617..e3011fc 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -119,6 +119,111 @@ describe('cp.config', function() end) end) end) + + describe('auto-configuration', function() + it('sets default extensions for cpp and python', function() + local user_config = { + contests = { + test = { + cpp = { compile = { 'g++' } }, + python = { test = { 'python3' } }, + }, + }, + } + + local result = config.setup(user_config) + + assert.equals('cpp', result.contests.test.cpp.extension) + assert.equals('py', result.contests.test.python.extension) + end) + + it('sets default_language to cpp when available', function() + local user_config = { + contests = { + test = { + cpp = { compile = { 'g++' } }, + python = { test = { 'python3' } }, + }, + }, + } + + local result = config.setup(user_config) + + assert.equals('cpp', result.contests.test.default_language) + end) + + it('sets default_language to first available when cpp not present', function() + local user_config = { + contests = { + test = { + python = { test = { 'python3' } }, + rust = { compile = { 'rustc' } }, + }, + }, + } + + local result = config.setup(user_config) + + assert.equals('python', result.contests.test.default_language) + end) + + it('preserves explicit default_language', function() + local user_config = { + contests = { + test = { + cpp = { compile = { 'g++' } }, + python = { test = { 'python3' } }, + default_language = 'python', + }, + }, + } + + local result = config.setup(user_config) + + assert.equals('python', result.contests.test.default_language) + end) + + it('errors when no language configurations exist', function() + local invalid_config = { + contests = { + test = {}, + }, + } + + assert.has_error(function() + config.setup(invalid_config) + end, 'No language configurations found for test') + end) + + it('validates language names against canonical_filetypes', function() + local invalid_config = { + contests = { + test = { + invalid_lang = { compile = { 'gcc' } }, + }, + }, + } + + assert.has_error(function() + config.setup(invalid_config) + end, "Invalid language 'invalid_lang'") + end) + + it('validates default_language value', function() + local invalid_config = { + contests = { + test = { + cpp = { compile = { 'g++' } }, + default_language = 'xd', + }, + }, + } + + assert.has_error(function() + config.setup(invalid_config) + end, "Invalid default_language 'xd'") + end) + end) end) describe('default_filename', function() diff --git a/spec/execute_spec.lua b/spec/execute_spec.lua index b647d98..5967dac 100644 --- a/spec/execute_spec.lua +++ b/spec/execute_spec.lua @@ -186,18 +186,15 @@ describe('cp.execute', function() } end - -- Test the internal execute_command function indirectly local language_config = { run = { '{binary_file}' }, } - -- This would be called by a higher-level function that uses execute_command execute.compile_generic(language_config, { binary_file = './test.run' }) end) it('handles command execution', function() vim.system = function(_, opts) - -- Compilation doesn't set timeout, only text=true if opts then assert.equals(true, opts.text) end @@ -259,8 +256,6 @@ describe('cp.execute', function() describe('directory creation', function() it('creates build and io directories', function() - -- This tests the ensure_directories function indirectly - -- since it's called by other functions local language_config = { compile = { 'mkdir', '-p', 'build', 'io' }, } @@ -276,15 +271,10 @@ describe('cp.execute', function() describe('language detection', function() it('detects cpp from extension', function() - -- This tests get_language_from_file indirectly - - -- Mock the file extension detection vim.fn.fnamemodify = function() return 'cpp' end - -- The actual function is local, but we can test it indirectly - -- through functions that use it assert.has_no_errors(function() execute.compile_generic({}, {}) end) diff --git a/spec/scraper_spec.lua b/spec/scraper_spec.lua index a06d97e..81ed211 100644 --- a/spec/scraper_spec.lua +++ b/spec/scraper_spec.lua @@ -111,7 +111,6 @@ describe('cp.scrape', function() stored_data = { platform = platform, contest_id = contest_id, problems = problems } end - -- Reload the scraper module to pick up the updated mock package.loaded['cp.scrape'] = nil scrape = require('cp.scrape') From 9ea6f878dec312e4a70b2c59dd8074da2116db7e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 23:13:23 -0400 Subject: [PATCH 2/5] fix(config): extension is optional --- lua/cp/config.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 2da2ee0..c28407a 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -4,7 +4,7 @@ ---@field debug? string[] Debug command template ---@field executable? string Executable name ---@field version? number Language version ----@field extension string File extension +---@field extension? string File extension ---@class PartialLanguageConfig ---@field compile? string[] Compile command template @@ -17,7 +17,7 @@ ---@class ContestConfig ---@field cpp LanguageConfig ---@field python LanguageConfig ----@field default_language string +---@field default_language? string ---@class PartialContestConfig ---@field cpp? PartialLanguageConfig From b34ace85a5a17e13e3a0580edeec6a36eb2780c7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 23:19:49 -0400 Subject: [PATCH 3/5] fix: cleanup config logic --- lua/cp/config.lua | 25 ------------------------- spec/config_spec.lua | 34 ++++++++++++---------------------- 2 files changed, 12 insertions(+), 47 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index c28407a..b80d5ad 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -141,17 +141,6 @@ function M.setup(user_config) for lang_name, lang_config in pairs(config) do if type(lang_config) == 'table' then - if - lang_name ~= 'default_language' - and not vim.tbl_contains(vim.tbl_keys(constants.canonical_filetypes), lang_name) - then - return false, - ("Invalid language '%s'. Valid languages: %s"):format( - lang_name, - table.concat(vim.tbl_keys(constants.canonical_filetypes), ', ') - ) - end - if lang_config.extension and not vim.tbl_contains( @@ -168,20 +157,6 @@ function M.setup(user_config) end end - if - config.default_language - and not vim.tbl_contains( - vim.tbl_keys(constants.canonical_filetypes), - config.default_language - ) - then - return false, - ("Invalid default_language '%s'. Valid languages: %s"):format( - config.default_language, - table.concat(vim.tbl_keys(constants.canonical_filetypes), ', ') - ) - end - return true end, 'contest configuration', diff --git a/spec/config_spec.lua b/spec/config_spec.lua index e3011fc..b248fc5 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -157,7 +157,6 @@ describe('cp.config', function() contests = { test = { python = { test = { 'python3' } }, - rust = { compile = { 'rustc' } }, }, }, } @@ -192,36 +191,27 @@ describe('cp.config', function() assert.has_error(function() config.setup(invalid_config) - end, 'No language configurations found for test') + end, 'No language configurations found') end) - it('validates language names against canonical_filetypes', function() - local invalid_config = { - contests = { - test = { - invalid_lang = { compile = { 'gcc' } }, - }, - }, - } - - assert.has_error(function() - config.setup(invalid_config) - end, "Invalid language 'invalid_lang'") - end) - - it('validates default_language value', function() - local invalid_config = { + it('allows custom language names', function() + local user_config = { contests = { test = { + rust = { + compile = { 'rustc', '{source}', '-o', '{binary}' }, + test = { '{binary}' }, + extension = 'rs', + }, cpp = { compile = { 'g++' } }, - default_language = 'xd', }, }, } - assert.has_error(function() - config.setup(invalid_config) - end, "Invalid default_language 'xd'") + assert.has_no.errors(function() + local result = config.setup(user_config) + assert.equals('cpp', result.contests.test.default_language) + end) end) end) end) From ad3cd32bac24fe0466640e43f1a6929e35eba356 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 23:22:24 -0400 Subject: [PATCH 4/5] fix(ci): relax extensino validation --- lua/cp/config.lua | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index b80d5ad..e0b3a6c 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -139,23 +139,7 @@ function M.setup(user_config) return false end - for lang_name, lang_config in pairs(config) do - if type(lang_config) == 'table' then - if - lang_config.extension - and not vim.tbl_contains( - vim.tbl_keys(constants.filetype_to_language), - lang_config.extension - ) - then - return false, - ("Invalid extension '%s'. Valid extensions: %s"):format( - lang_config.extension, - table.concat(vim.tbl_keys(constants.filetype_to_language), ', ') - ) - end - end - end + -- Allow any language and extension configurations return true end, From 3e2cff09e5c79c1431d86e3e0134de0f69b0613f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 23:24:15 -0400 Subject: [PATCH 5/5] fix(test): remove oudated test --- spec/config_spec.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/config_spec.lua b/spec/config_spec.lua index b248fc5..95dda3d 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -30,17 +30,17 @@ describe('cp.config', function() assert.equals('table', type(result.scrapers)) end) - it('validates extension against supported filetypes', function() - local invalid_config = { + it('allows custom extensions', function() + local custom_config = { contests = { test_contest = { - cpp = { extension = 'invalid' }, + cpp = { extension = 'custom' }, }, }, } - assert.has_error(function() - config.setup(invalid_config) + assert.has_no.errors(function() + config.setup(custom_config) end) end)