Merge pull request #56 from barrett-ruth/fix/doc-keybindings

Test Panel Updates
This commit is contained in:
Barrett Ruth 2025-09-20 01:36:59 +02:00 committed by GitHub
commit 653a139395
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1824 additions and 246 deletions

View file

@ -44,7 +44,7 @@ jobs:
- uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
version: 2.1.0
args: --check .
lua-lint:
@ -114,4 +114,4 @@ jobs:
- name: Install dependencies with mypy
run: uv sync --dev
- name: Type check Python files with mypy
run: uv run mypy scrapers/ tests/scrapers/
run: uv run mypy scrapers/ tests/scrapers/

29
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,29 @@
minimum_pre_commit_version: "3.5.0"
repos:
- repo: https://github.com/JohnnyMorganz/StyLua
rev: v2.1.0
hooks:
- id: stylua-github
name: stylua (Lua formatter)
args: ["--check", "."]
files: ^(lua/|spec/|plugin/|after/|ftdetect/|.*\.lua$)
additional_dependencies: []
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff-format
name: ruff (format)
files: ^(scrapers/|tests/scrapers/|.*\.py$)
- id: ruff
name: ruff (lint)
args: ["--no-fix"]
files: ^(scrapers/|tests/scrapers/|.*\.py$)
- repo: local
hooks:
- id: mypy
name: mypy (type check)
entry: uv run mypy
language: system
args: ["scrapers/", "tests/scrapers/"]
files: ^(scrapers/|tests/scrapers/|.*\.py$)
pass_filenames: false

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

@ -49,9 +49,9 @@ Setup Commands ~
Action Commands ~
:CP test [--debug] Toggle test panel for individual test case
debugging. Shows per-test results with three-pane
layout for easy Expected/Actual comparison.
:CP run [--debug] Toggle run panel for individual test case
debugging. Shows per-test results with redesigned
layout for efficient comparison.
Use --debug flag to compile with debug flags
Requires contest setup first.
@ -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 = {
@ -85,7 +85,7 @@ Optional configuration with lazy.nvim: >
'g++', '-std=c++{version}', '-O2', '-Wall', '-Wextra',
'-DLOCAL', '{source}', '-o', '{binary}',
},
run = { '{binary}' },
test = { '{binary}' },
debug = {
'g++', '-std=c++{version}', '-g3',
'-fsanitize=address,undefined', '-DLOCAL',
@ -95,7 +95,7 @@ Optional configuration with lazy.nvim: >
extension = "cc",
},
python = {
run = { 'python3', '{source}' },
test = { 'python3', '{source}' },
debug = { 'python3', '{source}' },
extension = "py",
},
@ -105,16 +105,26 @@ Optional configuration with lazy.nvim: >
},
hooks = {
before_run = function(ctx) vim.cmd.w() end,
before_debug = function(ctx)
-- ctx.problem_id, ctx.platform, ctx.source_file, etc.
vim.cmd.w()
end,
before_debug = function(ctx) ... end,
setup_code = function(ctx)
vim.wo.foldmethod = "marker"
vim.wo.foldmarker = "{{{,}}}"
vim.diagnostic.enable(false)
end,
},
run_panel = {
diff_mode = "vim",
next_test_key = "<c-n>",
prev_test_key = "<c-p>",
toggle_diff_key = "t",
},
diff = {
git = {
command = "git",
args = {"diff", "--no-index", "--word-diff=plain",
"--word-diff-regex=.", "--no-prefix"},
},
},
snippets = { ... }, -- LuaSnip snippets
filename = function(contest, contest_id, problem_id, config, language) ... end,
}
@ -131,6 +141,8 @@ Optional configuration with lazy.nvim: >
during operation.
• {scrapers} (`table<string,boolean>`) Per-platform scraper control.
Default enables all platforms.
• {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.
@ -151,21 +163,50 @@ Optional configuration with lazy.nvim: >
Fields: ~
• {compile}? (`string[]`) Compile command template with
`{version}`, `{source}`, `{binary}` placeholders.
• {run} (`string[]`) Run command template.
• {test} (`string[]`) Test execution command template.
• {debug}? (`string[]`) Debug compile command template.
• {version}? (`number`) Language version (e.g. 20, 23 for C++).
• {extension} (`string`) File extension (e.g. "cc", "py").
• {executable}? (`string`) Executable name for interpreted languages.
*cp.RunPanelConfig*
Fields: ~
• {diff_mode} (`string`, default: `"vim"`) Diff backend: "vim" or "git".
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*
Fields: ~
• {git} (`DiffGitConfig`) Git diff backend configuration.
*cp.Hooks*
Fields: ~
• {before_run}? (`function`) Called before test panel opens.
`function(ctx: ProblemContext)`
• {before_debug}? (`function`) Called before debug compilation.
`function(ctx: ProblemContext)`
• {setup_code}? (`function`) Called after source file is opened.
Used to configure buffer settings.
Good for configuring buffer settings.
`function(ctx: ProblemContext)`
*ProblemContext*
Fields: ~
• {contest} (`string`) Platform name (e.g. "atcoder", "codeforces")
• {contest_id} (`string`) Contest ID (e.g. "abc123", "1933")
• {problem_id}? (`string`) Problem ID (e.g. "a", "b") - nil for CSES
• {source_file} (`string`) Source filename (e.g. "abc123a.cpp")
• {binary_file} (`string`) Binary output path (e.g. "build/abc123a.run")
• {input_file} (`string`) Test input path (e.g. "io/abc123a.cpin")
• {output_file} (`string`) Program output path (e.g. "io/abc123a.cpout")
• {expected_file} (`string`) Expected output path (e.g. "io/abc123a.expected")
• {problem_name} (`string`) Display name (e.g. "abc123a")
WORKFLOW *cp-workflow*
For the sake of consistency and simplicity, cp.nvim extracts contest/problem identifiers from
@ -232,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
@ -248,20 +293,17 @@ 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
<
TEST PANEL *cp-test*
RUN PANEL *cp-run*
The test panel provides individual test case debugging with a three-pane
layout showing test list, expected output, and actual output side-by-side.
The run panel provides individual test case debugging with a streamlined
layout optimized for modern screens. Shows test status with competitive
programming terminology and efficient space usage.
Activation ~
*:CP-test*
:CP test [--debug] Toggle test panel on/off. When activated,
*:CP-run*
:CP run [--debug] Toggle run panel on/off. When activated,
replaces current layout with test interface.
Automatically compiles and runs all tests.
Use --debug flag to compile with debug symbols
@ -270,29 +312,48 @@ Activation ~
Interface ~
The test panel uses a three-pane layout for easy 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) >
┌─────────────────────────────────────────────────────────────┐
│ 1. [ok:true ] [code:0] [time:12ms] │
│> 2. [ok:false] [code:0] [time:45ms] │
│ │
│ Input: │
│ 5 3 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─ Expected ──────────────────┐ ┌───── Actual ────────────────┐
│ 8 │ │ 7 │
│ │ │ │
│ │ │ │
│ │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
┌──────┬────────┬────────┬───────────┐ ┌─ 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 ~
Test cases use competitive programming terminology:
AC Accepted (passed)
WA Wrong Answer (output mismatch)
TLE Time Limit Exceeded (timeout)
RTE Runtime Error (non-zero exit)
Keymaps ~
*cp-test-keys*
<c-n> Navigate to next test case
<c-p> Navigate to previous test case
q Exit test panel (restore layout)
<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)
t Toggle diff mode between vim and git (configurable via run_panel.toggle_diff_key)
q Exit test panel and restore layout
Diff Modes ~
Two diff backends are available:
vim Built-in vim diff (default, always available)
git Character-level git word-diff (requires git, more precise)
The git backend shows character-level changes with [-removed-] and {+added+}
markers for precise difference analysis.
Execution Details ~
@ -309,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
@ -321,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

