fix: permit lowercase snippets

This commit is contained in:
Barrett Ruth 2025-09-19 19:11:40 -04:00
parent 5e412e341a
commit 99340e551b
6 changed files with 234 additions and 114 deletions

View file

@ -63,12 +63,3 @@ follows:
- [competitest.nvim](https://github.com/xeluxee/competitest.nvim) - [competitest.nvim](https://github.com/xeluxee/competitest.nvim)
- [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim) - [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim)
## TODO
- general `:CP test` window improvements
- fzf/telescope integration (whichever available)
- finer-tuned problem limits (i.e. per-problem codeforces time, memory)
- notify discord members
- handle infinite output/trimming file to 500 lines (customizable)
- update barrettruth.com to post

View file

@ -67,7 +67,7 @@ CONFIGURATION *cp-config*
cp.nvim works out of the box. No setup required. cp.nvim works out of the box. No setup required.
Optional configuration with lazy.nvim: > Here's an example configuration with lazy.nvim: >
{ {
'barrett-ruth/cp.nvim', 'barrett-ruth/cp.nvim',
cmd = 'CP', cmd = 'CP',
@ -75,7 +75,7 @@ Optional configuration with lazy.nvim: >
debug = false, debug = false,
scrapers = { scrapers = {
atcoder = true, atcoder = true,
codeforces = false, -- disable codeforces scraping codeforces = false,
cses = true, cses = true,
}, },
contests = { contests = {
@ -113,9 +113,10 @@ Optional configuration with lazy.nvim: >
end, end,
}, },
run_panel = { run_panel = {
diff_mode = "vim", -- "vim" or "git" diff_mode = "vim",
next_test_key = "<c-n>", -- navigate to next test case next_test_key = "<c-n>",
prev_test_key = "<c-p>", -- navigate to previous test case prev_test_key = "<c-p>",
toggle_diff_key = "t",
}, },
diff = { diff = {
git = { git = {
@ -175,6 +176,7 @@ Optional configuration with lazy.nvim: >
Git provides character-level precision, vim uses built-in diff. Git provides character-level precision, vim uses built-in diff.
• {next_test_key} (`string`, default: `"<c-n>"`) Key to navigate to next test case. • {next_test_key} (`string`, default: `"<c-n>"`) Key to navigate to next test case.
• {prev_test_key} (`string`, default: `"<c-p>"`) Key to navigate to previous test case. • {prev_test_key} (`string`, default: `"<c-p>"`) Key to navigate to previous test case.
• {toggle_diff_key} (`string`, default: `"t"`) Key to toggle diff mode between vim and git.
*cp.DiffConfig* *cp.DiffConfig*
@ -271,15 +273,19 @@ Example: Setting up and solving AtCoder contest ABC324
3. Start with problem A: > 3. Start with problem A: >
:CP a :CP a
Or do both at once with:
:CP atcoder abc324 a
< This creates a.cc and scrapes test cases < This creates a.cc and scrapes test cases
4. Code your solution, then test: > 4. Code your solution, then test: >
:CP test :CP run
< Navigate with j/k, run specific tests with <enter> < Navigate with j/k, run specific tests with <enter>
Exit test panel with q or :CP test when done Exit test panel with q or :CP run when done
5. If needed, debug with sanitizers: > 5. If needed, debug with sanitizers: >
:CP test --debug :CP run --debug
< <
6. Move to next problem: > 6. Move to next problem: >
:CP next :CP next
@ -287,10 +293,6 @@ Example: Setting up and solving AtCoder contest ABC324
6. Continue solving problems with :CP next/:CP prev navigation 6. Continue solving problems with :CP next/:CP prev navigation
7. Submit solutions on AtCoder website 7. Submit solutions on AtCoder website
Example: Quick setup for single Codeforces problem >
:CP codeforces 1933 a " One command setup
:CP test " Test immediately
< <
RUN PANEL *cp-run* RUN PANEL *cp-run*
@ -310,19 +312,21 @@ Activation ~
Interface ~ Interface ~
The run panel uses a redesigned two-pane layout for efficient comparison: The run panel uses a professional table layout with precise column alignment:
(note that the diff is indeed highlighted, not the weird amalgamation of (note that the diff is indeed highlighted, not the weird amalgamation of
characters below) > characters below) >
┌─ Tests ─────────────────────┐ ┌─ Expected vs Actual ───────────────────────┐ ┌──────┬────────┬────────┬───────────┐ ┌─ Expected vs Actual ──────────────────┐
│ AC 1. 12ms │ │ 45ms │ Exit: 0 │ │ # │ Status │ Time │ Exit Code │ │ 45.70ms │ Exit: 0 │
│ WA > 2. 45ms │ ├────────────────────────────────────────────┤ ├──────┼────────┼────────┼───────────┤ ├────────────────────────────────────────┤
│ 5 3 │ │ │ │ 1 │ AC │12.00ms │ 0 │ │ │
│ │ │ 4[-2-]{+3+} │ │ >2 │ WA │45.70ms │ 1 │ │ 4[-2-]{+3+} │
│ AC 3. 9ms │ │ 100 │ ├──────┴────────┴────────┴───────────┤ │ 100 │
│ RTE 4. 0ms │ │ hello w[-o-]r{+o+}ld │ │5 3 │ │ hello w[-o-]r{+o+}ld │
│ │ │ │ ├──────┬────────┬────────┬───────────┤ │ │
└─────────────────────────────┘ └────────────────────────────────────────────┘ │ 3 │ AC │ 9.00ms │ 0 │ └────────────────────────────────────────┘
│ 4 │ RTE │ 0.00ms │139 (SIGUSR2)│
└──────┴────────┴────────┴───────────┘
< <
Status Indicators ~ Status Indicators ~
@ -338,7 +342,8 @@ Keymaps ~
*cp-test-keys* *cp-test-keys*
<c-n> Navigate to next test case (configurable via run_panel.next_test_key) <c-n> Navigate to next test case (configurable via run_panel.next_test_key)
<c-p> Navigate to previous test case (configurable via run_panel.prev_test_key) <c-p> Navigate to previous test case (configurable via run_panel.prev_test_key)
q Exit test panel (restore layout) t Toggle diff mode between vim and git (configurable via run_panel.toggle_diff_key)
q Exit test panel and restore layout
Diff Modes ~ Diff Modes ~
@ -365,8 +370,8 @@ cp.nvim creates the following file structure upon problem setup:
build/ build/
{problem_id}.run " Compiled binary {problem_id}.run " Compiled binary
io/ io/
{problem_id}.cpin " Test input {problem_id}.n.cpin " nth test input
{problem_id}.cpout " Program output {problem_id}.n.cpout " nth program output
{problem_id}.expected " Expected output {problem_id}.expected " Expected output
The plugin automatically manages this structure and navigation between problems The plugin automatically manages this structure and navigation between problems
@ -377,8 +382,9 @@ SNIPPETS *cp-snippets*
cp.nvim integrates with LuaSnip for automatic template expansion. Built-in cp.nvim integrates with LuaSnip for automatic template expansion. Built-in
snippets include basic C++ and Python templates for each contest type. snippets include basic C++ and Python templates for each contest type.
Snippet trigger names must EXACTLY match platform names ("codeforces" for Snippet trigger names must match the following format exactly:
CodeForces, "cses" for CSES, etc.).
cp.nvim/{platform}
Custom snippets can be added via the `snippets` configuration field. Custom snippets can be added via the `snippets` configuration field.

View file

@ -35,6 +35,7 @@
---@field diff_mode "vim"|"git" Diff backend to use ---@field diff_mode "vim"|"git" Diff backend to use
---@field next_test_key string Key to navigate to next test case ---@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 prev_test_key string Key to navigate to previous test case
---@field toggle_diff_key string Key to toggle diff mode
---@class DiffGitConfig ---@class DiffGitConfig
---@field command string Git executable name ---@field command string Git executable name
@ -82,6 +83,7 @@ M.defaults = {
diff_mode = 'vim', diff_mode = 'vim',
next_test_key = '<c-n>', next_test_key = '<c-n>',
prev_test_key = '<c-p>', prev_test_key = '<c-p>',
toggle_diff_key = 't',
}, },
diff = { diff = {
git = { git = {
@ -153,6 +155,13 @@ function M.setup(user_config)
end, end,
'prev_test_key must be a non-empty string', 'prev_test_key must be a non-empty string',
}, },
toggle_diff_key = {
user_config.run_panel.toggle_diff_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'toggle_diff_key must be a non-empty string',
},
}) })
end end

View file

@ -28,6 +28,9 @@ local state = {
run_panel_active = false, run_panel_active = false,
} }
local current_diff_layout = nil
local current_mode = nil
local constants = require('cp.constants') local constants = require('cp.constants')
local platforms = constants.PLATFORMS local platforms = constants.PLATFORMS
local actions = constants.ACTIONS local actions = constants.ACTIONS
@ -151,6 +154,11 @@ end
local function toggle_run_panel(is_debug) local function toggle_run_panel(is_debug)
if state.run_panel_active then if state.run_panel_active then
if current_diff_layout then
current_diff_layout.cleanup()
current_diff_layout = nil
current_mode = nil
end
if state.saved_session then if state.saved_session then
vim.cmd(('source %s'):format(state.saved_session)) vim.cmd(('source %s'):format(state.saved_session))
vim.fn.delete(state.saved_session) vim.fn.delete(state.saved_session)
@ -187,41 +195,15 @@ local function toggle_run_panel(is_debug)
vim.cmd('silent only') vim.cmd('silent only')
local tab_buf = vim.api.nvim_create_buf(false, true) local tab_buf = create_buffer_with_options()
local expected_buf = vim.api.nvim_create_buf(false, true)
local actual_buf = vim.api.nvim_create_buf(false, true)
-- Set buffer options
for _, buf in ipairs({ tab_buf, expected_buf, actual_buf }) do
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf })
vim.api.nvim_set_option_value('readonly', true, { buf = buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
end
local main_win = vim.api.nvim_get_current_win() local main_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(main_win, tab_buf) vim.api.nvim_win_set_buf(main_win, tab_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = tab_buf })
vim.cmd.split()
vim.api.nvim_win_set_buf(0, actual_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf })
vim.cmd.vsplit()
vim.api.nvim_win_set_buf(0, expected_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf })
local expected_win = vim.fn.bufwinid(expected_buf)
local actual_win = vim.fn.bufwinid(actual_buf)
local test_windows = { local test_windows = {
tab_win = main_win, tab_win = main_win,
actual_win = actual_win,
expected_win = expected_win,
} }
local test_buffers = { local test_buffers = {
tab_buf = tab_buf, tab_buf = tab_buf,
expected_buf = expected_buf,
actual_buf = actual_buf,
} }
local highlight = require('cp.highlight') local highlight = require('cp.highlight')
@ -248,30 +230,93 @@ local function toggle_run_panel(is_debug)
end end
end end
local function update_expected_pane() local function create_buffer_with_options()
local test_state = test_module.get_run_panel_state() local buf = vim.api.nvim_create_buf(false, true)
local current_test = test_state.test_cases[test_state.current_index] vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf })
vim.api.nvim_set_option_value('readonly', true, { buf = buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = buf })
return buf
end
if not current_test then local function create_vim_diff_layout(parent_win, expected_content, actual_content)
return local expected_buf = create_buffer_with_options()
end local actual_buf = create_buffer_with_options()
local expected_text = current_test.expected vim.api.nvim_set_current_win(parent_win)
local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true }) vim.cmd.split()
local actual_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(actual_win, actual_buf)
update_buffer_content(test_buffers.expected_buf, expected_lines, {}) vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(expected_buf, expected_lines, {})
update_buffer_content(actual_buf, actual_lines, {})
vim.api.nvim_set_option_value('diff', true, { win = expected_win })
vim.api.nvim_set_option_value('diff', true, { win = actual_win })
vim.api.nvim_win_call(expected_win, function()
vim.cmd.diffthis()
end)
vim.api.nvim_win_call(actual_win, function()
vim.cmd.diffthis()
end)
return {
buffers = { expected_buf, actual_buf },
windows = { expected_win, actual_win },
cleanup = function()
pcall(vim.api.nvim_win_close, expected_win, true)
pcall(vim.api.nvim_win_close, actual_win, true)
pcall(vim.api.nvim_buf_delete, expected_buf, { force = true })
pcall(vim.api.nvim_buf_delete, actual_buf, { force = true })
end,
}
end
local function create_git_diff_layout(parent_win, expected_content, actual_content)
local diff_buf = create_buffer_with_options()
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
local diff_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(diff_win, diff_buf)
local diff_backend = require('cp.diff') local diff_backend = require('cp.diff')
local backend = diff_backend.get_best_backend(config.run_panel.diff_mode) local backend = diff_backend.get_best_backend('git')
local diff_result = backend.render(expected_content, actual_content)
if backend.name == 'vim' and current_test.status == 'fail' then if diff_result.raw_diff and diff_result.raw_diff ~= '' then
vim.api.nvim_set_option_value('diff', true, { win = test_windows.expected_win }) highlight.parse_and_apply_diff(diff_buf, diff_result.raw_diff, diff_namespace)
else else
vim.api.nvim_set_option_value('diff', false, { win = test_windows.expected_win }) local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(diff_buf, lines, {})
end
return {
buffers = { diff_buf },
windows = { diff_win },
cleanup = function()
pcall(vim.api.nvim_win_close, diff_win, true)
pcall(vim.api.nvim_buf_delete, diff_buf, { force = true })
end,
}
end
local function create_diff_layout(mode, parent_win, expected_content, actual_content)
if mode == 'git' then
return create_git_diff_layout(parent_win, expected_content, actual_content)
else
return create_vim_diff_layout(parent_win, expected_content, actual_content)
end end
end end
local function update_actual_pane() local function update_diff_panes()
local test_state = test_module.get_run_panel_state() local test_state = test_module.get_run_panel_state()
local current_test = test_state.test_cases[test_state.current_index] local current_test = test_state.test_cases[test_state.current_index]
@ -279,45 +324,67 @@ local function toggle_run_panel(is_debug)
return return
end end
local actual_lines = {} local expected_content = current_test.expected or ''
local enable_diff = false local actual_content = current_test.actual or '(not run yet)'
local should_show_diff = current_test.status == 'fail' and current_test.actual
if current_test.actual then if not should_show_diff then
actual_lines = vim.split(current_test.actual, '\n', { plain = true, trimempty = true }) expected_content = expected_content
enable_diff = current_test.status == 'fail' actual_content = actual_content
else
actual_lines = { '(not run yet)' }
end end
if enable_diff then local desired_mode = should_show_diff and config.run_panel.diff_mode or 'vim'
local diff_backend = require('cp.diff')
local backend = diff_backend.get_best_backend(config.run_panel.diff_mode) if current_diff_layout and current_mode ~= desired_mode then
current_diff_layout.cleanup()
current_diff_layout = nil
current_mode = nil
end
if not current_diff_layout then
current_diff_layout =
create_diff_layout(desired_mode, main_win, expected_content, actual_content)
current_mode = desired_mode
for _, buf in ipairs(current_diff_layout.buffers) do
setup_keybindings_for_buffer(buf)
end
else
if desired_mode == 'git' then
local diff_backend = require('cp.diff')
local backend = diff_backend.get_best_backend('git')
local diff_result = backend.render(expected_content, actual_content)
if backend.name == 'git' then
local diff_result = backend.render(current_test.expected, current_test.actual)
if diff_result.raw_diff and diff_result.raw_diff ~= '' then if diff_result.raw_diff and diff_result.raw_diff ~= '' then
highlight.parse_and_apply_diff( highlight.parse_and_apply_diff(
test_buffers.actual_buf, current_diff_layout.buffers[1],
diff_result.raw_diff, diff_result.raw_diff,
diff_namespace diff_namespace
) )
else else
update_buffer_content(test_buffers.actual_buf, actual_lines, {}) local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(current_diff_layout.buffers[1], lines, {})
end end
else else
update_buffer_content(test_buffers.actual_buf, actual_lines, {}) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
vim.api.nvim_set_option_value('diff', true, { win = test_windows.actual_win }) local actual_lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
vim.api.nvim_win_call(test_windows.expected_win, function() update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})
vim.cmd.diffthis() update_buffer_content(current_diff_layout.buffers[2], actual_lines, {})
end)
vim.api.nvim_win_call(test_windows.actual_win, function() if should_show_diff then
vim.cmd.diffthis() vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[1] })
end) vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[2] })
vim.api.nvim_win_call(current_diff_layout.windows[1], function()
vim.cmd.diffthis()
end)
vim.api.nvim_win_call(current_diff_layout.windows[2], function()
vim.cmd.diffthis()
end)
else
vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[2] })
end
end end
else
update_buffer_content(test_buffers.actual_buf, actual_lines, {})
vim.api.nvim_set_option_value('diff', false, { win = test_windows.expected_win })
vim.api.nvim_set_option_value('diff', false, { win = test_windows.actual_win })
end end
end end
@ -332,8 +399,7 @@ local function toggle_run_panel(is_debug)
local tab_lines, tab_highlights = test_render.render_test_list(test_state) local tab_lines, tab_highlights = test_render.render_test_list(test_state)
update_buffer_content(test_buffers.tab_buf, tab_lines, tab_highlights) update_buffer_content(test_buffers.tab_buf, tab_lines, tab_highlights)
update_expected_pane() update_diff_panes()
update_actual_pane()
end end
local function navigate_test_case(delta) local function navigate_test_case(delta)
@ -352,6 +418,16 @@ local function toggle_run_panel(is_debug)
refresh_run_panel() refresh_run_panel()
end end
local function setup_keybindings_for_buffer(buf)
vim.keymap.set('n', 'q', function()
toggle_run_panel()
end, { buffer = buf, silent = true })
vim.keymap.set('n', config.run_panel.toggle_diff_key, function()
config.run_panel.diff_mode = config.run_panel.diff_mode == 'vim' and 'git' or 'vim'
refresh_run_panel()
end, { buffer = buf, silent = true })
end
vim.keymap.set('n', config.run_panel.next_test_key, function() vim.keymap.set('n', config.run_panel.next_test_key, function()
navigate_test_case(1) navigate_test_case(1)
end, { buffer = test_buffers.tab_buf, silent = true }) end, { buffer = test_buffers.tab_buf, silent = true })
@ -359,11 +435,7 @@ local function toggle_run_panel(is_debug)
navigate_test_case(-1) navigate_test_case(-1)
end, { buffer = test_buffers.tab_buf, silent = true }) end, { buffer = test_buffers.tab_buf, silent = true })
for _, buf in pairs(test_buffers) do setup_keybindings_for_buffer(test_buffers.tab_buf)
vim.keymap.set('n', 'q', function()
toggle_run_panel()
end, { buffer = buf, silent = true })
end
if config.hooks and config.hooks.before_test then if config.hooks and config.hooks.before_test then
config.hooks.before_test(ctx) config.hooks.before_test(ctx)

