diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 97b7786..8b6432f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -29,8 +29,9 @@ jobs: - '.luarc.json' - '*.toml' python: - - 'scrapers/**' - - 'tests/scrapers/**' + - 'scripts/**/.py' + - 'scrapers/**/*.py' + - 'tests/**/*.py' - 'pyproject.toml' - 'uv.lock' markdown: @@ -103,7 +104,7 @@ jobs: - name: Install ruff run: uv tool install ruff - name: Lint Python files with ruff - run: ruff check scrapers/ tests/scrapers/ + run: ruff check scripts/ scrapers/ tests/scrapers/ python-typecheck: name: Python Type Check @@ -117,7 +118,7 @@ jobs: - name: Install dependencies with mypy run: uv sync --dev - name: Type check Python files with mypy - run: uv run mypy scrapers/ tests/scrapers/ + run: uv run mypy scripts/ scrapers/ tests/scrapers/ markdown-format: name: Markdown Format Check diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 5fb4cf2..61d8388 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -50,6 +50,13 @@ COMMANDS *cp-commands* :CP pick Launch configured picker for interactive platform/contest selection. + :CP interact [script] + Open an interactive terminal for the current problem. + If an executable interactor is provided, runs the compiled + binary against the source file (see + *cp-interact*). Otherwise, runs the source + file. Only valid for interactive problems. + Navigation Commands ~ :CP next Navigate to next problem in current contest. Stops at last problem (no wrapping). @@ -78,7 +85,7 @@ COMMANDS *cp-commands* Template Variables ~ *cp-template-vars* - Command templates support variable substitution using `{variable}` syntax: + Command templates support variable substitution using {variable} syntax: • {source} Source file path (e.g. "abc324a.cpp") • {binary} Output binary path (e.g. "build/abc324a.run") @@ -155,8 +162,8 @@ Here's an example configuration with lazy.nvim: < By default, C++ (g++ with ISO C++17) and Python are preconfigured under -`languages`. Platforms select which languages are enabled and which one is -the default; per-platform overrides can tweak `extension` or `commands`. +'languages'. Platforms select which languages are enabled and which one is +the default; per-platform overrides can tweak 'extension' or 'commands'. For example, to run CodeForces contests with Python by default: >lua @@ -398,6 +405,37 @@ Test cases use competitive programming terminology with color highlighting: NA Any other state < +============================================================================== +INTERACTIVE MODE *cp-interact* + +Run interactive problems manually or with an orchestrator. :CP interact is +available for interactive problems. Test cases are ignored in interactive mode +(no run panel, no diffs). + +When using :CP interact {interactor}, the interactor must be executable +(chmod +x). Completion after :CP interact suggests executables in CWD. + +1) Terminal-only ~ + :CP interact + Execute the current program and open an interactive terminal running + it directly. Use this for manual testing. + +2) Orchestrated ~ + :CP interact {interactor} + Execute the current program and open an interactive terminal that runs + your interactor script against it. + {interactor} is an executable file relative to the CWD. + Example: + :CP interact my-executable-interactor.py + + +Keymaps ~ + Close the terminal and restore the previous layout. + +============================================================================== +COMMANDS (update) *cp-commands* + + ============================================================================== ANSI COLORS AND HIGHLIGHTING *cp-ansi* diff --git a/lua/cp/commands/cache.lua b/lua/cp/commands/cache.lua index e7a2c1e..e85e7ea 100644 --- a/lua/cp/commands/cache.lua +++ b/lua/cp/commands/cache.lua @@ -43,18 +43,12 @@ function M.handle_cache_command(cmd) if vim.tbl_contains(platforms, cmd.platform) then cache.clear_platform(cmd.platform) logger.log( - ('Cache cleared for platform %s'):format(cmd.platform), + ("Cache cleared for platform '%s'"):format(constants.PLATFORM_DISPLAY_NAMES[cmd.platform]), vim.log.levels.INFO, true ) else - logger.log( - ("Unknown platform: '%s'. Available: %s"):format( - cmd.platform, - table.concat(platforms, ', ') - ), - vim.log.levels.ERROR - ) + logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR) end else cache.clear_all() diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 73f3338..ae07889 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -15,6 +15,7 @@ local actions = constants.ACTIONS ---@field contest? string ---@field platform? string ---@field problem_id? string +---@field interactor_cmd? string --- Turn raw args into normalized structure to later dispatch ---@param args string[] The raw command-line mode args @@ -32,7 +33,7 @@ local function parse_command(args) if first == 'cache' then local subcommand = args[2] if not subcommand then - return { type = 'error', message = 'cache command requires subcommand: clear' } + return { type = 'error', message = 'cache command requires subcommand' } end if vim.tbl_contains({ 'clear', 'read' }, subcommand) then local platform = args[3] @@ -44,6 +45,13 @@ local function parse_command(args) else return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand } end + elseif first == 'interact' then + local inter = args[2] + if inter and inter ~= '' then + return { type = 'action', action = 'interact', interactor_cmd = inter } + else + return { type = 'action', action = 'interact' } + end else return { type = 'action', action = first } end @@ -99,7 +107,7 @@ function M.handle_command(opts) local ui = require('cp.ui.panel') if cmd.action == 'interact' then - ui.toggle_interactive() + ui.toggle_interactive(cmd.interactor_cmd) elseif cmd.action == 'run' then ui.toggle_run_panel() elseif cmd.action == 'debug' then @@ -128,7 +136,11 @@ function M.handle_command(opts) if not (contest_data and contest_data.index_map and contest_data.index_map[problem_id]) then logger.log( - ("%s contest '%s' has no problem '%s'."):format(platform, contest_id, problem_id), + ("%s contest '%s' has no problem '%s'."):format( + constants.PLATFORM_DISPLAY_NAMES[platform], + contest_id, + problem_id + ), vim.log.levels.ERROR ) return diff --git a/lua/cp/pickers/fzf_lua.lua b/lua/cp/pickers/fzf_lua.lua index da29d74..6735f54 100644 --- a/lua/cp/pickers/fzf_lua.lua +++ b/lua/cp/pickers/fzf_lua.lua @@ -4,13 +4,13 @@ local M = {} local function contest_picker(platform, refresh) local constants = require('cp.constants') - local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] local fzf = require('fzf-lua') local contests = picker_utils.get_platform_contests(platform, refresh) if vim.tbl_isempty(contests) then vim.notify( - ('No contests found for platform: %s'):format(platform_display_name), + ("No contests found for platform '%s'"):format(platform_display_name), vim.log.levels.WARN ) return diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index c877f9a..0c3494a 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -1,4 +1,6 @@ local M = {} + +local constants = require('cp.log') local logger = require('cp.log') local utils = require('cp.utils') @@ -113,7 +115,10 @@ function M.scrape_contest_metadata(platform, contest_id, callback) on_exit = function(result) if not result or not result.success then logger.log( - ("Failed to scrape metadata for %s contest '%s'."):format(platform, contest_id), + ("Failed to scrape metadata for %s contest '%s'."):format( + constants.PLATFORM_DISPLAY_NAMES[platform], + contest_id + ), vim.log.levels.ERROR ) return @@ -121,7 +126,10 @@ function M.scrape_contest_metadata(platform, contest_id, callback) local data = result.data or {} if not data.problems or #data.problems == 0 then logger.log( - ("No problems returned for %s contest '%s'."):format(platform, contest_id), + ("No problems returned for %s contest '%s'."):format( + constants.PLATFORM_DISPLAY_NAMES[platform], + contest_id + ), vim.log.levels.ERROR ) return diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 2ed1747..3406c27 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -11,10 +11,7 @@ local platforms = constants.PLATFORMS function M.set_platform(platform) if not vim.tbl_contains(platforms, platform) then - logger.log( - ('unknown platform: %s. supported: %s'):format(platform, table.concat(platforms, ', ')), - vim.log.levels.ERROR - ) + logger.log(("Unknown platform '%s'"):format(platform), vim.log.levels.ERROR) return false end state.set_platform(platform) @@ -57,7 +54,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) logger.log(('Fetching test cases...'):format(cached_len, #problems)) scraper.scrape_all_tests(platform, contest_id, function(ev) local cached_tests = {} - if vim.tbl_isempty(ev.tests) then + if not ev.interactive and vim.tbl_isempty(ev.tests) then logger.log( ("No tests found for problem '%s'."):format(ev.problem_id), vim.log.levels.WARN diff --git a/lua/cp/ui/panel.lua b/lua/cp/ui/panel.lua index 949d503..6cc1526 100644 --- a/lua/cp/ui/panel.lua +++ b/lua/cp/ui/panel.lua @@ -4,6 +4,7 @@ local M = {} ---@field debug? boolean local config_module = require('cp.config') +local constants = require('cp.constants') local layouts = require('cp.ui.layouts') local logger = require('cp.log') local state = require('cp.state') @@ -28,7 +29,8 @@ function M.disable() end end -function M.toggle_interactive() +---@param interactor_cmd? string +function M.toggle_interactive(interactor_cmd) if state.get_active_panel() == 'interactive' then if state.interactive_buf and vim.api.nvim_buf_is_valid(state.interactive_buf) then local job = vim.b[state.interactive_buf].terminal_job_id @@ -42,7 +44,6 @@ function M.toggle_interactive() state.saved_interactive_session = nil end state.set_active_panel(nil) - logger.log('Interactive panel closed.') return end @@ -52,15 +53,16 @@ function M.toggle_interactive() end local platform, contest_id = state.get_platform(), state.get_contest_id() - if not platform then logger.log('No platform configured.', vim.log.levels.ERROR) return end - if not contest_id then logger.log( - ('No contest %s configured for platform %s.'):format(contest_id, platform), + ("No contest %s configured for platform '%s'."):format( + contest_id, + constants.PLATFORM_DISPLAY_NAMES[platform] + ), vim.log.levels.ERROR ) return @@ -68,7 +70,7 @@ function M.toggle_interactive() local problem_id = state.get_problem_id() if not problem_id then - logger.log(('No problem found for the current problem id %s'):format(problem_id)) + logger.log('No problem is active.', vim.log.levels.ERROR) return end @@ -76,10 +78,12 @@ function M.toggle_interactive() cache.load() local contest_data = cache.get_contest_data(platform, contest_id) if - contest_data - and not contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive + not contest_data + or not contest_data.index_map + or not contest_data.problems[contest_data.index_map[problem_id]] + or not contest_data.problems[contest_data.index_map[problem_id]].interactive then - logger.log('This is NOT an interactive problem. Use :CP run instead.', vim.log.levels.WARN) + logger.log('This problem is not interactive. Use :CP run.', vim.log.levels.ERROR) return end @@ -95,19 +99,104 @@ function M.toggle_interactive() end local binary = state.get_binary_file() - if not binary then - logger.log('no binary path found', vim.log.levels.ERROR) + if not binary or binary == '' then + logger.log('No binary produced.', vim.log.levels.ERROR) return end - vim.cmd('terminal') + local cmdline + if interactor_cmd and interactor_cmd ~= '' then + local interactor = interactor_cmd + if not interactor:find('/') then + interactor = './' .. interactor + end + if vim.fn.executable(interactor) ~= 1 then + logger.log( + ("Interactor '%s' is not executable."):format(interactor_cmd), + vim.log.levels.ERROR + ) + if state.saved_interactive_session then + vim.cmd(('source %s'):format(state.saved_interactive_session)) + vim.fn.delete(state.saved_interactive_session) + state.saved_interactive_session = nil + end + return + end + local orchestrator = vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p') + cmdline = table.concat({ + 'uv', + 'run', + vim.fn.shellescape(orchestrator), + vim.fn.shellescape(interactor), + vim.fn.shellescape(binary), + }, ' ') + else + cmdline = vim.fn.shellescape(binary) + end + + vim.cmd('terminal ' .. cmdline) local term_buf = vim.api.nvim_get_current_buf() local term_win = vim.api.nvim_get_current_win() - vim.fn.chansend(vim.b.terminal_job_id, binary .. '\n') + local cleaned = false + local function cleanup() + if cleaned then + return + end + cleaned = true + if term_buf and vim.api.nvim_buf_is_valid(term_buf) then + local job = vim.b[term_buf] and vim.b[term_buf].terminal_job_id or nil + if job then + pcall(vim.fn.jobstop, job) + end + end + if state.saved_interactive_session then + vim.cmd(('source %s'):format(state.saved_interactive_session)) + vim.fn.delete(state.saved_interactive_session) + state.saved_interactive_session = nil + end + state.interactive_buf = nil + state.interactive_win = nil + state.set_active_panel(nil) + end + + vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, { + buffer = term_buf, + callback = function() + cleanup() + end, + }) + + vim.api.nvim_create_autocmd('WinClosed', { + callback = function() + if cleaned then + return + end + local any = false + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then + any = true + break + end + end + if not any then + cleanup() + end + end, + }) + + vim.api.nvim_create_autocmd('TermClose', { + buffer = term_buf, + callback = function() + vim.b[term_buf].cp_interactive_exited = true + end, + }) vim.keymap.set('t', '', function() - M.toggle_interactive() + cleanup() + end, { buffer = term_buf, silent = true }) + vim.keymap.set('n', '', function() + cleanup() end, { buffer = term_buf, silent = true }) state.interactive_buf = term_buf @@ -149,18 +238,15 @@ function M.toggle_run_panel(run_opts) if not contest_id then logger.log( - ('No contest %s configured for platform %s.'):format(contest_id, platform), + ("No contest '%s' configured for platform '%s'."):format( + contest_id, + constants.PLATFORM_DISPLAY_NAMES[platform] + ), vim.log.levels.ERROR ) return end - local problem_id = state.get_problem_id() - if not problem_id then - logger.log(('No problem found for the current problem id %s'):format(problem_id)) - return - end - local cache = require('cp.cache') cache.load() local contest_data = cache.get_contest_data(platform, contest_id) @@ -172,14 +258,6 @@ function M.toggle_run_panel(run_opts) return end - logger.log( - ('Run panel: platform=%s, contest=%s, problem=%s'):format( - tostring(platform), - tostring(contest_id), - tostring(problem_id) - ) - ) - local config = config_module.get_config() local run = require('cp.runner.run') local input_file = state.get_input_file() diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 5fa7c73..e78b056 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -228,4 +228,27 @@ function M.timeout_capability() return { ok = path ~= nil, path = path, reason = reason } end +function M.cwd_executables() + local uv = vim.uv or vim.loop + local req = uv.fs_scandir('.') + if not req then + return {} + end + local out = {} + while true do + local name, t = uv.fs_scandir_next(req) + if not name then + break + end + if t == 'file' or t == 'link' then + local path = './' .. name + if vim.fn.executable(path) == 1 then + out[#out + 1] = name + end + end + end + table.sort(out) + return out +end + return M diff --git a/plugin/cp.lua b/plugin/cp.lua index 7081f15..5d4df32 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -3,6 +3,8 @@ if vim.g.loaded_cp then end vim.g.loaded_cp = 1 +local utils = require('cp.utils') + vim.api.nvim_create_user_command('CP', function(opts) local cp = require('cp') cp.handle_command(opts) @@ -51,6 +53,11 @@ end, { return vim.tbl_filter(function(cmd) return cmd:find(ArgLead, 1, true) == 1 end, { 'clear', 'read' }) + elseif args[2] == 'interact' then + local cands = utils.cwd_executables() + return vim.tbl_filter(function(cmd) + return cmd:find(ArgLead, 1, true) == 1 + end, cands) end elseif num_args == 4 then if args[2] == 'cache' and args[3] == 'clear' then diff --git a/scripts/interact.py b/scripts/interact.py new file mode 100644 index 0000000..4c24173 --- /dev/null +++ b/scripts/interact.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import asyncio +import shlex +import sys +from collections.abc import Sequence + + +async def pump( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter | None +) -> None: + while True: + data = await reader.readline() + if not data: + break + sys.stdout.buffer.write(data) + sys.stdout.flush() + if writer: + writer.write(data) + await writer.drain() + + +async def main(interactor_cmd: Sequence[str], interactee_cmd: Sequence[str]) -> None: + interactor = await asyncio.create_subprocess_exec( + *interactor_cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + interactee = await asyncio.create_subprocess_exec( + *interactee_cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + + assert ( + interactor.stdout + and interactor.stdin + and interactee.stdout + and interactee.stdin + ) + + tasks = [ + asyncio.create_task(pump(interactor.stdout, interactee.stdin)), + asyncio.create_task(pump(interactee.stdout, interactor.stdin)), + ] + await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + await interactor.wait() + await interactee.wait() + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: interact.py ", file=sys.stderr) + sys.exit(1) + + interactor_cmd = shlex.split(sys.argv[1]) + interactee_cmd = shlex.split(sys.argv[2]) + + asyncio.run(main(interactor_cmd, interactee_cmd))