@ -1,6 +1,6 @@
---@class LanguageConfig
---@field compile? string[] Compile command template
---@field run string[] Run command template
---@field test string[] Test execution command template
---@field debug? string[] Debug command template
---@field executable? string Executable name
---@field version? number Language version
@ -8,7 +8,7 @@
---@class PartialLanguageConfig
---@field compile? string[] Compile command template
---@field run? string[] Run command template
---@field test? string[] Test execution command template
---@field debug? string[] Debug command template
---@field executable? string Executable name
---@field version? number Language version
@ -31,6 +31,19 @@
---@field before_debug? fun(ctx: ProblemContext)
---@field setup_code? fun(ctx: ProblemContext)
---@class RunPanelConfig
---@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
---@field args string[] Additional git diff arguments
---@class DiffConfig
---@field git DiffGitConfig
---@class cp.Config
---@field contests table<string, ContestConfig>
---@field snippets table[]
@ -38,6 +51,8 @@
---@field debug boolean
---@field scrapers table<string, boolean>
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
---@field run_panel RunPanelConfig
---@field diff DiffConfig
---@class cp.UserConfig
---@field contests? table<string, PartialContestConfig>
@ -46,6 +61,8 @@
---@field debug? boolean
---@field scrapers? table<string, boolean>
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
---@field run_panel? RunPanelConfig
---@field diff? DiffConfig
local M = {}
local constants = require('cp.constants')
@ -62,6 +79,18 @@ M.defaults = {
debug = false,
scrapers = constants.PLATFORMS,
filename = nil,
run_panel = {
diff_mode = 'vim',
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
toggle_diff_key = 't',
},
diff = {
git = {
command = 'git',
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
},
},
}
---@param user_config cp.UserConfig|nil
@ -79,28 +108,10 @@ function M.setup(user_config)
debug = { user_config.debug, { 'boolean', 'nil' }, true },
scrapers = { user_config.scrapers, { 'table', 'nil' }, true },
filename = { user_config.filename, { 'function', 'nil' }, true },
run_panel = { user_config.run_panel, { 'table', 'nil' }, true },
diff = { user_config.diff, { 'table', 'nil' }, true },
})
if user_config.hooks then
vim.validate({
before_run = {
user_config.hooks.before_run,
{ 'function', 'nil' },
true,
},
before_debug = {
user_config.hooks.before_debug,
{ 'function', 'nil' },
true,
},
setup_code = {
user_config.hooks.setup_code,
{ 'function', 'nil' },
true,
},
})
end
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
@ -144,6 +155,60 @@ function M.setup(user_config)
local config = vim.tbl_deep_extend('force', M.defaults, user_config or {})
-- Validate merged config values
vim.validate({
before_run = {
config.hooks.before_run,
{ 'function', 'nil' },
true,
},
before_debug = {
config.hooks.before_debug,
{ 'function', 'nil' },
true,
},
setup_code = {
config.hooks.setup_code,
{ 'function', 'nil' },
true,
},
})
vim.validate({
diff_mode = {
config.run_panel.diff_mode,
function(value)
return vim.tbl_contains({ 'vim', 'git' }, value)
end,
"diff_mode must be 'vim' or 'git'",
},
next_test_key = {
config.run_panel.next_test_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'next_test_key must be a non-empty string',
},
prev_test_key = {
config.run_panel.prev_test_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'prev_test_key must be a non-empty string',
},
toggle_diff_key = {
config.run_panel.toggle_diff_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'toggle_diff_key must be a non-empty string',
},
})
vim.validate({
git = { config.diff.git, { 'table', 'nil' }, true },
})
for _, contest_config in pairs(config.contests) do
for lang_name, lang_config in pairs(contest_config) do
if type(lang_config) == 'table' and not lang_config.extension then

View file

@ -1,7 +1,7 @@
local M = {}
M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' }
M.ACTIONS = { 'test', 'next', 'prev' }
M.ACTIONS = { 'run', 'next', 'prev' }
M.CPP = 'cpp'
M.PYTHON = 'python'

121
lua/cp/diff.lua Normal file
View file

@ -0,0 +1,121 @@
---@class DiffResult
---@field content string[]
---@field highlights table[]?
---@field raw_diff string?
---@class DiffBackend
---@field name string
---@field render fun(expected: string, actual: string): DiffResult
local M = {}
---Vim's built-in diff backend using diffthis
---@type DiffBackend
local vim_backend = {
name = 'vim',
render = function(_, actual)
local actual_lines = vim.split(actual, '\n', { plain = true, trimempty = true })
return {
content = actual_lines,
highlights = nil, -- diffthis handles highlighting
}
end,
}
---Git word-diff backend for character-level precision
---@type DiffBackend
local git_backend = {
name = 'git',
render = function(expected, actual)
-- Create temporary files for git diff
local tmp_expected = vim.fn.tempname()
local tmp_actual = vim.fn.tempname()
vim.fn.writefile(vim.split(expected, '\n', { plain = true }), tmp_expected)
vim.fn.writefile(vim.split(actual, '\n', { plain = true }), tmp_actual)
local cmd = {
'git',
'diff',
'--no-index',
'--word-diff=plain',
'--word-diff-regex=.',
'--no-prefix',
tmp_expected,
tmp_actual,
}
local result = vim.system(cmd, { text = true }):wait()
-- Clean up temp files
vim.fn.delete(tmp_expected)
vim.fn.delete(tmp_actual)
if result.code == 0 then
return {
content = vim.split(actual, '\n', { plain = true, trimempty = true }),
highlights = {},
}
else
return {
content = {},
highlights = {},
raw_diff = result.stdout or '',
}
end
end,
}
---Available diff backends
---@type table<string, DiffBackend>
local backends = {
vim = vim_backend,
git = git_backend,
}
---Get available backend names
---@return string[]
function M.get_available_backends()
return vim.tbl_keys(backends)
end
---Get a diff backend by name
---@param name string
---@return DiffBackend?
function M.get_backend(name)
return backends[name]
end
---Check if git backend is available
---@return boolean
function M.is_git_available()
local result = vim.system({ 'git', '--version' }, { text = true }):wait()
return result.code == 0
end
---Get the best available backend based on config and system availability
---@param preferred_backend? string
---@return DiffBackend
function M.get_best_backend(preferred_backend)
if preferred_backend and backends[preferred_backend] then
if preferred_backend == 'git' and not M.is_git_available() then
return backends.vim
end
return backends[preferred_backend]
end
return backends.vim
end
---Render diff using specified backend
---@param expected string
---@param actual string
---@param backend_name? string
---@return DiffResult
function M.render_diff(expected, actual, backend_name)
local backend = M.get_best_backend(backend_name)
return backend.render(expected, actual)
end
return M

