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)
- [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.
Optional configuration with lazy.nvim: >
Here's an example configuration with lazy.nvim: >
{
'barrett-ruth/cp.nvim',
cmd = 'CP',
@ -75,7 +75,7 @@ Optional configuration with lazy.nvim: >
debug = false,
scrapers = {
atcoder = true,
codeforces = false, -- disable codeforces scraping
codeforces = false,
cses = true,
},
contests = {
@ -113,9 +113,10 @@ Optional configuration with lazy.nvim: >
end,
},
run_panel = {
diff_mode = "vim", -- "vim" or "git"
next_test_key = "<c-n>", -- navigate to next test case
prev_test_key = "<c-p>", -- navigate to previous test case
diff_mode = "vim",
next_test_key = "<c-n>",
prev_test_key = "<c-p>",
toggle_diff_key = "t",
},
diff = {
git = {
@ -175,6 +176,7 @@ Optional configuration with lazy.nvim: >
Git provides character-level precision, vim uses built-in diff.
• {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.
• {toggle_diff_key} (`string`, default: `"t"`) Key to toggle diff mode between vim and git.
*cp.DiffConfig*
@ -271,15 +273,19 @@ Example: Setting up and solving AtCoder contest ABC324
3. Start with problem A: >
:CP a
Or do both at once with:
:CP atcoder abc324 a
< This creates a.cc and scrapes test cases
4. Code your solution, then test: >
:CP test
:CP run
< 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: >
:CP test --debug
:CP run --debug
<
6. Move to next problem: >
:CP next
@ -287,10 +293,6 @@ Example: Setting up and solving AtCoder contest ABC324
6. Continue solving problems with :CP next/:CP prev navigation
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*
@ -310,19 +312,21 @@ Activation ~
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
characters below) >
┌─ Tests ─────────────────────┐ ┌─ Expected vs Actual ───────────────────────┐
│ AC 1. 12ms │ │ 45ms │ Exit: 0 │
│ WA > 2. 45ms │ ├────────────────────────────────────────────┤
│ 5 3 │ │ │
│ │ │ 4[-2-]{+3+} │
│ AC 3. 9ms │ │ 100 │
│ RTE 4. 0ms │ │ hello w[-o-]r{+o+}ld │
│ │ │ │
└─────────────────────────────┘ └────────────────────────────────────────────┘
┌──────┬────────┬────────┬───────────┐ ┌─ Expected vs Actual ──────────────────┐
│ # │ Status │ Time │ Exit Code │ │ 45.70ms │ Exit: 0 │
├──────┼────────┼────────┼───────────┤ ├────────────────────────────────────────┤
│ 1 │ AC │12.00ms │ 0 │ │ │
│ >2 │ WA │45.70ms │ 1 │ │ 4[-2-]{+3+} │
├──────┴────────┴────────┴───────────┤ │ 100 │
│5 3 │ │ hello w[-o-]r{+o+}ld │
├──────┬────────┬────────┬───────────┤ │ │
│ 3 │ AC │ 9.00ms │ 0 │ └────────────────────────────────────────┘
│ 4 │ RTE │ 0.00ms │139 (SIGUSR2)│
└──────┴────────┴────────┴───────────┘
<
Status Indicators ~
@ -338,7 +342,8 @@ Keymaps ~
*cp-test-keys*
<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)
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 ~
@ -365,8 +370,8 @@ cp.nvim creates the following file structure upon problem setup:
build/
{problem_id}.run " Compiled binary
io/
{problem_id}.cpin " Test input
{problem_id}.cpout " Program output
{problem_id}.n.cpin " nth test input
{problem_id}.n.cpout " nth program output
{problem_id}.expected " Expected output
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
snippets include basic C++ and Python templates for each contest type.
Snippet trigger names must EXACTLY match platform names ("codeforces" for
CodeForces, "cses" for CSES, etc.).
Snippet trigger names must match the following format exactly:
cp.nvim/{platform}
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 next_test_key string Key to navigate to next 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
---@field command string Git executable name
@ -82,6 +83,7 @@ M.defaults = {
diff_mode = 'vim',
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
toggle_diff_key = 't',
},
diff = {
git = {
@ -153,6 +155,13 @@ function M.setup(user_config)
end,
'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

View file

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

View file

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

View file

@ -211,5 +211,47 @@ describe('cp.snippets', function()
assert.equals(1, codeforces_count)
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)