From 99340e551b0d70c7d1082ed8f4d518c72f033535 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 19 Sep 2025 19:11:40 -0400 Subject: [PATCH] fix: permit lowercase snippets --- README.md | 9 -- doc/cp.txt | 60 ++++++----- lua/cp/config.lua | 9 ++ lua/cp/init.lua | 220 +++++++++++++++++++++++++++-------------- lua/cp/snippets.lua | 8 +- spec/snippets_spec.lua | 42 ++++++++ 6 files changed, 234 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 49ffb34..14b5fe9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/cp.txt b/doc/cp.txt index 8229fbf..3933a70 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -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 = "", -- navigate to next test case - prev_test_key = "", -- navigate to previous test case + diff_mode = "vim", + next_test_key = "", + prev_test_key = "", + 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: `""`) Key to navigate to next test case. • {prev_test_key} (`string`, default: `""`) 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 - 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* Navigate to next test case (configurable via run_panel.next_test_key) 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. diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 225aab7..2242fa2 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -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 = '', prev_test_key = '', + 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 diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 2e9927b..d4a212a 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -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) diff --git a/lua/cp/snippets.lua b/lua/cp/snippets.lua index b7b03ed..e0237c8 100644 --- a/lua/cp/snippets.lua +++ b/lua/cp/snippets.lua @@ -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 diff --git a/spec/snippets_spec.lua b/spec/snippets_spec.lua index bdddb56..861c102 100644 --- a/spec/snippets_spec.lua +++ b/spec/snippets_spec.lua @@ -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)