View file

@ -278,13 +278,19 @@ function M.run_problem(ctx, contest_config, is_debug)
input_data = table.concat(vim.fn.readfile(ctx.input_file), '\n') .. '\n'
end
local run_cmd = build_command(language_config.run, language_config.executable, substitutions)
local run_cmd = build_command(language_config.test, language_config.executable, substitutions)
local exec_result = execute_command(run_cmd, input_data, contest_config.timeout_ms)
local formatted_output = format_output(exec_result, ctx.expected_file, is_debug)
local output_buf = vim.fn.bufnr(ctx.output_file)
if output_buf ~= -1 then
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = output_buf })
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = output_buf })
vim.api.nvim_set_option_value('readonly', false, { buf = output_buf })
vim.api.nvim_set_option_value('modifiable', true, { buf = output_buf })
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.split(formatted_output, '\n'))
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = output_buf })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = output_buf })
vim.api.nvim_buf_call(output_buf, function()
vim.cmd.write()
end)

170
lua/cp/highlight.lua Normal file
View file

@ -0,0 +1,170 @@
---@class DiffHighlight
---@field line number
---@field col_start number
---@field col_end number
---@field highlight_group string
---@class ParsedDiff
---@field content string[]
---@field highlights DiffHighlight[]
local M = {}
---Parse git diff markers and extract highlight information
---@param text string Raw git diff output line
---@return string cleaned_text, DiffHighlight[]
local function parse_diff_line(text)
local result_text = ''
local highlights = {}
local pos = 1
while pos <= #text do
local removed_start, removed_end, removed_content = text:find('%[%-(.-)%-%]', pos)
if removed_start and removed_start == pos then
local highlight_start = #result_text
result_text = result_text .. removed_content
table.insert(highlights, {
line = 0,
col_start = highlight_start,
col_end = #result_text,
highlight_group = 'CpDiffRemoved',
})
pos = removed_end + 1
else
local added_start, added_end, added_content = text:find('{%+(.-)%+}', pos)
if added_start and added_start == pos then
local highlight_start = #result_text
result_text = result_text .. added_content
table.insert(highlights, {
line = 0,
col_start = highlight_start,
col_end = #result_text,
highlight_group = 'CpDiffAdded',
})
pos = added_end + 1
else
result_text = result_text .. text:sub(pos, pos)
pos = pos + 1
end
end
end
return result_text, highlights
end
---Parse complete git diff output
---@param diff_output string
---@return ParsedDiff
function M.parse_git_diff(diff_output)
if diff_output == '' then
return { content = {}, highlights = {} }
end
local lines = vim.split(diff_output, '\n', { plain = true })
local content_lines = {}
local all_highlights = {}
-- Skip git diff header lines
local content_started = false
for _, line in ipairs(lines) do
-- Skip header lines (@@, +++, ---, index, etc.)
if
content_started
or (
not line:match('^@@')
and not line:match('^%+%+%+')
and not line:match('^%-%-%-')
and not line:match('^index')
and not line:match('^diff %-%-git')
)
then
content_started = true
-- Process content lines
if line:match('^%+') then
-- Added line - remove + prefix and parse highlights
local clean_line = line:sub(2) -- Remove + prefix
local parsed_line, line_highlights = parse_diff_line(clean_line)
table.insert(content_lines, parsed_line)
-- Set line numbers for highlights
local line_num = #content_lines
for _, highlight in ipairs(line_highlights) do
highlight.line = line_num - 1 -- 0-based for extmarks
table.insert(all_highlights, highlight)
end
elseif not line:match('^%-') and not line:match('^\\') then -- Skip removed lines and "\ No newline" messages
-- Word-diff content line or unchanged line
local clean_line = line:match('^%s') and line:sub(2) or line
local parsed_line, line_highlights = parse_diff_line(clean_line)
-- Only add non-empty lines
if parsed_line ~= '' then
table.insert(content_lines, parsed_line)
-- Set line numbers for highlights
local line_num = #content_lines
for _, highlight in ipairs(line_highlights) do
highlight.line = line_num - 1 -- 0-based for extmarks
table.insert(all_highlights, highlight)
end
end
end
end
end
return {
content = content_lines,
highlights = all_highlights,
}
end
---Apply highlights to a buffer using extmarks
---@param bufnr number
---@param highlights DiffHighlight[]
---@param namespace number
function M.apply_highlights(bufnr, highlights, namespace)
-- Clear existing highlights in this namespace
vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
for _, highlight in ipairs(highlights) do
if highlight.col_start < highlight.col_end then
vim.api.nvim_buf_set_extmark(bufnr, namespace, highlight.line, highlight.col_start, {
end_col = highlight.col_end,
hl_group = highlight.highlight_group,
priority = 100,
})
end
end
end
---Create namespace for diff highlights
---@return number
function M.create_namespace()
return vim.api.nvim_create_namespace('cp_diff_highlights')
end
---Parse and apply git diff to buffer
---@param bufnr number
---@param diff_output string
---@param namespace number
---@return string[] content_lines
function M.parse_and_apply_diff(bufnr, diff_output, namespace)
local parsed = M.parse_git_diff(diff_output)
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr })
vim.api.nvim_set_option_value('readonly', false, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, parsed.content)
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr })
M.apply_highlights(bufnr, parsed.highlights, namespace)
return parsed.content
end
return M

View file

