From b507dad4a7c06ad96763929a4afeefb18ac8aabc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 20 Sep 2025 12:52:12 -0400 Subject: [PATCH] feat: simplify ansi buffer approach --- lua/cp/execute.lua | 21 ++++---- lua/cp/init.lua | 62 +++++++++++----------- lua/cp/run.lua | 89 +++++++++---------------------- tests/execute_spec.lua | 116 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 103 deletions(-) create mode 100644 tests/execute_spec.lua diff --git a/lua/cp/execute.lua b/lua/cp/execute.lua index a098cfe..c78de3d 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 @@ -83,10 +82,13 @@ 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) + redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1' local start_time = vim.uv.hrtime() - local result = vim.system(compile_cmd, { text = false }):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') @@ -113,12 +115,13 @@ 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) + redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1' 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, @@ -203,7 +206,7 @@ end ---@param ctx ProblemContext ---@param contest_config ContestConfig ---@param is_debug? boolean ----@return {success: boolean, stderr: string?} +---@return {success: boolean, output: string?} function M.compile_problem(ctx, contest_config, is_debug) vim.validate({ ctx = { ctx, 'table' }, @@ -215,7 +218,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 { success = false, stderr = 'No configuration for language: ' .. language } + return { success = false, output = 'No configuration for language: ' .. language } end local substitutions = { @@ -230,12 +233,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 - return { success = false, stderr = compile_result.stderr or 'unknown error' } + 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 { success = true, stderr = nil } + 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 eed270d..49cc2ec 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -227,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 }) @@ -237,30 +238,8 @@ 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 }) - local ansi = require('cp.ansi') - ansi.setup_highlight_groups() - - vim.api.nvim_buf_clear_namespace(bufnr, test_list_namespace, 0, -1) - for _, hl in ipairs(highlights) do - logger.log( - 'Applying extmark: buf=' - .. bufnr - .. ' line=' - .. hl.line - .. ' col=' - .. hl.col_start - .. '-' - .. hl.col_end - .. ' group=' - .. (hl.highlight_group or 'nil') - ) - - 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 + local highlight = require('cp.highlight') + highlight.apply_highlights(bufnr, highlights, namespace or test_list_namespace) end local function create_vim_diff_layout(parent_win, expected_content, actual_content) @@ -427,7 +406,12 @@ local function toggle_run_panel(is_debug) else 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) + 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') @@ -441,13 +425,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, actual_highlights) + 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, actual_highlights) + 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] }) @@ -476,8 +470,6 @@ local function toggle_run_panel(is_debug) local run_render = require('cp.run_render') run_render.setup_highlights() - local ansi = require('cp.ansi') - ansi.setup_highlight_groups() 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) @@ -540,11 +532,19 @@ local function toggle_run_panel(is_debug) if compile_result.success then run.run_all_test_cases(ctx, contest_config, config) else - run.handle_compilation_failure(compile_result.stderr) + 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 diff --git a/lua/cp/run.lua b/lua/cp/run.lua index 1562835..3303cbd 100644 --- a/lua/cp/run.lua +++ b/lua/cp/run.lua @@ -188,13 +188,22 @@ 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'), - stderr = compile_result.stderr or '', + error = 'Compilation failed: ' .. (compile_result.stdout or 'Unknown error'), + stderr = compile_result.stdout or '', time_ms = 0, code = compile_result.code, ok = false, @@ -214,8 +223,10 @@ 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 = false, @@ -225,35 +236,14 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case) local ansi = require('cp.ansi') local stdout_str = ansi.bytes_to_string(result.stdout or '') - local stderr_str = ansi.bytes_to_string(result.stderr or '') - local actual_output = stdout_str:gsub('\n$', '') - local stderr_output = stderr_str:gsub('\n$', '') local actual_highlights = {} - if stderr_output ~= '' then - local stderr_parsed = ansi.parse_ansi_text(stderr_output) - local clean_stderr = table.concat(stderr_parsed.lines, '\n') - - local line_offset - if actual_output ~= '' then - local stdout_lines = vim.split(actual_output, '\n') - line_offset = #stdout_lines + 2 -- +1 for empty line, +1 for "--- stderr ---" - actual_output = actual_output .. '\n\n--- stderr ---\n' .. clean_stderr - else - line_offset = 1 -- +1 for "--- stderr ---" - actual_output = '--- stderr ---\n' .. clean_stderr - end - - for _, highlight in ipairs(stderr_parsed.highlights) do - table.insert(actual_highlights, { - line = highlight.line + line_offset, - col_start = highlight.col_start, - col_end = highlight.col_end, - highlight_group = highlight.highlight_group, - }) - end + 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 @@ -289,8 +279,8 @@ local function run_single_test_case(ctx, contest_config, cp_config, test_case) status = status, actual = actual_output, actual_highlights = actual_highlights, - error = result.code ~= 0 and result.stderr or nil, - stderr = result.stderr or '', + error = result.code ~= 0 and actual_output or nil, + stderr = '', time_ms = execution_time, code = result.code, ok = ok, @@ -335,7 +325,6 @@ 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) @@ -371,39 +360,13 @@ function M.get_run_panel_state() return run_panel_state end -function M.handle_compilation_failure(compilation_stderr) +function M.handle_compilation_failure(compilation_output) local ansi = require('cp.ansi') - local clean_text = 'Compilation failed' - local highlights = {} - if compilation_stderr and compilation_stderr ~= '' then - logger.log('Raw compilation stderr length: ' .. #compilation_stderr) - logger.log('Has ANSI codes: ' .. tostring(compilation_stderr:find('\027%[[%d;]*m') ~= nil)) - - -- Show first 200 chars to see actual ANSI sequences - local sample = compilation_stderr:sub(1, 200):gsub('\027', '\\027') - logger.log('Stderr sample: ' .. sample) - - local parsed = ansi.parse_ansi_text(compilation_stderr) - clean_text = table.concat(parsed.lines, '\n') - highlights = parsed.highlights - - logger.log('Parsed highlights count: ' .. #highlights) - for i, hl in ipairs(highlights) do - logger.log( - 'Highlight ' - .. i - .. ': line=' - .. hl.line - .. ' col=' - .. hl.col_start - .. '-' - .. hl.col_end - .. ' group=' - .. (hl.highlight_group or 'nil') - ) - end - end + -- 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' diff --git a/tests/execute_spec.lua b/tests/execute_spec.lua new file mode 100644 index 0000000..09211cb --- /dev/null +++ b/tests/execute_spec.lua @@ -0,0 +1,116 @@ +local execute = require('cp.execute') + +describe('execute module', function() + local test_ctx + local test_config + + before_each(function() + test_ctx = { + source_file = 'test.cpp', + binary_file = 'build/test', + input_file = 'io/test.cpin', + output_file = 'io/test.cpout', + } + + test_config = { + default_language = 'cpp', + cpp = { + version = 17, + compile = { 'g++', '-std=c++17', '-o', '{binary}', '{source}' }, + test = { '{binary}' }, + executable = nil, + }, + } + end) + + describe('compile_generic', function() + it('should use stderr redirection (2>&1)', function() + local original_system = vim.system + local captured_command = nil + + vim.system = function(cmd, opts) + captured_command = cmd + return { + wait = function() + return { code = 0, stdout = '', stderr = '' } + end, + } + end + + local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' } + execute.compile_generic(test_config.cpp, substitutions) + + assert.is_not_nil(captured_command) + assert.equals('sh', captured_command[1]) + assert.equals('-c', captured_command[2]) + assert.is_true( + string.find(captured_command[3], '2>&1') ~= nil, + '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(cmd, opts) + return { + wait = function() + return { code = 1, stdout = test_output, stderr = '' } + end, + } + end + + local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' } + local result = execute.compile_generic(test_config.cpp, substitutions) + + assert.equals(1, result.code) + assert.equals(test_output, result.stdout) + + vim.system = original_system + end) + end) + + describe('compile_problem', function() + it('should return combined output in stderr field for compatibility', function() + local original_system = vim.system + local test_error_output = 'test.cpp:1:1: error: expected declaration\n' + + vim.system = function(cmd, opts) + return { + wait = function() + return { code = 1, stdout = test_error_output, stderr = '' } + end, + } + end + + local result = execute.compile_problem(test_ctx, test_config, false) + + assert.is_false(result.success) + assert.equals(test_error_output, result.output) + + vim.system = original_system + end) + + it('should return success=true when compilation succeeds', function() + local original_system = vim.system + + vim.system = function(cmd, opts) + return { + wait = function() + return { code = 0, stdout = '', stderr = '' } + end, + } + end + + local result = execute.compile_problem(test_ctx, test_config, false) + + assert.is_true(result.success) + assert.is_nil(result.output) + + vim.system = original_system + end) + end) +end)