Compare commits

...
Sign in to create a new pull request.

63 commits

Author SHA1 Message Date
dc635d5167 chore: add issue templates 2026-02-03 21:07:01 -05:00
Barrett Ruth
81ddd1ea87
Merge pull request #231 from barrettruth/fix/config
Some checks failed
luarocks / ci (push) Has been cancelled
luarocks / publish (push) Has been cancelled
use `vim.g` for setup
2026-02-03 16:14:19 -05:00
Barrett Ruth
7444a99b22
Merge branch 'main' into fix/config 2026-02-03 16:13:35 -05:00
ec487aa489 feat: config update to viom.g 2026-02-03 16:12:47 -05:00
Barrett Ruth
c4af9bf604
Merge pull request #228 from barrettruth/fix/doc
via, not main
2026-02-03 01:51:38 -05:00
Barrett Ruth
a4437bc1c6
Merge branch 'main' into fix/doc 2026-02-03 01:50:46 -05:00
1a7e9517ba force 2026-02-03 01:50:22 -05:00
11b8365aac via, not main 2026-02-03 01:49:47 -05:00
Barrett Ruth
585ebf0daf
Merge pull request #227 from barrettruth/fix/doc
Some checks failed
luarocks / ci (push) Has been cancelled
luarocks / publish (push) Has been cancelled
update installation method
2026-02-03 01:43:56 -05:00
08fb654d23 format yml too in pre-commit 2026-02-03 01:43:13 -05:00
01efc7c344 fix(ci): prettier format 2026-02-03 01:41:35 -05:00
f9f993db0c fix: pre-commit syntax error 2026-02-03 01:39:26 -05:00
f184a7874a feat: update docs 2026-02-03 01:38:13 -05:00
Barrett Ruth
89e3c0e21d
Merge pull request #226 from barrettruth/feat/dir-bug
misc bugfixes
2026-02-02 13:16:46 -05:00
Barrett Ruth
a9ce31a291
Merge branch 'main' into feat/dir-bug 2026-02-02 13:13:41 -05:00
c8f735617a misc bugfixes 2026-02-02 13:13:08 -05:00
Barrett Ruth
a14f543371
Merge pull request #225 from barrettruth/fix/rockspec
fix username docs
2026-02-01 17:13:00 -05:00
Barrett Ruth
56ec178cdd
Merge branch 'main' into fix/rockspec 2026-02-01 17:12:38 -05:00
5cd6f75419 fix username too 2026-02-01 17:11:51 -05:00
Barrett Ruth
99d907aa7a
Merge pull request #224 from barrettruth/fix/rockspec
fix rockspec url for new username
2026-02-01 17:02:22 -05:00
c06d819597 fix(ci): fix rockspec url 2026-02-01 17:01:29 -05:00
Barrett Ruth
682b267019
Merge pull request #223 from barrettruth/fix/ci
fix ci
2026-01-27 17:20:43 -06:00
Barrett Ruth
8a2871ec1b
Merge branch 'main' into fix/ci 2026-01-27 17:20:25 -06:00
de1295d361 fix ci
Some checks failed
luarocks / ci (push) Has been cancelled
luarocks / publish (push) Has been cancelled
2026-01-27 18:19:49 -05:00
Barrett Ruth
32f449850b
Merge pull request #222 from barrettruth/fix/ci
feat: misc tests
2026-01-27 17:16:16 -06:00
6966e8e101 feat: misc tests 2026-01-27 18:14:54 -05:00
Barrett Ruth
a5e094d44a
Merge pull request #221 from barrettruth/fix/ci
fix(ci): only run on tag push
2026-01-27 17:12:10 -06:00
Barrett Ruth
5de6fb2fee
Merge branch 'main' into fix/ci 2026-01-27 17:10:47 -06:00
bd25f1db0b fix(ci): only run on tag push 2026-01-27 18:09:57 -05:00
Barrett Ruth
9daa4e4ec4
Merge pull request #220 from barrettruth/fix/ci
run luarocks build on successful ci
2026-01-27 17:06:12 -06:00
0b5c0f0c40 fix(ci): only run luarocks build on successful ci 2026-01-27 18:04:56 -05:00
Barrett Ruth
af559b0fa3
Merge pull request #219 from barrettruth/fix/misc
Some checks failed
Release / Publish to LuaRocks (push) Has been cancelled
improve config validation
2026-01-27 16:38:03 -06:00
d496509fce feat(config): improve config parsing phrasing 2026-01-27 17:33:16 -05:00
383b327442 fix(config): validate scraper names better 2026-01-27 17:32:21 -05:00
3f677137de fix(config): one of validation 2026-01-27 17:27:15 -05:00
0a1cea9b43 feat: debug 2026-01-27 17:25:03 -05:00
Barrett Ruth
6ba51a92c2
Merge pull request #218 from barrettruth/fix/scraper-refactor
misc tweaks
2026-01-27 16:22:08 -06:00
Barrett Ruth
86f2e41983
Merge branch 'main' into fix/scraper-refactor 2026-01-27 16:20:44 -06:00
d89a40b21f feat: update git formatting 2026-01-27 17:18:52 -05:00
3348ac3e51 feat: improve formatting 2026-01-27 16:48:04 -05:00
ee38da5074 feat(layout): change formatting 2026-01-27 16:47:50 -05:00
9af359eb01 feat(layout): cleanup mode labels 2026-01-27 16:47:42 -05:00
0b21d02f24 fix(runner): save buffer before compile 2026-01-27 16:42:16 -05:00
282d701327 fix: minor log msg tweak 2026-01-27 16:10:00 -05:00
Barrett Ruth
dcadf7447d
Merge pull request #215 from barrettruth/fix/scraper-refactor
refactor scrapers
2026-01-27 15:06:05 -06:00
89c1a3c683 fix(ci): more fixes 2026-01-27 15:56:34 -05:00
83514c453e fix(ci): remove unused import 2026-01-27 15:48:26 -05:00
d5c6783124 feat(scrapers): refactor 2026-01-27 15:43:40 -05:00
5293515aca feat(scrapers): refactor 2026-01-27 14:44:08 -05:00
Barrett Ruth
7dafb7ea43
Merge pull request #214 from barrettruth/feat/highlights
use default neovim group highlights
2026-01-27 13:33:01 -06:00
0f82ae4fdb Merge branch 'main' into feat/highlights 2026-01-27 14:31:23 -05:00
873ddee0d4 fix(doc): feature-parity 2026-01-27 14:30:22 -05:00
fb7888b83c feat(highlight): use default highlights 2026-01-27 14:27:41 -05:00
Barrett Ruth
ae7b571b68
Merge pull request #212 from barrettruth/feat/async
make `:CP {run,panel}` asynchronous
2026-01-27 13:25:15 -06:00
4c5c44742e feat: refactors 2026-01-27 14:23:23 -05:00
d4c5f08b5f fix(render): change pending status text to symbol 2026-01-27 13:31:07 -05:00
0f513370ac fix(render): fix table render in partial state 2026-01-27 13:30:32 -05:00
8969dbccf8 fix(panel): table rendering 2026-01-27 13:18:11 -05:00
ba26cee7f9 feat(run): make running entirely asynchronous 2026-01-27 12:55:35 -05:00
Barrett Ruth
b88e2ce746
Merge pull request #211 from barrettruth/fix/disappearing-io-view
fix `:CP {prev,next}` race condition
2026-01-27 11:28:16 -06:00
c8c0da6d61 fix(ci): format 2026-01-27 12:27:09 -05:00
d40d80c541 fix: race condition & logs 2026-01-27 12:22:53 -05:00
Barrett Ruth
4369fe8b0c
Merge pull request #210 from barrettruth/feat/about
motivation
2026-01-15 17:08:34 -06:00
31 changed files with 1149 additions and 922 deletions

78
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View file

@ -0,0 +1,78 @@
name: Bug Report
description: Report a bug
title: 'bug: '
labels: [bug]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/cp.nvim/issues)
required: true
- label: I have updated to the latest version
required: true
- type: textarea
attributes:
label: 'Neovim version'
description: 'Output of `nvim --version`'
render: text
validations:
required: true
- type: input
attributes:
label: 'Operating system'
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
validations:
required: true
- type: textarea
attributes:
label: Description
description: What happened? What did you expect?
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug
value: |
1.
2.
3.
validations:
required: true
- type: textarea
attributes:
label: 'Health check'
description: 'Output of `:checkhealth cp`'
render: text
- type: textarea
attributes:
label: Minimal reproduction
description: |
Save the script below as `repro.lua`, edit if needed, and run:
```
nvim -u repro.lua
```
Confirm the bug reproduces with this config before submitting.
render: lua
value: |
vim.env.LAZY_STDPATH = '.repro'
load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))()
require('lazy.nvim').setup({
spec = {
{
'barrett-ruth/cp.nvim',
opts = {},
},
},
})
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yaml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/barrettruth/cp.nvim/discussions
about: Ask questions and discuss ideas

View file

@ -0,0 +1,30 @@
name: Feature Request
description: Suggest a feature
title: 'feat: '
labels: [enhancement]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/cp.nvim/issues)
required: true
- type: textarea
attributes:
label: Problem
description: What problem does this solve?
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered

112
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,112 @@
name: ci
on:
workflow_call:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
lua: ${{ steps.changes.outputs.lua }}
python: ${{ steps.changes.outputs.python }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
lua:
- 'lua/**'
- 'spec/**'
- 'plugin/**'
- 'after/**'
- 'ftdetect/**'
- '*.lua'
- '.luarc.json'
- 'stylua.toml'
- 'selene.toml'
python:
- 'scripts/**'
- 'scrapers/**'
- 'tests/**'
- 'pyproject.toml'
- 'uv.lock'
lua-format:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: 2.1.0
args: --check .
lua-lint:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: NTBBloodbath/selene-action@v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --display-style quiet .
lua-typecheck:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: mrcjkb/lua-typecheck-action@v0
with:
checklevel: Warning
directories: lua
configpath: .luarc.json
python-format:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv tool install ruff
- run: ruff format --check .
python-lint:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv tool install ruff
- run: ruff check .
python-typecheck:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync --dev
- run: uvx ty check .
python-test:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync --dev
- run: uv run camoufox fetch
- run: uv run pytest tests/ -v

View file

@ -1,18 +1,21 @@
name: Release name: luarocks
on: on:
push: push:
tags: tags:
- '*' - 'v*'
workflow_dispatch:
jobs: jobs:
publish-luarocks: ci:
name: Publish to LuaRocks uses: ./.github/workflows/ci.yaml
publish:
needs: ci
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Publish to LuaRocks
uses: nvim-neorocks/luarocks-tag-release@v7 - uses: nvim-neorocks/luarocks-tag-release@v7
env: env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}

View file

@ -1,4 +1,4 @@
name: Code Quality name: quality
on: on:
pull_request: pull_request:

View file

@ -1,4 +1,4 @@
name: Tests name: tests
on: on:
pull_request: pull_request:

9
.gitignore vendored
View file

@ -1,11 +1,16 @@
.venv/ .venv
venv
doc/tags doc/tags
*.log *.log
build build
io io
debug debug
venv/ create
.*cache*
CLAUDE.md CLAUDE.md
__pycache__ __pycache__
.claude/ .claude/
node_modules/ node_modules/

View file

@ -25,6 +25,7 @@ repos:
hooks: hooks:
- id: prettier - id: prettier
name: prettier name: prettier
files: \.(md|toml|ya?ml|sh)$
- repo: local - repo: local
hooks: hooks:

