From 69fc2ecdbb5ce052c0cdf525339a108e436749b8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 22:45:36 -0400 Subject: [PATCH] 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')