View file

@ -102,7 +102,7 @@ if __name__ == "__main__":
local user_overrides = {} local user_overrides = {}
for _, snippet in ipairs(config.snippets or {}) do for _, snippet in ipairs(config.snippets or {}) do
user_overrides[snippet.trigger] = snippet user_overrides[snippet.trigger:lower()] = snippet
end end
for language, template_set in pairs(template_definitions) do for language, template_set in pairs(template_definitions) do
@ -110,14 +110,14 @@ if __name__ == "__main__":
local filetype = constants.canonical_filetypes[language] local filetype = constants.canonical_filetypes[language]
for contest, template in pairs(template_set) do for contest, template in pairs(template_set) do
local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest, language) local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest:lower(), language)
if not user_overrides[prefixed_trigger] then if not user_overrides[prefixed_trigger:lower()] then
table.insert(snippets, s(prefixed_trigger, fmt(template, { i(1) }))) table.insert(snippets, s(prefixed_trigger, fmt(template, { i(1) })))
end end
end end
for trigger, snippet in pairs(user_overrides) do for trigger, snippet in pairs(user_overrides) do
local prefix_match = trigger:match('^cp%.nvim/[^.]+%.(.+)$') local prefix_match = trigger:lower():match('^cp%.nvim/[^.]+%.(.+)$')
if prefix_match == language then if prefix_match == language then
table.insert(snippets, snippet) table.insert(snippets, snippet)
end end