View file

@ -19,6 +19,15 @@ https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148
- **Language agnostic**: Works with any language - **Language agnostic**: Works with any language
- **Diff viewer**: Compare expected vs actual output with 3 diff modes - **Diff viewer**: Compare expected vs actual output with 3 diff modes
## Installation
Install using your package manager of choice or via
[luarocks](https://luarocks.org/modules/barrettruth/cp.nvim):
```
luarocks install cp.nvim
```
## Optional Dependencies ## Optional Dependencies
- [uv](https://docs.astral.sh/uv/) for problem scraping - [uv](https://docs.astral.sh/uv/) for problem scraping

View file

@ -2,7 +2,7 @@ rockspec_format = '3.0'
package = 'cp.nvim' package = 'cp.nvim'
version = 'scm-1' version = 'scm-1'
source = { url = 'git://github.com/barrett-ruth/cp.nvim' } source = { url = 'git://github.com/barrettruth/cp.nvim' }
build = { type = 'builtin' } build = { type = 'builtin' }
test_dependencies = { test_dependencies = {

View file

@ -205,71 +205,66 @@ Debug Builds ~
============================================================================== ==============================================================================
CONFIGURATION *cp-config* CONFIGURATION *cp-config*
Here's an example configuration with lazy.nvim: Configuration is done via `vim.g.cp_config`. Set this before using the plugin:
>lua >lua
{ vim.g.cp_config = {
'barrett-ruth/cp.nvim', languages = {
cmd = 'CP', cpp = {
build = 'uv sync', extension = 'cc',
opts = { commands = {
languages = { build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}',
cpp = { '-fdiagnostics-color=always' },
extension = 'cc', run = { '{binary}' },
commands = { debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}', '{source}', '-o', '{binary}' },
'-fdiagnostics-color=always' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
},
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
}, },
}, },
platforms = { python = {
cses = { extension = 'py',
enabled_languages = { 'cpp', 'python' }, commands = {
default_language = 'cpp', run = { 'python', '{source}' },
overrides = { debug = { 'python', '{source}' },
cpp = { extension = 'cpp', commands = { build = { ... } } }
},
},
atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
}, },
}, },
open_url = true, },
debug = false, platforms = {
ui = { cses = {
ansi = true, enabled_languages = { 'cpp', 'python' },
run = { default_language = 'cpp',
width = 0.3, overrides = {
next_test_key = '<c-n>', -- or nil to disable cpp = { extension = 'cpp', commands = { build = { ... } } }
prev_test_key = '<c-p>', -- or nil to disable
}, },
panel = {
diff_mode = 'vim',
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' },
},
},
picker = 'telescope',
}, },
} atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
open_url = true,
debug = false,
ui = {
ansi = true,
run = {
width = 0.3,
next_test_key = '<c-n>', -- or nil to disable
prev_test_key = '<c-p>', -- or nil to disable
},
panel = {
diff_modes = { 'side-by-side', 'git', 'vim' },
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' },
},
},
picker = 'telescope',
},
} }
< <
@ -279,7 +274,7 @@ the default; per-platform overrides can tweak 'extension' or 'commands'.
For example, to run CodeForces contests with Python by default: For example, to run CodeForces contests with Python by default:
>lua >lua
{ vim.g.cp_config = {
platforms = { platforms = {
codeforces = { codeforces = {
default_language = 'python', default_language = 'python',
@ -290,7 +285,7 @@ For example, to run CodeForces contests with Python by default:
Any language is supported provided the proper configuration. For example, to Any language is supported provided the proper configuration. For example, to
run CSES problems with Rust using the single schema: run CSES problems with Rust using the single schema:
>lua >lua
{ vim.g.cp_config = {
languages = { languages = {
rust = { rust = {
extension = 'rs', extension = 'rs',
@ -378,8 +373,10 @@ run CSES problems with Rust using the single schema:
*cp.PanelConfig* *cp.PanelConfig*
Fields: ~ Fields: ~
{diff_mode} (string, default: "none") Diff backend: "none", {diff_modes} (string[], default: {'side-by-side', 'git', 'vim'})
"vim", or "git". List of diff modes to cycle through with 't' key.
First element is the default mode.
Valid modes: 'side-by-side', 'git', 'vim'.
{max_output_lines} (number, default: 50) Maximum lines of test output. {max_output_lines} (number, default: 50) Maximum lines of test output.
*cp.DiffConfig* *cp.DiffConfig*
@ -784,12 +781,15 @@ HIGHLIGHT GROUPS *cp-highlights*
Test Status Groups ~ Test Status Groups ~
CpTestAC Green foreground for AC status All test status groups link to builtin highlight groups, automatically adapting
CpTestWA Red foreground for WA status to your colorscheme:
CpTestTLE Orange foreground for TLE status
CpTestMLE Orange foreground for MLE status CpTestAC Links to DiagnosticOk (AC status)
CpTestRTE Purple foreground for RTE status CpTestWA Links to DiagnosticError (WA status)
CpTestNA Gray foreground for remaining state CpTestTLE Links to DiagnosticWarn (TLE status)
CpTestMLE Links to DiagnosticWarn (MLE status)
CpTestRTE Links to DiagnosticHint (RTE status)
CpTestNA Links to Comment (pending/unknown status)
ANSI Color Groups ~ ANSI Color Groups ~
@ -848,17 +848,20 @@ PANEL KEYMAPS *cp-panel-keys*
<c-n> Navigate to next test case <c-n> Navigate to next test case
<c-p> Navigate to previous test case <c-p> Navigate to previous test case
t Cycle through diff modes: none → git → vim t Cycle through configured diff modes (see |cp.PanelConfig|)
q Exit panel and restore layout q Exit panel and restore layout
<c-q> Exit interactive terminal and restore layout <c-q> Exit interactive terminal and restore layout
Diff Modes ~ Diff Modes ~
Three diff backends are available: Three diff modes are available:
none Nothing side-by-side Expected and actual output shown side-by-side (default)
vim Built-in vim diff (default, always available) vim Built-in vim diff (always available)
git Character-level git word-diff (requires git, more precise) git Character-level git word-diff (requires git, more precise)
Configure which modes to cycle through via |cp.PanelConfig|.diff_modes.
The first element is used as the default mode.
The git backend shows character-level changes with [-removed-] and {+added+} The git backend shows character-level changes with [-removed-] and {+added+}
markers. markers.

View file

@ -18,7 +18,7 @@
---@field overrides? table<string, CpPlatformOverrides> ---@field overrides? table<string, CpPlatformOverrides>
---@class PanelConfig ---@class PanelConfig
---@field diff_mode "none"|"vim"|"git" ---@field diff_modes string[]
---@field max_output_lines integer ---@field max_output_lines integer
---@class DiffGitConfig ---@class DiffGitConfig
@ -173,7 +173,7 @@ M.defaults = {
add_test_key = 'ga', add_test_key = 'ga',
save_and_exit_key = 'q', save_and_exit_key = 'q',
}, },
panel = { diff_mode = 'none', max_output_lines = 50 }, panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50 },
diff = { diff = {
git = { git = {
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
@ -305,7 +305,24 @@ function M.setup(user_config)
vim.validate({ vim.validate({
hooks = { cfg.hooks, { 'table' } }, hooks = { cfg.hooks, { 'table' } },
ui = { cfg.ui, { 'table' } }, ui = { cfg.ui, { 'table' } },
debug = { cfg.debug, { 'boolean', 'nil' }, true },
open_url = { cfg.open_url, { 'boolean', 'nil' }, true }, open_url = { cfg.open_url, { 'boolean', 'nil' }, true },
filename = { cfg.filename, { 'function', 'nil' }, true },
scrapers = {
cfg.scrapers,
function(v)
if type(v) ~= 'table' then
return false
end
for _, s in ipairs(v) do
if not vim.tbl_contains(constants.PLATFORMS, s) then
return false
end
end
return true
end,
('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')),
},
before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true }, before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true }, before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true }, setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
@ -313,14 +330,23 @@ function M.setup(user_config)
setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true }, setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true },
}) })
local layouts = require('cp.ui.layouts')
vim.validate({ vim.validate({
ansi = { cfg.ui.ansi, 'boolean' }, ansi = { cfg.ui.ansi, 'boolean' },
diff_mode = { diff_modes = {
cfg.ui.panel.diff_mode, cfg.ui.panel.diff_modes,
function(v) function(v)
return vim.tbl_contains({ 'none', 'vim', 'git' }, v) if type(v) ~= 'table' then
return false
end
for _, mode in ipairs(v) do
if not layouts.DIFF_MODES[mode] then
return false
end
end
return true
end, end,
"diff_mode must be 'none', 'vim', or 'git'", ('one of {%s}'):format(table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',')),
}, },
max_output_lines = { max_output_lines = {
cfg.ui.panel.max_output_lines, cfg.ui.panel.max_output_lines,
@ -330,6 +356,14 @@ function M.setup(user_config)
'positive integer', 'positive integer',
}, },
git = { cfg.ui.diff.git, { 'table' } }, git = { cfg.ui.diff.git, { 'table' } },
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
width = {
cfg.ui.run.width,
function(v)
return type(v) == 'number' and v > 0 and v <= 1
end,
'decimal between 0 and 1',
},
next_test_key = { next_test_key = {
cfg.ui.run.next_test_key, cfg.ui.run.next_test_key,
function(v) function(v)
@ -383,6 +417,13 @@ function M.setup(user_config)
end, end,
'nil or non-empty string', 'nil or non-empty string',
}, },
picker = {
cfg.ui.picker,
function(v)
return v == nil or v == 'telescope' or v == 'fzf-lua'
end,
"nil, 'telescope', or 'fzf-lua'",
},
}) })
for id, lang in pairs(cfg.languages) do for id, lang in pairs(cfg.languages) do
@ -443,7 +484,18 @@ function M.get_language_for_platform(platform_id, language_id)
} }
end end
local effective = cfg.runtime.effective[platform_id][language_id] local platform_effective = cfg.runtime.effective[platform_id]
if not platform_effective then
return {
valid = false,
error = string.format(
'No runtime config for platform %s (plugin not initialized)',
platform_id
),
}
end
local effective = platform_effective[language_id]
if not effective then if not effective then
return { return {
valid = false, valid = false,

View file

@ -11,25 +11,25 @@ if vim.fn.has('nvim-0.10.0') == 0 then
return {} return {}
end end
local user_config = {}
local config = nil
local initialized = false local initialized = false
local function ensure_initialized()
if initialized then
return
end
local user_config = vim.g.cp_config or {}
local config = config_module.setup(user_config)
config_module.set_current_config(config)
initialized = true
end
---@return nil ---@return nil
function M.handle_command(opts) function M.handle_command(opts)
ensure_initialized()
local commands = require('cp.commands') local commands = require('cp.commands')
commands.handle_command(opts) commands.handle_command(opts)
end end
function M.setup(opts)
opts = opts or {}
user_config = opts
config = config_module.setup(user_config)
config_module.set_current_config(config)
initialized = true
end
function M.is_initialized() function M.is_initialized()
return initialized return initialized
end end

View file

@ -39,24 +39,27 @@ end
---@param compile_cmd string[] ---@param compile_cmd string[]
---@param substitutions SubstitutableCommand ---@param substitutions SubstitutableCommand
function M.compile(compile_cmd, substitutions) ---@param on_complete fun(r: {code: integer, stdout: string})
function M.compile(compile_cmd, substitutions, on_complete)
local cmd = substitute_template(compile_cmd, substitutions) local cmd = substitute_template(compile_cmd, substitutions)
local sh = table.concat(cmd, ' ') .. ' 2>&1' local sh = table.concat(cmd, ' ') .. ' 2>&1'
local t0 = vim.uv.hrtime() local t0 = vim.uv.hrtime()
local r = vim.system({ 'sh', '-c', sh }, { text = false }):wait() vim.system({ 'sh', '-c', sh }, { text = false }, function(r)
local dt = (vim.uv.hrtime() - t0) / 1e6 local dt = (vim.uv.hrtime() - t0) / 1e6
local ansi = require('cp.ui.ansi')
r.stdout = ansi.bytes_to_string(r.stdout or '')
local ansi = require('cp.ui.ansi') if r.code == 0 then
r.stdout = ansi.bytes_to_string(r.stdout or '') logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO)
else
logger.log(('Compilation failed in %.1fms.'):format(dt))
end
if r.code == 0 then vim.schedule(function()
logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO) on_complete(r)
else end)
logger.log(('Compilation failed in %.1fms.'):format(dt)) end)
end
return r
end end
local function parse_and_strip_time_v(output) local function parse_and_strip_time_v(output)
@ -103,7 +106,8 @@ local function parse_and_strip_time_v(output)
return head, peak_mb return head, peak_mb
end end
function M.run(cmd, stdin, timeout_ms, memory_mb) ---@param on_complete fun(result: ExecuteResult)
function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete)
local time_bin = utils.time_path() local time_bin = utils.time_path()
local timeout_bin = utils.timeout_path() local timeout_bin = utils.timeout_path()
@ -117,76 +121,91 @@ function M.run(cmd, stdin, timeout_ms, memory_mb)
local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog) local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog)
local t0 = vim.uv.hrtime() local t0 = vim.uv.hrtime()
local r = vim vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)
.system({ 'sh', '-c', sh }, { local dt = (vim.uv.hrtime() - t0) / 1e6
stdin = stdin,
text = true,
})
:wait()
local dt = (vim.uv.hrtime() - t0) / 1e6
local code = r.code or 0 local code = r.code or 0
local raw = r.stdout or '' local raw = r.stdout or ''
local cleaned, peak_mb = parse_and_strip_time_v(raw) local cleaned, peak_mb = parse_and_strip_time_v(raw)
local tled = code == 124 local tled = code == 124
local signal = nil local signal = nil
if code >= 128 then if code >= 128 then
signal = constants.signal_codes[code] signal = constants.signal_codes[code]
end end
local lower = (cleaned or ''):lower() local lower = (cleaned or ''):lower()
local oom_hint = lower:find('std::bad_alloc', 1, true) local oom_hint = lower:find('std::bad_alloc', 1, true)
or lower:find('cannot allocate memory', 1, true) or lower:find('cannot allocate memory', 1, true)
or lower:find('out of memory', 1, true) or lower:find('out of memory', 1, true)
or lower:find('oom', 1, true) or lower:find('oom', 1, true)
or lower:find('enomem', 1, true) or lower:find('enomem', 1, true)
local near_cap = peak_mb >= (0.90 * memory_mb) local near_cap = peak_mb >= (0.90 * memory_mb)
local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint and not tled) local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint ~= nil and not tled)
if tled then if tled then
logger.log(('Execution timed out in %.1fms.'):format(dt)) logger.log(('Execution timed out in %.1fms.'):format(dt))
elseif mled then elseif mled then
logger.log(('Execution memory limit exceeded in %.1fms.'):format(dt)) logger.log(('Execution memory limit exceeded in %.1fms.'):format(dt))
elseif code ~= 0 then elseif code ~= 0 then
logger.log(('Execution failed in %.1fms (exit code %d).'):format(dt, code)) logger.log(('Execution failed in %.1fms (exit code %d).'):format(dt, code))
else else
logger.log(('Execution successful in %.1fms.'):format(dt)) logger.log(('Execution successful in %.1fms.'):format(dt))
end end
return { vim.schedule(function()
stdout = cleaned, on_complete({
code = code, stdout = cleaned,
time_ms = dt, code = code,
tled = tled, time_ms = dt,
mled = mled, tled = tled,
peak_mb = peak_mb, mled = mled,
signal = signal, peak_mb = peak_mb,
} signal = signal,
})
end)
end)
end end
function M.compile_problem(debug) ---@param debug boolean?
---@param on_complete fun(result: {success: boolean, output: string?})
function M.compile_problem(debug, on_complete)
local state = require('cp.state') local state = require('cp.state')
local config = require('cp.config').get_config() local config = require('cp.config').get_config()
local platform = state.get_platform() local platform = state.get_platform()
local language = state.get_language() or config.platforms[platform].default_language local language = state.get_language() or config.platforms[platform].default_language
local eff = config.runtime.effective[platform][language] local eff = config.runtime.effective[platform][language]
local source_file = state.get_source_file()
if source_file then
local buf = vim.fn.bufnr(source_file)
if buf ~= -1 and vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].modified then
vim.api.nvim_buf_call(buf, function()
vim.cmd.write({ mods = { silent = true, noautocmd = true } })
end)
end
end
local compile_config = (debug and eff.commands.debug) or eff.commands.build local compile_config = (debug and eff.commands.debug) or eff.commands.build
if not compile_config then if not compile_config then
return { success = true, output = nil } on_complete({ success = true, output = nil })
return
end end
require('cp.utils').ensure_dirs()
local binary = debug and state.get_debug_file() or state.get_binary_file() local binary = debug and state.get_debug_file() or state.get_binary_file()
local substitutions = { source = state.get_source_file(), binary = binary } local substitutions = { source = state.get_source_file(), binary = binary }
local r = M.compile(compile_config, substitutions)
if r.code ~= 0 then M.compile(compile_config, substitutions, function(r)
return { success = false, output = r.stdout or 'unknown error' } if r.code ~= 0 then
end on_complete({ success = false, output = r.stdout or 'unknown error' })
return { success = true, output = nil } else
on_complete({ success = true, output = nil })
end
end)
end end
return M return M

