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 - uses: JohnnyMorganz/stylua-action@v4
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
version: latest version: 2.1.0
args: --check . args: --check .
lua-lint: lua-lint:
@ -114,4 +114,4 @@ jobs:
- name: Install dependencies with mypy - name: Install dependencies with mypy
run: uv sync --dev run: uv sync --dev
- name: Type check Python files with mypy - 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) - [competitest.nvim](https://github.com/xeluxee/competitest.nvim)
- [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim) - [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim)
## TODO
- general `:CP test` window improvements
- fzf/telescope integration (whichever available)
- finer-tuned problem limits (i.e. per-problem codeforces time, memory)
- notify discord members
- handle infinite output/trimming file to 500 lines (customizable)
- update barrettruth.com to post

View file

@ -49,9 +49,9 @@ Setup Commands ~
Action Commands ~ Action Commands ~
:CP test [--debug] Toggle test panel for individual test case :CP run [--debug] Toggle run panel for individual test case
debugging. Shows per-test results with three-pane debugging. Shows per-test results with redesigned
layout for easy Expected/Actual comparison. layout for efficient comparison.
Use --debug flag to compile with debug flags Use --debug flag to compile with debug flags
Requires contest setup first. Requires contest setup first.
@ -67,7 +67,7 @@ CONFIGURATION *cp-config*
cp.nvim works out of the box. No setup required. cp.nvim works out of the box. No setup required.
Optional configuration with lazy.nvim: > Here's an example configuration with lazy.nvim: >
{ {
'barrett-ruth/cp.nvim', 'barrett-ruth/cp.nvim',
cmd = 'CP', cmd = 'CP',
@ -75,7 +75,7 @@ Optional configuration with lazy.nvim: >
debug = false, debug = false,
scrapers = { scrapers = {
atcoder = true, atcoder = true,
codeforces = false, -- disable codeforces scraping codeforces = false,
cses = true, cses = true,
}, },
contests = { contests = {
@ -85,7 +85,7 @@ Optional configuration with lazy.nvim: >
'g++', '-std=c++{version}', '-O2', '-Wall', '-Wextra', 'g++', '-std=c++{version}', '-O2', '-Wall', '-Wextra',
'-DLOCAL', '{source}', '-o', '{binary}', '-DLOCAL', '{source}', '-o', '{binary}',
}, },
run = { '{binary}' }, test = { '{binary}' },
debug = { debug = {
'g++', '-std=c++{version}', '-g3', 'g++', '-std=c++{version}', '-g3',
'-fsanitize=address,undefined', '-DLOCAL', '-fsanitize=address,undefined', '-DLOCAL',
@ -95,7 +95,7 @@ Optional configuration with lazy.nvim: >
extension = "cc", extension = "cc",
}, },
python = { python = {
run = { 'python3', '{source}' }, test = { 'python3', '{source}' },
debug = { 'python3', '{source}' }, debug = { 'python3', '{source}' },
extension = "py", extension = "py",
}, },
@ -105,16 +105,26 @@ Optional configuration with lazy.nvim: >
}, },
hooks = { hooks = {
before_run = function(ctx) vim.cmd.w() end, before_run = function(ctx) vim.cmd.w() end,
before_debug = function(ctx) before_debug = function(ctx) ... end,
-- ctx.problem_id, ctx.platform, ctx.source_file, etc.
vim.cmd.w()
end,
setup_code = function(ctx) setup_code = function(ctx)
vim.wo.foldmethod = "marker" vim.wo.foldmethod = "marker"
vim.wo.foldmarker = "{{{,}}}" vim.wo.foldmarker = "{{{,}}}"
vim.diagnostic.enable(false) vim.diagnostic.enable(false)
end, 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 snippets = { ... }, -- LuaSnip snippets
filename = function(contest, contest_id, problem_id, config, language) ... end, filename = function(contest, contest_id, problem_id, config, language) ... end,
} }
@ -131,6 +141,8 @@ Optional configuration with lazy.nvim: >
during operation. during operation.
• {scrapers} (`table<string,boolean>`) Per-platform scraper control. • {scrapers} (`table<string,boolean>`) Per-platform scraper control.
Default enables all platforms. Default enables all platforms.
• {run_panel} (`RunPanelConfig`) Test panel behavior configuration.
• {diff} (`DiffConfig`) Diff backend configuration.
• {filename}? (`function`) Custom filename generation function. • {filename}? (`function`) Custom filename generation function.
`function(contest, contest_id, problem_id, config, language)` `function(contest, contest_id, problem_id, config, language)`
Should return full filename with extension. Should return full filename with extension.
@ -151,21 +163,50 @@ Optional configuration with lazy.nvim: >
Fields: ~ Fields: ~
• {compile}? (`string[]`) Compile command template with • {compile}? (`string[]`) Compile command template with
`{version}`, `{source}`, `{binary}` placeholders. `{version}`, `{source}`, `{binary}` placeholders.
• {run} (`string[]`) Run command template. • {test} (`string[]`) Test execution command template.
• {debug}? (`string[]`) Debug compile command template. • {debug}? (`string[]`) Debug compile command template.
• {version}? (`number`) Language version (e.g. 20, 23 for C++). • {version}? (`number`) Language version (e.g. 20, 23 for C++).
• {extension} (`string`) File extension (e.g. "cc", "py"). • {extension} (`string`) File extension (e.g. "cc", "py").
• {executable}? (`string`) Executable name for interpreted languages. • {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* *cp.Hooks*
Fields: ~ Fields: ~
• {before_run}? (`function`) Called before test panel opens.
`function(ctx: ProblemContext)`
• {before_debug}? (`function`) Called before debug compilation. • {before_debug}? (`function`) Called before debug compilation.
`function(ctx: ProblemContext)` `function(ctx: ProblemContext)`
• {setup_code}? (`function`) Called after source file is opened. • {setup_code}? (`function`) Called after source file is opened.
Used to configure buffer settings. Good for configuring buffer settings.
`function(ctx: ProblemContext)` `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* WORKFLOW *cp-workflow*
For the sake of consistency and simplicity, cp.nvim extracts contest/problem identifiers from 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: > 3. Start with problem A: >
:CP a :CP a
Or do both at once with:
:CP atcoder abc324 a
< This creates a.cc and scrapes test cases < This creates a.cc and scrapes test cases
4. Code your solution, then test: > 4. Code your solution, then test: >
:CP test :CP run
< Navigate with j/k, run specific tests with <enter> < Navigate with j/k, run specific tests with <enter>
Exit test panel with q or :CP test when done Exit test panel with q or :CP run when done
5. If needed, debug with sanitizers: > 5. If needed, debug with sanitizers: >
:CP test --debug :CP run --debug
< <
6. Move to next problem: > 6. Move to next problem: >
:CP next :CP next
@ -248,20 +293,17 @@ Example: Setting up and solving AtCoder contest ABC324
6. Continue solving problems with :CP next/:CP prev navigation 6. Continue solving problems with :CP next/:CP prev navigation
7. Submit solutions on AtCoder website 7. Submit solutions on AtCoder website
Example: Quick setup for single Codeforces problem >
:CP codeforces 1933 a " One command setup
:CP test " Test immediately
< <
TEST PANEL *cp-test* RUN PANEL *cp-run*
The test panel provides individual test case debugging with a three-pane The run panel provides individual test case debugging with a streamlined
layout showing test list, expected output, and actual output side-by-side. layout optimized for modern screens. Shows test status with competitive
programming terminology and efficient space usage.
Activation ~ Activation ~
*:CP-test* *:CP-run*
:CP test [--debug] Toggle test panel on/off. When activated, :CP run [--debug] Toggle run panel on/off. When activated,
replaces current layout with test interface. replaces current layout with test interface.
Automatically compiles and runs all tests. Automatically compiles and runs all tests.
Use --debug flag to compile with debug symbols Use --debug flag to compile with debug symbols
@ -270,29 +312,48 @@ Activation ~
Interface ~ 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) >
┌─────────────────────────────────────────────────────────────┐ ┌──────┬────────┬────────┬───────────┐ ┌─ Expected vs Actual ──────────────────┐
│ 1. [ok:true ] [code:0] [time:12ms] │ │ # │ Status │ Time │ Exit Code │ │ 45.70ms │ Exit: 0 │
│> 2. [ok:false] [code:0] [time:45ms] │ ├──────┼────────┼────────┼───────────┤ ├────────────────────────────────────────┤
│ │ │ 1 │ AC │12.00ms │ 0 │ │ │
│ Input: │ │ >2 │ WA │45.70ms │ 1 │ │ 4[-2-]{+3+} │
│ 5 3 │ ├──────┴────────┴────────┴───────────┤ │ 100 │
│ │ │5 3 │ │ hello w[-o-]r{+o+}ld │
└─────────────────────────────────────────────────────────────┘ ├──────┬────────┬────────┬───────────┤ │ │
┌─ Expected ──────────────────┐ ┌───── Actual ────────────────┐ │ 3 │ AC │ 9.00ms │ 0 │ └────────────────────────────────────────┘
│ 8 │ │ 7 │ │ 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 ~ Keymaps ~
*cp-test-keys* *cp-test-keys*
<c-n> Navigate to next test case <c-n> Navigate to next test case (configurable via run_panel.next_test_key)
<c-p> Navigate to previous test case <c-p> Navigate to previous test case (configurable via run_panel.prev_test_key)
q Exit test panel (restore layout) t Toggle diff mode between vim and git (configurable via run_panel.toggle_diff_key)
q Exit test panel and restore layout
Diff Modes ~
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 ~ Execution Details ~
@ -309,8 +370,8 @@ cp.nvim creates the following file structure upon problem setup:
build/ build/
{problem_id}.run " Compiled binary {problem_id}.run " Compiled binary
io/ io/
{problem_id}.cpin " Test input {problem_id}.n.cpin " nth test input
{problem_id}.cpout " Program output {problem_id}.n.cpout " nth program output
{problem_id}.expected " Expected output {problem_id}.expected " Expected output
The plugin automatically manages this structure and navigation between problems The plugin automatically manages this structure and navigation between problems
@ -321,8 +382,9 @@ SNIPPETS *cp-snippets*
cp.nvim integrates with LuaSnip for automatic template expansion. Built-in cp.nvim integrates with LuaSnip for automatic template expansion. Built-in
snippets include basic C++ and Python templates for each contest type. snippets include basic C++ and Python templates for each contest type.
Snippet trigger names must EXACTLY match platform names ("codeforces" for Snippet trigger names must match the following format exactly:
CodeForces, "cses" for CSES, etc.).
cp.nvim/{platform}
Custom snippets can be added via the `snippets` configuration field. Custom snippets can be added via the `snippets` configuration field.

View file

@ -1,6 +1,6 @@
---@class LanguageConfig ---@class LanguageConfig
---@field compile? string[] Compile command template ---@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 debug? string[] Debug command template
---@field executable? string Executable name ---@field executable? string Executable name
---@field version? number Language version ---@field version? number Language version
@ -8,7 +8,7 @@
---@class PartialLanguageConfig ---@class PartialLanguageConfig
---@field compile? string[] Compile command template ---@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 debug? string[] Debug command template
---@field executable? string Executable name ---@field executable? string Executable name
---@field version? number Language version ---@field version? number Language version
@ -31,6 +31,19 @@
---@field before_debug? fun(ctx: ProblemContext) ---@field before_debug? fun(ctx: ProblemContext)
---@field setup_code? 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 ---@class cp.Config
---@field contests table<string, ContestConfig> ---@field contests table<string, ContestConfig>
---@field snippets table[] ---@field snippets table[]
@ -38,6 +51,8 @@
---@field debug boolean ---@field debug boolean
---@field scrapers table<string, boolean> ---@field scrapers table<string, boolean>
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@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 ---@class cp.UserConfig
---@field contests? table<string, PartialContestConfig> ---@field contests? table<string, PartialContestConfig>
@ -46,6 +61,8 @@
---@field debug? boolean ---@field debug? boolean
---@field scrapers? table<string, boolean> ---@field scrapers? table<string, boolean>
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@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 M = {}
local constants = require('cp.constants') local constants = require('cp.constants')
@ -62,6 +79,18 @@ M.defaults = {
debug = false, debug = false,
scrapers = constants.PLATFORMS, scrapers = constants.PLATFORMS,
filename = nil, 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 ---@param user_config cp.UserConfig|nil
@ -79,28 +108,10 @@ function M.setup(user_config)
debug = { user_config.debug, { 'boolean', 'nil' }, true }, debug = { user_config.debug, { 'boolean', 'nil' }, true },
scrapers = { user_config.scrapers, { 'table', 'nil' }, true }, scrapers = { user_config.scrapers, { 'table', 'nil' }, true },
filename = { user_config.filename, { 'function', '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 if user_config.contests then
for contest_name, contest_config in pairs(user_config.contests) do for contest_name, contest_config in pairs(user_config.contests) do
for lang_name, lang_config in pairs(contest_config) 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 {}) 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 _, contest_config in pairs(config.contests) do
for lang_name, lang_config in pairs(contest_config) do for lang_name, lang_config in pairs(contest_config) do
if type(lang_config) == 'table' and not lang_config.extension then if type(lang_config) == 'table' and not lang_config.extension then

View file

@ -1,7 +1,7 @@
local M = {} local M = {}
M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' } M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' }
M.ACTIONS = { 'test', 'next', 'prev' } M.ACTIONS = { 'run', 'next', 'prev' }
M.CPP = 'cpp' M.CPP = 'cpp'
M.PYTHON = 'python' 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' input_data = table.concat(vim.fn.readfile(ctx.input_file), '\n') .. '\n'
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 exec_result = execute_command(run_cmd, input_data, contest_config.timeout_ms) 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 formatted_output = format_output(exec_result, ctx.expected_file, is_debug)
local output_buf = vim.fn.bufnr(ctx.output_file) local output_buf = vim.fn.bufnr(ctx.output_file)
if output_buf ~= -1 then 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_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.api.nvim_buf_call(output_buf, function()
vim.cmd.write() vim.cmd.write()
end) 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, saved_session = nil,
test_cases = nil, test_cases = nil,
test_states = {}, 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 constants = require('cp.constants')
local platforms = constants.PLATFORMS local platforms = constants.PLATFORMS
local actions = constants.ACTIONS local actions = constants.ACTIONS
@ -149,14 +152,32 @@ local function get_current_problem()
return filename return filename
end end
local function toggle_test_panel(is_debug) local function create_buffer_with_options(filetype)
if state.test_panel_active then 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 if state.saved_session then
vim.cmd(('source %s'):format(state.saved_session)) vim.cmd(('source %s'):format(state.saved_session))
vim.fn.delete(state.saved_session) vim.fn.delete(state.saved_session)
state.saved_session = nil state.saved_session = nil
end end
state.test_panel_active = false state.run_panel_active = false
logger.log('test panel closed') logger.log('test panel closed')
return return
end end
@ -187,167 +208,224 @@ local function toggle_test_panel(is_debug)
vim.cmd('silent only') vim.cmd('silent only')
local tab_buf = vim.api.nvim_create_buf(false, true) local tab_buf = create_buffer_with_options()
local expected_buf = vim.api.nvim_create_buf(false, true)
local actual_buf = vim.api.nvim_create_buf(false, true)
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 main_win = vim.api.nvim_get_current_win() local main_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(main_win, tab_buf) vim.api.nvim_win_set_buf(main_win, tab_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = tab_buf }) vim.api.nvim_set_option_value('filetype', 'cptest', { buf = tab_buf })
vim.cmd.split()
vim.api.nvim_win_set_buf(0, actual_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf })
vim.cmd.vsplit()
vim.api.nvim_win_set_buf(0, expected_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf })
local expected_win = vim.fn.bufwinid(expected_buf)
local actual_win = vim.fn.bufwinid(actual_buf)
local test_windows = { local test_windows = {
tab_win = main_win, tab_win = main_win,
actual_win = actual_win,
expected_win = expected_win,
} }
local test_buffers = { local test_buffers = {
tab_buf = tab_buf, tab_buf = tab_buf,
expected_buf = expected_buf,
actual_buf = actual_buf,
} }
local function render_test_tabs() local highlight = require('cp.highlight')
local test_state = test_module.get_test_panel_state() local diff_namespace = highlight.create_namespace()
local tab_lines = {}
local max_status_width = 0 local test_list_namespace = vim.api.nvim_create_namespace('cp_test_list')
local max_code_width = 0
local max_time_width = 0
for _, test_case in ipairs(test_state.test_cases) do local function update_buffer_content(bufnr, lines, highlights)
local status_text = test_case.status == 'pending' and '' or string.upper(test_case.status) local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr })
max_status_width = math.max(max_status_width, #status_text)
if test_case.code then vim.api.nvim_set_option_value('readonly', false, { buf = bufnr })
max_code_width = math.max(max_code_width, #tostring(test_case.code)) vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
end 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 vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1)
local time_text = string.format('%.0fms', test_case.time_ms) for _, hl in ipairs(highlights) do
max_time_width = math.max(max_time_width, #time_text) vim.api.nvim_buf_set_extmark(bufnr, test_list_namespace, hl.line, hl.col_start, {
end end_col = hl.col_end,
end hl_group = hl.highlight_group,
priority = 100,
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 })
end end
end end
local function update_actual_pane() local function create_vim_diff_layout(parent_win, expected_content, actual_content)
local test_state = test_module.get_test_panel_state() local expected_buf = create_buffer_with_options()
local current_test = test_state.test_cases[test_state.current_index] local actual_buf = create_buffer_with_options()
if not current_test then vim.api.nvim_set_current_win(parent_win)
return vim.cmd.split()
end local actual_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(actual_win, actual_buf)
local actual_lines = {} vim.cmd.vsplit()
local enable_diff = false local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
if current_test.actual then vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf })
actual_lines = vim.split(current_test.actual, '\n', { plain = true, trimempty = true }) vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf })
enable_diff = current_test.status == 'fail'
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 else
actual_lines = { '(not run yet)' } local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(diff_buf, lines, {})
end 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 local function create_diff_layout(mode, parent_win, expected_content, actual_content)
vim.api.nvim_set_option_value('winbar', 'Actual', { win = test_windows.actual_win }) if mode == 'git' then
end return create_git_diff_layout(parent_win, expected_content, actual_content)
else
vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.expected_win }) return create_vim_diff_layout(parent_win, expected_content, actual_content)
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)
end end
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 if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
return return
end end
local tab_lines = render_test_tabs() local test_render = require('cp.test_render')
vim.api.nvim_buf_set_lines(test_buffers.tab_buf, 0, -1, false, tab_lines) 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_diff_panes()
update_actual_pane()
end end
local function navigate_test_case(delta) 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 if #test_state.test_cases == 0 then
return return
end end
@ -359,20 +437,30 @@ local function toggle_test_panel(is_debug)
test_state.current_index = 1 test_state.current_index = 1
end end
refresh_test_panel() refresh_run_panel()
end 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) navigate_test_case(1)
end, { buffer = test_buffers.tab_buf, silent = true }) 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) navigate_test_case(-1)
end, { buffer = test_buffers.tab_buf, silent = true }) end, { buffer = test_buffers.tab_buf, silent = true })
for _, buf in pairs(test_buffers) do setup_keybindings_for_buffer(test_buffers.tab_buf)
vim.keymap.set('n', 'q', function()
toggle_test_panel() if config.hooks and config.hooks.before_run then
end, { buffer = buf, silent = true }) config.hooks.before_run(ctx)
end end
if is_debug and config.hooks and config.hooks.before_debug then 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) test_module.run_all_test_cases(ctx, contest_config)
end end
refresh_test_panel() refresh_run_panel()
vim.api.nvim_set_current_win(test_windows.tab_win) 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_buffers = test_buffers
state.test_windows = test_windows 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)) logger.log(string.format('test panel opened (%d test cases)', #test_state.test_cases))
end end
@ -559,8 +647,8 @@ function M.handle_command(opts)
end end
if cmd.type == 'action' then if cmd.type == 'action' then
if cmd.action == 'test' then if cmd.action == 'run' then
toggle_test_panel(cmd.debug) toggle_run_panel(cmd.debug)
elseif cmd.action == 'next' then elseif cmd.action == 'next' then
navigate_problem(1, cmd.language) navigate_problem(1, cmd.language)
elseif cmd.action == 'prev' then elseif cmd.action == 'prev' then

View file

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

View file

@ -12,7 +12,7 @@
---@field signal string? ---@field signal string?
---@field timed_out boolean? ---@field timed_out boolean?
---@class TestPanelState ---@class RunPanelState
---@field test_cases TestCase[] ---@field test_cases TestCase[]
---@field current_index number ---@field current_index number
---@field buffer number? ---@field buffer number?
@ -24,8 +24,8 @@ local M = {}
local constants = require('cp.constants') local constants = require('cp.constants')
local logger = require('cp.log') local logger = require('cp.log')
---@type TestPanelState ---@type RunPanelState
local test_panel_state = { local run_panel_state = {
test_cases = {}, test_cases = {},
current_index = 1, current_index = 1,
buffer = nil, buffer = nil,
@ -172,7 +172,7 @@ local function run_single_test_case(ctx, contest_config, test_case)
end end
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' 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) test_cases = parse_test_cases_from_files(ctx.input_file, ctx.expected_file)
end end
test_panel_state.test_cases = test_cases run_panel_state.test_cases = test_cases
test_panel_state.current_index = 1 run_panel_state.current_index = 1
logger.log(('loaded %d test case(s)'):format(#test_cases)) logger.log(('loaded %d test case(s)'):format(#test_cases))
return #test_cases > 0 return #test_cases > 0
@ -239,7 +239,7 @@ end
---@param index number ---@param index number
---@return boolean ---@return boolean
function M.run_test_case(ctx, contest_config, index) 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 if not test_case then
return false return false
end end
@ -266,16 +266,16 @@ end
---@return TestCase[] ---@return TestCase[]
function M.run_all_test_cases(ctx, contest_config) function M.run_all_test_cases(ctx, contest_config)
local results = {} 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) 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 end
return results return results
end end
---@return TestPanelState ---@return RunPanelState
function M.get_test_panel_state() function M.get_run_panel_state()
return test_panel_state return run_panel_state
end end
return M 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", "types-requests>=2.32.4.20250913",
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-mock>=3.12.0", "pytest-mock>=3.12.0",
"pre-commit>=4.3.0",
] ]
[tool.pytest.ini_options] [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() describe('action commands', function()
it('handles test action without error', function() it('handles test action without error', function()
local opts = { fargs = { 'test' } } local opts = { fargs = { 'run' } }
assert.has_no_errors(function() assert.has_no_errors(function()
cp.handle_command(opts) cp.handle_command(opts)
@ -126,7 +126,7 @@ describe('cp command parsing', function()
describe('language flag parsing', function() describe('language flag parsing', function()
it('logs error for --lang flag missing value', function() it('logs error for --lang flag missing value', function()
local opts = { fargs = { 'test', '--lang' } } local opts = { fargs = { 'run', '--lang' } }
cp.handle_command(opts) cp.handle_command(opts)
@ -169,7 +169,7 @@ describe('cp command parsing', function()
describe('debug flag parsing', function() describe('debug flag parsing', function()
it('handles debug flag without error', function() it('handles debug flag without error', function()
local opts = { fargs = { 'test', '--debug' } } local opts = { fargs = { 'run', '--debug' } }
assert.has_no_errors(function() assert.has_no_errors(function()
cp.handle_command(opts) cp.handle_command(opts)
@ -177,7 +177,7 @@ describe('cp command parsing', function()
end) end)
it('handles combined language and debug flags', function() 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() assert.has_no_errors(function()
cp.handle_command(opts) cp.handle_command(opts)
@ -234,7 +234,7 @@ describe('cp command parsing', function()
end) end)
it('handles flag order variations', function() 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() assert.has_no_errors(function()
cp.handle_command(opts) cp.handle_command(opts)
@ -242,7 +242,7 @@ describe('cp command parsing', function()
end) end)
it('handles multiple language flags', function() 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() assert.has_no_errors(function()
cp.handle_command(opts) cp.handle_command(opts)

View file

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

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" }, { 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]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.3" 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.10" 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" 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" }, { 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]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" 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" }, { 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]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" 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" }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@ -278,6 +383,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "mypy" }, { name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-mock" }, { name = "pytest-mock" },
{ name = "types-beautifulsoup4" }, { name = "types-beautifulsoup4" },
@ -294,6 +400,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "mypy", specifier = ">=1.18.2" }, { name = "mypy", specifier = ">=1.18.2" },
{ name = "pre-commit", specifier = ">=4.3.0" },
{ name = "pytest", specifier = ">=8.0.0" }, { name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-mock", specifier = ">=3.12.0" }, { name = "pytest-mock", specifier = ">=3.12.0" },
{ name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" }, { name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" },
@ -359,3 +466,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
wheels = [ 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" }, { 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] [after_each]
any = true any = true
[spy]
any = true
[stub]
any = true