diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index fc2c8b5..c100808 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -44,7 +44,7 @@ jobs: - uses: JohnnyMorganz/stylua-action@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - version: latest + version: 2.1.0 args: --check . lua-lint: @@ -114,4 +114,4 @@ jobs: - name: Install dependencies with mypy run: uv sync --dev - name: Type check Python files with mypy - run: uv run mypy scrapers/ tests/scrapers/ \ No newline at end of file + run: uv run mypy scrapers/ tests/scrapers/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4acb307 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/README.md b/README.md index 49ffb34..14b5fe9 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,3 @@ follows: - [competitest.nvim](https://github.com/xeluxee/competitest.nvim) - [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim) - -## TODO - -- general `:CP test` window improvements -- fzf/telescope integration (whichever available) -- finer-tuned problem limits (i.e. per-problem codeforces time, memory) -- notify discord members -- handle infinite output/trimming file to 500 lines (customizable) -- update barrettruth.com to post diff --git a/doc/cp.txt b/doc/cp.txt index f743f51..64f4609 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -49,9 +49,9 @@ Setup Commands ~ Action Commands ~ -:CP test [--debug] Toggle test panel for individual test case - debugging. Shows per-test results with three-pane - layout for easy Expected/Actual comparison. +:CP run [--debug] Toggle run panel for individual test case + debugging. Shows per-test results with redesigned + layout for efficient comparison. Use --debug flag to compile with debug flags Requires contest setup first. @@ -67,7 +67,7 @@ CONFIGURATION *cp-config* cp.nvim works out of the box. No setup required. -Optional configuration with lazy.nvim: > +Here's an example configuration with lazy.nvim: > { 'barrett-ruth/cp.nvim', cmd = 'CP', @@ -75,7 +75,7 @@ Optional configuration with lazy.nvim: > debug = false, scrapers = { atcoder = true, - codeforces = false, -- disable codeforces scraping + codeforces = false, cses = true, }, contests = { @@ -85,7 +85,7 @@ Optional configuration with lazy.nvim: > 'g++', '-std=c++{version}', '-O2', '-Wall', '-Wextra', '-DLOCAL', '{source}', '-o', '{binary}', }, - run = { '{binary}' }, + test = { '{binary}' }, debug = { 'g++', '-std=c++{version}', '-g3', '-fsanitize=address,undefined', '-DLOCAL', @@ -95,7 +95,7 @@ Optional configuration with lazy.nvim: > extension = "cc", }, python = { - run = { 'python3', '{source}' }, + test = { 'python3', '{source}' }, debug = { 'python3', '{source}' }, extension = "py", }, @@ -105,16 +105,26 @@ Optional configuration with lazy.nvim: > }, hooks = { before_run = function(ctx) vim.cmd.w() end, - before_debug = function(ctx) - -- ctx.problem_id, ctx.platform, ctx.source_file, etc. - vim.cmd.w() - end, + before_debug = function(ctx) ... end, setup_code = function(ctx) vim.wo.foldmethod = "marker" vim.wo.foldmarker = "{{{,}}}" vim.diagnostic.enable(false) end, }, + run_panel = { + diff_mode = "vim", + next_test_key = "", + prev_test_key = "", + toggle_diff_key = "t", + }, + diff = { + git = { + command = "git", + args = {"diff", "--no-index", "--word-diff=plain", + "--word-diff-regex=.", "--no-prefix"}, + }, + }, snippets = { ... }, -- LuaSnip snippets filename = function(contest, contest_id, problem_id, config, language) ... end, } @@ -131,6 +141,8 @@ Optional configuration with lazy.nvim: > during operation. • {scrapers} (`table`) Per-platform scraper control. Default enables all platforms. + • {run_panel} (`RunPanelConfig`) Test panel behavior configuration. + • {diff} (`DiffConfig`) Diff backend configuration. • {filename}? (`function`) Custom filename generation function. `function(contest, contest_id, problem_id, config, language)` Should return full filename with extension. @@ -151,21 +163,50 @@ Optional configuration with lazy.nvim: > Fields: ~ • {compile}? (`string[]`) Compile command template with `{version}`, `{source}`, `{binary}` placeholders. - • {run} (`string[]`) Run command template. + • {test} (`string[]`) Test execution command template. • {debug}? (`string[]`) Debug compile command template. • {version}? (`number`) Language version (e.g. 20, 23 for C++). • {extension} (`string`) File extension (e.g. "cc", "py"). • {executable}? (`string`) Executable name for interpreted languages. +*cp.RunPanelConfig* + + Fields: ~ + • {diff_mode} (`string`, default: `"vim"`) Diff backend: "vim" or "git". + Git provides character-level precision, vim uses built-in diff. + • {next_test_key} (`string`, default: `""`) Key to navigate to next test case. + • {prev_test_key} (`string`, default: `""`) Key to navigate to previous test case. + • {toggle_diff_key} (`string`, default: `"t"`) Key to toggle diff mode between vim and git. + +*cp.DiffConfig* + + Fields: ~ + • {git} (`DiffGitConfig`) Git diff backend configuration. + *cp.Hooks* Fields: ~ + • {before_run}? (`function`) Called before test panel opens. + `function(ctx: ProblemContext)` • {before_debug}? (`function`) Called before debug compilation. `function(ctx: ProblemContext)` • {setup_code}? (`function`) Called after source file is opened. - Used to configure buffer settings. + Good for configuring buffer settings. `function(ctx: ProblemContext)` +*ProblemContext* + + Fields: ~ + • {contest} (`string`) Platform name (e.g. "atcoder", "codeforces") + • {contest_id} (`string`) Contest ID (e.g. "abc123", "1933") + • {problem_id}? (`string`) Problem ID (e.g. "a", "b") - nil for CSES + • {source_file} (`string`) Source filename (e.g. "abc123a.cpp") + • {binary_file} (`string`) Binary output path (e.g. "build/abc123a.run") + • {input_file} (`string`) Test input path (e.g. "io/abc123a.cpin") + • {output_file} (`string`) Program output path (e.g. "io/abc123a.cpout") + • {expected_file} (`string`) Expected output path (e.g. "io/abc123a.expected") + • {problem_name} (`string`) Display name (e.g. "abc123a") + WORKFLOW *cp-workflow* For the sake of consistency and simplicity, cp.nvim extracts contest/problem identifiers from @@ -232,15 +273,19 @@ Example: Setting up and solving AtCoder contest ABC324 3. Start with problem A: > :CP a + + Or do both at once with: + :CP atcoder abc324 a + < This creates a.cc and scrapes test cases 4. Code your solution, then test: > - :CP test + :CP run < Navigate with j/k, run specific tests with - Exit test panel with q or :CP test when done + Exit test panel with q or :CP run when done 5. If needed, debug with sanitizers: > - :CP test --debug + :CP run --debug < 6. Move to next problem: > :CP next @@ -248,20 +293,17 @@ Example: Setting up and solving AtCoder contest ABC324 6. Continue solving problems with :CP next/:CP prev navigation 7. Submit solutions on AtCoder website - -Example: Quick setup for single Codeforces problem > - :CP codeforces 1933 a " One command setup - :CP test " Test immediately < -TEST PANEL *cp-test* +RUN PANEL *cp-run* -The test panel provides individual test case debugging with a three-pane -layout showing test list, expected output, and actual output side-by-side. +The run panel provides individual test case debugging with a streamlined +layout optimized for modern screens. Shows test status with competitive +programming terminology and efficient space usage. Activation ~ - *:CP-test* -:CP test [--debug] Toggle test panel on/off. When activated, + *:CP-run* +:CP run [--debug] Toggle run panel on/off. When activated, replaces current layout with test interface. Automatically compiles and runs all tests. Use --debug flag to compile with debug symbols @@ -270,29 +312,48 @@ Activation ~ Interface ~ -The test panel uses a three-pane layout for easy comparison: > +The run panel uses a professional table layout with precise column alignment: +(note that the diff is indeed highlighted, not the weird amalgamation of +characters below) > - ┌─────────────────────────────────────────────────────────────┐ - │ 1. [ok:true ] [code:0] [time:12ms] │ - │> 2. [ok:false] [code:0] [time:45ms] │ - │ │ - │ Input: │ - │ 5 3 │ - │ │ - └─────────────────────────────────────────────────────────────┘ - ┌─ Expected ──────────────────┐ ┌───── Actual ────────────────┐ - │ 8 │ │ 7 │ - │ │ │ │ - │ │ │ │ - │ │ │ │ - └─────────────────────────────┘ └─────────────────────────────┘ + ┌──────┬────────┬────────┬───────────┐ ┌─ Expected vs Actual ──────────────────┐ + │ # │ Status │ Time │ Exit Code │ │ 45.70ms │ Exit: 0 │ + ├──────┼────────┼────────┼───────────┤ ├────────────────────────────────────────┤ + │ 1 │ AC │12.00ms │ 0 │ │ │ + │ >2 │ WA │45.70ms │ 1 │ │ 4[-2-]{+3+} │ + ├──────┴────────┴────────┴───────────┤ │ 100 │ + │5 3 │ │ hello w[-o-]r{+o+}ld │ + ├──────┬────────┬────────┬───────────┤ │ │ + │ 3 │ AC │ 9.00ms │ 0 │ └────────────────────────────────────────┘ + │ 4 │ RTE │ 0.00ms │139 (SIGUSR2)│ + └──────┴────────┴────────┴───────────┘ < +Status Indicators ~ + +Test cases use competitive programming terminology: + + AC Accepted (passed) + WA Wrong Answer (output mismatch) + TLE Time Limit Exceeded (timeout) + RTE Runtime Error (non-zero exit) + Keymaps ~ *cp-test-keys* - Navigate to next test case - Navigate to previous test case -q Exit test panel (restore layout) + Navigate to next test case (configurable via run_panel.next_test_key) + Navigate to previous test case (configurable via run_panel.prev_test_key) +t Toggle diff mode between vim and git (configurable via run_panel.toggle_diff_key) +q Exit test panel and restore layout + +Diff Modes ~ + +Two diff backends are available: + + vim Built-in vim diff (default, always available) + git Character-level git word-diff (requires git, more precise) + +The git backend shows character-level changes with [-removed-] and {+added+} +markers for precise difference analysis. Execution Details ~ @@ -309,8 +370,8 @@ cp.nvim creates the following file structure upon problem setup: build/ {problem_id}.run " Compiled binary io/ - {problem_id}.cpin " Test input - {problem_id}.cpout " Program output + {problem_id}.n.cpin " nth test input + {problem_id}.n.cpout " nth program output {problem_id}.expected " Expected output The plugin automatically manages this structure and navigation between problems @@ -321,8 +382,9 @@ SNIPPETS *cp-snippets* cp.nvim integrates with LuaSnip for automatic template expansion. Built-in snippets include basic C++ and Python templates for each contest type. -Snippet trigger names must EXACTLY match platform names ("codeforces" for -CodeForces, "cses" for CSES, etc.). +Snippet trigger names must match the following format exactly: + + cp.nvim/{platform} Custom snippets can be added via the `snippets` configuration field. diff --git a/lua/cp/config.lua b/lua/cp/config.lua index cae943a..d92c267 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -1,6 +1,6 @@ ---@class LanguageConfig ---@field compile? string[] Compile command template ----@field run string[] Run command template +---@field test string[] Test execution command template ---@field debug? string[] Debug command template ---@field executable? string Executable name ---@field version? number Language version @@ -8,7 +8,7 @@ ---@class PartialLanguageConfig ---@field compile? string[] Compile command template ----@field run? string[] Run command template +---@field test? string[] Test execution command template ---@field debug? string[] Debug command template ---@field executable? string Executable name ---@field version? number Language version @@ -31,6 +31,19 @@ ---@field before_debug? fun(ctx: ProblemContext) ---@field setup_code? fun(ctx: ProblemContext) +---@class RunPanelConfig +---@field diff_mode "vim"|"git" Diff backend to use +---@field next_test_key string Key to navigate to next test case +---@field prev_test_key string Key to navigate to previous test case +---@field toggle_diff_key string Key to toggle diff mode + +---@class DiffGitConfig +---@field command string Git executable name +---@field args string[] Additional git diff arguments + +---@class DiffConfig +---@field git DiffGitConfig + ---@class cp.Config ---@field contests table ---@field snippets table[] @@ -38,6 +51,8 @@ ---@field debug boolean ---@field scrapers table ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string +---@field run_panel RunPanelConfig +---@field diff DiffConfig ---@class cp.UserConfig ---@field contests? table @@ -46,6 +61,8 @@ ---@field debug? boolean ---@field scrapers? table ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string +---@field run_panel? RunPanelConfig +---@field diff? DiffConfig local M = {} local constants = require('cp.constants') @@ -62,6 +79,18 @@ M.defaults = { debug = false, scrapers = constants.PLATFORMS, filename = nil, + run_panel = { + diff_mode = 'vim', + next_test_key = '', + prev_test_key = '', + toggle_diff_key = 't', + }, + diff = { + git = { + command = 'git', + args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, + }, + }, } ---@param user_config cp.UserConfig|nil @@ -79,28 +108,10 @@ function M.setup(user_config) debug = { user_config.debug, { 'boolean', 'nil' }, true }, scrapers = { user_config.scrapers, { 'table', 'nil' }, true }, filename = { user_config.filename, { 'function', 'nil' }, true }, + run_panel = { user_config.run_panel, { 'table', 'nil' }, true }, + diff = { user_config.diff, { 'table', 'nil' }, true }, }) - if user_config.hooks then - vim.validate({ - before_run = { - user_config.hooks.before_run, - { 'function', 'nil' }, - true, - }, - before_debug = { - user_config.hooks.before_debug, - { 'function', 'nil' }, - true, - }, - setup_code = { - user_config.hooks.setup_code, - { 'function', 'nil' }, - true, - }, - }) - end - if user_config.contests then for contest_name, contest_config in pairs(user_config.contests) do for lang_name, lang_config in pairs(contest_config) do @@ -144,6 +155,60 @@ function M.setup(user_config) local config = vim.tbl_deep_extend('force', M.defaults, user_config or {}) + -- Validate merged config values + vim.validate({ + before_run = { + config.hooks.before_run, + { 'function', 'nil' }, + true, + }, + before_debug = { + config.hooks.before_debug, + { 'function', 'nil' }, + true, + }, + setup_code = { + config.hooks.setup_code, + { 'function', 'nil' }, + true, + }, + }) + + vim.validate({ + diff_mode = { + config.run_panel.diff_mode, + function(value) + return vim.tbl_contains({ 'vim', 'git' }, value) + end, + "diff_mode must be 'vim' or 'git'", + }, + next_test_key = { + config.run_panel.next_test_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'next_test_key must be a non-empty string', + }, + prev_test_key = { + config.run_panel.prev_test_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'prev_test_key must be a non-empty string', + }, + toggle_diff_key = { + config.run_panel.toggle_diff_key, + function(value) + return type(value) == 'string' and value ~= '' + end, + 'toggle_diff_key must be a non-empty string', + }, + }) + + vim.validate({ + git = { config.diff.git, { 'table', 'nil' }, true }, + }) + for _, contest_config in pairs(config.contests) do for lang_name, lang_config in pairs(contest_config) do if type(lang_config) == 'table' and not lang_config.extension then diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index e6cdc95..acabc3c 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -1,7 +1,7 @@ local M = {} M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' } -M.ACTIONS = { 'test', 'next', 'prev' } +M.ACTIONS = { 'run', 'next', 'prev' } M.CPP = 'cpp' M.PYTHON = 'python' diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua new file mode 100644 index 0000000..2784b4b --- /dev/null +++ b/lua/cp/diff.lua @@ -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 +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 diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index 1d25b87..b59a954 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -278,13 +278,19 @@ function M.run_problem(ctx, contest_config, is_debug) input_data = table.concat(vim.fn.readfile(ctx.input_file), '\n') .. '\n' end - local run_cmd = build_command(language_config.run, language_config.executable, substitutions) + local run_cmd = build_command(language_config.test, language_config.executable, substitutions) local exec_result = execute_command(run_cmd, input_data, contest_config.timeout_ms) local formatted_output = format_output(exec_result, ctx.expected_file, is_debug) local output_buf = vim.fn.bufnr(ctx.output_file) if output_buf ~= -1 then + local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = output_buf }) + local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = output_buf }) + vim.api.nvim_set_option_value('readonly', false, { buf = output_buf }) + vim.api.nvim_set_option_value('modifiable', true, { buf = output_buf }) vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.split(formatted_output, '\n')) + vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = output_buf }) + vim.api.nvim_set_option_value('readonly', was_readonly, { buf = output_buf }) vim.api.nvim_buf_call(output_buf, function() vim.cmd.write() end) diff --git a/lua/cp/highlight.lua b/lua/cp/highlight.lua new file mode 100644 index 0000000..8cf8bf4 --- /dev/null +++ b/lua/cp/highlight.lua @@ -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 diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 075ee65..54b75e4 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -25,9 +25,12 @@ local state = { saved_session = nil, test_cases = nil, test_states = {}, - test_panel_active = false, + run_panel_active = false, } +local current_diff_layout = nil +local current_mode = nil + local constants = require('cp.constants') local platforms = constants.PLATFORMS local actions = constants.ACTIONS @@ -149,14 +152,32 @@ local function get_current_problem() return filename end -local function toggle_test_panel(is_debug) - if state.test_panel_active then +local function create_buffer_with_options(filetype) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = buf }) + vim.api.nvim_set_option_value('readonly', true, { buf = buf }) + vim.api.nvim_set_option_value('modifiable', false, { buf = buf }) + if filetype then + vim.api.nvim_set_option_value('filetype', filetype, { buf = buf }) + end + return buf +end + +local setup_keybindings_for_buffer + +local function toggle_run_panel(is_debug) + if state.run_panel_active then + if current_diff_layout then + current_diff_layout.cleanup() + current_diff_layout = nil + current_mode = nil + end if state.saved_session then vim.cmd(('source %s'):format(state.saved_session)) vim.fn.delete(state.saved_session) state.saved_session = nil end - state.test_panel_active = false + state.run_panel_active = false logger.log('test panel closed') return end @@ -187,167 +208,224 @@ local function toggle_test_panel(is_debug) vim.cmd('silent only') - local tab_buf = vim.api.nvim_create_buf(false, true) - local expected_buf = vim.api.nvim_create_buf(false, true) - local actual_buf = vim.api.nvim_create_buf(false, true) - - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = tab_buf }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = expected_buf }) - vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = actual_buf }) - + local tab_buf = create_buffer_with_options() local main_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(main_win, tab_buf) vim.api.nvim_set_option_value('filetype', 'cptest', { buf = tab_buf }) - vim.cmd.split() - vim.api.nvim_win_set_buf(0, actual_buf) - vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf }) - - vim.cmd.vsplit() - vim.api.nvim_win_set_buf(0, expected_buf) - vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf }) - - local expected_win = vim.fn.bufwinid(expected_buf) - local actual_win = vim.fn.bufwinid(actual_buf) - local test_windows = { tab_win = main_win, - actual_win = actual_win, - expected_win = expected_win, } local test_buffers = { tab_buf = tab_buf, - expected_buf = expected_buf, - actual_buf = actual_buf, } - local function render_test_tabs() - local test_state = test_module.get_test_panel_state() - local tab_lines = {} + local highlight = require('cp.highlight') + local diff_namespace = highlight.create_namespace() - local max_status_width = 0 - local max_code_width = 0 - local max_time_width = 0 + local test_list_namespace = vim.api.nvim_create_namespace('cp_test_list') - for _, test_case in ipairs(test_state.test_cases) do - local status_text = test_case.status == 'pending' and '' or string.upper(test_case.status) - max_status_width = math.max(max_status_width, #status_text) + local function update_buffer_content(bufnr, lines, highlights) + local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr }) - if test_case.code then - max_code_width = math.max(max_code_width, #tostring(test_case.code)) - end + vim.api.nvim_set_option_value('readonly', false, { buf = bufnr }) + vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) + vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr }) - if test_case.time_ms then - local time_text = string.format('%.0fms', test_case.time_ms) - max_time_width = math.max(max_time_width, #time_text) - end - end - - for i, test_case in ipairs(test_state.test_cases) do - local prefix = i == test_state.current_index and '> ' or ' ' - local tab = string.format('%s%d.', prefix, i) - - if test_case.ok ~= nil then - tab = tab .. string.format(' [ok:%-5s]', tostring(test_case.ok)) - end - - if test_case.code then - tab = tab .. string.format(' [code:%-' .. max_code_width .. 's]', tostring(test_case.code)) - end - - if test_case.time_ms then - local time_text = string.format('%.0fms', test_case.time_ms) - tab = tab .. string.format(' [time:%-' .. max_time_width .. 's]', time_text) - end - - if test_case.signal then - tab = tab .. string.format(' [%s]', test_case.signal) - end - - table.insert(tab_lines, tab) - end - - local current_test = test_state.test_cases[test_state.current_index] - if current_test then - table.insert(tab_lines, '') - table.insert(tab_lines, 'Input:') - for _, line in ipairs(vim.split(current_test.input, '\n', { plain = true, trimempty = true })) do - table.insert(tab_lines, line) - end - end - - return tab_lines - end - - local function update_expected_pane() - local test_state = test_module.get_test_panel_state() - local current_test = test_state.test_cases[test_state.current_index] - - if not current_test then - return - end - - local expected_text = current_test.expected - local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true }) - - vim.api.nvim_buf_set_lines(test_buffers.expected_buf, 0, -1, false, expected_lines) - - if vim.fn.has('nvim-0.8.0') == 1 then - vim.api.nvim_set_option_value('winbar', 'Expected', { win = test_windows.expected_win }) + vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1) + for _, hl in ipairs(highlights) do + vim.api.nvim_buf_set_extmark(bufnr, test_list_namespace, hl.line, hl.col_start, { + end_col = hl.col_end, + hl_group = hl.highlight_group, + priority = 100, + }) end end - local function update_actual_pane() - local test_state = test_module.get_test_panel_state() - local current_test = test_state.test_cases[test_state.current_index] + local function create_vim_diff_layout(parent_win, expected_content, actual_content) + local expected_buf = create_buffer_with_options() + local actual_buf = create_buffer_with_options() - if not current_test then - return - end + vim.api.nvim_set_current_win(parent_win) + vim.cmd.split() + local actual_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(actual_win, actual_buf) - local actual_lines = {} - local enable_diff = false + vim.cmd.vsplit() + local expected_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(expected_win, expected_buf) - if current_test.actual then - actual_lines = vim.split(current_test.actual, '\n', { plain = true, trimempty = true }) - enable_diff = current_test.status == 'fail' + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf }) + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf }) + + local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) + local actual_lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) + + update_buffer_content(expected_buf, expected_lines, {}) + update_buffer_content(actual_buf, actual_lines, {}) + + vim.api.nvim_set_option_value('diff', true, { win = expected_win }) + vim.api.nvim_set_option_value('diff', true, { win = actual_win }) + vim.api.nvim_win_call(expected_win, function() + vim.cmd.diffthis() + end) + vim.api.nvim_win_call(actual_win, function() + vim.cmd.diffthis() + end) + + return { + buffers = { expected_buf, actual_buf }, + windows = { expected_win, actual_win }, + cleanup = function() + pcall(vim.api.nvim_win_close, expected_win, true) + pcall(vim.api.nvim_win_close, actual_win, true) + pcall(vim.api.nvim_buf_delete, expected_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, actual_buf, { force = true }) + end, + } + end + + local function create_git_diff_layout(parent_win, expected_content, actual_content) + local diff_buf = create_buffer_with_options() + + vim.api.nvim_set_current_win(parent_win) + vim.cmd.split() + local diff_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(diff_win, diff_buf) + + vim.api.nvim_set_option_value('filetype', 'cptest', { buf = diff_buf }) + + local diff_backend = require('cp.diff') + local backend = diff_backend.get_best_backend('git') + local diff_result = backend.render(expected_content, actual_content) + + if diff_result.raw_diff and diff_result.raw_diff ~= '' then + highlight.parse_and_apply_diff(diff_buf, diff_result.raw_diff, diff_namespace) else - actual_lines = { '(not run yet)' } + local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) + update_buffer_content(diff_buf, lines, {}) end - vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines) + return { + buffers = { diff_buf }, + windows = { diff_win }, + cleanup = function() + pcall(vim.api.nvim_win_close, diff_win, true) + pcall(vim.api.nvim_buf_delete, diff_buf, { force = true }) + end, + } + end - if vim.fn.has('nvim-0.8.0') == 1 then - vim.api.nvim_set_option_value('winbar', 'Actual', { win = test_windows.actual_win }) - end - - vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.expected_win }) - vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.actual_win }) - - if enable_diff then - vim.api.nvim_win_call(test_windows.expected_win, function() - vim.cmd.diffthis() - end) - vim.api.nvim_win_call(test_windows.actual_win, function() - vim.cmd.diffthis() - end) + local function create_diff_layout(mode, parent_win, expected_content, actual_content) + if mode == 'git' then + return create_git_diff_layout(parent_win, expected_content, actual_content) + else + return create_vim_diff_layout(parent_win, expected_content, actual_content) end end - local function refresh_test_panel() + local function update_diff_panes() + local test_state = test_module.get_run_panel_state() + local current_test = test_state.test_cases[test_state.current_index] + + if not current_test then + return + end + + local expected_content = current_test.expected or '' + local actual_content = current_test.actual or '(not run yet)' + local should_show_diff = current_test.status == 'fail' and current_test.actual + + if not should_show_diff then + expected_content = expected_content + actual_content = actual_content + end + + local desired_mode = should_show_diff and config.run_panel.diff_mode or 'vim' + + if current_diff_layout and current_mode ~= desired_mode then + local saved_pos = vim.api.nvim_win_get_cursor(0) + current_diff_layout.cleanup() + current_diff_layout = nil + current_mode = nil + + current_diff_layout = + create_diff_layout(desired_mode, main_win, expected_content, actual_content) + current_mode = desired_mode + + for _, buf in ipairs(current_diff_layout.buffers) do + setup_keybindings_for_buffer(buf) + end + + pcall(vim.api.nvim_win_set_cursor, 0, saved_pos) + return + end + + if not current_diff_layout then + current_diff_layout = + create_diff_layout(desired_mode, main_win, expected_content, actual_content) + current_mode = desired_mode + + for _, buf in ipairs(current_diff_layout.buffers) do + setup_keybindings_for_buffer(buf) + end + else + if desired_mode == 'git' then + local diff_backend = require('cp.diff') + local backend = diff_backend.get_best_backend('git') + local diff_result = backend.render(expected_content, actual_content) + + if diff_result.raw_diff and diff_result.raw_diff ~= '' then + highlight.parse_and_apply_diff( + current_diff_layout.buffers[1], + diff_result.raw_diff, + diff_namespace + ) + else + local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) + update_buffer_content(current_diff_layout.buffers[1], lines, {}) + end + else + local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) + local actual_lines = vim.split(actual_content, '\n', { plain = true, trimempty = true }) + update_buffer_content(current_diff_layout.buffers[1], expected_lines, {}) + update_buffer_content(current_diff_layout.buffers[2], actual_lines, {}) + + if should_show_diff then + vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[1] }) + vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[2] }) + vim.api.nvim_win_call(current_diff_layout.windows[1], function() + vim.cmd.diffthis() + end) + vim.api.nvim_win_call(current_diff_layout.windows[2], function() + vim.cmd.diffthis() + end) + else + vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[1] }) + vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[2] }) + end + end + end + end + + local function refresh_run_panel() if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then return end - local tab_lines = render_test_tabs() - vim.api.nvim_buf_set_lines(test_buffers.tab_buf, 0, -1, false, tab_lines) + local test_render = require('cp.test_render') + test_render.setup_highlights() + local test_state = test_module.get_run_panel_state() + local tab_lines, tab_highlights = test_render.render_test_list(test_state) + update_buffer_content(test_buffers.tab_buf, tab_lines, tab_highlights) - update_expected_pane() - update_actual_pane() + update_diff_panes() end local function navigate_test_case(delta) - local test_state = test_module.get_test_panel_state() + local test_state = test_module.get_run_panel_state() if #test_state.test_cases == 0 then return end @@ -359,20 +437,30 @@ local function toggle_test_panel(is_debug) test_state.current_index = 1 end - refresh_test_panel() + refresh_run_panel() end - vim.keymap.set('n', '', function() + setup_keybindings_for_buffer = function(buf) + vim.keymap.set('n', 'q', function() + toggle_run_panel() + end, { buffer = buf, silent = true }) + vim.keymap.set('n', config.run_panel.toggle_diff_key, function() + config.run_panel.diff_mode = config.run_panel.diff_mode == 'vim' and 'git' or 'vim' + refresh_run_panel() + end, { buffer = buf, silent = true }) + end + + vim.keymap.set('n', config.run_panel.next_test_key, function() navigate_test_case(1) end, { buffer = test_buffers.tab_buf, silent = true }) - vim.keymap.set('n', '', function() + vim.keymap.set('n', config.run_panel.prev_test_key, function() navigate_test_case(-1) end, { buffer = test_buffers.tab_buf, silent = true }) - for _, buf in pairs(test_buffers) do - vim.keymap.set('n', 'q', function() - toggle_test_panel() - end, { buffer = buf, silent = true }) + setup_keybindings_for_buffer(test_buffers.tab_buf) + + if config.hooks and config.hooks.before_run then + config.hooks.before_run(ctx) end if is_debug and config.hooks and config.hooks.before_debug then @@ -385,14 +473,14 @@ local function toggle_test_panel(is_debug) test_module.run_all_test_cases(ctx, contest_config) end - refresh_test_panel() + refresh_run_panel() vim.api.nvim_set_current_win(test_windows.tab_win) - state.test_panel_active = true + state.run_panel_active = true state.test_buffers = test_buffers state.test_windows = test_windows - local test_state = test_module.get_test_panel_state() + local test_state = test_module.get_run_panel_state() logger.log(string.format('test panel opened (%d test cases)', #test_state.test_cases)) end @@ -559,8 +647,8 @@ function M.handle_command(opts) end if cmd.type == 'action' then - if cmd.action == 'test' then - toggle_test_panel(cmd.debug) + if cmd.action == 'run' then + toggle_run_panel(cmd.debug) elseif cmd.action == 'next' then navigate_problem(1, cmd.language) elseif cmd.action == 'prev' then diff --git a/lua/cp/snippets.lua b/lua/cp/snippets.lua index b7b03ed..e0237c8 100644 --- a/lua/cp/snippets.lua +++ b/lua/cp/snippets.lua @@ -102,7 +102,7 @@ if __name__ == "__main__": local user_overrides = {} for _, snippet in ipairs(config.snippets or {}) do - user_overrides[snippet.trigger] = snippet + user_overrides[snippet.trigger:lower()] = snippet end for language, template_set in pairs(template_definitions) do @@ -110,14 +110,14 @@ if __name__ == "__main__": local filetype = constants.canonical_filetypes[language] for contest, template in pairs(template_set) do - local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest, language) - if not user_overrides[prefixed_trigger] then + local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest:lower(), language) + if not user_overrides[prefixed_trigger:lower()] then table.insert(snippets, s(prefixed_trigger, fmt(template, { i(1) }))) end end for trigger, snippet in pairs(user_overrides) do - local prefix_match = trigger:match('^cp%.nvim/[^.]+%.(.+)$') + local prefix_match = trigger:lower():match('^cp%.nvim/[^.]+%.(.+)$') if prefix_match == language then table.insert(snippets, snippet) end diff --git a/lua/cp/test.lua b/lua/cp/test.lua index a1fdd85..329f03c 100644 --- a/lua/cp/test.lua +++ b/lua/cp/test.lua @@ -12,7 +12,7 @@ ---@field signal string? ---@field timed_out boolean? ----@class TestPanelState +---@class RunPanelState ---@field test_cases TestCase[] ---@field current_index number ---@field buffer number? @@ -24,8 +24,8 @@ local M = {} local constants = require('cp.constants') local logger = require('cp.log') ----@type TestPanelState -local test_panel_state = { +---@type RunPanelState +local run_panel_state = { test_cases = {}, current_index = 1, buffer = nil, @@ -172,7 +172,7 @@ local function run_single_test_case(ctx, contest_config, test_case) end end - local run_cmd = build_command(language_config.run, language_config.executable, substitutions) + local run_cmd = build_command(language_config.test, language_config.executable, substitutions) local stdin_content = test_case.input .. '\n' @@ -227,8 +227,8 @@ function M.load_test_cases(ctx, state) test_cases = parse_test_cases_from_files(ctx.input_file, ctx.expected_file) end - test_panel_state.test_cases = test_cases - test_panel_state.current_index = 1 + run_panel_state.test_cases = test_cases + run_panel_state.current_index = 1 logger.log(('loaded %d test case(s)'):format(#test_cases)) return #test_cases > 0 @@ -239,7 +239,7 @@ end ---@param index number ---@return boolean function M.run_test_case(ctx, contest_config, index) - local test_case = test_panel_state.test_cases[index] + local test_case = run_panel_state.test_cases[index] if not test_case then return false end @@ -266,16 +266,16 @@ end ---@return TestCase[] function M.run_all_test_cases(ctx, contest_config) local results = {} - for i, _ in ipairs(test_panel_state.test_cases) do + for i, _ in ipairs(run_panel_state.test_cases) do M.run_test_case(ctx, contest_config, i) - table.insert(results, test_panel_state.test_cases[i]) + table.insert(results, run_panel_state.test_cases[i]) end return results end ----@return TestPanelState -function M.get_test_panel_state() - return test_panel_state +---@return RunPanelState +function M.get_run_panel_state() + return run_panel_state end return M diff --git a/lua/cp/test_render.lua b/lua/cp/test_render.lua new file mode 100644 index 0000000..9bbe699 --- /dev/null +++ b/lua/cp/test_render.lua @@ -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 +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 diff --git a/pyproject.toml b/pyproject.toml index 289baf0..5c731b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "types-requests>=2.32.4.20250913", "pytest>=8.0.0", "pytest-mock>=3.12.0", + "pre-commit>=4.3.0", ] [tool.pytest.ini_options] diff --git a/selene.toml b/selene.toml index d03ab0a..96cf5ab 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1 @@ -std = "vim" +std = 'vim' diff --git a/spec/command_parsing_spec.lua b/spec/command_parsing_spec.lua index ddeaca1..b66927a 100644 --- a/spec/command_parsing_spec.lua +++ b/spec/command_parsing_spec.lua @@ -51,7 +51,7 @@ describe('cp command parsing', function() describe('action commands', function() it('handles test action without error', function() - local opts = { fargs = { 'test' } } + local opts = { fargs = { 'run' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -126,7 +126,7 @@ describe('cp command parsing', function() describe('language flag parsing', function() it('logs error for --lang flag missing value', function() - local opts = { fargs = { 'test', '--lang' } } + local opts = { fargs = { 'run', '--lang' } } cp.handle_command(opts) @@ -169,7 +169,7 @@ describe('cp command parsing', function() describe('debug flag parsing', function() it('handles debug flag without error', function() - local opts = { fargs = { 'test', '--debug' } } + local opts = { fargs = { 'run', '--debug' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -177,7 +177,7 @@ describe('cp command parsing', function() end) it('handles combined language and debug flags', function() - local opts = { fargs = { 'test', '--lang=cpp', '--debug' } } + local opts = { fargs = { 'run', '--lang=cpp', '--debug' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -234,7 +234,7 @@ describe('cp command parsing', function() end) it('handles flag order variations', function() - local opts = { fargs = { '--debug', 'test', '--lang=python' } } + local opts = { fargs = { '--debug', 'run', '--lang=python' } } assert.has_no_errors(function() cp.handle_command(opts) @@ -242,7 +242,7 @@ describe('cp command parsing', function() end) it('handles multiple language flags', function() - local opts = { fargs = { 'test', '--lang=cpp', '--lang=python' } } + local opts = { fargs = { 'run', '--lang=cpp', '--lang=python' } } assert.has_no_errors(function() cp.handle_command(opts) diff --git a/spec/config_spec.lua b/spec/config_spec.lua index 3b94ddf..5b41617 100644 --- a/spec/config_spec.lua +++ b/spec/config_spec.lua @@ -73,6 +73,52 @@ describe('cp.config', function() config.setup(invalid_config) end) end) + + describe('run_panel config validation', function() + it('validates diff_mode values', function() + local invalid_config = { + run_panel = { diff_mode = 'invalid' }, + } + + assert.has_error(function() + config.setup(invalid_config) + end) + end) + + it('validates next_test_key is non-empty string', function() + local invalid_config = { + run_panel = { next_test_key = '' }, + } + + assert.has_error(function() + config.setup(invalid_config) + end) + end) + + it('validates prev_test_key is non-empty string', function() + local invalid_config = { + run_panel = { prev_test_key = '' }, + } + + assert.has_error(function() + config.setup(invalid_config) + end) + end) + + it('accepts valid run_panel config', function() + local valid_config = { + run_panel = { + diff_mode = 'git', + next_test_key = 'j', + prev_test_key = 'k', + }, + } + + assert.has_no.errors(function() + config.setup(valid_config) + end) + end) + end) end) describe('default_filename', function() diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..d9ac3b3 --- /dev/null +++ b/spec/diff_spec.lua @@ -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) diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua new file mode 100644 index 0000000..d1b2ad2 --- /dev/null +++ b/spec/highlight_spec.lua @@ -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) diff --git a/spec/snippets_spec.lua b/spec/snippets_spec.lua index bdddb56..861c102 100644 --- a/spec/snippets_spec.lua +++ b/spec/snippets_spec.lua @@ -211,5 +211,47 @@ describe('cp.snippets', function() assert.equals(1, codeforces_count) end) + + it('handles case-insensitive snippet triggers', function() + local mixed_case_snippet = { + trigger = 'cp.nvim/CodeForces.cpp', + body = 'mixed case template', + } + local upper_case_snippet = { + trigger = 'cp.nvim/ATCODER.cpp', + body = 'upper case template', + } + local config = { + snippets = { mixed_case_snippet, upper_case_snippet }, + } + + snippets.setup(config) + + local cpp_snippets = mock_luasnip.added.cpp or {} + + local has_mixed_case = false + local has_upper_case = false + local default_codeforces_count = 0 + local default_atcoder_count = 0 + + for _, snippet in ipairs(cpp_snippets) do + if snippet.trigger == 'cp.nvim/CodeForces.cpp' then + has_mixed_case = true + assert.equals('mixed case template', snippet.body) + elseif snippet.trigger == 'cp.nvim/ATCODER.cpp' then + has_upper_case = true + assert.equals('upper case template', snippet.body) + elseif snippet.trigger == 'cp.nvim/codeforces.cpp' then + default_codeforces_count = default_codeforces_count + 1 + elseif snippet.trigger == 'cp.nvim/atcoder.cpp' then + default_atcoder_count = default_atcoder_count + 1 + end + end + + assert.is_true(has_mixed_case) + assert.is_true(has_upper_case) + assert.equals(0, default_codeforces_count, 'Default codeforces snippet should be overridden') + assert.equals(0, default_atcoder_count, 'Default atcoder snippet should be overridden') + end) end) end) diff --git a/spec/test_render_spec.lua b/spec/test_render_spec.lua new file mode 100644 index 0000000..f7ace52 --- /dev/null +++ b/spec/test_render_spec.lua @@ -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) diff --git a/uv.lock b/uv.lock index bfe4d6d..744b4ae 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.3" @@ -100,6 +109,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "identify" +version = "2.6.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -165,6 +201,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -183,6 +228,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -192,6 +246,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -238,6 +308,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -278,6 +383,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-mock" }, { name = "types-beautifulsoup4" }, @@ -294,6 +400,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.18.2" }, + { name = "pre-commit", specifier = ">=4.3.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-mock", specifier = ">=3.12.0" }, { name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" }, @@ -359,3 +466,17 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] diff --git a/vim.toml b/vim.toml index 9a4528d..8bf26ea 100644 --- a/vim.toml +++ b/vim.toml @@ -22,3 +22,9 @@ any = true [after_each] any = true + +[spy] +any = true + +[stub] +any = true