View file

@ -101,8 +101,8 @@ end
---@param test_case RanTestCase ---@param test_case RanTestCase
---@param debug boolean? ---@param debug boolean?
---@return { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string, tled: boolean, mled: boolean, rss_mb: number } ---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string?, tled: boolean, mled: boolean, rss_mb: number })
local function run_single_test_case(test_case, debug) local function run_single_test_case(test_case, debug, on_complete)
local source_file = state.get_source_file() local source_file = state.get_source_file()
local binary_file = debug and state.get_debug_file() or state.get_binary_file() local binary_file = debug and state.get_debug_file() or state.get_binary_file()
@ -117,65 +117,65 @@ local function run_single_test_case(test_case, debug)
local timeout_ms = (panel_state.constraints and panel_state.constraints.timeout_ms) or 0 local timeout_ms = (panel_state.constraints and panel_state.constraints.timeout_ms) or 0
local memory_mb = panel_state.constraints and panel_state.constraints.memory_mb or 0 local memory_mb = panel_state.constraints and panel_state.constraints.memory_mb or 0
local r = execute.run(cmd, stdin_content, timeout_ms, memory_mb) execute.run(cmd, stdin_content, timeout_ms, memory_mb, function(r)
local ansi = require('cp.ui.ansi')
local out = r.stdout or ''
local highlights = {}
if out ~= '' then
if config.ui.ansi then
local parsed = ansi.parse_ansi_text(out)
out = table.concat(parsed.lines, '\n')
highlights = parsed.highlights
else
out = out:gsub('\027%[[%d;]*[a-zA-Z]', '')
end
end
local ansi = require('cp.ui.ansi') local max_lines = config.ui.panel.max_output_lines
local out = r.stdout or '' local lines = vim.split(out, '\n')
local highlights = {} if #lines > max_lines then
if out ~= '' then local trimmed = {}
if config.ui.ansi then for i = 1, max_lines do
local parsed = ansi.parse_ansi_text(out) table.insert(trimmed, lines[i])
out = table.concat(parsed.lines, '\n') end
highlights = parsed.highlights table.insert(trimmed, string.format('... (output trimmed after %d lines)', max_lines))
out = table.concat(trimmed, '\n')
end
local expected = test_case.expected or ''
local ok = normalize_lines(out) == normalize_lines(expected)
local signal = r.signal
if not signal and r.code and r.code >= 128 then
signal = constants.signal_codes[r.code]
end
local status
if r.tled then
status = 'tle'
elseif r.mled then
status = 'mle'
elseif ok then
status = 'pass'
else else
out = out:gsub('\027%[[%d;]*[a-zA-Z]', '') status = 'fail'
end end
end
local max_lines = config.ui.panel.max_output_lines on_complete({
local lines = vim.split(out, '\n') status = status,
if #lines > max_lines then actual = out,
local trimmed = {} actual_highlights = highlights,
for i = 1, max_lines do error = (r.code ~= 0 and not ok) and out or '',
table.insert(trimmed, lines[i]) stderr = '',
end time_ms = r.time_ms,
table.insert(trimmed, string.format('... (output trimmed after %d lines)', max_lines)) code = r.code,
out = table.concat(trimmed, '\n') ok = ok,
end signal = signal,
tled = r.tled or false,
local expected = test_case.expected or '' mled = r.mled or false,
local ok = normalize_lines(out) == normalize_lines(expected) rss_mb = r.peak_mb or 0,
})
local signal = r.signal end)
if not signal and r.code and r.code >= 128 then
signal = constants.signal_codes[r.code]
end
local status
if r.tled then
status = 'tle'
elseif r.mled then
status = 'mle'
elseif ok then
status = 'pass'
else
status = 'fail'
end
return {
status = status,
actual = out,
actual_highlights = highlights,
error = (r.code ~= 0 and not ok) and out or '',
stderr = '',
time_ms = r.time_ms,
code = r.code,
ok = ok,
signal = signal,
tled = r.tled or false,
mled = r.mled or false,
rss_mb = r.peak_mb or 0,
}
end end
---@return boolean ---@return boolean
@ -199,8 +199,8 @@ function M.load_test_cases()
end end
---@param debug boolean? ---@param debug boolean?
---@return RanTestCase? ---@param on_complete fun(result: RanTestCase?)
function M.run_combined_test(debug) function M.run_combined_test(debug, on_complete)
local combined = cache.get_combined_test( local combined = cache.get_combined_test(
state.get_platform() or '', state.get_platform() or '',
state.get_contest_id() or '', state.get_contest_id() or '',
@ -209,7 +209,8 @@ function M.run_combined_test(debug)
if not combined then if not combined then
logger.log('No combined test found', vim.log.levels.ERROR) logger.log('No combined test found', vim.log.levels.ERROR)
return nil on_complete(nil)
return
end end
local ran_test = { local ran_test = {
@ -228,42 +229,45 @@ function M.run_combined_test(debug)
selected = true, selected = true,
} }
local result = run_single_test_case(ran_test, debug) run_single_test_case(ran_test, debug, function(result)
return result on_complete(result)
end)
end end
---@param index number ---@param index number
---@param debug boolean? ---@param debug boolean?
---@return boolean ---@param on_complete fun(success: boolean)
function M.run_test_case(index, debug) function M.run_test_case(index, debug, on_complete)
local tc = panel_state.test_cases[index] local tc = panel_state.test_cases[index]
if not tc then if not tc then
return false on_complete(false)
return
end end
tc.status = 'running' tc.status = 'running'
local r = run_single_test_case(tc, debug) run_single_test_case(tc, debug, function(r)
tc.status = r.status
tc.actual = r.actual
tc.actual_highlights = r.actual_highlights
tc.error = r.error
tc.stderr = r.stderr
tc.time_ms = r.time_ms
tc.code = r.code
tc.ok = r.ok
tc.signal = r.signal
tc.tled = r.tled
tc.mled = r.mled
tc.rss_mb = r.rss_mb
tc.status = r.status on_complete(true)
tc.actual = r.actual end)
tc.actual_highlights = r.actual_highlights
tc.error = r.error
tc.stderr = r.stderr
tc.time_ms = r.time_ms
tc.code = r.code
tc.ok = r.ok
tc.signal = r.signal
tc.tled = r.tled
tc.mled = r.mled
tc.rss_mb = r.rss_mb
return true
end end
---@param indices? integer[] ---@param indices? integer[]
---@param debug boolean? ---@param debug boolean?
---@return RanTestCase[] ---@param on_each? fun(index: integer, total: integer)
function M.run_all_test_cases(indices, debug) ---@param on_done fun(results: RanTestCase[])
function M.run_all_test_cases(indices, debug, on_each, on_done)
local to_run = indices local to_run = indices
if not to_run then if not to_run then
to_run = {} to_run = {}
@ -272,20 +276,26 @@ function M.run_all_test_cases(indices, debug)
end end
end end
for _, i in ipairs(to_run) do local function run_next(pos)
M.run_test_case(i, debug) if pos > #to_run then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
return
end
M.run_test_case(to_run[pos], debug, function()
if on_each then
on_each(pos, #to_run)
end
run_next(pos + 1)
end)
end end
logger.log( run_next(1)
('Finished %s %s test cases.'):format(
debug and 'debugging' or 'running',
#panel_state.test_cases
),
vim.log.levels.INFO,
true
)
return panel_state.test_cases
end end
---@return PanelState ---@return PanelState

View file

@ -4,6 +4,10 @@
local M = {} local M = {}
local function strwidth(s)
return vim.api.nvim_strwidth(s)
end
local exit_code_names = { local exit_code_names = {
[128] = 'SIGHUP', [128] = 'SIGHUP',
[129] = 'SIGINT', [129] = 'SIGINT',
@ -26,6 +30,12 @@ local exit_code_names = {
---@param ran_test_case RanTestCase ---@param ran_test_case RanTestCase
---@return StatusInfo ---@return StatusInfo
function M.get_status_info(ran_test_case) function M.get_status_info(ran_test_case)
if ran_test_case.status == 'pending' then
return { text = '...', highlight_group = 'CpTestNA' }
elseif ran_test_case.status == 'running' then
return { text = 'RUN', highlight_group = 'CpTestNA' }
end
if ran_test_case.ok then if ran_test_case.ok then
return { text = 'AC', highlight_group = 'CpTestAC' } return { text = 'AC', highlight_group = 'CpTestAC' }
end end
@ -34,7 +44,7 @@ function M.get_status_info(ran_test_case)
return { text = 'TLE', highlight_group = 'CpTestTLE' } return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif ran_test_case.mled then elseif ran_test_case.mled then
return { text = 'MLE', highlight_group = 'CpTestMLE' } return { text = 'MLE', highlight_group = 'CpTestMLE' }
elseif ran_test_case.code > 0 and ran_test_case.code >= 128 then elseif ran_test_case.code and ran_test_case.code >= 128 then
return { text = 'RTE', highlight_group = 'CpTestRTE' } return { text = 'RTE', highlight_group = 'CpTestRTE' }
elseif ran_test_case.code == 0 and not ran_test_case.ok then elseif ran_test_case.code == 0 and not ran_test_case.ok then
return { text = 'WA', highlight_group = 'CpTestWA' } return { text = 'WA', highlight_group = 'CpTestWA' }
@ -63,24 +73,24 @@ local function compute_cols(test_state)
for i, tc in ipairs(test_state.test_cases) do for i, tc in ipairs(test_state.test_cases) do
local prefix = (i == test_state.current_index) and '>' or ' ' local prefix = (i == test_state.current_index) and '>' or ' '
w.num = math.max(w.num, #(' ' .. prefix .. i .. ' ')) w.num = math.max(w.num, strwidth(' ' .. prefix .. i .. ' '))
w.status = math.max(w.status, #(' ' .. M.get_status_info(tc).text .. ' ')) w.status = math.max(w.status, strwidth(' ' .. M.get_status_info(tc).text .. ' '))
local time_str = tc.time_ms and string.format('%.2f', tc.time_ms) or '' local time_str = tc.time_ms and string.format('%.2f', tc.time_ms) or ''
w.time = math.max(w.time, #(' ' .. time_str .. ' ')) w.time = math.max(w.time, strwidth(' ' .. time_str .. ' '))
w.timeout = math.max(w.timeout, #(' ' .. timeout_str .. ' ')) w.timeout = math.max(w.timeout, strwidth(' ' .. timeout_str .. ' '))
local rss_str = (tc.rss_mb and string.format('%.0f', tc.rss_mb)) or '' local rss_str = (tc.rss_mb and string.format('%.0f', tc.rss_mb)) or ''
w.rss = math.max(w.rss, #(' ' .. rss_str .. ' ')) w.rss = math.max(w.rss, strwidth(' ' .. rss_str .. ' '))
w.memory = math.max(w.memory, #(' ' .. memory_str .. ' ')) w.memory = math.max(w.memory, strwidth(' ' .. memory_str .. ' '))
w.exit = math.max(w.exit, #(' ' .. format_exit_code(tc.code) .. ' ')) w.exit = math.max(w.exit, strwidth(' ' .. format_exit_code(tc.code) .. ' '))
end end
w.num = math.max(w.num, #' # ') w.num = math.max(w.num, strwidth(' # '))
w.status = math.max(w.status, #' Status ') w.status = math.max(w.status, strwidth(' Status '))
w.time = math.max(w.time, #' Runtime (ms) ') w.time = math.max(w.time, strwidth(' Runtime (ms) '))
w.timeout = math.max(w.timeout, #' Time (ms) ') w.timeout = math.max(w.timeout, strwidth(' Time (ms) '))
w.rss = math.max(w.rss, #' RSS (MB) ') w.rss = math.max(w.rss, strwidth(' RSS (MB) '))
w.memory = math.max(w.memory, #' Mem (MB) ') w.memory = math.max(w.memory, strwidth(' Mem (MB) '))
w.exit = math.max(w.exit, #' Exit Code ') w.exit = math.max(w.exit, strwidth(' Exit Code '))
local sum = w.num + w.status + w.time + w.timeout + w.rss + w.memory + w.exit local sum = w.num + w.status + w.time + w.timeout + w.rss + w.memory + w.exit
local inner = sum + 6 local inner = sum + 6
@ -89,7 +99,7 @@ local function compute_cols(test_state)
end end
local function center(text, width) local function center(text, width)
local pad = width - #text local pad = width - strwidth(text)
if pad <= 0 then if pad <= 0 then
return text return text
end end
@ -101,7 +111,7 @@ local function format_num_column(prefix, idx, width)
local num_str = tostring(idx) local num_str = tostring(idx)
local content = (#num_str == 1) and (' ' .. prefix .. ' ' .. num_str .. ' ') local content = (#num_str == 1) and (' ' .. prefix .. ' ' .. num_str .. ' ')
or (' ' .. prefix .. num_str .. ' ') or (' ' .. prefix .. num_str .. ' ')
local total_pad = width - #content local total_pad = width - strwidth(content)
if total_pad <= 0 then if total_pad <= 0 then
return content return content
end end
@ -314,10 +324,10 @@ function M.render_test_list(test_state)
for _, input_line in ipairs(vim.split(tc.input, '\n', { plain = true, trimempty = false })) do for _, input_line in ipairs(vim.split(tc.input, '\n', { plain = true, trimempty = false })) do
local s = input_line or '' local s = input_line or ''
if #s > c.inner then if strwidth(s) > c.inner then
s = string.sub(s, 1, c.inner) s = string.sub(s, 1, c.inner)
end end
local pad = c.inner - #s local pad = c.inner - strwidth(s)
table.insert(lines, '' .. s .. string.rep(' ', pad) .. '') table.insert(lines, '' .. s .. string.rep(' ', pad) .. '')
end end
@ -357,14 +367,12 @@ end
---@return table<string, table> ---@return table<string, table>
function M.get_highlight_groups() function M.get_highlight_groups()
return { return {
CpTestAC = { fg = '#10b981' }, CpTestAC = { link = 'DiagnosticOk' },
CpTestWA = { fg = '#ef4444' }, CpTestWA = { link = 'DiagnosticError' },
CpTestTLE = { fg = '#f59e0b' }, CpTestTLE = { link = 'DiagnosticWarn' },
CpTestMLE = { fg = '#f59e0b' }, CpTestMLE = { link = 'DiagnosticWarn' },
CpTestRTE = { fg = '#8b5cf6' }, CpTestRTE = { link = 'DiagnosticHint' },
CpTestNA = { fg = '#6b7280' }, CpTestNA = { link = 'Comment' },
CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' },
CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' },
} }
end end

View file

@ -186,7 +186,7 @@ function M.scrape_all_tests(platform, contest_id, callback)
return return
end end
vim.schedule(function() vim.schedule(function()
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait() require('cp.utils').ensure_dirs()
local config = require('cp.config') local config = require('cp.config')
local base_name = config.default_filename(contest_id, ev.problem_id) local base_name = config.default_filename(contest_id, ev.problem_id)
for i, t in ipairs(ev.tests) do for i, t in ipairs(ev.tests) do

View file

@ -82,7 +82,7 @@ local function start_tests(platform, contest_id, problems)
return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id)) return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id))
end, problems) end, problems)
if cached_len ~= #problems then if cached_len ~= #problems then
logger.log(('Fetching problem test data... (%d/%d)'):format(cached_len, #problems)) logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems))
scraper.scrape_all_tests(platform, contest_id, function(ev) scraper.scrape_all_tests(platform, contest_id, function(ev)
local cached_tests = {} local cached_tests = {}
if not ev.interactive and vim.tbl_isempty(ev.tests) then if not ev.interactive and vim.tbl_isempty(ev.tests) then
@ -348,6 +348,8 @@ function M.navigate_problem(direction, language)
return return
end end
logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id))
local active_panel = state.get_active_panel() local active_panel = state.get_active_panel()
if active_panel == 'run' then if active_panel == 'run' then
require('cp.ui.views').disable() require('cp.ui.views').disable()

View file

@ -10,6 +10,7 @@
---@field output_buf integer ---@field output_buf integer
---@field input_buf integer ---@field input_buf integer
---@field current_test_index integer? ---@field current_test_index integer?
---@field source_buf integer?
---@class cp.State ---@class cp.State
---@field get_platform fun(): string? ---@field get_platform fun(): string?

View file

@ -26,7 +26,7 @@ local function parse_diff_line(text)
line = 0, line = 0,
col_start = highlight_start, col_start = highlight_start,
col_end = #result_text, col_end = #result_text,
highlight_group = 'CpDiffRemoved', highlight_group = 'DiffDelete',
}) })
pos = removed_end + 1 pos = removed_end + 1
else else
@ -38,7 +38,7 @@ local function parse_diff_line(text)
line = 0, line = 0,
col_start = highlight_start, col_start = highlight_start,
col_end = #result_text, col_end = #result_text,
highlight_group = 'CpDiffAdded', highlight_group = 'DiffAdd',
}) })
pos = added_end + 1 pos = added_end + 1
else else

View file

@ -3,7 +3,13 @@ local M = {}
local helpers = require('cp.helpers') local helpers = require('cp.helpers')
local utils = require('cp.utils') local utils = require('cp.utils')
local function create_none_diff_layout(parent_win, expected_content, actual_content) M.DIFF_MODES = {
['side-by-side'] = 'side-by-side',
vim = 'vim',
git = 'git',
}
local function create_side_by_side_layout(parent_win, expected_content, actual_content)
local expected_buf = utils.create_buffer_with_options() local expected_buf = utils.create_buffer_with_options()
local actual_buf = utils.create_buffer_with_options() local actual_buf = utils.create_buffer_with_options()
helpers.clearcol(expected_buf) helpers.clearcol(expected_buf)
@ -21,8 +27,13 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win }) local label = M.DIFF_MODES['side-by-side']
vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win }) vim.api.nvim_set_option_value(
'winbar',
('expected (diff: %s)'):format(label),
{ win = expected_win }
)
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true })
@ -33,6 +44,7 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont
return { return {
buffers = { expected_buf, actual_buf }, buffers = { expected_buf, actual_buf },
windows = { expected_win, actual_win }, windows = { expected_win, actual_win },
mode = 'side-by-side',
cleanup = function() cleanup = function()
pcall(vim.api.nvim_win_close, expected_win, true) pcall(vim.api.nvim_win_close, expected_win, true)
pcall(vim.api.nvim_win_close, actual_win, true) pcall(vim.api.nvim_win_close, actual_win, true)
@ -60,8 +72,13 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win }) local label = M.DIFF_MODES.vim
vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win }) vim.api.nvim_set_option_value(
'winbar',
('expected (diff: %s)'):format(label),
{ win = expected_win }
)
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true })
@ -83,6 +100,7 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte
return { return {
buffers = { expected_buf, actual_buf }, buffers = { expected_buf, actual_buf },
windows = { expected_win, actual_win }, windows = { expected_win, actual_win },
mode = 'vim',
cleanup = function() cleanup = function()
pcall(vim.api.nvim_win_close, expected_win, true) pcall(vim.api.nvim_win_close, expected_win, true)
pcall(vim.api.nvim_win_close, actual_win, true) pcall(vim.api.nvim_win_close, actual_win, true)
@ -103,7 +121,8 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte
vim.api.nvim_win_set_buf(diff_win, diff_buf) vim.api.nvim_win_set_buf(diff_win, diff_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf }) vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf })
vim.api.nvim_set_option_value('winbar', 'Expected vs Actual', { win = diff_win }) local label = M.DIFF_MODES.git
vim.api.nvim_set_option_value('winbar', ('diff: %s'):format(label), { win = diff_win })
local diff_backend = require('cp.ui.diff') local diff_backend = require('cp.ui.diff')
local backend = diff_backend.get_best_backend('git') local backend = diff_backend.get_best_backend('git')
@ -121,6 +140,7 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte
return { return {
buffers = { diff_buf }, buffers = { diff_buf },
windows = { diff_win }, windows = { diff_win },
mode = 'git',
cleanup = function() cleanup = function()
pcall(vim.api.nvim_win_close, diff_win, true) pcall(vim.api.nvim_win_close, diff_win, true)
pcall(vim.api.nvim_buf_delete, diff_buf, { force = true }) pcall(vim.api.nvim_buf_delete, diff_buf, { force = true })
@ -143,6 +163,7 @@ local function create_single_layout(parent_win, content)
return { return {
buffers = { buf }, buffers = { buf },
windows = { win }, windows = { win },
mode = 'single',
cleanup = function() cleanup = function()
pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_win_close, win, true)
pcall(vim.api.nvim_buf_delete, buf, { force = true }) pcall(vim.api.nvim_buf_delete, buf, { force = true })
@ -153,12 +174,14 @@ end
function M.create_diff_layout(mode, parent_win, expected_content, actual_content) function M.create_diff_layout(mode, parent_win, expected_content, actual_content)
if mode == 'single' then if mode == 'single' then
return create_single_layout(parent_win, actual_content) return create_single_layout(parent_win, actual_content)
elseif mode == 'none' then elseif mode == 'side-by-side' then
return create_none_diff_layout(parent_win, expected_content, actual_content) return create_side_by_side_layout(parent_win, expected_content, actual_content)
elseif mode == 'git' then elseif mode == 'git' then
return create_git_diff_layout(parent_win, expected_content, actual_content) return create_git_diff_layout(parent_win, expected_content, actual_content)
else elseif mode == 'vim' then
return create_vim_diff_layout(parent_win, expected_content, actual_content) return create_vim_diff_layout(parent_win, expected_content, actual_content)
else
return create_side_by_side_layout(parent_win, expected_content, actual_content)
end end
end end
@ -191,12 +214,13 @@ function M.update_diff_panes(
actual_content = actual_content actual_content = actual_content
end end
local desired_mode = is_compilation_failure and 'single' or config.ui.panel.diff_mode local default_mode = config.ui.panel.diff_modes[1]
local desired_mode = is_compilation_failure and 'single' or (current_mode or default_mode)
local highlight = require('cp.ui.highlight') local highlight = require('cp.ui.highlight')
local diff_namespace = highlight.create_namespace() local diff_namespace = highlight.create_namespace()
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights') local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')
if current_diff_layout and current_mode ~= desired_mode then if current_diff_layout and current_diff_layout.mode ~= desired_mode then
local saved_pos = vim.api.nvim_win_get_cursor(0) local saved_pos = vim.api.nvim_win_get_cursor(0)
current_diff_layout.cleanup() current_diff_layout.cleanup()
current_diff_layout = nil current_diff_layout = nil
@ -251,7 +275,7 @@ function M.update_diff_panes(
ansi_namespace ansi_namespace
) )
end end
elseif desired_mode == 'none' then elseif desired_mode == 'side-by-side' then
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true }) local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true }) local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {}) utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})