View file

@ -211,5 +211,47 @@ describe('cp.snippets', function()
assert.equals(1, codeforces_count) assert.equals(1, codeforces_count)
end) end)
it('handles case-insensitive snippet triggers', function()
local mixed_case_snippet = {
trigger = 'cp.nvim/CodeForces.cpp',
body = 'mixed case template',
}
local upper_case_snippet = {
trigger = 'cp.nvim/ATCODER.cpp',
body = 'upper case template',
}
local config = {
snippets = { mixed_case_snippet, upper_case_snippet },
}
snippets.setup(config)
local cpp_snippets = mock_luasnip.added.cpp or {}
local has_mixed_case = false
local has_upper_case = false
local default_codeforces_count = 0
local default_atcoder_count = 0
for _, snippet in ipairs(cpp_snippets) do
if snippet.trigger == 'cp.nvim/CodeForces.cpp' then
has_mixed_case = true
assert.equals('mixed case template', snippet.body)
elseif snippet.trigger == 'cp.nvim/ATCODER.cpp' then
has_upper_case = true
assert.equals('upper case template', snippet.body)
elseif snippet.trigger == 'cp.nvim/codeforces.cpp' then
default_codeforces_count = default_codeforces_count + 1
elseif snippet.trigger == 'cp.nvim/atcoder.cpp' then
default_atcoder_count = default_atcoder_count + 1
end
end
assert.is_true(has_mixed_case)
assert.is_true(has_upper_case)
assert.equals(0, default_codeforces_count, 'Default codeforces snippet should be overridden')
assert.equals(0, default_atcoder_count, 'Default atcoder snippet should be overridden')
end)
end) end)
end) end)