Merge pull request #75 from barrett-ruth/feat/color

Terminal Color
This commit is contained in:
Barrett Ruth 2025-09-20 19:42:11 +02:00 committed by GitHub
commit 07be94d7aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1030 additions and 156 deletions

View file

@ -12,6 +12,7 @@ jobs:
outputs:
lua: ${{ steps.changes.outputs.lua }}
python: ${{ steps.changes.outputs.python }}
markdown: ${{ steps.changes.outputs.markdown }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@ -33,6 +34,9 @@ jobs:
- 'tests/scrapers/**'
- 'pyproject.toml'
- 'uv.lock'
markdown:
- '*.md'
- 'docs/**/*.md'
lua-format:
name: Lua Format Check
@ -115,3 +119,23 @@ jobs:
run: uv sync --dev
- name: Type check Python files with mypy
run: uv run mypy scrapers/ tests/scrapers/
markdown-format:
name: Markdown Format Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.markdown == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install prettier
run: pnpm add -g prettier@3.1.0
- name: Check markdown formatting with prettier
run: prettier --check "*.md" "docs/**/*.md" || true

View file

@ -5,7 +5,7 @@ repos:
hooks:
- id: stylua-github
name: stylua (Lua formatter)
args: ["--check", "."]
args: ["."]
files: ^(lua/|spec/|plugin/|after/|ftdetect/|.*\.lua$)
additional_dependencies: []
- repo: https://github.com/astral-sh/ruff-pre-commit
@ -16,7 +16,7 @@ repos:
files: ^(scrapers/|tests/scrapers/|.*\.py$)
- id: ruff
name: ruff (lint)
args: ["--no-fix"]
args: ["--fix", "--select=I"]
files: ^(scrapers/|tests/scrapers/|.*\.py$)
- repo: local
hooks:
@ -27,3 +27,9 @@ repos:
args: ["scrapers/", "tests/scrapers/"]
files: ^(scrapers/|tests/scrapers/|.*\.py$)
pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
hooks:
- id: prettier
name: prettier (format markdown)
files: \.(md)$

View file

@ -1,27 +1,54 @@
# cp.nvim
neovim plugin for competitive programming.
**Fast, minimal competitive programming environment for Neovim**
Scrape problems, run tests, and debug solutions across multiple platforms with zero configuration.
> **Disclaimer**: cp.nvim webs crapes data from competitive programming platforms - use at your own risk.
https://github.com/user-attachments/assets/cb142535-fba0-4280-8f11-66ad1ca50ca9
[video config](https://github.com/barrett-ruth/dots/blob/main/nvim/lua/plugins/cp.lua)
> Sample test data from [codeforces](https://codeforces.com) is scraped via [cloudscraper](https://github.com/VeNoMouS/cloudscraper). Use at your own risk.
## Features
- Support for multiple online judges ([AtCoder](https://atcoder.jp/), [Codeforces](https://codeforces.com/), [CSES](https://cses.fi))
- Language-agnostic features
- Automatic problem scraping and test case management
- Integrated running and debugging
- Enhanced test viewer
- Templates via LuaSnip
- **Multi-platform support**: AtCoder, Codeforces, CSES with consistent interface
- **Automatic problem setup**: Scrape test cases and metadata in seconds
- **Rich test output**: ANSI color support for compiler errors and program output
- **Language agnostic**: Works with any compiled language
- **Template integration**: Contest-specific snippets via LuaSnip
- **Diff viewer**: Compare expected vs actual output with precision
## Requirements
## Optional Dependencies
- Neovim 0.10.0+
- [uv](https://docs.astral.sh/uv/): problem scraping (optional)
- [LuaSnip](https://github.com/L3MON4D3/LuaSnip): contest-specific snippets (optional)
- [uv](https://docs.astral.sh/uv/) for problem scraping
- [LuaSnip](https://github.com/L3MON4D3/LuaSnip) for templates
## Quick Start
cp.nvim follows a simple principle: **solve locally, submit remotely**.
### Basic Usage
1. **Find a problem** on the judge website
2. **Set up locally** with `:CP <platform> <contest> <problem>`
```
:CP codeforces 1848 A
```
3. **Code and test** with instant feedback and rich diffs
```
:CP run
```
4. **Navigate between problems**
```
:CP next
:CP prev
```
5. **Submit** on the original website
## Documentation
@ -29,37 +56,13 @@ https://github.com/user-attachments/assets/cb142535-fba0-4280-8f11-66ad1ca50ca9
:help cp.nvim
```
## Philosophy
This plugin is highly tuned to my workflow and may not fit for you. Personally,
I believe there are two aspects of a cp workflow:
- local work (i.e. coding, running test cases)
- site work (i.e. problem reading, submitting)
Namely, I do not like the idea of submitting problems locally - the experience
will never quite offer what the remote does. Therefore, cp.nvim works as
follows:
1. Find a problem
- Browse the remote and find it
- Read it on the remote
2. Set up your local environment with `:CP ...`
- test cases and expected output automatically scraped
- templates automatically configured
3. Solve the problem locally
- easy to run/debug
- easy to diff actual vs. expected output
4. Submit the problem (on the remote!)
See [my config](https://github.com/barrett-ruth/dots/blob/main/nvim/lua/plugins/cp.lua) for a relatively advanced setup.
## Similar Projects
- [competitest.nvim](https://github.com/xeluxee/competitest.nvim)
- [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim)
## TODO
- Windows support

View file

@ -16,10 +16,10 @@ Supported languages: C++, Python
REQUIREMENTS *cp-requirements*
- Neovim 0.10.0+
- uv package manager (https://docs.astral.sh/uv/)
- Language runtime/compiler (g++, python3)
- Unix-like operating system
Optional:
- uv package manager (https://docs.astral.sh/uv/)
- LuaSnip for template expansion (https://github.com/L3MON4D3/LuaSnip)
==============================================================================
@ -380,7 +380,7 @@ Example: Setting up and solving AtCoder contest ABC324
7. Continue solving problems with :CP next/:CP prev navigation
8. Switch to another file (e.g., previous contest): >
8. Switch to another file (e.g. previous contest): >
:e ~/contests/abc323/a.cpp
:CP
< Automatically restores abc323 contest context
@ -421,9 +421,9 @@ The run panel uses the following table layout: >
└─────┴────────┴──────────────┴───────────┴──────────┴─────────────┘
┌──────────────────────────────────────────────────────────────────┐
│Expected vs Actual │
│4[-2-]{+3+}
│423
│100 │
│hello w[-o-]r{+o+}ld
│hello world
└──────────────────────────────────────────────────────────────────┘
Status Indicators ~
@ -434,10 +434,20 @@ Test cases use competitive programming terminology with color highlighting:
WA Wrong Answer (output mismatch) - Red
TLE Time Limit Exceeded (timeout) - Orange
RTE Runtime Error (non-zero exit) - Purple
<
Highlight Groups ~
*cp-highlights*
cp.nvim defines the following highlight groups for status indicators:
==============================================================================
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
cp.nvim provides comprehensive ANSI color support and highlighting for
compiler output, program stderr, and diff visualization.
==============================================================================
HIGHLIGHT GROUPS *cp-highlights*
Test Status Groups ~
Test cases use competitive programming terminology with color highlighting:
CpTestAC Green foreground for AC status
CpTestWA Red foreground for WA status
@ -445,14 +455,93 @@ cp.nvim defines the following highlight groups for status indicators:
CpTestRTE Purple foreground for RTE status
CpTestPending Gray foreground for pending tests
You can customize these colors by linking to other highlight groups in your
colorscheme or by redefining them: >lua
vim.api.nvim_set_hl(0, 'CpTestAC', { link = 'DiffAdd' })
vim.api.nvim_set_hl(0, 'CpTestWA', { fg = '#ff0000' })
ANSI Color Groups ~
cp.nvim preserves ANSI colors from compiler output and program stderr using
a sophisticated parsing system. Colors are automatically mapped to your
terminal colorscheme via vim.g.terminal_color_* variables.
Basic formatting groups:
CpAnsiBold Bold text formatting
CpAnsiItalic Italic text formatting
CpAnsiBoldItalic Combined bold and italic formatting
Standard terminal colors (each supports Bold, Italic, BoldItalic variants):
CpAnsiRed Standard red (terminal_color_1)
CpAnsiGreen Standard green (terminal_color_2)
CpAnsiYellow Standard yellow (terminal_color_3)
CpAnsiBlue Standard blue (terminal_color_4)
CpAnsiMagenta Standard magenta (terminal_color_5)
CpAnsiCyan Standard cyan (terminal_color_6)
CpAnsiWhite Standard white (terminal_color_7)
CpAnsiBlack Standard black (terminal_color_0)
Bright color variants:
CpAnsiBrightRed Bright red (terminal_color_9)
CpAnsiBrightGreen Bright green (terminal_color_10)
CpAnsiBrightYellow Bright yellow (terminal_color_11)
CpAnsiBrightBlue Bright blue (terminal_color_12)
CpAnsiBrightMagenta Bright magenta (terminal_color_13)
CpAnsiBrightCyan Bright cyan (terminal_color_14)
CpAnsiBrightWhite Bright white (terminal_color_15)
CpAnsiBrightBlack Bright black (terminal_color_8)
Example combinations:
CpAnsiBoldRed Bold red combination
CpAnsiItalicGreen Italic green combination
CpAnsiBoldItalicYellow Bold italic yellow combination
Diff Highlighting ~
Diff visualization uses Neovim's built-in highlight groups that automatically
adapt to your colorscheme:
DiffAdd Highlights added text in git diffs
DiffDelete Highlights removed text in git diffs
These groups are automatically used by the git diff backend for character-level
difference visualization with optimal colorscheme integration.
==============================================================================
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
ANSI colors automatically use your terminal's color palette through Neovim's
vim.g.terminal_color_* variables. This ensures compiler colors match your
colorscheme without manual configuration.
If your colorscheme doesn't set terminal colors, cp.nvim falls back to
sensible defaults. You can override terminal colors in your configuration: >vim
let g:terminal_color_1 = '#ff6b6b' " Custom red
let g:terminal_color_2 = '#51cf66' " Custom green
<
Keymaps ~
*cp-test-keys*
==============================================================================
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
You can customize any highlight group by linking to existing groups or
defining custom colors: >lua
-- Customize the color of "TLE" text in run panel:
vim.api.nvim_set_hl(0, 'CpTestTLE', { fg = '#ffa500', bold = true })
-- ... or the ANSI colors used to display stderr
vim.api.nvim_set_hl(0, 'CpAnsiRed', {
fg = vim.g.terminal_color_1 or '#ef4444'
})
<
Place customizations in your init.lua or after the colorscheme loads to
prevent them from being overridden: >lua
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
-- Your cp.nvim highlight customizations here
vim.api.nvim_set_hl(0, 'CpTestAC', { link = 'String' })
end
})
<
==============================================================================
RUN PANEL KEYMAPS *cp-test-keys*
<c-n> Navigate to next test case (configurable via
run_panel.next_test_key)
<c-p> Navigate to previous test case (configurable via
@ -507,10 +596,6 @@ Cache Location ~
Cache is stored at: >
vim.fn.stdpath('data') .. '/cp-nvim.json'
<
Typically resolves to:
• Linux/macOS: `~/.local/share/nvim/cp-nvim.json`
• Windows: `%LOCALAPPDATA%\nvim-data\cp-nvim.json`
Cache Structure ~
*cp-cache-structure*
The cache contains four main sections:

227
lua/cp/ansi.lua Normal file
View file

@ -0,0 +1,227 @@
---@class AnsiParseResult
---@field lines string[]
---@field highlights table[]
local M = {}
---@param raw_output string|table
---@return string
function M.bytes_to_string(raw_output)
if type(raw_output) == 'string' then
return raw_output
end
return table.concat(vim.tbl_map(string.char, raw_output))
end
---@param text string
---@return AnsiParseResult
function M.parse_ansi_text(text)
local clean_text = text:gsub('\027%[[%d;]*[a-zA-Z]', '')
local lines = vim.split(clean_text, '\n', { plain = true, trimempty = false })
local highlights = {}
local line_num = 0
local col_pos = 0
local ansi_state = {
bold = false,
italic = false,
foreground = nil,
}
local function get_highlight_group()
if not ansi_state.bold and not ansi_state.italic and not ansi_state.foreground then
return nil
end
local parts = { 'CpAnsi' }
if ansi_state.bold then
table.insert(parts, 'Bold')
end
if ansi_state.italic then
table.insert(parts, 'Italic')
end
if ansi_state.foreground then
table.insert(parts, ansi_state.foreground)
end
return table.concat(parts)
end
local function apply_highlight(start_line, start_col, end_col)
local hl_group = get_highlight_group()
if hl_group then
table.insert(highlights, {
line = start_line,
col_start = start_col,
col_end = end_col,
highlight_group = hl_group,
})
end
end
local i = 1
while i <= #text do
local ansi_start, ansi_end, code, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i)
if ansi_start then
if ansi_start > i then
local segment = text:sub(i, ansi_start - 1)
local start_line = line_num
local start_col = col_pos
for char in segment:gmatch('.') do
if char == '\n' then
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
line_num = line_num + 1
start_line = line_num
col_pos = 0
start_col = 0
else
col_pos = col_pos + 1
end
end
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
end
if cmd == 'm' then
M.update_ansi_state(ansi_state, code)
end
i = ansi_end + 1
else
local segment = text:sub(i)
if segment ~= '' then
local start_line = line_num
local start_col = col_pos
for char in segment:gmatch('.') do
if char == '\n' then
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
line_num = line_num + 1
start_line = line_num
col_pos = 0
start_col = 0
else
col_pos = col_pos + 1
end
end
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
end
break
end
end
return {
lines = lines,
highlights = highlights,
}
end
---@param ansi_state table
---@param code_string string
function M.update_ansi_state(ansi_state, code_string)
if code_string == '' or code_string == '0' then
ansi_state.bold = false
ansi_state.italic = false
ansi_state.foreground = nil
return
end
local codes = vim.split(code_string, ';', { plain = true })
for _, code in ipairs(codes) do
local num = tonumber(code)
if num then
if num == 1 then
ansi_state.bold = true
elseif num == 3 then
ansi_state.italic = true
elseif num == 22 then
ansi_state.bold = false
elseif num == 23 then
ansi_state.italic = false
elseif num >= 30 and num <= 37 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.foreground = colors[num - 29]
elseif num >= 90 and num <= 97 then
local colors = {
'BrightBlack',
'BrightRed',
'BrightGreen',
'BrightYellow',
'BrightBlue',
'BrightMagenta',
'BrightCyan',
'BrightWhite',
}
ansi_state.foreground = colors[num - 89]
elseif num == 39 then
ansi_state.foreground = nil
end
end
end
end
function M.setup_highlight_groups()
local color_map = {
Black = vim.g.terminal_color_0,
Red = vim.g.terminal_color_1,
Green = vim.g.terminal_color_2,
Yellow = vim.g.terminal_color_3,
Blue = vim.g.terminal_color_4,
Magenta = vim.g.terminal_color_5,
Cyan = vim.g.terminal_color_6,
White = vim.g.terminal_color_7,
BrightBlack = vim.g.terminal_color_8,
BrightRed = vim.g.terminal_color_9,
BrightGreen = vim.g.terminal_color_10,
BrightYellow = vim.g.terminal_color_11,
BrightBlue = vim.g.terminal_color_12,
BrightMagenta = vim.g.terminal_color_13,
BrightCyan = vim.g.terminal_color_14,
BrightWhite = vim.g.terminal_color_15,
}
local combinations = {
{ bold = false, italic = false },
{ bold = true, italic = false },
{ bold = false, italic = true },
{ bold = true, italic = true },
}
for _, combo in ipairs(combinations) do
for color_name, terminal_color in pairs(color_map) do
local parts = { 'CpAnsi' }
local opts = { fg = terminal_color }
if combo.bold then
table.insert(parts, 'Bold')
opts.bold = true
end
if combo.italic then
table.insert(parts, 'Italic')
opts.italic = true
end
table.insert(parts, color_name)
local hl_name = table.concat(parts)
vim.api.nvim_set_hl(0, hl_name, opts)
end
end
vim.api.nvim_set_hl(0, 'CpAnsiBold', { bold = true })
vim.api.nvim_set_hl(0, 'CpAnsiItalic', { italic = true })
vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true })
end
return M

View file

@ -58,10 +58,95 @@ local git_backend = {
highlights = {},
}
else
-- Parse git diff output to extract content and highlights
local diff_content = result.stdout or ''
local lines = {}
local highlights = {}
local line_num = 0
-- Extract content lines that start with space, +, or -
for line in diff_content:gmatch('[^\n]*') do
if
line:match('^[%s%+%-]')
or (not line:match('^[@%-+]') and not line:match('^index') and not line:match('^diff'))
then
-- This is content, not metadata
local clean_line = line
if line:match('^[%+%-]') then
clean_line = line:sub(2) -- Remove +/- prefix
end
-- Parse diff markers in the line
local col_pos = 0
local processed_line = ''
local i = 1
while i <= #clean_line do
local removed_start, removed_end = clean_line:find('%[%-[^%-]*%-]', i)
local added_start, added_end = clean_line:find('{%+[^%+]*%+}', i)
local next_marker_start = nil
local marker_type = nil
if removed_start and (not added_start or removed_start < added_start) then
next_marker_start = removed_start
marker_type = 'removed'
elseif added_start then
next_marker_start = added_start
marker_type = 'added'
end
if next_marker_start then
-- Add text before marker
if next_marker_start > i then
local before_text = clean_line:sub(i, next_marker_start - 1)
processed_line = processed_line .. before_text
col_pos = col_pos + #before_text
end
-- Extract and add marker content with highlighting
local marker_end = (marker_type == 'removed') and removed_end or added_end
local marker_text = clean_line:sub(next_marker_start, marker_end)
local content_text
if marker_type == 'removed' then
content_text = marker_text:sub(3, -3) -- Remove [- and -]
table.insert(highlights, {
line = line_num,
col_start = col_pos,
col_end = col_pos + #content_text,
highlight_group = 'DiffDelete',
})
else -- added
content_text = marker_text:sub(3, -3) -- Remove {+ and +}
table.insert(highlights, {
line = line_num,
col_start = col_pos,
col_end = col_pos + #content_text,
highlight_group = 'DiffAdd',
})
end
processed_line = processed_line .. content_text
col_pos = col_pos + #content_text
i = marker_end + 1
else
-- No more markers, add rest of line
local rest = clean_line:sub(i)
processed_line = processed_line .. rest
break
end
end
table.insert(lines, processed_line)
line_num = line_num + 1
end
end
return {
content = {},
highlights = {},
raw_diff = result.stdout or '',
content = lines,
highlights = highlights,
raw_diff = diff_content,
}
end
end,

View file

@ -22,7 +22,6 @@ local function get_language_from_file(source_file, contest_config)
local extension = vim.fn.fnamemodify(source_file, ':e')
local language = filetype_to_language[extension] or contest_config.default_language
logger.log(('detected language: %s (extension: %s)'):format(language, extension))
return language
end
@ -70,7 +69,7 @@ end
---@param language_config table
---@param substitutions table<string, string>
---@return {code: integer, stderr: string}
---@return {code: integer, stdout: string, stderr: string}
function M.compile_generic(language_config, substitutions)
vim.validate({
language_config = { language_config, 'table' },
@ -83,19 +82,25 @@ function M.compile_generic(language_config, substitutions)
end
local compile_cmd = substitute_template(language_config.compile, substitutions)
logger.log(('compiling: %s'):format(table.concat(compile_cmd, ' ')))
local redirected_cmd = vim.deepcopy(compile_cmd)
if #redirected_cmd > 0 then
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
end
local start_time = vim.uv.hrtime()
local result = vim.system(compile_cmd, { text = true }):wait()
local result = vim
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, { text = false })
:wait()
local compile_time = (vim.uv.hrtime() - start_time) / 1000000
local ansi = require('cp.ansi')
result.stdout = ansi.bytes_to_string(result.stdout or '')
result.stderr = ansi.bytes_to_string(result.stderr or '')
if result.code == 0 then
logger.log(('compilation successful (%.1fms)'):format(compile_time))
else
logger.log(
('compilation failed (%.1fms): %s'):format(compile_time, result.stderr),
vim.log.levels.WARN
)
logger.log(('compilation failed (%.1fms)'):format(compile_time))
end
return result
@ -112,12 +117,15 @@ local function execute_command(cmd, input_data, timeout_ms)
timeout_ms = { timeout_ms, 'number' },
})
logger.log(('executing: %s'):format(table.concat(cmd, ' ')))
local redirected_cmd = vim.deepcopy(cmd)
if #redirected_cmd > 0 then
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
end
local start_time = vim.uv.hrtime()
local result = vim
.system(cmd, {
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, {
stdin = input_data,
timeout = timeout_ms,
text = true,
@ -202,7 +210,7 @@ end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@param is_debug? boolean
---@return boolean success
---@return {success: boolean, output: string?}
function M.compile_problem(ctx, contest_config, is_debug)
vim.validate({
ctx = { ctx, 'table' },
@ -214,7 +222,7 @@ function M.compile_problem(ctx, contest_config, is_debug)
if not language_config then
logger.log('No configuration for language: ' .. language, vim.log.levels.ERROR)
return false
return { success = false, output = 'No configuration for language: ' .. language }
end
local substitutions = {
@ -229,16 +237,12 @@ function M.compile_problem(ctx, contest_config, is_debug)
language_config.compile = compile_cmd
local compile_result = M.compile_generic(language_config, substitutions)
if compile_result.code ~= 0 then
logger.log(
'compilation failed: ' .. (compile_result.stderr or 'unknown error'),
vim.log.levels.ERROR
)
return false
return { success = false, output = compile_result.stdout or 'unknown error' }
end
logger.log(('compilation successful (%s)'):format(is_debug and 'debug mode' or 'test mode'))
end
return true
return { success = true, output = nil }
end
function M.run_problem(ctx, contest_config, is_debug)

View file

@ -179,6 +179,7 @@ local function toggle_run_panel(is_debug)
vim.fn.delete(state.saved_session)
state.saved_session = nil
end
state.run_panel_active = false
logger.log('test panel closed')
return
@ -198,9 +199,9 @@ local function toggle_run_panel(is_debug)
end
local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config)
local test_module = require('cp.test')
local run = require('cp.run')
if not test_module.load_test_cases(ctx, state) then
if not run.load_test_cases(ctx, state) then
logger.log('no test cases found', vim.log.levels.WARN)
return
end
@ -226,8 +227,9 @@ local function toggle_run_panel(is_debug)
local diff_namespace = highlight.create_namespace()
local test_list_namespace = vim.api.nvim_create_namespace('cp_test_list')
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')
local function update_buffer_content(bufnr, lines, highlights)
local function update_buffer_content(bufnr, lines, highlights, namespace)
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr })
vim.api.nvim_set_option_value('readonly', false, { buf = bufnr })
@ -236,14 +238,7 @@ local function toggle_run_panel(is_debug)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr })
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 = 200,
})
end
highlight.apply_highlights(bufnr, highlights, namespace or test_list_namespace)
end
local function create_vim_diff_layout(parent_win, expected_content, actual_content)
@ -326,8 +321,31 @@ local function toggle_run_panel(is_debug)
}
end
local function create_single_layout(parent_win, content)
local buf = create_buffer_with_options()
local lines = vim.split(content, '\n', { plain = true, trimempty = true })
update_buffer_content(buf, lines, {})
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = buf })
return {
buffers = { buf },
windows = { win },
cleanup = function()
pcall(vim.api.nvim_win_close, win, true)
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end,
}
end
local function create_diff_layout(mode, parent_win, expected_content, actual_content)
if mode == 'git' then
if mode == 'single' then
return create_single_layout(parent_win, actual_content)
elseif 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)
@ -335,7 +353,7 @@ local function toggle_run_panel(is_debug)
end
local function update_diff_panes()
local test_state = test_module.get_run_panel_state()
local test_state = run.get_run_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
if not current_test then
@ -344,14 +362,19 @@ local function toggle_run_panel(is_debug)
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
local actual_highlights = current_test.actual_highlights or {}
local is_compilation_failure = current_test.error
and current_test.error:match('Compilation failed')
local should_show_diff = current_test.status == 'fail'
and current_test.actual
and not is_compilation_failure
if not should_show_diff then
expected_content = expected_content
actual_content = actual_content
end
local desired_mode = config.run_panel.diff_mode
local desired_mode = is_compilation_failure and 'single' or config.run_panel.diff_mode
if current_diff_layout and current_mode ~= desired_mode then
local saved_pos = vim.api.nvim_win_get_cursor(0)
@ -380,7 +403,15 @@ local function toggle_run_panel(is_debug)
setup_keybindings_for_buffer(buf)
end
else
if desired_mode == 'git' then
if desired_mode == 'single' then
local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(
current_diff_layout.buffers[1],
lines,
actual_highlights,
ansi_namespace
)
elseif 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)
@ -393,13 +424,23 @@ local function toggle_run_panel(is_debug)
)
else
local lines = vim.split(actual_content, '\n', { plain = true, trimempty = true })
update_buffer_content(current_diff_layout.buffers[1], lines, {})
update_buffer_content(
current_diff_layout.buffers[1],
lines,
actual_highlights,
ansi_namespace
)
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, {})
update_buffer_content(
current_diff_layout.buffers[2],
actual_lines,
actual_highlights,
ansi_namespace
)
if should_show_diff then
vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[1] })
@ -410,6 +451,8 @@ local function toggle_run_panel(is_debug)
vim.api.nvim_win_call(current_diff_layout.windows[2], function()
vim.cmd.diffthis()
end)
vim.api.nvim_set_option_value('foldcolumn', '0', { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = current_diff_layout.windows[2] })
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] })
@ -423,17 +466,18 @@ local function toggle_run_panel(is_debug)
return
end
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)
local run_render = require('cp.run_render')
run_render.setup_highlights()
local test_state = run.get_run_panel_state()
local tab_lines, tab_highlights = run_render.render_test_list(test_state)
update_buffer_content(test_buffers.tab_buf, tab_lines, tab_highlights)
update_diff_panes()
end
local function navigate_test_case(delta)
local test_state = test_module.get_run_panel_state()
local test_state = run.get_run_panel_state()
if #test_state.test_cases == 0 then
return
end
@ -456,6 +500,12 @@ local function toggle_run_panel(is_debug)
config.run_panel.diff_mode = config.run_panel.diff_mode == 'vim' and 'git' or 'vim'
refresh_run_panel()
end, { buffer = buf, silent = true })
vim.keymap.set('n', config.run_panel.next_test_key, function()
navigate_test_case(1)
end, { buffer = buf, silent = true })
vim.keymap.set('n', config.run_panel.prev_test_key, function()
navigate_test_case(-1)
end, { buffer = buf, silent = true })
end
vim.keymap.set('n', config.run_panel.next_test_key, function()
@ -477,18 +527,29 @@ local function toggle_run_panel(is_debug)
local execute_module = require('cp.execute')
local contest_config = config.contests[state.platform]
if execute_module.compile_problem(ctx, contest_config, is_debug) then
test_module.run_all_test_cases(ctx, contest_config, config)
local compile_result = execute_module.compile_problem(ctx, contest_config, is_debug)
if compile_result.success then
run.run_all_test_cases(ctx, contest_config, config)
else
run.handle_compilation_failure(compile_result.output)
end
refresh_run_panel()
vim.schedule(function()
local ansi = require('cp.ansi')
ansi.setup_highlight_groups()
if current_diff_layout then
update_diff_panes()
end
end)
vim.api.nvim_set_current_win(test_windows.tab_win)
state.run_panel_active = true
state.test_buffers = test_buffers
state.test_windows = test_windows
local test_state = test_module.get_run_panel_state()
local test_state = run.get_run_panel_state()
logger.log(string.format('test panel opened (%d test cases)', #test_state.test_cases))
end
@ -540,8 +601,8 @@ local function navigate_problem(delta, language)
local new_index = current_index + delta
if new_index < 1 or new_index > #problems then
local direction = delta > 0 and 'next' or 'previous'
logger.log(('no %s problem available'):format(direction), vim.log.levels.INFO)
local msg = delta > 0 and 'at last problem' or 'at first problem'
logger.log(msg, vim.log.levels.WARN)
return
end

View file

@ -4,8 +4,10 @@
---@field expected string
---@field status "pending"|"pass"|"fail"|"running"|"timeout"
---@field actual string?
---@field actual_highlights table[]?
---@field time_ms number?
---@field error string?
---@field stderr string?
---@field selected boolean
---@field code number?
---@field ok boolean?
@ -186,13 +188,27 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case)
if language_config.compile and vim.fn.filereadable(ctx.binary_file) == 0 then
logger.log('binary not found, compiling first...')
local compile_cmd = substitute_template(language_config.compile, substitutions)
local compile_result = vim.system(compile_cmd, { text = true }):wait()
local redirected_cmd = vim.deepcopy(compile_cmd)
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
local compile_result = vim
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, { text = false })
:wait()
local ansi = require('cp.ansi')
compile_result.stdout = ansi.bytes_to_string(compile_result.stdout or '')
compile_result.stderr = ansi.bytes_to_string(compile_result.stderr or '')
if compile_result.code ~= 0 then
return {
status = 'fail',
actual = '',
error = 'Compilation failed: ' .. (compile_result.stderr or 'Unknown error'),
error = 'Compilation failed: ' .. (compile_result.stdout or 'Unknown error'),
stderr = compile_result.stdout or '',
time_ms = 0,
code = compile_result.code,
ok = false,
signal = nil,
timed_out = false,
}
end
end
@ -207,16 +223,28 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case)
if not run_panel_state.constraints then
logger.log('no problem constraints available, using default 2000ms timeout')
end
local redirected_run_cmd = vim.deepcopy(run_cmd)
redirected_run_cmd[#redirected_run_cmd] = redirected_run_cmd[#redirected_run_cmd] .. ' 2>&1'
local result = vim
.system(run_cmd, {
.system({ 'sh', '-c', table.concat(redirected_run_cmd, ' ') }, {
stdin = stdin_content,
timeout = timeout_ms,
text = true,
text = false,
})
:wait()
local execution_time = (vim.uv.hrtime() - start_time) / 1000000
local actual_output = (result.stdout or ''):gsub('\n$', '')
local ansi = require('cp.ansi')
local stdout_str = ansi.bytes_to_string(result.stdout or '')
local actual_output = stdout_str:gsub('\n$', '')
local actual_highlights = {}
if actual_output ~= '' then
local parsed = ansi.parse_ansi_text(actual_output)
actual_output = table.concat(parsed.lines, '\n')
actual_highlights = parsed.highlights
end
local max_lines = cp_config.run_panel.max_output_lines
local output_lines = vim.split(actual_output, '\n')
@ -250,7 +278,9 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case)
return {
status = status,
actual = actual_output,
error = result.code ~= 0 and result.stderr or nil,
actual_highlights = actual_highlights,
error = result.code ~= 0 and actual_output or nil,
stderr = '',
time_ms = execution_time,
code = result.code,
ok = ok,
@ -295,14 +325,15 @@ function M.run_test_case(ctx, contest_config, cp_config, index)
return false
end
logger.log(('running test case %d'):format(index))
test_case.status = 'running'
local result = run_single_test_case(ctx, contest_config, cp_config, test_case)
test_case.status = result.status
test_case.actual = result.actual
test_case.actual_highlights = result.actual_highlights
test_case.error = result.error
test_case.stderr = result.stderr
test_case.time_ms = result.time_ms
test_case.code = result.code
test_case.ok = result.ok
@ -329,4 +360,26 @@ function M.get_run_panel_state()
return run_panel_state
end
function M.handle_compilation_failure(compilation_output)
local ansi = require('cp.ansi')
-- Always parse the compilation output - it contains everything now
local parsed = ansi.parse_ansi_text(compilation_output or '')
local clean_text = table.concat(parsed.lines, '\n')
local highlights = parsed.highlights
for _, test_case in ipairs(run_panel_state.test_cases) do
test_case.status = 'fail'
test_case.actual = clean_text
test_case.actual_highlights = highlights
test_case.error = 'Compilation failed'
test_case.stderr = ''
test_case.time_ms = 0
test_case.code = 1
test_case.ok = false
test_case.signal = nil
test_case.timed_out = false
end
end
return M

215
spec/ansi_spec.lua Normal file
View file

@ -0,0 +1,215 @@
describe('ansi parser', function()
local ansi = require('cp.ansi')
describe('bytes_to_string', function()
it('returns string as-is', function()
local input = 'hello world'
assert.equals('hello world', ansi.bytes_to_string(input))
end)
it('converts byte array to string', function()
local input = { 104, 101, 108, 108, 111 }
assert.equals('hello', ansi.bytes_to_string(input))
end)
end)
describe('parse_ansi_text', function()
it('strips ansi codes from simple text', function()
local input = 'Hello \027[31mworld\027[0m!'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello world!', table.concat(result.lines, '\n'))
end)
it('handles text without ansi codes', function()
local input = 'Plain text'
local result = ansi.parse_ansi_text(input)
assert.equals('Plain text', table.concat(result.lines, '\n'))
assert.equals(0, #result.highlights)
end)
it('creates correct highlight for simple colored text', function()
local input = 'Hello \027[31mworld\027[0m!'
local result = ansi.parse_ansi_text(input)
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals(0, highlight.line)
assert.equals(6, highlight.col_start)
assert.equals(11, highlight.col_end)
assert.equals('CpAnsiRed', highlight.highlight_group)
end)
it('handles bold text', function()
local input = 'Hello \027[1mbold\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBold', highlight.highlight_group)
end)
it('handles italic text', function()
local input = 'Hello \027[3mitalic\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello italic world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiItalic', highlight.highlight_group)
end)
it('handles bold + color combination', function()
local input = 'Hello \027[1;31mbold red\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold red world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBoldRed', highlight.highlight_group)
assert.equals(6, highlight.col_start)
assert.equals(14, highlight.col_end)
end)
it('handles italic + color combination', function()
local input = 'Hello \027[3;32mitalic green\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello italic green world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiItalicGreen', highlight.highlight_group)
end)
it('handles bold + italic + color combination', function()
local input = 'Hello \027[1;3;33mbold italic yellow\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold italic yellow world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBoldItalicYellow', highlight.highlight_group)
end)
it('handles sequential attribute setting', function()
local input = 'Hello \027[1m\027[31mbold red\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold red world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBoldRed', highlight.highlight_group)
end)
it('handles selective attribute reset', function()
local input = 'Hello \027[1;31mbold red\027[22mno longer bold\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold redno longer bold world', table.concat(result.lines, '\n'))
assert.equals(2, #result.highlights)
local bold_red = result.highlights[1]
assert.equals('CpAnsiBoldRed', bold_red.highlight_group)
assert.equals(6, bold_red.col_start)
assert.equals(14, bold_red.col_end)
local just_red = result.highlights[2]
assert.equals('CpAnsiRed', just_red.highlight_group)
assert.equals(14, just_red.col_start)
assert.equals(28, just_red.col_end)
end)
it('handles bright colors', function()
local input = 'Hello \027[91mbright red\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBrightRed', highlight.highlight_group)
end)
it('handles compiler-like output with complex formatting', function()
local input =
"error.cpp:10:5: \027[1m\027[31merror:\027[0m\027[1m 'undefined' was not declared\027[0m"
local result = ansi.parse_ansi_text(input)
local clean_text = table.concat(result.lines, '\n')
assert.equals("error.cpp:10:5: error: 'undefined' was not declared", clean_text)
assert.equals(2, #result.highlights)
local error_highlight = result.highlights[1]
assert.equals('CpAnsiBoldRed', error_highlight.highlight_group)
assert.equals(16, error_highlight.col_start)
assert.equals(22, error_highlight.col_end)
local message_highlight = result.highlights[2]
assert.equals('CpAnsiBold', message_highlight.highlight_group)
assert.equals(22, message_highlight.col_start)
assert.equals(51, message_highlight.col_end)
end)
it('handles multiline with persistent state', function()
local input = '\027[1;31mline1\nline2\nline3\027[0m'
local result = ansi.parse_ansi_text(input)
assert.equals('line1\nline2\nline3', table.concat(result.lines, '\n'))
assert.equals(3, #result.highlights)
for i, highlight in ipairs(result.highlights) do
assert.equals('CpAnsiBoldRed', highlight.highlight_group)
assert.equals(i - 1, highlight.line)
assert.equals(0, highlight.col_start)
assert.equals(5, highlight.col_end)
end
end)
end)
describe('update_ansi_state', function()
it('resets all state on reset code', function()
local state = { bold = true, italic = true, foreground = 'Red' }
ansi.update_ansi_state(state, '0')
assert.is_false(state.bold)
assert.is_false(state.italic)
assert.is_nil(state.foreground)
end)
it('sets individual attributes', function()
local state = { bold = false, italic = false, foreground = nil }
ansi.update_ansi_state(state, '1')
assert.is_true(state.bold)
ansi.update_ansi_state(state, '3')
assert.is_true(state.italic)
ansi.update_ansi_state(state, '31')
assert.equals('Red', state.foreground)
end)
it('handles compound codes', function()
local state = { bold = false, italic = false, foreground = nil }
ansi.update_ansi_state(state, '1;3;31')
assert.is_true(state.bold)
assert.is_true(state.italic)
assert.equals('Red', state.foreground)
end)
it('handles selective resets', function()
local state = { bold = true, italic = true, foreground = 'Red' }
ansi.update_ansi_state(state, '22')
assert.is_false(state.bold)
assert.is_true(state.italic)
assert.equals('Red', state.foreground)
ansi.update_ansi_state(state, '39')
assert.is_false(state.bold)
assert.is_true(state.italic)
assert.is_nil(state.foreground)
end)
end)
end)

View file

@ -82,10 +82,10 @@ describe('cp.execute', function()
assert.is_true(#mock_system_calls > 0)
local compile_call = mock_system_calls[1]
assert.equals('g++', compile_call.cmd[1])
assert.equals('test.cpp', compile_call.cmd[2])
assert.equals('-o', compile_call.cmd[3])
assert.equals('test.run', compile_call.cmd[4])
assert.equals('sh', compile_call.cmd[1])
assert.equals('-c', compile_call.cmd[2])
assert.is_not_nil(string.find(compile_call.cmd[3], 'g%+%+ test%.cpp %-o test%.run'))
assert.is_not_nil(string.find(compile_call.cmd[3], '2>&1'))
end)
it('handles multiple substitutions in single argument', function()
@ -100,7 +100,7 @@ describe('cp.execute', function()
execute.compile_generic(language_config, substitutions)
local compile_call = mock_system_calls[1]
assert.equals('-omain.out', compile_call.cmd[3])
assert.is_not_nil(string.find(compile_call.cmd[3], '%-omain%.out'))
end)
end)
@ -131,8 +131,8 @@ describe('cp.execute', function()
assert.is_true(#mock_system_calls > 0)
local compile_call = mock_system_calls[1]
assert.equals('g++', compile_call.cmd[1])
assert.is_true(vim.tbl_contains(compile_call.cmd, '-std=c++17'))
assert.equals('sh', compile_call.cmd[1])
assert.is_not_nil(string.find(compile_call.cmd[3], '%-std=c%+%+17'))
end)
it('handles compilation errors gracefully', function()
@ -199,7 +199,7 @@ describe('cp.execute', function()
it('handles command execution', function()
vim.system = function(_, opts)
if opts then
assert.equals(true, opts.text)
assert.equals(false, opts.text)
end
return {
wait = function()
@ -266,9 +266,10 @@ describe('cp.execute', function()
execute.compile_generic(language_config, {})
local mkdir_call = mock_system_calls[1]
assert.equals('mkdir', mkdir_call.cmd[1])
assert.is_true(vim.tbl_contains(mkdir_call.cmd, 'build'))
assert.is_true(vim.tbl_contains(mkdir_call.cmd, 'io'))
assert.equals('sh', mkdir_call.cmd[1])
assert.is_not_nil(string.find(mkdir_call.cmd[3], 'mkdir'))
assert.is_not_nil(string.find(mkdir_call.cmd[3], 'build'))
assert.is_not_nil(string.find(mkdir_call.cmd[3], 'io'))
end)
end)
@ -316,8 +317,8 @@ describe('cp.execute', function()
assert.equals(0, result.code)
local echo_call = mock_system_calls[1]
assert.equals('echo', echo_call.cmd[1])
assert.equals('hello', echo_call.cmd[2])
assert.equals('sh', echo_call.cmd[1])
assert.is_not_nil(string.find(echo_call.cmd[3], 'echo hello'))
end)
it('handles multiple consecutive substitutions', function()
@ -332,8 +333,119 @@ describe('cp.execute', function()
execute.compile_generic(language_config, substitutions)
local call = mock_system_calls[1]
assert.equals('g++g++', call.cmd[1])
assert.equals('test.cpptest.cpp', call.cmd[2])
assert.equals('sh', call.cmd[1])
assert.is_not_nil(string.find(call.cmd[3], 'g%+%+g%+%+ test%.cpptest%.cpp'))
end)
end)
describe('stderr/stdout redirection', function()
it('should use stderr redirection (2>&1)', function()
local original_system = vim.system
local captured_command = nil
vim.system = function(cmd, _)
captured_command = cmd
return {
wait = function()
return { code = 0, stdout = '', stderr = '' }
end,
}
end
local language_config = {
compile = { 'g++', '-std=c++17', '-o', '{binary}', '{source}' },
}
local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' }
execute.compile_generic(language_config, substitutions)
assert.is_not_nil(captured_command)
assert.equals('sh', captured_command[1])
assert.equals('-c', captured_command[2])
assert.is_not_nil(
string.find(captured_command[3], '2>&1'),
'Command should contain 2>&1 redirection'
)
vim.system = original_system
end)
it('should return combined stdout+stderr in result', function()
local original_system = vim.system
local test_output = 'STDOUT: Hello\nSTDERR: Error message\n'
vim.system = function(_, _)
return {
wait = function()
return { code = 1, stdout = test_output, stderr = '' }
end,
}
end
local language_config = {
compile = { 'g++', '-std=c++17', '-o', '{binary}', '{source}' },
}
local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' }
local result = execute.compile_generic(language_config, substitutions)
assert.equals(1, result.code)
assert.equals(test_output, result.stdout)
vim.system = original_system
end)
end)
describe('integration with execute_command function', function()
it('tests the full execute_command flow with stderr/stdout combination', function()
local cmd = { 'echo', 'test output' }
local input_data = 'test input'
local timeout_ms = 1000
local original_system = vim.system
vim.system = function(shell_cmd, opts)
assert.equals('sh', shell_cmd[1])
assert.equals('-c', shell_cmd[2])
assert.is_not_nil(string.find(shell_cmd[3], '2>&1'))
assert.equals(input_data, opts.stdin)
assert.equals(timeout_ms, opts.timeout)
assert.is_true(opts.text)
return {
wait = function()
return { code = 0, stdout = 'combined output from stdout and stderr', stderr = '' }
end,
}
end
local execute_command = require('cp.execute').execute_command
or function(command, stdin_data, timeout)
local redirected_cmd = vim.deepcopy(command)
if #redirected_cmd > 0 then
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
end
local result = vim
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, {
stdin = stdin_data,
timeout = timeout,
text = true,
})
:wait()
return {
stdout = result.stdout or '',
stderr = result.stderr or '',
code = result.code or 0,
time_ms = 0,
timed_out = result.code == 124,
}
end
local result = execute_command(cmd, input_data, timeout_ms)
assert.equals(0, result.code)
assert.equals('combined output from stdout and stderr', result.stdout)
vim.system = original_system
end)
end)
end)

View file

@ -1,10 +1,9 @@
describe('cp.test_render', function()
describe('cp.run_render', function()
local run_render = require('cp.run_render')
local spec_helper = require('spec.spec_helper')
local test_render
before_each(function()
spec_helper.setup()
test_render = require('cp.test_render')
end)
after_each(function()
@ -14,49 +13,49 @@ describe('cp.test_render', function()
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)
local result = run_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)
local result = run_render.get_status_info(test_case)
assert.equals('WA', result.text)
assert.equals('CpTestWA', 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)
local result = run_render.get_status_info(test_case)
assert.equals('TLE', result.text)
assert.equals('CpTestTLE', 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)
local result = run_render.get_status_info(test_case)
assert.equals('TLE', result.text)
assert.equals('CpTestTLE', 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)
local result = run_render.get_status_info(test_case)
assert.equals('RTE', result.text)
assert.equals('CpTestRTE', 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)
local result = run_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)
local result = run_render.get_status_info(test_case)
assert.equals('...', result.text)
assert.equals('CpTestPending', result.highlight_group)
end)
@ -71,7 +70,7 @@ describe('cp.test_render', function()
},
current_index = 1,
}
local result = test_render.render_test_list(test_state)
local result = run_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)
@ -85,7 +84,7 @@ describe('cp.test_render', function()
},
current_index = 2,
}
local result = test_render.render_test_list(test_state)
local result = run_render.render_test_list(test_state)
local found_current = false
for _, line in ipairs(result) do
if line:match('│.*> 2.*│') then
@ -104,7 +103,7 @@ describe('cp.test_render', function()
},
current_index = 1,
}
local result = test_render.render_test_list(test_state)
local result = run_render.render_test_list(test_state)
local found_input = false
for _, line in ipairs(result) do
if line:match('│5 3') then
@ -117,7 +116,7 @@ describe('cp.test_render', function()
it('handles empty test cases', function()
local test_state = { test_cases = {}, current_index = 1 }
local result = test_render.render_test_list(test_state)
local result = run_render.render_test_list(test_state)
assert.equals(3, #result)
end)
@ -128,7 +127,7 @@ describe('cp.test_render', function()
},
current_index = 1,
}
local result = test_render.render_test_list(test_state)
local result = run_render.render_test_list(test_state)
local input_lines = {}
for _, line in ipairs(result) do
if line:match('^│[531]') then
@ -142,24 +141,24 @@ describe('cp.test_render', function()
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)
local result = run_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)
local result = run_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)
local result = run_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)
local result = run_render.render_status_bar(nil)
assert.equals('', result)
end)
end)
@ -167,7 +166,7 @@ describe('cp.test_render', function()
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()
run_render.setup_highlights()
assert.spy(mock_set_hl).was_called(7)
assert.spy(mock_set_hl).was_called_with(0, 'CpTestAC', { fg = '#10b981' })
@ -188,7 +187,7 @@ describe('cp.test_render', function()
},
current_index = 1,
}
local lines, highlights = test_render.render_test_list(test_state)
local lines, highlights = run_render.render_test_list(test_state)
assert.equals(2, #highlights)