View file

@ -13,6 +13,7 @@ local utils = require('cp.utils')
local current_diff_layout = nil local current_diff_layout = nil
local current_mode = nil local current_mode = nil
local io_view_running = false
function M.disable() function M.disable()
local active_panel = state.get_active_panel() local active_panel = state.get_active_panel()
@ -81,127 +82,145 @@ function M.toggle_interactive(interactor_cmd)
local execute = require('cp.runner.execute') local execute = require('cp.runner.execute')
local run = require('cp.runner.run') local run = require('cp.runner.run')
local compile_result = execute.compile_problem()
if not compile_result.success then
run.handle_compilation_failure(compile_result.output)
return
end
local binary = state.get_binary_file() local function restore_session()
if not binary or binary == '' then
logger.log('No binary produced.', vim.log.levels.ERROR)
return
end
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(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()
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 if state.saved_interactive_session then
vim.cmd.source(state.saved_interactive_session) vim.cmd.source(state.saved_interactive_session)
vim.fn.delete(state.saved_interactive_session) vim.fn.delete(state.saved_interactive_session)
state.saved_interactive_session = nil state.saved_interactive_session = nil
end end
state.interactive_buf = nil
state.interactive_win = nil
state.set_active_panel(nil)
end end
vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, { execute.compile_problem(false, function(compile_result)
buffer = term_buf, if not compile_result.success then
callback = cleanup, run.handle_compilation_failure(compile_result.output)
}) restore_session()
return
end
vim.api.nvim_create_autocmd('WinClosed', { local binary = state.get_binary_file()
callback = function() if not binary or binary == '' then
logger.log('No binary produced.', vim.log.levels.ERROR)
restore_session()
return
end
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
)
restore_session()
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()
local cleaned = false
local function cleanup()
if cleaned then if cleaned then
return return
end end
local any = false cleaned = true
for _, win in ipairs(vim.api.nvim_list_wins()) do if term_buf and vim.api.nvim_buf_is_valid(term_buf) then
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then local job = vim.b[term_buf] and vim.b[term_buf].terminal_job_id or nil
any = true if job then
break pcall(vim.fn.jobstop, job)
end end
end end
if not any then restore_session()
cleanup() state.interactive_buf = nil
end state.interactive_win = nil
end, state.set_active_panel(nil)
}) end
vim.api.nvim_create_autocmd('TermClose', { vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, {
buffer = term_buf, buffer = term_buf,
callback = function() callback = cleanup,
vim.b[term_buf].cp_interactive_exited = true })
end,
})
vim.keymap.set('t', '<c-q>', function() vim.api.nvim_create_autocmd('WinClosed', {
cleanup() callback = function()
end, { buffer = term_buf, silent = true }) if cleaned then
vim.keymap.set('n', '<c-q>', function() return
cleanup() end
end, { buffer = term_buf, silent = true }) 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,
})
state.interactive_buf = term_buf vim.api.nvim_create_autocmd('TermClose', {
state.interactive_win = term_win buffer = term_buf,
state.set_active_panel('interactive') callback = function()
vim.b[term_buf].cp_interactive_exited = true
end,
})
vim.keymap.set('t', '<c-q>', function()
cleanup()
end, { buffer = term_buf, silent = true })
vim.keymap.set('n', '<c-q>', function()
cleanup()
end, { buffer = term_buf, silent = true })
state.interactive_buf = term_buf
state.interactive_win = term_win
state.set_active_panel('interactive')
end)
end end
---@return integer, integer ---@return integer, integer
local function get_or_create_io_buffers() local function get_or_create_io_buffers()
local io_state = state.get_io_view_state() local io_state = state.get_io_view_state()
local solution_win = state.get_solution_win()
local current_source_buf = vim.api.nvim_win_get_buf(solution_win)
if io_state then if io_state then
local output_valid = io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) local output_valid = io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf)
local input_valid = io_state.input_buf and vim.api.nvim_buf_is_valid(io_state.input_buf) local input_valid = io_state.input_buf and vim.api.nvim_buf_is_valid(io_state.input_buf)
local same_source = io_state.source_buf == current_source_buf
if output_valid and input_valid then if output_valid and input_valid and same_source then
return io_state.output_buf, io_state.input_buf return io_state.output_buf, io_state.input_buf
end end
if io_state.source_buf then
pcall(vim.api.nvim_del_augroup_by_name, 'cp_io_cleanup_buf' .. io_state.source_buf)
end
if output_valid then
pcall(vim.api.nvim_buf_delete, io_state.output_buf, { force = true })
end
if input_valid then
pcall(vim.api.nvim_buf_delete, io_state.input_buf, { force = true })
end
end end
local output_buf = utils.create_buffer_with_options('cpout') local output_buf = utils.create_buffer_with_options('cpout')
@ -211,10 +230,10 @@ local function get_or_create_io_buffers()
output_buf = output_buf, output_buf = output_buf,
input_buf = input_buf, input_buf = input_buf,
current_test_index = 1, current_test_index = 1,
source_buf = current_source_buf,
}) })
local solution_win = state.get_solution_win() local source_buf = current_source_buf
local source_buf = vim.api.nvim_win_get_buf(solution_win)
local group_name = 'cp_io_cleanup_buf' .. source_buf local group_name = 'cp_io_cleanup_buf' .. source_buf
vim.api.nvim_create_augroup(group_name, { clear = true }) vim.api.nvim_create_augroup(group_name, { clear = true })
@ -249,6 +268,10 @@ local function get_or_create_io_buffers()
return return
end end
if io.source_buf ~= source_buf then
return
end
local wins = vim.api.nvim_list_wins() local wins = vim.api.nvim_list_wins()
for _, win in ipairs(wins) do for _, win in ipairs(wins) do
if vim.api.nvim_win_get_buf(win) == source_buf then if vim.api.nvim_win_get_buf(win) == source_buf then
@ -368,6 +391,8 @@ function M.ensure_io_view()
return return
end end
require('cp.utils').ensure_dirs()
local source_file = state.get_source_file() local source_file = state.get_source_file()
if source_file then if source_file then
local source_file_abs = vim.fn.fnamemodify(source_file, ':p') local source_file_abs = vim.fn.fnamemodify(source_file, ':p')
@ -447,166 +472,44 @@ function M.ensure_io_view()
end end
end end
function M.run_io_view(test_indices_arg, debug, mode) local function render_io_view_results(io_state, test_indices, mode, combined_result, combined_input)
logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), vim.log.levels.INFO, true)
mode = mode or 'combined'
local platform, contest_id, problem_id =
state.get_platform(), state.get_contest_id(), state.get_problem_id()
if not platform or not contest_id or not problem_id then
logger.log(
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
vim.log.levels.ERROR
)
return
end
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if not contest_data or not contest_data.index_map then
logger.log('No test cases available.', vim.log.levels.ERROR)
return
end
if mode == 'combined' then
local problem_data = contest_data.problems[contest_data.index_map[problem_id]]
if not problem_data.multi_test then
mode = 'individual'
end
end
local run = require('cp.runner.run') local run = require('cp.runner.run')
if mode == 'combined' then
local combined = cache.get_combined_test(platform, contest_id, problem_id)
if not combined then
logger.log('No combined test available', vim.log.levels.ERROR)
return
end
else
if not run.load_test_cases() then
logger.log('No test cases available', vim.log.levels.ERROR)
return
end
end
local test_indices = {}
if mode == 'individual' then
local test_state = run.get_panel_state()
if test_indices_arg then
for _, idx in ipairs(test_indices_arg) do
if idx < 1 or idx > #test_state.test_cases then
logger.log(
string.format(
'Test %d does not exist (only %d tests available)',
idx,
#test_state.test_cases
),
vim.log.levels.WARN
)
return
end
end
test_indices = test_indices_arg
else
for i = 1, #test_state.test_cases do
test_indices[i] = i
end
end
end
if not test_indices_arg then
M.ensure_io_view()
end
local io_state = state.get_io_view_state()
if not io_state then
return
end
local config = config_module.get_config()
if config.ui.ansi then
require('cp.ui.ansi').setup_highlight_groups()
end
local execute = require('cp.runner.execute')
local compile_result = execute.compile_problem(debug)
if not compile_result.success then
local ansi = require('cp.ui.ansi')
local output = compile_result.output or ''
local lines, highlights
if config.ui.ansi then
local parsed = ansi.parse_ansi_text(output)
lines = parsed.lines
highlights = parsed.highlights
else
lines = vim.split(output:gsub('\027%[[%d;]*[a-zA-Z]', ''), '\n')
highlights = {}
end
local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error')
utils.update_buffer_content(io_state.output_buf, lines, highlights, ns)
return
end
local run_render = require('cp.runner.run_render') local run_render = require('cp.runner.run_render')
local cfg = config_module.get_config()
run_render.setup_highlights() run_render.setup_highlights()
local input_lines = {} local input_lines = {}
local output_lines = {} local output_lines = {}
local verdict_lines = {} local verdict_lines = {}
local verdict_highlights = {} local verdict_highlights = {}
local formatter = cfg.ui.run.format_verdict
local test_state = run.get_panel_state()
local formatter = config.ui.run.format_verdict if mode == 'combined' and combined_result then
input_lines = vim.split(combined_input, '\n')
if mode == 'combined' then if combined_result.actual and combined_result.actual ~= '' then
local combined = cache.get_combined_test(platform, contest_id, problem_id) output_lines = vim.split(combined_result.actual, '\n')
if not combined then
logger.log('No combined test found', vim.log.levels.ERROR)
return
end end
run.load_test_cases() local status = run_render.get_status_info(combined_result)
local result = run.run_combined_test(debug)
if not result then
logger.log('Failed to run combined test', vim.log.levels.ERROR)
return
end
input_lines = vim.split(combined.input, '\n')
if result.actual and result.actual ~= '' then
output_lines = vim.split(result.actual, '\n')
end
local status = run_render.get_status_info(result)
local test_state = run.get_panel_state()
---@type VerdictFormatData
local format_data = { local format_data = {
index = 1, index = 1,
status = status, status = status,
time_ms = result.time_ms or 0, time_ms = combined_result.time_ms or 0,
time_limit_ms = test_state.constraints and test_state.constraints.timeout_ms or 0, time_limit_ms = test_state.constraints and test_state.constraints.timeout_ms or 0,
memory_mb = result.rss_mb or 0, memory_mb = combined_result.rss_mb or 0,
memory_limit_mb = test_state.constraints and test_state.constraints.memory_mb or 0, memory_limit_mb = test_state.constraints and test_state.constraints.memory_mb or 0,
exit_code = result.code or 0, exit_code = combined_result.code or 0,
signal = (result.code and result.code >= 128) signal = (combined_result.code and combined_result.code >= 128)
and require('cp.constants').signal_codes[result.code] and require('cp.constants').signal_codes[combined_result.code]
or nil, or nil,
time_actual_width = #string.format('%.2f', result.time_ms or 0), time_actual_width = #string.format('%.2f', combined_result.time_ms or 0),
time_limit_width = #tostring( time_limit_width = #tostring(
test_state.constraints and test_state.constraints.timeout_ms or 0 test_state.constraints and test_state.constraints.timeout_ms or 0
), ),
mem_actual_width = #string.format('%.0f', result.rss_mb or 0), mem_actual_width = #string.format('%.0f', combined_result.rss_mb or 0),
mem_limit_width = #string.format( mem_limit_width = #string.format(
'%.0f', '%.0f',
test_state.constraints and test_state.constraints.memory_mb or 0 test_state.constraints and test_state.constraints.memory_mb or 0
@ -627,13 +530,7 @@ function M.run_io_view(test_indices_arg, debug, mode)
end end
end end
else else
run.run_all_test_cases(test_indices, debug) local max_time_actual, max_time_limit, max_mem_actual, max_mem_limit = 0, 0, 0, 0
local test_state = run.get_panel_state()
local max_time_actual = 0
local max_time_limit = 0
local max_mem_actual = 0
local max_mem_limit = 0
for _, idx in ipairs(test_indices) do for _, idx in ipairs(test_indices) do
local tc = test_state.test_cases[idx] local tc = test_state.test_cases[idx]
@ -652,11 +549,9 @@ function M.run_io_view(test_indices_arg, debug, mode)
local all_outputs = {} local all_outputs = {}
for _, idx in ipairs(test_indices) do for _, idx in ipairs(test_indices) do
local tc = test_state.test_cases[idx] local tc = test_state.test_cases[idx]
for _, line in ipairs(vim.split(tc.input, '\n')) do for _, line in ipairs(vim.split(tc.input, '\n')) do
table.insert(input_lines, line) table.insert(input_lines, line)
end end
if tc.actual then if tc.actual then
table.insert(all_outputs, tc.actual) table.insert(all_outputs, tc.actual)
end end
@ -673,7 +568,6 @@ function M.run_io_view(test_indices_arg, debug, mode)
local tc = test_state.test_cases[idx] local tc = test_state.test_cases[idx]
local status = run_render.get_status_info(tc) local status = run_render.get_status_info(tc)
---@type VerdictFormatData
local format_data = { local format_data = {
index = idx, index = idx,
status = status, status = status,
@ -726,11 +620,169 @@ function M.run_io_view(test_indices_arg, debug, mode)
end end
utils.update_buffer_content(io_state.input_buf, input_lines, nil, nil) utils.update_buffer_content(io_state.input_buf, input_lines, nil, nil)
local output_ns = vim.api.nvim_create_namespace('cp_io_view_output') local output_ns = vim.api.nvim_create_namespace('cp_io_view_output')
utils.update_buffer_content(io_state.output_buf, output_lines, final_highlights, output_ns) utils.update_buffer_content(io_state.output_buf, output_lines, final_highlights, output_ns)
end end
function M.run_io_view(test_indices_arg, debug, mode)
if io_view_running then
logger.log('Tests already running', vim.log.levels.WARN)
return
end
io_view_running = true
logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), vim.log.levels.INFO, true)
mode = mode or 'combined'
local platform, contest_id, problem_id =
state.get_platform(), state.get_contest_id(), state.get_problem_id()
if not platform or not contest_id or not problem_id then
logger.log(
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
vim.log.levels.ERROR
)
io_view_running = false
return
end
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if not contest_data or not contest_data.index_map then
logger.log('No test cases available.', vim.log.levels.ERROR)
io_view_running = false
return
end
if mode == 'combined' then
local problem_data = contest_data.problems[contest_data.index_map[problem_id]]
if not problem_data.multi_test then
mode = 'individual'
end
end
local run = require('cp.runner.run')
if mode == 'combined' then
local combined = cache.get_combined_test(platform, contest_id, problem_id)
if not combined then
logger.log('No combined test available', vim.log.levels.ERROR)
io_view_running = false
return
end
else
if not run.load_test_cases() then
logger.log('No test cases available', vim.log.levels.ERROR)
io_view_running = false
return
end
end
local test_indices = {}
if mode == 'individual' then
local test_state = run.get_panel_state()
if test_indices_arg then
for _, idx in ipairs(test_indices_arg) do
if idx < 1 or idx > #test_state.test_cases then
logger.log(
string.format(
'Test %d does not exist (only %d tests available)',
idx,
#test_state.test_cases
),
vim.log.levels.WARN
)
io_view_running = false
return
end
end
test_indices = test_indices_arg
else
for i = 1, #test_state.test_cases do
test_indices[i] = i
end
end
end
if not test_indices_arg then
M.ensure_io_view()
end
local io_state = state.get_io_view_state()
if not io_state then
io_view_running = false
return
end
local cfg = config_module.get_config()
if cfg.ui.ansi then
require('cp.ui.ansi').setup_highlight_groups()
end
local execute = require('cp.runner.execute')
execute.compile_problem(debug, function(compile_result)
if not vim.api.nvim_buf_is_valid(io_state.output_buf) then
io_view_running = false
return
end
if not compile_result.success then
local ansi = require('cp.ui.ansi')
local output = compile_result.output or ''
local lines, highlights
if cfg.ui.ansi then
local parsed = ansi.parse_ansi_text(output)
lines = parsed.lines
highlights = parsed.highlights
else
lines = vim.split(output:gsub('\027%[[%d;]*[a-zA-Z]', ''), '\n')
highlights = {}
end
local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error')
utils.update_buffer_content(io_state.output_buf, lines, highlights, ns)
io_view_running = false
return
end
if mode == 'combined' then
local combined = cache.get_combined_test(platform, contest_id, problem_id)
if not combined then
logger.log('No combined test found', vim.log.levels.ERROR)
io_view_running = false
return
end
run.load_test_cases()
run.run_combined_test(debug, function(result)
if not result then
logger.log('Failed to run combined test', vim.log.levels.ERROR)
io_view_running = false
return
end
if vim.api.nvim_buf_is_valid(io_state.output_buf) then
render_io_view_results(io_state, test_indices, mode, result, combined.input)
end
io_view_running = false
end)
else
run.run_all_test_cases(test_indices, debug, nil, function()
if vim.api.nvim_buf_is_valid(io_state.output_buf) then
render_io_view_results(io_state, test_indices, mode, nil, nil)
end
io_view_running = false
end)
end
end)
end
---@param panel_opts? PanelOpts ---@param panel_opts? PanelOpts
function M.toggle_panel(panel_opts) function M.toggle_panel(panel_opts)
if state.get_active_panel() == 'run' then if state.get_active_panel() == 'run' then
@ -828,6 +880,9 @@ function M.toggle_panel(panel_opts)
end end
local function refresh_panel() local function refresh_panel()
if state.get_active_panel() ~= 'run' then
return
end
if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
return return
end end
@ -853,6 +908,10 @@ function M.toggle_panel(panel_opts)
vim.cmd.normal({ 'zz', bang = true }) vim.cmd.normal({ 'zz', bang = true })
end) end)
end end
if test_windows.tab_win and vim.api.nvim_win_is_valid(test_windows.tab_win) then
vim.api.nvim_set_current_win(test_windows.tab_win)
end
end end
local function navigate_test_case(delta) local function navigate_test_case(delta)
@ -869,15 +928,15 @@ function M.toggle_panel(panel_opts)
M.toggle_panel() M.toggle_panel()
end, { buffer = buf, silent = true }) end, { buffer = buf, silent = true })
vim.keymap.set('n', 't', function() vim.keymap.set('n', 't', function()
local modes = { 'none', 'git', 'vim' } local modes = config.ui.panel.diff_modes
local current_idx = 1 local current_idx = 1
for i, mode in ipairs(modes) do for i, mode in ipairs(modes) do
if config.ui.panel.diff_mode == mode then if current_mode == mode then
current_idx = i current_idx = i
break break
end end
end end
config.ui.panel.diff_mode = modes[(current_idx % #modes) + 1] current_mode = modes[(current_idx % #modes) + 1]
refresh_panel() refresh_panel()
end, { buffer = buf, silent = true }) end, { buffer = buf, silent = true })
vim.keymap.set('n', '<c-n>', function() vim.keymap.set('n', '<c-n>', function()
@ -901,30 +960,47 @@ function M.toggle_panel(panel_opts)
end) end)
end end
local execute = require('cp.runner.execute')
local compile_result = execute.compile_problem(panel_opts and panel_opts.debug)
if compile_result.success then
run.run_all_test_cases(nil, panel_opts and panel_opts.debug)
else
run.handle_compilation_failure(compile_result.output)
end
refresh_panel()
vim.schedule(function()
if config.ui.ansi then
require('cp.ui.ansi').setup_highlight_groups()
end
if current_diff_layout then
update_diff_panes()
end
end)
vim.api.nvim_set_current_win(test_windows.tab_win) vim.api.nvim_set_current_win(test_windows.tab_win)
state.test_buffers = test_buffers state.test_buffers = test_buffers
state.test_windows = test_windows state.test_windows = test_windows
state.set_active_panel('run') state.set_active_panel('run')
logger.log('test panel opened') logger.log('test panel opened')
refresh_panel()
local function finalize_panel()
vim.schedule(function()
if state.get_active_panel() ~= 'run' then
return
end
if config.ui.ansi then
require('cp.ui.ansi').setup_highlight_groups()
end
if current_diff_layout then
update_diff_panes()
end
end)
end
local execute = require('cp.runner.execute')
execute.compile_problem(panel_opts and panel_opts.debug, function(compile_result)
if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
return
end
if compile_result.success then
run.run_all_test_cases(nil, panel_opts and panel_opts.debug, function()
refresh_panel()
end, function()
refresh_panel()
finalize_panel()
end)
else
run.handle_compilation_failure(compile_result.output)
refresh_panel()
finalize_panel()
end
end)
end end
return M return M

View file

@ -262,4 +262,8 @@ function M.cwd_executables()
return out return out
end end
function M.ensure_dirs()
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
end
return M return M

0
new Normal file
View file

View file

@ -266,43 +266,31 @@ class AtcoderScraper(BaseScraper):
return "atcoder" return "atcoder"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
async def impl(cid: str) -> MetadataResult: try:
try: rows = await asyncio.to_thread(_scrape_tasks_sync, contest_id)
rows = await asyncio.to_thread(_scrape_tasks_sync, cid)
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
return self._create_metadata_error(
f"No problems found for contest {cid}", cid
)
raise
problems = _to_problem_summaries(rows) problems = _to_problem_summaries(rows)
if not problems: if not problems:
return self._create_metadata_error( return self._metadata_error(
f"No problems found for contest {cid}", cid f"No problems found for contest {contest_id}"
) )
return MetadataResult( return MetadataResult(
success=True, success=True,
error="", error="",
contest_id=cid, contest_id=contest_id,
problems=problems, problems=problems,
url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s", url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s",
) )
except Exception as e:
return await self._safe_execute("metadata", impl, contest_id) return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult: async def scrape_contest_list(self) -> ContestListResult:
async def impl() -> ContestListResult: try:
try: contests = await _fetch_all_contests_async()
contests = await _fetch_all_contests_async()
except Exception as e:
return self._create_contests_error(str(e))
if not contests: if not contests:
return self._create_contests_error("No contests found") return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests) return ContestListResult(success=True, error="", contests=contests)
except Exception as e:
return await self._safe_execute("contests", impl) return self._contests_error(str(e))
async def stream_tests_for_category_async(self, category_id: str) -> None: async def stream_tests_for_category_async(self, category_id: str) -> None:
rows = await asyncio.to_thread(_scrape_tasks_sync, category_id) rows = await asyncio.to_thread(_scrape_tasks_sync, category_id)

View file

@ -1,9 +1,8 @@
import asyncio
import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Awaitable, Callable, ParamSpec, cast
from .models import ContestListResult, MetadataResult, TestsResult from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
P = ParamSpec("P")
class BaseScraper(ABC): class BaseScraper(ABC):
@ -20,57 +19,65 @@ class BaseScraper(ABC):
@abstractmethod @abstractmethod
async def stream_tests_for_category_async(self, category_id: str) -> None: ... async def stream_tests_for_category_async(self, category_id: str) -> None: ...
def _create_metadata_error( def _usage(self) -> str:
self, error_msg: str, contest_id: str = "" name = self.platform_name
) -> MetadataResult: return f"Usage: {name}.py metadata <id> | tests <id> | contests"
return MetadataResult(
success=False,
error=f"{self.platform_name}: {error_msg}",
contest_id=contest_id,
problems=[],
url="",
)
def _create_tests_error( def _metadata_error(self, msg: str) -> MetadataResult:
self, error_msg: str, problem_id: str = "", url: str = "" return MetadataResult(success=False, error=msg, url="")
) -> TestsResult:
from .models import CombinedTest
def _tests_error(self, msg: str) -> TestsResult:
return TestsResult( return TestsResult(
success=False, success=False,
error=f"{self.platform_name}: {error_msg}", error=msg,
problem_id=problem_id, problem_id="",
combined=CombinedTest(input="", expected=""), combined=CombinedTest(input="", expected=""),
tests=[], tests=[],
timeout_ms=0, timeout_ms=0,
memory_mb=0, memory_mb=0,
interactive=False,
) )
def _create_contests_error(self, error_msg: str) -> ContestListResult: def _contests_error(self, msg: str) -> ContestListResult:
return ContestListResult( return ContestListResult(success=False, error=msg)
success=False,
error=f"{self.platform_name}: {error_msg}",
contests=[],
)
async def _safe_execute( async def _run_cli_async(self, args: list[str]) -> int:
self, if len(args) < 2:
operation: str, print(self._metadata_error(self._usage()).model_dump_json())
func: Callable[P, Awaitable[Any]], return 1
*args: P.args,
**kwargs: P.kwargs, mode = args[1]
):
try: match mode:
return await func(*args, **kwargs) case "metadata":
except Exception as e: if len(args) != 3:
if operation == "metadata": print(self._metadata_error(self._usage()).model_dump_json())
contest_id = cast(str, args[0]) if args else "" return 1
return self._create_metadata_error(str(e), contest_id) result = await self.scrape_contest_metadata(args[2])
elif operation == "tests": print(result.model_dump_json())
problem_id = cast(str, args[1]) if len(args) > 1 else "" return 0 if result.success else 1
return self._create_tests_error(str(e), problem_id)
elif operation == "contests": case "tests":
return self._create_contests_error(str(e)) if len(args) != 3:
else: print(self._tests_error(self._usage()).model_dump_json())
raise return 1
await self.stream_tests_for_category_async(args[2])
return 0
case "contests":
if len(args) != 2:
print(self._contests_error(self._usage()).model_dump_json())
return 1
result = await self.scrape_contest_list()
print(result.model_dump_json())
return 0 if result.success else 1
case _:
print(
self._metadata_error(
f"Unknown mode: {mode}. {self._usage()}"
).model_dump_json()
)
return 1
def run_cli(self) -> None:
sys.exit(asyncio.run(self._run_cli_async(sys.argv)))

View file

@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import json import json
import re import re
import sys
from typing import Any from typing import Any
import httpx import httpx
@ -10,13 +10,11 @@ from scrapling.fetchers import Fetcher
from .base import BaseScraper from .base import BaseScraper
from .models import ( from .models import (
CombinedTest,
ContestListResult, ContestListResult,
ContestSummary, ContestSummary,
MetadataResult, MetadataResult,
ProblemSummary, ProblemSummary,
TestCase, TestCase,
TestsResult,
) )
BASE_URL = "https://www.codechef.com" BASE_URL = "https://www.codechef.com"
@ -62,42 +60,40 @@ class CodeChefScraper(BaseScraper):
return "codechef" return "codechef"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
async with httpx.AsyncClient() as client: try:
try: async with httpx.AsyncClient() as client:
data = await fetch_json( data = await fetch_json(
client, API_CONTEST.format(contest_id=contest_id) client, API_CONTEST.format(contest_id=contest_id)
) )
except httpx.HTTPStatusError as e: if not data.get("problems"):
return self._create_metadata_error( return self._metadata_error(
f"Failed to fetch contest {contest_id}: {e}", contest_id f"No problems found for contest {contest_id}"
) )
if not data.get("problems"): problems = []
return self._create_metadata_error( for problem_code, problem_data in data["problems"].items():
f"No problems found for contest {contest_id}", contest_id if problem_data.get("category_name") == "main":
) problems.append(
problems = [] ProblemSummary(
for problem_code, problem_data in data["problems"].items(): id=problem_code,
if problem_data.get("category_name") == "main": name=problem_data.get("name", problem_code),
problems.append( )
ProblemSummary(
id=problem_code,
name=problem_data.get("name", problem_code),
) )
) return MetadataResult(
return MetadataResult( success=True,
success=True, error="",
error="", contest_id=contest_id,
contest_id=contest_id, problems=problems,
problems=problems, url=f"{BASE_URL}/{contest_id}",
url=f"{BASE_URL}/{contest_id}", )
) except Exception as e:
return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}")
async def scrape_contest_list(self) -> ContestListResult: async def scrape_contest_list(self) -> ContestListResult:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
data = await fetch_json(client, API_CONTESTS_ALL) data = await fetch_json(client, API_CONTESTS_ALL)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
return self._create_contests_error(f"Failed to fetch contests: {e}") return self._contests_error(f"Failed to fetch contests: {e}")
all_contests = data.get("future_contests", []) + data.get( all_contests = data.get("future_contests", []) + data.get(
"past_contests", [] "past_contests", []
) )
@ -110,7 +106,7 @@ class CodeChefScraper(BaseScraper):
num = int(match.group(1)) num = int(match.group(1))
max_num = max(max_num, num) max_num = max(max_num, num)
if max_num == 0: if max_num == 0:
return self._create_contests_error("No Starters contests found") return self._contests_error("No Starters contests found")
contests = [] contests = []
sem = asyncio.Semaphore(CONNECTIONS) sem = asyncio.Semaphore(CONNECTIONS)
@ -252,68 +248,5 @@ class CodeChefScraper(BaseScraper):
print(json.dumps(payload), flush=True) print(json.dumps(payload), flush=True)
async def main_async() -> int:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: codechef.py metadata <contest_id> OR codechef.py tests <contest_id> OR codechef.py contests",
url="",
)
print(result.model_dump_json())
return 1
mode: str = sys.argv[1]
scraper = CodeChefScraper()
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False,
error="Usage: codechef.py metadata <contest_id>",
url="",
)
print(result.model_dump_json())
return 1
contest_id = sys.argv[2]
result = await scraper.scrape_contest_metadata(contest_id)
print(result.model_dump_json())
return 0 if result.success else 1
if mode == "tests":
if len(sys.argv) != 3:
tests_result = TestsResult(
success=False,
error="Usage: codechef.py tests <contest_id>",
problem_id="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(tests_result.model_dump_json())
return 1
contest_id = sys.argv[2]
await scraper.stream_tests_for_category_async(contest_id)
return 0
if mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: codechef.py contests"
)
print(contest_result.model_dump_json())
return 1
contest_result = await scraper.scrape_contest_list()
print(contest_result.model_dump_json())
return 0 if contest_result.success else 1
result = MetadataResult(
success=False,
error=f"Unknown mode: {mode}. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
url="",
)
print(result.model_dump_json())
return 1
def main() -> None:
sys.exit(asyncio.run(main_async()))
if __name__ == "__main__": if __name__ == "__main__":
main() CodeChefScraper().run_cli()

