feat(config): more sophisticated param validation

This commit is contained in:
Barrett Ruth 2025-09-19 22:45:36 -04:00
parent db85bacd4c
commit 69fc2ecdbb
5 changed files with 221 additions and 43 deletions

View file

@ -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 = '<c-n>',
@ -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<string,boolean>`) Per-platform scraper control.
Default enables all platforms.
- {scrapers} (`table<string>`) 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
└──────────────────────────────────────────────────────────────────┘
<

View file

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

View file

@ -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()

View file

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

View file

@ -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')