@ -25,9 +25,12 @@ local state = {
saved_session = nil,
test_cases = nil,
test_states = {},
test_panel_active = false,
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
@ -149,14 +152,32 @@ local function get_current_problem()
return filename
end
local function toggle_test_panel(is_debug)
if state.test_panel_active then
local function create_buffer_with_options(filetype)
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 })
if filetype then
vim.api.nvim_set_option_value('filetype', filetype, { buf = buf })
end
return buf
end
local setup_keybindings_for_buffer
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)
state.saved_session = nil
end
state.test_panel_active = false
state.run_panel_active = false
logger.log('test panel closed')
return
end
@ -187,167 +208,224 @@ local function toggle_test_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)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = tab_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = expected_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = actual_buf })
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 function render_test_tabs()
local test_state = test_module.get_test_panel_state()
local tab_lines = {}
local highlight = require('cp.highlight')
local diff_namespace = highlight.create_namespace()
local max_status_width = 0
local max_code_width = 0
local max_time_width = 0
local test_list_namespace = vim.api.nvim_create_namespace('cp_test_list')
for _, test_case in ipairs(test_state.test_cases) do
local status_text = test_case.status == 'pending' and '' or string.upper(test_case.status)
max_status_width = math.max(max_status_width, #status_text)
local function update_buffer_content(bufnr, lines, highlights)
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr })
if test_case.code then
max_code_width = math.max(max_code_width, #tostring(test_case.code))
end
vim.api.nvim_set_option_value('readonly', false, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr })
if test_case.time_ms then
local time_text = string.format('%.0fms', test_case.time_ms)
max_time_width = math.max(max_time_width, #time_text)
end
end
for i, test_case in ipairs(test_state.test_cases) do
local prefix = i == test_state.current_index and '> ' or ' '
local tab = string.format('%s%d.', prefix, i)
if test_case.ok ~= nil then
tab = tab .. string.format(' [ok:%-5s]', tostring(test_case.ok))
end
if test_case.code then
tab = tab .. string.format(' [code:%-' .. max_code_width .. 's]', tostring(test_case.code))
end
if test_case.time_ms then
local time_text = string.format('%.0fms', test_case.time_ms)
tab = tab .. string.format(' [time:%-' .. max_time_width .. 's]', time_text)
end
if test_case.signal then
tab = tab .. string.format(' [%s]', test_case.signal)
end
table.insert(tab_lines, tab)
end
local current_test = test_state.test_cases[test_state.current_index]
if current_test then
table.insert(tab_lines, '')
table.insert(tab_lines, 'Input:')
for _, line in ipairs(vim.split(current_test.input, '\n', { plain = true, trimempty = true })) do
table.insert(tab_lines, line)
end
end
return tab_lines
end
local function update_expected_pane()
local test_state = test_module.get_test_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
if not current_test then
return
end
local expected_text = current_test.expected
local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true })
vim.api.nvim_buf_set_lines(test_buffers.expected_buf, 0, -1, false, expected_lines)
if vim.fn.has('nvim-0.8.0') == 1 then
vim.api.nvim_set_option_value('winbar', 'Expected', { win = test_windows.expected_win })
vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1)
for _, hl in ipairs(highlights) do
vim.api.nvim_buf_set_extmark(bufnr, test_list_namespace, hl.line, hl.col_start, {
end_col = hl.col_end,
hl_group = hl.highlight_group,
priority = 100,
})
end
end
local function update_actual_pane()
local test_state = test_module.get_test_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
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()
if not current_test then
return
end
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)
local actual_lines = {}
local enable_diff = false
vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
if current_test.actual then
actual_lines = vim.split(current_test.actual, '\n', { plain = true, trimempty = true })
enable_diff = current_test.status == 'fail'
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_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)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = diff_buf })
local diff_backend = require('cp.diff')
local backend = diff_backend.get_best_backend('git')
local diff_result = backend.render(expected_content, actual_content)
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
actual_lines = { '(not run yet)' }
local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(diff_buf, lines, {})
end
vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines)
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
if vim.fn.has('nvim-0.8.0') == 1 then
vim.api.nvim_set_option_value('winbar', 'Actual', { win = test_windows.actual_win })
end
vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.expected_win })
vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.actual_win })
if enable_diff then
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 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 refresh_test_panel()
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]
if not current_test then
return
end
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 not should_show_diff then
expected_content = expected_content
actual_content = actual_content
end
local desired_mode = should_show_diff and config.run_panel.diff_mode or 'vim'
if current_diff_layout and current_mode ~= desired_mode then
local saved_pos = vim.api.nvim_win_get_cursor(0)
current_diff_layout.cleanup()
current_diff_layout = nil
current_mode = nil
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
pcall(vim.api.nvim_win_set_cursor, 0, saved_pos)
return
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 diff_result.raw_diff and diff_result.raw_diff ~= '' then
highlight.parse_and_apply_diff(
current_diff_layout.buffers[1],
diff_result.raw_diff,
diff_namespace
)
else
local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(current_diff_layout.buffers[1], lines, {})
end
else
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
end
end
local function refresh_run_panel()
if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
return
end
local tab_lines = render_test_tabs()
vim.api.nvim_buf_set_lines(test_buffers.tab_buf, 0, -1, false, tab_lines)
local test_render = require('cp.test_render')
test_render.setup_highlights()
local test_state = test_module.get_run_panel_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_expected_pane()
update_actual_pane()
update_diff_panes()
end
local function navigate_test_case(delta)
local test_state = test_module.get_test_panel_state()
local test_state = test_module.get_run_panel_state()
if #test_state.test_cases == 0 then
return
end
@ -359,20 +437,30 @@ local function toggle_test_panel(is_debug)
test_state.current_index = 1
end
refresh_test_panel()
refresh_run_panel()
end
vim.keymap.set('n', '<c-n>', function()
setup_keybindings_for_buffer = function(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 })
vim.keymap.set('n', '<c-p>', function()
vim.keymap.set('n', config.run_panel.prev_test_key, function()
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_test_panel()
end, { buffer = buf, silent = true })
setup_keybindings_for_buffer(test_buffers.tab_buf)
if config.hooks and config.hooks.before_run then
config.hooks.before_run(ctx)
end
if is_debug and config.hooks and config.hooks.before_debug then
@ -385,14 +473,14 @@ local function toggle_test_panel(is_debug)
test_module.run_all_test_cases(ctx, contest_config)
end
refresh_test_panel()
refresh_run_panel()
vim.api.nvim_set_current_win(test_windows.tab_win)
state.test_panel_active = true
state.run_panel_active = true
state.test_buffers = test_buffers
state.test_windows = test_windows
local test_state = test_module.get_test_panel_state()
local test_state = test_module.get_run_panel_state()
logger.log(string.format('test panel opened (%d test cases)', #test_state.test_cases))
end
@ -559,8 +647,8 @@ function M.handle_command(opts)
end
if cmd.type == 'action' then
if cmd.action == 'test' then
toggle_test_panel(cmd.debug)
if cmd.action == 'run' then
toggle_run_panel(cmd.debug)
elseif cmd.action == 'next' then
navigate_problem(1, cmd.language)
elseif cmd.action == 'prev' then

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

@ -12,7 +12,7 @@
---@field signal string?
---@field timed_out boolean?
---@class TestPanelState
---@class RunPanelState
---@field test_cases TestCase[]
---@field current_index number
---@field buffer number?
@ -24,8 +24,8 @@ local M = {}
local constants = require('cp.constants')
local logger = require('cp.log')
---@type TestPanelState
local test_panel_state = {
---@type RunPanelState
local run_panel_state = {
test_cases = {},
current_index = 1,
buffer = nil,
@ -172,7 +172,7 @@ local function run_single_test_case(ctx, contest_config, test_case)
end
end
local run_cmd = build_command(language_config.run, language_config.executable, substitutions)
local run_cmd = build_command(language_config.test, language_config.executable, substitutions)
local stdin_content = test_case.input .. '\n'
@ -227,8 +227,8 @@ function M.load_test_cases(ctx, state)
test_cases = parse_test_cases_from_files(ctx.input_file, ctx.expected_file)
end
test_panel_state.test_cases = test_cases
test_panel_state.current_index = 1
run_panel_state.test_cases = test_cases
run_panel_state.current_index = 1
logger.log(('loaded %d test case(s)'):format(#test_cases))
return #test_cases > 0
@ -239,7 +239,7 @@ end
---@param index number
---@return boolean
function M.run_test_case(ctx, contest_config, index)
local test_case = test_panel_state.test_cases[index]
local test_case = run_panel_state.test_cases[index]
if not test_case then
return false
end
@ -266,16 +266,16 @@ end
---@return TestCase[]
function M.run_all_test_cases(ctx, contest_config)
local results = {}
for i, _ in ipairs(test_panel_state.test_cases) do
for i, _ in ipairs(run_panel_state.test_cases) do
M.run_test_case(ctx, contest_config, i)
table.insert(results, test_panel_state.test_cases[i])
table.insert(results, run_panel_state.test_cases[i])
end
return results
end
---@return TestPanelState
function M.get_test_panel_state()
return test_panel_state
---@return RunPanelState
function M.get_run_panel_state()
return run_panel_state
end
return M

289
lua/cp/test_render.lua Normal file
View file

@ -0,0 +1,289 @@
---@class StatusInfo
---@field text string
---@field highlight_group string
local M = {}
local exit_code_names = {
[128] = 'SIGHUP',
[129] = 'SIGINT',
[130] = 'SIGQUIT',
[131] = 'SIGILL',
[132] = 'SIGTRAP',
[133] = 'SIGABRT',
[134] = 'SIGBUS',
[135] = 'SIGFPE',
[136] = 'SIGKILL',
[137] = 'SIGUSR1',
[138] = 'SIGSEGV',
[139] = 'SIGUSR2',
[140] = 'SIGPIPE',
[141] = 'SIGALRM',
[142] = 'SIGTERM',
[143] = 'SIGCHLD',
}
---@param test_case TestCase
---@return StatusInfo
function M.get_status_info(test_case)
if test_case.status == 'pass' then
return { text = 'AC', highlight_group = 'CpTestAC' }
elseif test_case.status == 'fail' then
if test_case.timed_out then
return { text = 'TLE', highlight_group = 'CpTestError' }
elseif test_case.code and test_case.code >= 128 then
return { text = 'RTE', highlight_group = 'CpTestError' }
else
return { text = 'WA', highlight_group = 'CpTestError' }
end
elseif test_case.status == 'timeout' then
return { text = 'TLE', highlight_group = 'CpTestError' }
elseif test_case.status == 'running' then
return { text = '...', highlight_group = 'CpTestPending' }
else
return { text = '', highlight_group = 'CpTestPending' }
end
end
local function format_exit_code(code)
if not code then
return ''
end
local signal_name = exit_code_names[code]
return signal_name and string.format('%d (%s)', code, signal_name) or tostring(code)
end
-- Compute column widths + aggregates
local function compute_cols(test_state)
local w = { num = 3, status = 8, time = 6, exit = 11 }
for i, tc in ipairs(test_state.test_cases) do
local prefix = (i == test_state.current_index) and '>' or ' '
w.num = math.max(w.num, #(prefix .. i))
w.status = math.max(w.status, #(' ' .. M.get_status_info(tc).text))
local time_str = tc.time_ms and (string.format('%.2f', tc.time_ms) .. 'ms') or ''
w.time = math.max(w.time, #time_str)
w.exit = math.max(w.exit, #(' ' .. format_exit_code(tc.code)))
end
w.num = math.max(w.num, #' #')
w.status = math.max(w.status, #' Status')
w.time = math.max(w.time, #' Time')
w.exit = math.max(w.exit, #' Exit Code')
local sum = w.num + w.status + w.time + w.exit
local inner = sum + 3 -- three inner vertical dividers
local total = inner + 2 -- two outer borders
return { w = w, sum = sum, inner = inner, total = total }
end
local function center(text, width)
local pad = width - #text
if pad <= 0 then
return text
end
local left = math.floor(pad / 2)
return string.rep(' ', left) .. text .. string.rep(' ', pad - left)
end
local function top_border(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function row_sep(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function bottom_border(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function flat_fence_above(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function flat_fence_below(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function flat_bottom_border(c)
return '' .. string.rep('', c.inner) .. ''
end
local function header_line(c)
local w = c.w
return ''
.. center('#', w.num)
.. ''
.. center('Status', w.status)
.. ''
.. center('Time', w.time)
.. ''
.. center('Exit Code', w.exit)
.. ''
end
local function data_row(c, idx, tc, is_current)
local w = c.w
local prefix = is_current and '>' or ' '
local status = M.get_status_info(tc)
local time = tc.time_ms and (string.format('%.2f', tc.time_ms) .. 'ms') or ''
local exit = format_exit_code(tc.code)
local line = ''
.. center(prefix .. idx, w.num)
.. ''
.. center(status.text, w.status)
.. ''
.. center(time, w.time)
.. ''
.. center(exit, w.exit)
.. ''
local hi
if status.text ~= '' then
local pad = w.status - #status.text
local left = math.floor(pad / 2)
local status_start_col = 1 + w.num + 1 + left
local status_end_col = status_start_col + #status.text
hi = {
col_start = status_start_col,
col_end = status_end_col,
highlight_group = status.highlight_group,
}
end
return line, hi
end
---@param test_state RunPanelState
---@return string[], table[] lines and highlight positions
function M.render_test_list(test_state)
local lines, highlights = {}, {}
local c = compute_cols(test_state)
table.insert(lines, top_border(c))
table.insert(lines, header_line(c))
table.insert(lines, row_sep(c))
for i, tc in ipairs(test_state.test_cases) do
local is_current = (i == test_state.current_index)
local row, hi = data_row(c, i, tc, is_current)
table.insert(lines, row)
if hi then
hi.line = #lines - 1
table.insert(highlights, hi)
end
local has_next = (i < #test_state.test_cases)
local has_input = is_current and tc.input and tc.input ~= ''
if has_input then
table.insert(lines, flat_fence_above(c))
for _, input_line in ipairs(vim.split(tc.input, '\n', { plain = true, trimempty = false })) do
local s = input_line or ''
if #s > c.inner then
s = string.sub(s, 1, c.inner)
end
local pad = c.inner - #s
table.insert(lines, '' .. s .. string.rep(' ', pad) .. '')
end
if has_next then
table.insert(lines, flat_fence_below(c))
else
table.insert(lines, flat_bottom_border(c))
end
else
if has_next then
table.insert(lines, row_sep(c))
else
table.insert(lines, bottom_border(c))
end
end
end
return lines, highlights
end
---@param test_case TestCase?
---@return string
function M.render_status_bar(test_case)
if not test_case then
return ''
end
local parts = {}
if test_case.time_ms then
table.insert(parts, string.format('%.2fms', test_case.time_ms))
end
if test_case.code then
table.insert(parts, string.format('Exit: %d', test_case.code))
end
return table.concat(parts, '')
end
---@return table<string, table>
function M.get_highlight_groups()
return {
CpTestAC = { fg = '#10b981', bold = true },
CpTestError = { fg = '#ef4444', bold = true },
CpTestPending = { fg = '#6b7280' },
CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' },
CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' },
}
end
function M.setup_highlights()
local groups = M.get_highlight_groups()
for name, opts in pairs(groups) do
vim.api.nvim_set_hl(0, name, opts)
end
end
return M

View file

@ -17,6 +17,7 @@ dev = [
"types-requests>=2.32.4.20250913",
"pytest>=8.0.0",
"pytest-mock>=3.12.0",
"pre-commit>=4.3.0",
]
[tool.pytest.ini_options]

View file

@ -1 +1 @@
std = "vim"
std = 'vim'

View file

@ -51,7 +51,7 @@ describe('cp command parsing', function()
describe('action commands', function()
it('handles test action without error', function()
local opts = { fargs = { 'test' } }
local opts = { fargs = { 'run' } }
assert.has_no_errors(function()
cp.handle_command(opts)
@ -126,7 +126,7 @@ describe('cp command parsing', function()
describe('language flag parsing', function()
it('logs error for --lang flag missing value', function()
local opts = { fargs = { 'test', '--lang' } }
local opts = { fargs = { 'run', '--lang' } }
cp.handle_command(opts)
@ -169,7 +169,7 @@ describe('cp command parsing', function()
describe('debug flag parsing', function()
it('handles debug flag without error', function()
local opts = { fargs = { 'test', '--debug' } }
local opts = { fargs = { 'run', '--debug' } }
assert.has_no_errors(function()
cp.handle_command(opts)
@ -177,7 +177,7 @@ describe('cp command parsing', function()
end)
it('handles combined language and debug flags', function()
local opts = { fargs = { 'test', '--lang=cpp', '--debug' } }
local opts = { fargs = { 'run', '--lang=cpp', '--debug' } }
assert.has_no_errors(function()
cp.handle_command(opts)
@ -234,7 +234,7 @@ describe('cp command parsing', function()
end)
it('handles flag order variations', function()
local opts = { fargs = { '--debug', 'test', '--lang=python' } }
local opts = { fargs = { '--debug', 'run', '--lang=python' } }
assert.has_no_errors(function()
cp.handle_command(opts)
@ -242,7 +242,7 @@ describe('cp command parsing', function()
end)
it('handles multiple language flags', function()
local opts = { fargs = { 'test', '--lang=cpp', '--lang=python' } }
local opts = { fargs = { 'run', '--lang=cpp', '--lang=python' } }
assert.has_no_errors(function()
cp.handle_command(opts)

View file

@ -73,6 +73,52 @@ describe('cp.config', function()
config.setup(invalid_config)
end)
end)
describe('run_panel config validation', function()
it('validates diff_mode values', function()
local invalid_config = {
run_panel = { diff_mode = 'invalid' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('validates next_test_key is non-empty string', function()
local invalid_config = {
run_panel = { next_test_key = '' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('validates prev_test_key is non-empty string', function()
local invalid_config = {
run_panel = { prev_test_key = '' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('accepts valid run_panel config', function()
local valid_config = {
run_panel = {
diff_mode = 'git',
next_test_key = 'j',
prev_test_key = 'k',
},
}
assert.has_no.errors(function()
config.setup(valid_config)
end)
end)
end)
end)
describe('default_filename', function()

190
spec/diff_spec.lua Normal file
View file

@ -0,0 +1,190 @@
describe('cp.diff', function()
local diff = require('cp.diff')
describe('get_available_backends', function()
it('returns vim and git backends', function()
local backends = diff.get_available_backends()
table.sort(backends)
assert.same({ 'git', 'vim' }, backends)
end)
end)
describe('get_backend', function()
it('returns vim backend by name', function()
local backend = diff.get_backend('vim')
assert.is_not_nil(backend)
assert.equals('vim', backend.name)
end)
it('returns git backend by name', function()
local backend = diff.get_backend('git')
assert.is_not_nil(backend)
assert.equals('git', backend.name)
end)
it('returns nil for invalid name', function()
local backend = diff.get_backend('invalid')
assert.is_nil(backend)
end)
end)
describe('is_git_available', function()
it('returns true when git command succeeds', function()
local mock_system = stub(vim, 'system')
mock_system.returns({
wait = function()
return { code = 0 }
end,
})
local result = diff.is_git_available()
assert.is_true(result)
mock_system:revert()
end)
it('returns false when git command fails', function()
local mock_system = stub(vim, 'system')
mock_system.returns({
wait = function()
return { code = 1 }
end,
})
local result = diff.is_git_available()
assert.is_false(result)
mock_system:revert()
end)
end)
describe('get_best_backend', function()
it('returns preferred backend when available', function()
local mock_is_available = stub(diff, 'is_git_available')
mock_is_available.returns(true)
local backend = diff.get_best_backend('git')
assert.equals('git', backend.name)
mock_is_available:revert()
end)
it('falls back to vim when git unavailable', function()
local mock_is_available = stub(diff, 'is_git_available')
mock_is_available.returns(false)
local backend = diff.get_best_backend('git')
assert.equals('vim', backend.name)
mock_is_available:revert()
end)
it('defaults to vim backend', function()
local backend = diff.get_best_backend()
assert.equals('vim', backend.name)
end)
end)
describe('vim backend', function()
it('returns content as-is', function()
local backend = diff.get_backend('vim')
local result = backend.render('expected', 'actual')
assert.same({ 'actual' }, result.content)
assert.is_nil(result.highlights)
end)
end)
describe('git backend', function()
it('creates temp files for diff', function()
local mock_system = stub(vim, 'system')
local mock_tempname = stub(vim.fn, 'tempname')
local mock_writefile = stub(vim.fn, 'writefile')
local mock_delete = stub(vim.fn, 'delete')
mock_tempname.returns('/tmp/expected', '/tmp/actual')
mock_system.returns({
wait = function()
return { code = 1, stdout = 'diff output' }
end,
})
local backend = diff.get_backend('git')
backend.render('expected text', 'actual text')
assert.stub(mock_writefile).was_called(2)
assert.stub(mock_delete).was_called(2)
mock_system:revert()
mock_tempname:revert()
mock_writefile:revert()
mock_delete:revert()
end)
it('returns raw diff output', function()
local mock_system = stub(vim, 'system')
local mock_tempname = stub(vim.fn, 'tempname')
local mock_writefile = stub(vim.fn, 'writefile')
local mock_delete = stub(vim.fn, 'delete')
mock_tempname.returns('/tmp/expected', '/tmp/actual')
mock_system.returns({
wait = function()
return { code = 1, stdout = 'git diff output' }
end,
})
local backend = diff.get_backend('git')
local result = backend.render('expected', 'actual')
assert.equals('git diff output', result.raw_diff)
mock_system:revert()
mock_tempname:revert()
mock_writefile:revert()
mock_delete:revert()
end)
it('handles no differences', function()
local mock_system = stub(vim, 'system')
local mock_tempname = stub(vim.fn, 'tempname')
local mock_writefile = stub(vim.fn, 'writefile')
local mock_delete = stub(vim.fn, 'delete')
mock_tempname.returns('/tmp/expected', '/tmp/actual')
mock_system.returns({
wait = function()
return { code = 0 }
end,
})
local backend = diff.get_backend('git')
local result = backend.render('same', 'same')
assert.same({ 'same' }, result.content)
assert.same({}, result.highlights)
mock_system:revert()
mock_tempname:revert()
mock_writefile:revert()
mock_delete:revert()
end)
end)
describe('render_diff', function()
it('uses best available backend', function()
local mock_backend = {
render = function()
return {}
end,
}
local mock_get_best = stub(diff, 'get_best_backend')
mock_get_best.returns(mock_backend)
diff.render_diff('expected', 'actual', 'vim')
assert.stub(mock_get_best).was_called_with('vim')
mock_get_best:revert()
end)
end)
end)

182
spec/highlight_spec.lua Normal file
View file

@ -0,0 +1,182 @@
describe('cp.highlight', function()
local highlight = require('cp.highlight')
describe('parse_git_diff', function()
it('skips git diff headers', function()
local diff_output = [[diff --git a/test b/test
index 1234567..abcdefg 100644
--- a/test
+++ b/test
@@ -1,3 +1,3 @@
hello
+world
-goodbye]]
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'hello', 'world' }, result.content)
end)
it('processes added lines', function()
local diff_output = '+hello w{+o+}rld'
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'hello world' }, result.content)
assert.equals(1, #result.highlights)
assert.equals('CpDiffAdded', result.highlights[1].highlight_group)
end)
it('ignores removed lines', function()
local diff_output = 'hello\n-removed line\n+kept line'
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'hello', 'kept line' }, result.content)
end)
it('handles unchanged lines', function()
local diff_output = 'unchanged line\n+added line'
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'unchanged line', 'added line' }, result.content)
end)
it('sets correct line numbers', function()
local diff_output = '+first {+added+}\n+second {+text+}'
local result = highlight.parse_git_diff(diff_output)
assert.equals(0, result.highlights[1].line)
assert.equals(1, result.highlights[2].line)
end)
it('handles empty diff output', function()
local result = highlight.parse_git_diff('')
assert.same({}, result.content)
assert.same({}, result.highlights)
end)
end)
describe('apply_highlights', function()
it('clears existing highlights', function()
local mock_clear = spy.on(vim.api, 'nvim_buf_clear_namespace')
local bufnr = 1
local namespace = 100
highlight.apply_highlights(bufnr, {}, namespace)
assert.spy(mock_clear).was_called_with(bufnr, namespace, 0, -1)
mock_clear:revert()
end)
it('applies extmarks with correct positions', function()
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local bufnr = 1
local namespace = 100
local highlights = {
{
line = 0,
col_start = 5,
col_end = 10,
highlight_group = 'CpDiffAdded',
},
}
highlight.apply_highlights(bufnr, highlights, namespace)
assert.stub(mock_extmark).was_called_with(bufnr, namespace, 0, 5, {
end_col = 10,
hl_group = 'CpDiffAdded',
priority = 100,
})
mock_extmark:revert()
mock_clear:revert()
end)
it('uses correct highlight groups', function()
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local highlights = {
{
line = 0,
col_start = 0,
col_end = 5,
highlight_group = 'CpDiffAdded',
},
}
highlight.apply_highlights(1, highlights, 100)
assert.stub(mock_extmark).was_called_with(1, 100, 0, 0, {
end_col = 5,
hl_group = 'CpDiffAdded',
priority = 100,
})
mock_extmark:revert()
mock_clear:revert()
end)
it('handles empty highlights', function()
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
highlight.apply_highlights(1, {}, 100)
assert.stub(mock_extmark).was_not_called()
mock_extmark:revert()
mock_clear:revert()
end)
end)
describe('create_namespace', function()
it('creates unique namespace', function()
local mock_create = stub(vim.api, 'nvim_create_namespace')
mock_create.returns(42)
local result = highlight.create_namespace()
assert.equals(42, result)
assert.stub(mock_create).was_called_with('cp_diff_highlights')
mock_create:revert()
end)
end)
describe('parse_and_apply_diff', function()
it('parses diff and applies to buffer', function()
local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines')
local mock_apply = stub(highlight, 'apply_highlights')
local bufnr = 1
local namespace = 100
local diff_output = '+hello {+world+}'
local result = highlight.parse_and_apply_diff(bufnr, diff_output, namespace)
assert.same({ 'hello world' }, result)
assert.stub(mock_set_lines).was_called_with(bufnr, 0, -1, false, { 'hello world' })
assert.stub(mock_apply).was_called()
mock_set_lines:revert()
mock_apply:revert()
end)
it('sets buffer content', function()
local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines')
local mock_apply = stub(highlight, 'apply_highlights')
highlight.parse_and_apply_diff(1, '+test line', 100)
assert.stub(mock_set_lines).was_called_with(1, 0, -1, false, { 'test line' })
mock_set_lines:revert()
mock_apply:revert()
end)
it('applies highlights', function()
local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines')
local mock_apply = stub(highlight, 'apply_highlights')
highlight.parse_and_apply_diff(1, '+hello {+world+}', 100)
assert.stub(mock_apply).was_called()
mock_set_lines:revert()
mock_apply:revert()
end)
it('returns content lines', function()
local result = highlight.parse_and_apply_diff(1, '+first\n+second', 100)
assert.same({ 'first', 'second' }, result)
end)
end)
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)

169
spec/test_render_spec.lua Normal file
View file

@ -0,0 +1,169 @@
describe('cp.test_render', function()
local test_render = require('cp.test_render')
describe('get_status_info', function()
it('returns AC for pass status', function()
local test_case = { status = 'pass' }
local result = test_render.get_status_info(test_case)
assert.equals('AC', result.text)
assert.equals('CpTestAC', result.highlight_group)
end)
it('returns WA for fail status with normal exit codes', function()
local test_case = { status = 'fail', code = 1 }
local result = test_render.get_status_info(test_case)
assert.equals('WA', result.text)
assert.equals('CpTestError', result.highlight_group)
end)
it('returns TLE for timeout status', function()
local test_case = { status = 'timeout' }
local result = test_render.get_status_info(test_case)
assert.equals('TLE', result.text)
assert.equals('CpTestError', result.highlight_group)
end)
it('returns TLE for timed out fail status', function()
local test_case = { status = 'fail', timed_out = true }
local result = test_render.get_status_info(test_case)
assert.equals('TLE', result.text)
assert.equals('CpTestError', result.highlight_group)
end)
it('returns RTE for fail with signal codes (>= 128)', function()
local test_case = { status = 'fail', code = 139 }
local result = test_render.get_status_info(test_case)
assert.equals('RTE', result.text)
assert.equals('CpTestError', result.highlight_group)
end)
it('returns empty for pending status', function()
local test_case = { status = 'pending' }
local result = test_render.get_status_info(test_case)
assert.equals('', result.text)
assert.equals('CpTestPending', result.highlight_group)
end)
it('returns running indicator for running status', function()
local test_case = { status = 'running' }
local result = test_render.get_status_info(test_case)
assert.equals('...', result.text)
assert.equals('CpTestPending', result.highlight_group)
end)
end)
describe('render_test_list', function()
it('renders table with headers and borders', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '5' },
{ status = 'fail', code = 1, input = '3' },
},
current_index = 1,
}
local result = test_render.render_test_list(test_state)
assert.is_true(result[1]:find('^┌') ~= nil)
assert.is_true(result[2]:find('│.*#.*│.*Status.*│.*Time.*│.*Exit Code.*│') ~= nil)
assert.is_true(result[3]:find('^├') ~= nil)
end)
it('shows current test with > prefix in table', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '' },
{ status = 'pass', input = '' },
},
current_index = 2,
}
local result = test_render.render_test_list(test_state)
local found_current = false
for _, line in ipairs(result) do
if line:match('│.*>2.*│') then
found_current = true
break
end
end
assert.is_true(found_current)
end)
it('displays input only for current test', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '5 3' },
{ status = 'pass', input = '2 4' },
},
current_index = 1,
}
local result = test_render.render_test_list(test_state)
local found_input = false
for _, line in ipairs(result) do
if line:match('│5 3') then
found_input = true
break
end
end
assert.is_true(found_input)
end)
it('handles empty test cases', function()
local test_state = { test_cases = {}, current_index = 1 }
local result = test_render.render_test_list(test_state)
assert.equals(3, #result)
end)
it('preserves input line breaks', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '5\n3\n1' },
},
current_index = 1,
}
local result = test_render.render_test_list(test_state)
local input_lines = {}
for _, line in ipairs(result) do
if line:match('^│[531]') then
table.insert(input_lines, line:match('│([531])'))
end
end
assert.same({ '5', '3', '1' }, input_lines)
end)
end)
describe('render_status_bar', function()
it('formats time and exit code', function()
local test_case = { time_ms = 45.7, code = 0 }
local result = test_render.render_status_bar(test_case)
assert.equals('45.70ms │ Exit: 0', result)
end)
it('handles missing time', function()
local test_case = { code = 0 }
local result = test_render.render_status_bar(test_case)
assert.equals('Exit: 0', result)
end)
it('handles missing exit code', function()
local test_case = { time_ms = 123 }
local result = test_render.render_status_bar(test_case)
assert.equals('123.00ms', result)
end)
it('returns empty for nil test case', function()
local result = test_render.render_status_bar(nil)
assert.equals('', result)
end)
end)
describe('setup_highlights', function()
it('sets up all highlight groups', function()
local mock_set_hl = spy.on(vim.api, 'nvim_set_hl')
test_render.setup_highlights()
assert.spy(mock_set_hl).was_called(5)
assert.spy(mock_set_hl).was_called_with(0, 'CpTestAC', { fg = '#10b981', bold = true })
assert.spy(mock_set_hl).was_called_with(0, 'CpTestError', { fg = '#ef4444', bold = true })
mock_set_hl:revert()
end)
end)
end)

121
uv.lock generated
View file

@ -24,6 +24,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "cfgv"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
@ -100,6 +109,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
]
[[package]]
name = "identify"
version = "2.6.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" },
]
[[package]]
name = "idna"
version = "3.10"
@ -165,6 +201,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@ -183,6 +228,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "platformdirs"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@ -192,6 +246,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@ -238,6 +308,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" },
{ url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" },
{ url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" },
{ url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" },
{ url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" },
{ url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" },
{ url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" },
{ url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
@ -278,6 +383,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-mock" },
{ name = "types-beautifulsoup4" },
@ -294,6 +400,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=1.18.2" },
{ name = "pre-commit", specifier = ">=4.3.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-mock", specifier = ">=3.12.0" },
{ name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" },
@ -359,3 +466,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "virtualenv"
version = "20.34.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" },
]

View file

@ -22,3 +22,9 @@ any = true
[after_each]
any = true
[spy]
any = true
[stub]
any = true