View file

@ -4,7 +4,6 @@ import asyncio
import json import json
import logging import logging
import re import re
import sys
from typing import Any from typing import Any
import requests import requests
@ -13,13 +12,11 @@ from scrapling.fetchers import Fetcher
from .base import BaseScraper from .base import BaseScraper
from .models import ( from .models import (
CombinedTest,
ContestListResult, ContestListResult,
ContestSummary, ContestSummary,
MetadataResult, MetadataResult,
ProblemSummary, ProblemSummary,
TestCase, TestCase,
TestsResult,
) )
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31) # suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
@ -89,14 +86,14 @@ def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
if not st: if not st:
return [], False return [], False
input_pres: list[Tag] = [ # type: ignore[misc] input_pres: list[Tag] = [
inp.find("pre") # type: ignore[misc] inp.find("pre")
for inp in st.find_all("div", class_="input") # type: ignore[union-attr] for inp in st.find_all("div", class_="input")
if isinstance(inp, Tag) and inp.find("pre") if isinstance(inp, Tag) and inp.find("pre")
] ]
output_pres: list[Tag] = [ output_pres: list[Tag] = [
out.find("pre") # type: ignore[misc] out.find("pre")
for out in st.find_all("div", class_="output") # type: ignore[union-attr] for out in st.find_all("div", class_="output")
if isinstance(out, Tag) and out.find("pre") if isinstance(out, Tag) and out.find("pre")
] ]
input_pres = [p for p in input_pres if isinstance(p, Tag)] input_pres = [p for p in input_pres if isinstance(p, Tag)]
@ -209,49 +206,46 @@ class CodeforcesScraper(BaseScraper):
return "codeforces" return "codeforces"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
async def impl(cid: str) -> MetadataResult: try:
problems = await asyncio.to_thread(_scrape_contest_problems_sync, cid) problems = await asyncio.to_thread(
_scrape_contest_problems_sync, contest_id
)
if not problems: if not problems:
return self._create_metadata_error( return self._metadata_error(
f"No problems found for contest {cid}", cid f"No problems found for contest {contest_id}"
) )
return MetadataResult( return MetadataResult(
success=True, success=True,
error="", error="",
contest_id=cid, contest_id=contest_id,
problems=problems, problems=problems,
url=f"https://codeforces.com/contest/{contest_id}/problem/%s", url=f"https://codeforces.com/contest/{contest_id}/problem/%s",
) )
except Exception as e:
return await self._safe_execute("metadata", impl, contest_id) return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult: async def scrape_contest_list(self) -> ContestListResult:
async def impl() -> ContestListResult: try:
try: r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS)
r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS) r.raise_for_status()
r.raise_for_status() data = r.json()
data = r.json() if data.get("status") != "OK":
if data.get("status") != "OK": return self._contests_error("Invalid API response")
return self._create_contests_error("Invalid API response")
contests: list[ContestSummary] = [] contests: list[ContestSummary] = []
for c in data["result"]: for c in data["result"]:
if c.get("phase") != "FINISHED": if c.get("phase") != "FINISHED":
continue continue
cid = str(c["id"]) cid = str(c["id"])
name = c["name"] name = c["name"]
contests.append( contests.append(ContestSummary(id=cid, name=name, display_name=name))
ContestSummary(id=cid, name=name, display_name=name)
)
if not contests: if not contests:
return self._create_contests_error("No contests found") return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests) return ContestListResult(success=True, error="", contests=contests)
except Exception as e: except Exception as e:
return self._create_contests_error(str(e)) return self._contests_error(str(e))
return await self._safe_execute("contests", impl)
async def stream_tests_for_category_async(self, category_id: str) -> None: async def stream_tests_for_category_async(self, category_id: str) -> None:
html = await asyncio.to_thread(_fetch_problems_html, category_id) html = await asyncio.to_thread(_fetch_problems_html, category_id)
@ -281,73 +275,5 @@ class CodeforcesScraper(BaseScraper):
) )
async def main_async() -> int:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: codeforces.py metadata <contest_id> OR codeforces.py tests <contest_id> OR codeforces.py contests",
url="",
)
print(result.model_dump_json())
return 1
mode: str = sys.argv[1]
scraper = CodeforcesScraper()
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False,
error="Usage: codeforces.py metadata <contest_id>",
url="",
)
print(result.model_dump_json())
return 1
contest_id = sys.argv[2]
result = await scraper.scrape_contest_metadata(contest_id)
print(result.model_dump_json())
return 0 if result.success else 1
if mode == "tests":
if len(sys.argv) != 3:
tests_result = TestsResult(
success=False,
error="Usage: codeforces.py tests <contest_id>",
problem_id="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(tests_result.model_dump_json())
return 1
contest_id = sys.argv[2]
await scraper.stream_tests_for_category_async(contest_id)
return 0
if mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: codeforces.py contests"
)
print(contest_result.model_dump_json())
return 1
contest_result = await scraper.scrape_contest_list()
print(contest_result.model_dump_json())
return 0 if contest_result.success else 1
result = MetadataResult(
success=False,
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
url="",
)
print(result.model_dump_json())
return 1
def main() -> None:
sys.exit(asyncio.run(main_async()))
if __name__ == "__main__": if __name__ == "__main__":
main() CodeforcesScraper().run_cli()

