diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c100808..a608648 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4acb307..51a81f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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)$ diff --git a/README.md b/README.md index 87deda1..2e9ee89 100644 --- a/README.md +++ b/README.md @@ -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 ` + + ``` + :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 diff --git a/doc/cp.txt b/doc/cp.txt index e2111b1..37b2de9 100644 --- a/doc/cp.txt +++ b/doc/cp.txt @@ -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* Navigate to next test case (configurable via run_panel.next_test_key) 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: diff --git a/lua/cp/ansi.lua b/lua/cp/ansi.lua new file mode 100644 index 0000000..d8116f0 --- /dev/null +++ b/lua/cp/ansi.lua @@ -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 diff --git a/lua/cp/diff.lua b/lua/cp/diff.lua index 2784b4b..e576b8c 100644 --- a/lua/cp/diff.lua +++ b/lua/cp/diff.lua @@ -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, diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index d336ad8..a56bc62 100644 --- a/lua/cp/execute.lua +++ b/lua/cp/execute.lua @@ -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 ----@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) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index b24180c..374ddb0 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -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 diff --git a/lua/cp/test.lua b/lua/cp/run.lua similarity index 80% rename from lua/cp/test.lua rename to lua/cp/run.lua index e894d4a..3303cbd 100644 --- a/lua/cp/test.lua +++ b/lua/cp/run.lua @@ -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 diff --git a/lua/cp/test_render.lua b/lua/cp/run_render.lua similarity index 100% rename from lua/cp/test_render.lua rename to lua/cp/run_render.lua diff --git a/spec/ansi_spec.lua b/spec/ansi_spec.lua new file mode 100644 index 0000000..0b7faf8 --- /dev/null +++ b/spec/ansi_spec.lua @@ -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) diff --git a/spec/execute_spec.lua b/spec/execute_spec.lua index 73ec456..12e7f67 100644 --- a/spec/execute_spec.lua +++ b/spec/execute_spec.lua @@ -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) diff --git a/spec/test_render_spec.lua b/spec/run_render_spec.lua similarity index 82% rename from spec/test_render_spec.lua rename to spec/run_render_spec.lua index 2bdc98a..bcb0c78 100644 --- a/spec/test_render_spec.lua +++ b/spec/run_render_spec.lua @@ -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)