View file

@ -3,20 +3,17 @@
import asyncio import asyncio
import json import json
import re import re
import sys
from typing import Any from typing import Any
import httpx import httpx
from .base import BaseScraper from .base import BaseScraper
from .models import ( from .models import (
CombinedTest,
ContestListResult, ContestListResult,
ContestSummary, ContestSummary,
MetadataResult, MetadataResult,
ProblemSummary, ProblemSummary,
TestCase, TestCase,
TestsResult,
) )
BASE_URL = "https://cses.fi" BASE_URL = "https://cses.fi"
@ -261,73 +258,5 @@ class CSESScraper(BaseScraper):
print(json.dumps(payload), flush=True) print(json.dumps(payload), flush=True)
async def main_async() -> int:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: cses.py metadata <category_id> OR cses.py tests <category> OR cses.py contests",
url="",
)
print(result.model_dump_json())
return 1
mode: str = sys.argv[1]
scraper = CSESScraper()
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False,
error="Usage: cses.py metadata <category_id>",
url="",
)
print(result.model_dump_json())
return 1
category_id = sys.argv[2]
result = await scraper.scrape_contest_metadata(category_id)
print(result.model_dump_json())
return 0 if result.success else 1
if mode == "tests":
if len(sys.argv) != 3:
tests_result = TestsResult(
success=False,
error="Usage: cses.py tests <category>",
problem_id="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(tests_result.model_dump_json())
return 1
category = sys.argv[2]
await scraper.stream_tests_for_category_async(category)
return 0
if mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: cses.py contests"
)
print(contest_result.model_dump_json())
return 1
contest_result = await scraper.scrape_contest_list()
print(contest_result.model_dump_json())
return 0 if contest_result.success else 1
result = MetadataResult(
success=False,
error=f"Unknown mode: {mode}. Use 'metadata <category>', 'tests <category>', or 'contests'",
url="",
)
print(result.model_dump_json())
return 1
def main() -> None:
sys.exit(asyncio.run(main_async()))
if __name__ == "__main__": if __name__ == "__main__":
main() CSESScraper().run_cli()

View file

@ -232,33 +232,35 @@ def run_scraper_offline(fixture_text):
case _: case _:
raise AssertionError(f"Unknown scraper: {scraper_name}") raise AssertionError(f"Unknown scraper: {scraper_name}")
scraper_classes = {
"cses": "CSESScraper",
"atcoder": "AtcoderScraper",
"codeforces": "CodeforcesScraper",
"codechef": "CodeChefScraper",
}
def _run(scraper_name: str, mode: str, *args: str): def _run(scraper_name: str, mode: str, *args: str):
mod_path = ROOT / "scrapers" / f"{scraper_name}.py" mod_path = ROOT / "scrapers" / f"{scraper_name}.py"
ns = _load_scraper_module(mod_path, scraper_name) ns = _load_scraper_module(mod_path, scraper_name)
offline_fetches = _make_offline_fetches(scraper_name) offline_fetches = _make_offline_fetches(scraper_name)
if scraper_name == "codeforces": if scraper_name == "codeforces":
fetchers.Fetcher.get = offline_fetches["Fetcher.get"] # type: ignore[assignment] fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
requests.get = offline_fetches["requests.get"] requests.get = offline_fetches["requests.get"]
elif scraper_name == "atcoder": elif scraper_name == "atcoder":
ns._fetch = offline_fetches["_fetch"] ns._fetch = offline_fetches["_fetch"]
ns._get_async = offline_fetches["_get_async"] ns._get_async = offline_fetches["_get_async"]
elif scraper_name == "cses": elif scraper_name == "cses":
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] # type: ignore[assignment] httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
elif scraper_name == "codechef": elif scraper_name == "codechef":
httpx.AsyncClient.get = offline_fetches["__offline_get_async"] # type: ignore[assignment] httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
fetchers.Fetcher.get = offline_fetches["Fetcher.get"] # type: ignore[assignment] fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
main_async = getattr(ns, "main_async") scraper_class = getattr(ns, scraper_classes[scraper_name])
assert callable(main_async), f"main_async not found in {scraper_name}" scraper = scraper_class()
argv = [str(mod_path), mode, *args] argv = [str(mod_path), mode, *args]
old_argv = sys.argv rc, out = _capture_stdout(scraper._run_cli_async(argv))
sys.argv = argv
try:
rc, out = _capture_stdout(main_async())
finally:
sys.argv = old_argv
json_lines: list[Any] = [] json_lines: list[Any] = []
for line in (_line for _line in out.splitlines() if _line.strip()): for line in (_line for _line in out.splitlines() if _line.strip()):