Compare commits

..

No commits in common. "fix/edit-buffer-cleanup-acwrite" and "chore/add-issue-templates" have entirely different histories.

47 changed files with 2084 additions and 3211 deletions

3
.envrc Normal file
View file

@ -0,0 +1,3 @@
VIRTUAL_ENV="$PWD/.venv"
PATH_add "$VIRTUAL_ENV/bin"
export VIRTUAL_ENV

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

@ -28,7 +28,6 @@ jobs:
- '*.lua' - '*.lua'
- '.luarc.json' - '.luarc.json'
- '*.toml' - '*.toml'
- 'vim.yaml'
python: python:
- 'scripts/**/.py' - 'scripts/**/.py'
- 'scrapers/**/*.py' - 'scrapers/**/*.py'
@ -46,8 +45,11 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }} if: ${{ needs.changes.outputs.lua == 'true' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31 - uses: JohnnyMorganz/stylua-action@v4
- run: nix develop --command stylua --check . with:
token: ${{ secrets.GITHUB_TOKEN }}
version: 2.1.0
args: --check .
lua-lint: lua-lint:
name: Lua Lint Check name: Lua Lint Check
@ -56,8 +58,11 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }} if: ${{ needs.changes.outputs.lua == 'true' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31 - name: Lint with Selene
- run: nix develop --command selene --display-style quiet . uses: NTBBloodbath/selene-action@v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --display-style quiet .
lua-typecheck: lua-typecheck:
name: Lua Type Check name: Lua Type Check
@ -122,5 +127,15 @@ jobs:
if: ${{ needs.changes.outputs.markdown == 'true' }} if: ${{ needs.changes.outputs.markdown == 'true' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31 - name: Setup pnpm
- run: nix develop --command prettier --check . uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install prettier
run: pnpm add -g prettier@3.1.0
- name: Check markdown formatting with prettier
run: prettier --check .

View file

@ -44,7 +44,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v4 uses: astral-sh/setup-uv@v4
- name: Install dependencies - name: Install dependencies with pytest
run: uv sync --dev run: uv sync --dev
- name: Fetch camoufox data
run: uv run camoufox fetch
- name: Run Python tests - name: Run Python tests
run: uv run pytest tests/ -v run: uv run pytest tests/ -v

3
.gitignore vendored
View file

@ -14,6 +14,3 @@ __pycache__
.claude/ .claude/
node_modules/ node_modules/
.envrc
.direnv/

View file

@ -1,16 +1,8 @@
{ {
"runtime": { "runtime.version": "Lua 5.1",
"version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"path": ["lua/?.lua", "lua/?/init.lua"] "diagnostics.globals": ["vim"],
}, "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"diagnostics": { "workspace.checkThirdParty": false,
"globals": ["vim"] "completion.callSnippet": "Replace"
},
"workspace": {
"library": ["$VIMRUNTIME/lua", "${3rd}/luv/library", "${3rd}/busted/library"],
"checkThirdParty": false
},
"completion": {
"callSnippet": "Replace"
}
} }

View file

@ -28,12 +28,11 @@ Install using your package manager of choice or via
luarocks install cp.nvim luarocks install cp.nvim
``` ```
## Dependencies ## Optional Dependencies
- [uv](https://docs.astral.sh/uv/) for problem scraping
- GNU [time](https://www.gnu.org/software/time/) and - GNU [time](https://www.gnu.org/software/time/) and
[timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html) [timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html)
- [uv](https://docs.astral.sh/uv/) or [nix](https://nixos.org/) for problem
scraping
## Quick Start ## Quick Start

View file

@ -9,7 +9,7 @@ INTRODUCTION *cp.nvim*
cp.nvim is a competitive programming plugin that automates problem setup, cp.nvim is a competitive programming plugin that automates problem setup,
compilation, and testing workflow for online judges. compilation, and testing workflow for online judges.
Supported platforms: AtCoder, CodeChef, Codeforces, CSES, Kattis, USACO Supported platforms (for now!): AtCoder, Codeforces, CSES
============================================================================== ==============================================================================
REQUIREMENTS *cp-requirements* REQUIREMENTS *cp-requirements*
@ -19,20 +19,195 @@ REQUIREMENTS *cp-requirements*
- uv package manager (https://docs.astral.sh/uv/) - uv package manager (https://docs.astral.sh/uv/)
============================================================================== ==============================================================================
SETUP *cp-setup* COMMANDS *cp-commands*
Load cp.nvim with your package manager. For example, with lazy.nvim: >lua :CP *:CP*
{ 'barrettruth/cp.nvim' } cp.nvim uses a single :CP command with intelligent argument parsing:
Setup Commands ~
:CP {platform} {contest_id} [--lang {language}]
Full setup: set platform and load contest metadata.
Scrapes test cases and creates source file.
--lang: Use specific language (default: platform default)
Examples: >
:CP codeforces 1933
:CP codeforces 1933 --lang python
<
View Commands ~
:CP run [all|n|n,m,...] [--debug]
Run tests in I/O view (see |cp-io-view|).
Lightweight split showing test verdicts.
Execution modes:
• :CP run Combined: single execution with all tests
(auto-switches to individual when multiple samples)
• :CP run all Individual: N separate executions
• :CP run n Individual: run test n only
• :CP run n,m,... Individual: run specific tests (e.g. nth and mth)
--debug: Use debug build (builds to build/<name>.dbg)
Combined mode runs all test inputs in one execution (matching
platform behavior for multi-test problems). When a problem has
multiple independent sample test cases, :CP run auto-switches to
individual mode to run each sample separately.
Examples: >
:CP run " Combined: all tests, one execution
:CP run all " Individual: all tests, N executions
:CP run 2 " Individual: test 2 only
:CP run 1,3,5 " Individual: tests 1, 3, and 5
:CP run all --debug " Individual with debug build
<
:CP panel [--debug] [n]
Open full-screen test panel (see |cp-panel|).
Aggregate table with diff modes for detailed analysis.
Optional [n] focuses on specific test.
--debug: Use debug build (with sanitizers, etc.)
Examples: >
:CP panel " All tests
:CP panel --debug 3 " Test 3, debug build
<
:CP pick [--lang {language}]
Launch configured picker for interactive
platform/contest selection.
--lang: Pre-select language for chosen contest.
Example: >
:CP pick
:CP pick --lang python
<
:CP interact [script]
Open an interactive terminal for the current problem.
If an executable interactor is provided, runs the compiled
binary against the source file (see
*cp-interact*). Otherwise, runs the source
file. Only valid for interactive problems.
Navigation Commands ~
:CP next [--lang {language}]
Navigate to next problem in current contest.
Stops at last problem (no wrapping).
--lang: Use specific language for next problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP next
:CP next --lang python
<
:CP prev [--lang {language}]
Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
--lang: Use specific language for previous problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP prev
:CP prev --lang cpp
<
:CP {problem_id} [--lang {language}]
Jump to problem {problem_id} in a contest.
Requires that a contest has already been set up.
--lang: Use specific language for this problem.
Examples: >
:CP B
:CP C --lang python
<
Edit Commands ~
:CP edit [n]
Open grid test editor showing all test cases.
Tests displayed as 2×N grid (2 rows, N columns):
• Top row: Test inputs (editable)
• Bottom row: Expected outputs (editable)
Optional [n]: Jump cursor to test n's input buffer
Changes saved to both cache and disk on exit,
taking effect immediately in :CP run and CLI.
Keybindings (configurable via |EditConfig|):
q Save all and exit editor
]t Jump to next test column
[t Jump to previous test column
gd Delete current test column
ga Add new test column at end
<c-w> Normal window navigation
Examples: >
:CP edit " Edit all tests
:CP edit 3 " Edit all, start at test 3
<
State Restoration ~
:CP Restore state from current file.
Automatically detects platform, contest, problem,
and language from cached state. Use this after
switching files to restore your CP environment.
Cache Commands ~
:CP cache clear [platform] [contest]
Clear cache data at different granularities:
• No args: Clear all cached data
• [platform]: Clear all data for a platform
• [platform] [contest]: Clear specific contest
Examples: >
:CP cache clear
:CP cache clear codeforces
:CP cache clear codeforces 1848
<
:CP cache read
View the cache in a pretty-printed lua buffer.
Exit with q.
Template Variables ~
*cp-template-vars*
Command templates support variable substitution using {variable} syntax:
• {source} Source file path (e.g. "abc324a.cpp")
• {binary} Output binary path (e.g. "build/abc324a.run" or
"build/abc324a.dbg" for debug builds)
Example template: >
build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
< Would expand to: >
g++ abc324a.cpp -o build/abc324a.run -std=c++17
<
Debug Builds ~
*cp-debug-builds*
The --debug flag uses the debug command configuration instead of build:
• Normal build: commands.build → outputs to build/<name>.run
• Debug build: commands.debug → outputs to build/<name>.dbg
Debug builds typically include sanitizers (address, undefined behavior) to
catch memory errors, buffer overflows, and other issues. Both binaries
coexist, so you can switch between normal and debug mode without
recompiling.
Example debug configuration: >
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
}
}
}
< <
The plugin works automatically with no configuration required. For
customization, see |cp-config|.
============================================================================== ==============================================================================
CONFIGURATION *cp-config* CONFIGURATION *cp-config*
Configuration is done via `vim.g.cp`. Set this before using the plugin: Configuration is done via `vim.g.cp_config`. Set this before using the plugin:
>lua >lua
vim.g.cp = { vim.g.cp_config = {
languages = { languages = {
cpp = { cpp = {
extension = 'cc', extension = 'cc',
@ -68,18 +243,6 @@ Configuration is done via `vim.g.cp`. Set this before using the plugin:
enabled_languages = { 'cpp', 'python' }, enabled_languages = { 'cpp', 'python' },
default_language = 'cpp', default_language = 'cpp',
}, },
codechef = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
usaco = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
kattis = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
}, },
open_url = true, open_url = true,
debug = false, debug = false,
@ -111,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 = { vim.g.cp_config = {
platforms = { platforms = {
codeforces = { codeforces = {
default_language = 'python', default_language = 'python',
@ -122,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 = { vim.g.cp_config = {
languages = { languages = {
rust = { rust = {
extension = 'rs', extension = 'rs',
@ -155,7 +318,7 @@ run CSES problems with Rust using the single schema:
(default: concatenates contest_id and problem_id, lowercased) (default: concatenates contest_id and problem_id, lowercased)
{ui} (|CpUI|) UI settings: panel, diff backend, picker. {ui} (|CpUI|) UI settings: panel, diff backend, picker.
{open_url} (boolean) Open the contest & problem url in the browser {open_url} (boolean) Open the contest & problem url in the browser
when a new contest is opened or the active problem changes. when the contest is first opened.
*CpPlatform* *CpPlatform*
Fields: ~ Fields: ~
@ -232,340 +395,42 @@ run CSES problems with Rust using the single schema:
*cp.Hooks* *cp.Hooks*
Fields: ~ Fields: ~
{setup} (|cp.CpSetupHooks|, optional) One-time initialization hooks. {before_run} (function, optional) Called before test panel opens.
{on} (|cp.CpOnHooks|, optional) Recurring event hooks. function(state: cp.State)
{before_debug} (function, optional) Called before debug build/run.
function(state: cp.State)
{setup_code} (function, optional) Called after source file is opened.
function(state: cp.State)
{setup_io_input} (function, optional) Called when I/O input buffer created.
function(bufnr: integer, state: cp.State)
Default: helpers.clearcol (removes line numbers/columns)
{setup_io_output} (function, optional) Called when I/O output buffer created.
function(bufnr: integer, state: cp.State)
Default: helpers.clearcol (removes line numbers/columns)
*cp.CpSetupHooks* Hook functions receive the cp.nvim state object (|cp.State|). See
Fields: ~
{contest} (function, optional) Called once when a contest directory
is first created (not on subsequent visits).
function(state: cp.State)
{code} (function, optional) Called after the source buffer is
opened for the first time (guarded by cp_setup_done).
function(state: cp.State)
{io} (|cp.CpSetupIOHooks|, optional) I/O buffer hooks.
*cp.CpSetupIOHooks*
Fields: ~
{input} (function, optional) Called when the I/O input buffer is
created. function(bufnr: integer, state: cp.State)
Default: helpers.clearcol
{output} (function, optional) Called when the I/O output buffer is
created. function(bufnr: integer, state: cp.State)
Default: helpers.clearcol
*cp.CpOnHooks*
Fields: ~
{enter} (function, optional) Called on every BufEnter on the
solution buffer. Registered as a buffer-scoped autocmd and
fired immediately after setup.code.
function(state: cp.State)
{run} (function, optional) Called before the test panel opens.
function(state: cp.State)
{debug} (function, optional) Called before a debug run.
function(state: cp.State)
All hook functions receive the cp.nvim state object (|cp.State|). See
|lua/cp/state.lua| for available methods and fields. |lua/cp/state.lua| for available methods and fields.
The I/O buffer hooks are called once when the buffers are first created
during problem setup. Use these to customize buffer appearance (e.g.,
remove line numbers, set custom options). Access helpers via:
>lua
local helpers = require('cp').helpers
<
Example usage: Example usage:
>lua >lua
hooks = { hooks = {
setup = { setup_code = function(state)
contest = function(state) print("Setting up " .. state.get_base_name())
local dir = vim.fn.fnamemodify( print("Source file: " .. state.get_source_file())
state.get_source_file(state.get_language()), ':h') end,
vim.fn.system({ 'cp', '~/.clang-format', dir .. '/.clang-format' }) setup_io_input = function(bufnr, state)
end, -- Custom setup for input buffer
code = function(state) vim.api.nvim_set_option_value('number', false, { buf = bufnr })
vim.opt_local.foldmethod = 'marker' end
vim.diagnostic.enable(false)
end,
},
on = {
enter = function(state) vim.opt_local.winbar = '' end,
run = function(state) require('config.lsp').format() end,
},
} }
< <
==============================================================================
COMMANDS *cp-commands*
:CP *:CP*
cp.nvim uses a single :CP command with intelligent argument parsing:
Setup Commands ~
:CP {platform} {contest_id} [--lang {language}]
Full setup: set platform and load contest metadata.
Scrapes test cases and creates source file.
--lang: Use specific language (default: platform default)
Examples: >
:CP codeforces 1933
:CP codeforces 1933 --lang python
<
View Commands ~
:CP run [all|n|n,m,...] [--debug]
Run tests in I/O view (see |cp-io-view|).
Lightweight split showing test verdicts.
Execution modes:
• :CP run Combined: single execution with all tests
(auto-switches to individual when multiple samples)
• :CP run all Individual: N separate executions
• :CP run n Individual: run test n only
• :CP run n,m,... Individual: run specific tests (e.g. nth and mth)
--debug: Use debug build (builds to build/<name>.dbg)
Combined mode runs all test inputs in one execution (matching
platform behavior for multi-test problems). When a problem has
multiple independent sample test cases, :CP run auto-switches to
individual mode to run each sample separately.
Examples: >
:CP run " Combined: all tests, one execution
:CP run all " Individual: all tests, N executions
:CP run 2 " Individual: test 2 only
:CP run 1,3,5 " Individual: tests 1, 3, and 5
:CP run all --debug " Individual with debug build
<
:CP panel [--debug] [n]
Open full-screen test panel (see |cp-panel|).
Aggregate table with diff modes for detailed analysis.
Optional [n] focuses on specific test.
--debug: Use debug build (with sanitizers, etc.)
Examples: >
:CP panel " All tests
:CP panel --debug 3 " Test 3, debug build
<
:CP pick [--lang {language}]
Launch configured picker for interactive
platform/contest selection.
--lang: Pre-select language for chosen contest.
Example: >
:CP pick
:CP pick --lang python
<
:CP interact [script]
Open an interactive terminal for the current problem.
If an executable interactor is provided, runs the compiled
binary against the source file (see
*cp-interact*). Otherwise, runs the source
file. Only valid for interactive problems.
:CP stress [generator] [brute]
Start an automated stress test loop against a
brute-force reference. Toggles off if already
running. Without arguments, auto-detects a
generator and brute script in the working
directory. See |cp-stress|.
Navigation Commands ~
:CP next [--lang {language}]
Navigate to next problem in current contest.
Stops at last problem (no wrapping).
--lang: Use specific language for next problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP next
:CP next --lang python
<
:CP prev [--lang {language}]
Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
--lang: Use specific language for previous problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP prev
:CP prev --lang cpp
<
:CP {problem_id} [--lang {language}]
Jump to problem {problem_id} in a contest.
Requires that a contest has already been set up.
--lang: Use specific language for this problem.
Examples: >
:CP B
:CP C --lang python
<
Edit Commands ~
:CP edit [n]
Open grid test editor showing all test cases.
Tests displayed as 2×N grid (2 rows, N columns):
• Top row: Test inputs (editable)
• Bottom row: Expected outputs (editable)
Optional [n]: Jump cursor to test n's input buffer
Changes saved to both cache and disk on exit,
taking effect immediately in :CP run and CLI.
Keybindings (configurable via |EditConfig|):
q Save all and exit editor
]t Jump to next test column
[t Jump to previous test column
gd Delete current test column
ga Add new test column at end
<c-w> Normal window navigation
Examples: >
:CP edit " Edit all tests
:CP edit 3 " Edit all, start at test 3
<
Race Commands ~
:CP race {platform} {contest_id} [--lang {language}]
Start a countdown to the contest's scheduled
start time. At T=0, automatically runs:
:CP {platform} {contest_id} [--lang ...]
Examples: >
:CP race atcoder abc400
:CP race codeforces 2100 --lang python
<
:CP race stop
Cancel an active race countdown.
Credential Commands ~
:CP credentials set [platform]
Set or update stored credentials for a platform.
Always prompts for username and password,
overwriting any previously saved credentials.
If [platform] is omitted, uses the active platform.
Examples: >
:CP credentials set atcoder
:CP credentials set codeforces
<
:CP credentials clear [platform]
Remove stored credentials. Without [platform],
clears credentials for all platforms.
Examples: >
:CP credentials clear atcoder
:CP credentials clear
<
Submit Commands ~
:CP submit [--lang {language}]
Submit the current solution to the online
judge. Uses stored credentials (set via
:CP credentials set). Prompts on first use
if no credentials are saved.
--lang: Submit solution for a specific language.
State Restoration ~
:CP Restore state from current file.
Automatically detects platform, contest, problem,
and language from cached state. Use this after
switching files to restore your CP environment.
Cache Commands ~
:CP cache clear [platform] [contest]
Clear cache data at different granularities:
• No args: Clear all cached data
• [platform]: Clear all data for a platform
• [platform] [contest]: Clear specific contest
Examples: >
:CP cache clear
:CP cache clear codeforces
:CP cache clear codeforces 1848
<
:CP cache read
View the cache in a pretty-printed lua buffer.
Exit with q.
Template Variables ~
*cp-template-vars*
Command templates support variable substitution using {variable} syntax:
• {source} Source file path (e.g. "abc324a.cpp")
• {binary} Output binary path (e.g. "build/abc324a.run" or
"build/abc324a.dbg" for debug builds)
Example template: >
build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
< Would expand to: >
g++ abc324a.cpp -o build/abc324a.run -std=c++17
<
Debug Builds ~
*cp-debug-builds*
The --debug flag uses the debug command configuration instead of build:
• Normal build: commands.build → outputs to build/<name>.run
• Debug build: commands.debug → outputs to build/<name>.dbg
Debug builds typically include sanitizers (address, undefined behavior) to
catch memory errors, buffer overflows, and other issues. Both binaries
coexist, so you can switch between normal and debug mode without
recompiling.
Example debug configuration: >
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
}
}
}
<
==============================================================================
MAPPINGS *cp-mappings*
cp.nvim provides <Plug> mappings for all primary actions. These dispatch
through the same code path as |:CP|.
*<Plug>(cp-run)*
<Plug>(cp-run) Run tests in I/O view. Equivalent to :CP run.
*<Plug>(cp-panel)*
<Plug>(cp-panel) Open full-screen test panel. Equivalent to :CP panel.
*<Plug>(cp-edit)*
<Plug>(cp-edit) Open the test case editor. Equivalent to :CP edit.
*<Plug>(cp-next)*
<Plug>(cp-next) Navigate to the next problem. Equivalent to :CP next.
*<Plug>(cp-prev)*
<Plug>(cp-prev) Navigate to the previous problem. Equivalent to :CP prev.
*<Plug>(cp-pick)*
<Plug>(cp-pick) Launch the contest picker. Equivalent to :CP pick.
*<Plug>(cp-interact)*
<Plug>(cp-interact) Open interactive mode. Equivalent to :CP interact.
*<Plug>(cp-stress)*
<Plug>(cp-stress) Run stress test loop. Equivalent to :CP stress.
*<Plug>(cp-submit)*
<Plug>(cp-submit) Submit current solution. Equivalent to :CP submit.
*<Plug>(cp-race-stop)*
<Plug>(cp-race-stop) Cancel active race countdown. Equivalent to :CP race stop.
Example configuration: >lua
vim.keymap.set('n', '<leader>cr', '<Plug>(cp-run)')
vim.keymap.set('n', '<leader>cp', '<Plug>(cp-panel)')
vim.keymap.set('n', '<leader>ce', '<Plug>(cp-edit)')
vim.keymap.set('n', '<leader>cn', '<Plug>(cp-next)')
vim.keymap.set('n', '<leader>cN', '<Plug>(cp-prev)')
vim.keymap.set('n', '<leader>cc', '<Plug>(cp-pick)')
vim.keymap.set('n', '<leader>ci', '<Plug>(cp-interact)')
vim.keymap.set('n', '<leader>cs', '<Plug>(cp-stress)')
vim.keymap.set('n', '<leader>cu', '<Plug>(cp-submit)')
vim.keymap.set('n', '<leader>cR', '<Plug>(cp-race-stop)')
<
============================================================================== ==============================================================================
LANGUAGE SELECTION *cp-lang-selection* LANGUAGE SELECTION *cp-lang-selection*
@ -643,41 +508,6 @@ URL format: https://cses.fi/problemset/task/{problem_id}
Usage examples: > Usage examples: >
:CP cses dynamic_programming " Set up all problems in dp category :CP cses dynamic_programming " Set up all problems in dp category
CodeChef ~
*cp-codechef*
URL format: https://www.codechef.com/{contest_id}/problems/{problem_id}
The contest_id is the contest code from the URL (e.g. START209).
Usage examples: >
:CP codechef START209 " Set up codechef.com/START209
USACO ~
*cp-usaco*
URL format: https://usaco.org/index.php?page=viewproblem2&cpid={cpid}
The contest_id combines the abbreviated month, two-digit year, and division
in lowercase, joined by underscores (e.g. dec24_gold, feb23_silver).
Usage examples: >
:CP usaco dec24_gold " Set up December 2024 Gold division
:CP usaco feb23_silver " Set up February 2023 Silver division
Kattis ~
*cp-kattis*
Kattis supports single-problem and full-contest modes.
Single problem — the contest_id is the problem slug from the URL:
URL format: https://open.kattis.com/problems/{slug}
Full contest — the contest_id is the contest ID from the URL. All problems
are set up at once with :CP next/:CP prev navigation:
URL format: https://open.kattis.com/contests/{id}
Usage examples: >
:CP kattis primesieve " Single problem
:CP kattis t8tnpe " Full contest (all problems, AH navigation)
============================================================================== ==============================================================================
COMPLETE WORKFLOW EXAMPLE *cp-example* COMPLETE WORKFLOW EXAMPLE *cp-example*
@ -712,9 +542,7 @@ Example: Setting up and solving AtCoder contest ABC324
:CP :CP
< Automatically restores abc323 contest context < Automatically restores abc323 contest context
8. Submit solution: > 8. Submit solutions on AtCoder website
:CP submit
< Prompts for credentials on first use and submits to AtCoder.
============================================================================== ==============================================================================
I/O VIEW *cp-io-view* I/O VIEW *cp-io-view*
@ -784,9 +612,9 @@ While in the I/O view buffers, use the configured keymaps to cycle through tests
Buffer Customization ~ Buffer Customization ~
Use the hooks.setup.io.input and hooks.setup.io.output hooks (see |cp.Hooks|) Use the setup_io_input and setup_io_output hooks (see |cp.Hooks|) to customize
to customize buffer appearance. By default, line numbers and columns are buffer appearance. By default, line numbers and columns are removed via
removed via helpers.clearcol (see |cp-helpers|). helpers.clearcol (see |cp-helpers|).
============================================================================== ==============================================================================
VERDICT FORMATTING *cp-verdict-format* VERDICT FORMATTING *cp-verdict-format*
@ -925,72 +753,6 @@ When using :CP interact {interactor}, the interactor must be executable
Keymaps ~ Keymaps ~
<c-q> Close the terminal and restore the previous layout. <c-q> Close the terminal and restore the previous layout.
==============================================================================
STRESS TESTING *cp-stress*
Start an automated stress test loop to find inputs where your solution
disagrees with a brute-force reference.
:CP stress [generator] [brute]
Start the stress loop. Toggles off if the loop is already running.
{generator} Generator script path (default: auto-detected).
{brute} Brute-force solution path (default: auto-detected).
Auto-detection looks for files named gen.* and brute.* in the CWD.
The stress panel opens and streams results for each iteration.
On a mismatch, the failing input is displayed in the panel.
Keymaps ~
<c-q> Close the stress panel and restore the previous layout.
==============================================================================
RACE *cp-race*
Count down to a contest's start time and automatically run setup at T=0.
:CP race {platform} {contest_id} [--lang {language}]
Start a countdown timer. At T=0, automatically runs:
:CP {platform} {contest_id} [--lang {language}]
Examples: >
:CP race atcoder abc400
:CP race codeforces 2100 --lang python
<
:CP race stop
Cancel an active race countdown.
Statusline integration: see |cp-race-status|.
==============================================================================
CREDENTIALS *cp-credentials*
Manage stored login credentials for platform submission.
Credentials are stored under _credentials in the main cache file
(stdpath('data')/cp-nvim.json). Use :CP cache read to inspect them.
:CP credentials set [platform]
Set or update credentials for a platform. Always prompts for
username and password, overwriting any previously saved values.
Omit [platform] to use the currently active platform.
:CP credentials clear [platform]
Remove stored credentials. Without [platform], clears all platforms.
==============================================================================
SUBMIT *cp-submit*
Submit the current solution to the online judge.
:CP submit [--lang {language}]
Submit the current solution. Uses stored credentials (set via
:CP credentials set). Prompts on first use if no credentials
are saved.
--lang: Override the language to submit.
Platform support:
AtCoder Fully implemented.
Others Not yet implemented.
============================================================================== ==============================================================================
ANSI COLORS AND HIGHLIGHTING *cp-ansi* ANSI COLORS AND HIGHLIGHTING *cp-ansi*
@ -1081,124 +843,6 @@ Functions ~
Parameters: ~ Parameters: ~
{bufnr} (integer) Buffer handle {bufnr} (integer) Buffer handle
==============================================================================
STATUSLINE INTEGRATION *cp-statusline*
cp.nvim exposes its runtime state through a public module that can be queried
from any statusline plugin. Import it with: >lua
local state = require('cp.state')
<
All getters return nil when no problem is active, so guard every value before
use. Calling any getter outside a CP context is safe and has no side effects.
State API ~
*cp.State*
The following getters are available for statusline use:
get_platform() (string?) Platform id. e.g. "codeforces", "atcoder"
get_contest_id() (string?) Contest id. e.g. "1933", "abc324"
get_problem_id() (string?) Problem id. e.g. "A", "B"
get_language() (string?) Language id. e.g. "cpp", "python"
get_base_name() (string?) Derived filename stem. e.g. "1933a"
get_source_file() (string?) Full source filename. e.g. "1933a.cc"
get_active_panel() (string?) One of 'run', 'interactive', 'stress', or
nil when no panel is open.
Race API ~
*cp-race-status*
require('cp.race').status() returns a table describing the race state:
{ active = false }
{ active = true, platform = string, contest_id = string,
remaining_seconds = number }
Recipe: vanilla statusline ~
Set vim.o.statusline from an autocommand so it is recalculated on every
BufEnter: >lua
local function cp_component()
local state = require('cp.state')
local platform = state.get_platform()
if not platform then
return ''
end
local parts = {
platform,
state.get_contest_id(),
state.get_problem_id(),
state.get_language(),
}
local filtered = {}
for _, v in ipairs(parts) do
if v then filtered[#filtered + 1] = v end
end
return '[' .. table.concat(filtered, ' · ') .. ']'
end
vim.api.nvim_create_autocmd({ 'BufEnter', 'User' }, {
callback = function()
vim.o.statusline = cp_component() .. ' %f %=%l:%c'
end
})
<
Recipe: lualine ~
Add a custom component to any lualine section. The cond field hides the
component entirely when no problem is active: >lua
local function cp_lualine()
local state = require('cp.state')
local parts = {
state.get_platform(),
state.get_contest_id(),
state.get_problem_id(),
state.get_language(),
}
local filtered = {}
for _, v in ipairs(parts) do
if v then filtered[#filtered + 1] = v end
end
return table.concat(filtered, ' · ')
end
require('lualine').setup({
sections = {
lualine_c = {
{
cp_lualine,
cond = function()
return require('cp.state').get_platform() ~= nil
end,
},
},
},
})
<
Recipe: heirline ~
Build a heirline component using a provider and condition: >lua
local CpComponent = {
condition = function()
return require('cp.state').get_platform() ~= nil
end,
provider = function()
local state = require('cp.state')
local parts = {
state.get_platform(),
state.get_contest_id(),
state.get_problem_id(),
state.get_language(),
}
local filtered = {}
for _, v in ipairs(parts) do
if v then filtered[#filtered + 1] = v end
end
return '[' .. table.concat(filtered, ' · ') .. ']'
end,
}
<
Include CpComponent in your heirline StatusLine spec wherever desired.
============================================================================== ==============================================================================
PANEL KEYMAPS *cp-panel-keys* PANEL KEYMAPS *cp-panel-keys*

43
flake.lock generated
View file

@ -1,43 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1771008912,
"narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a82ccc39b39b621151d6732718e3e250109076fa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1689347949,
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
"owner": "nix-systems",
"repo": "default-linux",
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default-linux",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,76 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
systems.url = "github:nix-systems/default-linux";
};
outputs =
{
self,
nixpkgs,
systems,
}:
let
eachSystem = nixpkgs.lib.genAttrs (import systems);
pkgsFor = system: nixpkgs.legacyPackages.${system};
mkPythonEnv =
pkgs:
pkgs.python312.withPackages (ps: [
ps.backoff
ps.beautifulsoup4
ps.curl-cffi
ps.httpx
ps.ndjson
ps.pydantic
ps.requests
]);
mkPlugin =
pkgs:
let
pythonEnv = mkPythonEnv pkgs;
in
pkgs.vimUtils.buildVimPlugin {
pname = "cp-nvim";
version = "0-unstable-${self.shortRev or self.dirtyShortRev or "dev"}";
src = self;
postPatch = ''
substituteInPlace lua/cp/utils.lua \
--replace-fail "local _nix_python = nil" \
"local _nix_python = '${pythonEnv.interpreter}'"
'';
nvimSkipModule = [
"cp.pickers.telescope"
"cp.version"
];
passthru = { inherit pythonEnv; };
meta.description = "Competitive programming plugin for Neovim";
};
in
{
overlays.default = final: prev: {
vimPlugins = prev.vimPlugins // {
cp-nvim = mkPlugin final;
};
};
packages = eachSystem (system: {
default = mkPlugin (pkgsFor system);
pythonEnv = mkPythonEnv (pkgsFor system);
});
devShells = eachSystem (system: {
default = (pkgsFor system).mkShell {
packages = with (pkgsFor system); [
uv
python312
prettier
stylua
selene
lua-language-server
];
};
});
};
}

View file

@ -15,7 +15,6 @@
---@field display_name string ---@field display_name string
---@field name string ---@field name string
---@field id string ---@field id string
---@field start_time? integer
---@class CombinedTest ---@class CombinedTest
---@field input string ---@field input string
@ -28,7 +27,6 @@
---@field multi_test? boolean ---@field multi_test? boolean
---@field memory_mb? number ---@field memory_mb? number
---@field timeout_ms? number ---@field timeout_ms? number
---@field precision? number
---@field combined_test? CombinedTest ---@field combined_test? CombinedTest
---@field test_cases TestCase[] ---@field test_cases TestCase[]
@ -40,8 +38,7 @@
local M = {} local M = {}
local CACHE_VERSION = 1 local logger = require('cp.log')
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
local cache_data = {} local cache_data = {}
local loaded = false local loaded = false
@ -68,15 +65,9 @@ function M.load()
local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n'))
if ok then if ok then
if decoded._version ~= CACHE_VERSION then cache_data = decoded
cache_data = {}
M.save()
else
cache_data = decoded
end
else else
cache_data = {} logger.log('Could not decode json in cache file', vim.log.levels.ERROR)
M.save()
end end
loaded = true loaded = true
end end
@ -87,7 +78,6 @@ function M.save()
vim.schedule(function() vim.schedule(function()
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
cache_data._version = CACHE_VERSION
local encoded = vim.json.encode(cache_data) local encoded = vim.json.encode(cache_data)
local lines = vim.split(encoded, '\n') local lines = vim.split(encoded, '\n')
vim.fn.writefile(lines, cache_file) vim.fn.writefile(lines, cache_file)
@ -232,8 +222,7 @@ function M.set_test_cases(
timeout_ms, timeout_ms,
memory_mb, memory_mb,
interactive, interactive,
multi_test, multi_test
precision
) )
vim.validate({ vim.validate({
platform = { platform, 'string' }, platform = { platform, 'string' },
@ -245,7 +234,6 @@ function M.set_test_cases(
memory_mb = { memory_mb, { 'number', 'nil' }, true }, memory_mb = { memory_mb, { 'number', 'nil' }, true },
interactive = { interactive, { 'boolean', 'nil' }, true }, interactive = { interactive, { 'boolean', 'nil' }, true },
multi_test = { multi_test, { 'boolean', 'nil' }, true }, multi_test = { multi_test, { 'boolean', 'nil' }, true },
precision = { precision, { 'number', 'nil' }, true },
}) })
local index = cache_data[platform][contest_id].index_map[problem_id] local index = cache_data[platform][contest_id].index_map[problem_id]
@ -256,7 +244,6 @@ function M.set_test_cases(
cache_data[platform][contest_id].problems[index].memory_mb = memory_mb cache_data[platform][contest_id].problems[index].memory_mb = memory_mb
cache_data[platform][contest_id].problems[index].interactive = interactive cache_data[platform][contest_id].problems[index].interactive = interactive
cache_data[platform][contest_id].problems[index].multi_test = multi_test cache_data[platform][contest_id].problems[index].multi_test = multi_test
cache_data[platform][contest_id].problems[index].precision = precision
M.save() M.save()
end end
@ -278,34 +265,6 @@ function M.get_constraints(platform, contest_id, problem_id)
return problem_data.timeout_ms, problem_data.memory_mb return problem_data.timeout_ms, problem_data.memory_mb
end end
---@param platform string
---@param contest_id string
---@param problem_id? string
---@return number?
function M.get_precision(platform, contest_id, problem_id)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
})
if
not cache_data[platform]
or not cache_data[platform][contest_id]
or not cache_data[platform][contest_id].index_map
then
return nil
end
local index = cache_data[platform][contest_id].index_map[problem_id]
if not index then
return nil
end
local problem_data = cache_data[platform][contest_id].problems[index]
return problem_data and problem_data.precision or nil
end
---@param file_path string ---@param file_path string
---@return FileState|nil ---@return FileState|nil
function M.get_file_state(file_path) function M.get_file_state(file_path)
@ -353,59 +312,13 @@ function M.set_contest_summaries(platform, contests)
cache_data[platform][contest.id] = cache_data[platform][contest.id] or {} cache_data[platform][contest.id] = cache_data[platform][contest.id] or {}
cache_data[platform][contest.id].display_name = contest.display_name cache_data[platform][contest.id].display_name = contest.display_name
cache_data[platform][contest.id].name = contest.name cache_data[platform][contest.id].name = contest.name
if contest.start_time then
cache_data[platform][contest.id].start_time = contest.start_time
end
end end
M.save() M.save()
end end
---@param platform string
---@param contest_id string
---@return integer?
function M.get_contest_start_time(platform, contest_id)
if not cache_data[platform] or not cache_data[platform][contest_id] then
return nil
end
return cache_data[platform][contest_id].start_time
end
---@param platform string
---@return table?
function M.get_credentials(platform)
if not cache_data._credentials then
return nil
end
return cache_data._credentials[platform]
end
---@param platform string
---@param creds table
function M.set_credentials(platform, creds)
cache_data._credentials = cache_data._credentials or {}
cache_data._credentials[platform] = creds
M.save()
end
---@param platform string?
function M.clear_credentials(platform)
if platform then
if cache_data._credentials then
cache_data._credentials[platform] = nil
end
else
cache_data._credentials = nil
end
M.save()
end
function M.clear_all() function M.clear_all()
local creds = cache_data._credentials
cache_data = {} cache_data = {}
if creds then
cache_data._credentials = creds
end
M.save() M.save()
end end
@ -425,8 +338,6 @@ function M.get_data_pretty()
return vim.inspect(cache_data) return vim.inspect(cache_data)
end end
function M.get_raw_cache() M._cache = cache_data
return cache_data
end
return M return M

View file

@ -16,8 +16,6 @@ local actions = constants.ACTIONS
---@field platform? string ---@field platform? string
---@field problem_id? string ---@field problem_id? string
---@field interactor_cmd? string ---@field interactor_cmd? string
---@field generator_cmd? string
---@field brute_cmd? string
---@field test_index? integer ---@field test_index? integer
---@field test_indices? integer[] ---@field test_indices? integer[]
---@field mode? string ---@field mode? string
@ -55,27 +53,6 @@ local function parse_command(args)
else else
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand } return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
end end
elseif first == 'race' then
if args[2] == 'stop' then
return { type = 'action', action = 'race_stop' }
end
if not args[2] or not args[3] then
return {
type = 'error',
message = 'Usage: :CP race <platform> <contest_id> [--lang <lang>]',
}
end
local language = nil
if args[4] == '--lang' and args[5] then
language = args[5]
end
return {
type = 'action',
action = 'race',
platform = args[2],
contest = args[3],
language = language,
}
elseif first == 'interact' then elseif first == 'interact' then
local inter = args[2] local inter = args[2]
if inter and inter ~= '' then if inter and inter ~= '' then
@ -83,27 +60,6 @@ local function parse_command(args)
else else
return { type = 'action', action = 'interact' } return { type = 'action', action = 'interact' }
end end
elseif first == 'credentials' then
local subcommand = args[2]
if not subcommand then
return { type = 'error', message = 'credentials command requires subcommand (set, clear)' }
end
if vim.tbl_contains({ 'set', 'clear' }, subcommand) then
return {
type = 'credentials',
subcommand = subcommand,
platform = args[3],
}
else
return { type = 'error', message = 'unknown credentials subcommand: ' .. subcommand }
end
elseif first == 'stress' then
return {
type = 'action',
action = 'stress',
generator_cmd = args[2],
brute_cmd = args[3],
}
elseif first == 'edit' then elseif first == 'edit' then
local test_index = nil local test_index = nil
if #args >= 2 then if #args >= 2 then
@ -329,14 +285,6 @@ function M.handle_command(opts)
elseif cmd.action == 'edit' then elseif cmd.action == 'edit' then
local edit = require('cp.ui.edit') local edit = require('cp.ui.edit')
edit.toggle_edit(cmd.test_index) edit.toggle_edit(cmd.test_index)
elseif cmd.action == 'stress' then
require('cp.stress').toggle(cmd.generator_cmd, cmd.brute_cmd)
elseif cmd.action == 'submit' then
require('cp.submit').submit({ language = cmd.language })
elseif cmd.action == 'race' then
require('cp.race').start(cmd.platform, cmd.contest, cmd.language)
elseif cmd.action == 'race_stop' then
require('cp.race').stop()
end end
elseif cmd.type == 'problem_jump' then elseif cmd.type == 'problem_jump' then
local platform = state.get_platform() local platform = state.get_platform()
@ -366,13 +314,6 @@ function M.handle_command(opts)
local setup = require('cp.setup') local setup = require('cp.setup')
setup.setup_contest(platform, contest_id, problem_id, cmd.language) setup.setup_contest(platform, contest_id, problem_id, cmd.language)
elseif cmd.type == 'credentials' then
local creds = require('cp.credentials')
if cmd.subcommand == 'set' then
creds.set(cmd.platform)
elseif cmd.subcommand == 'clear' then
creds.clear(cmd.platform)
end
elseif cmd.type == 'cache' then elseif cmd.type == 'cache' then
local cache_commands = require('cp.commands.cache') local cache_commands = require('cp.commands.cache')
cache_commands.handle_cache_command(cmd) cache_commands.handle_cache_command(cmd)

View file

@ -7,15 +7,10 @@
---@class CpLanguage ---@class CpLanguage
---@field extension string ---@field extension string
---@field commands CpLangCommands ---@field commands CpLangCommands
---@field template? string
---@class CpTemplatesConfig
---@field cursor_marker? string
---@class CpPlatformOverrides ---@class CpPlatformOverrides
---@field extension? string ---@field extension? string
---@field commands? CpLangCommands ---@field commands? CpLangCommands
---@field template? string
---@class CpPlatform ---@class CpPlatform
---@field enabled_languages string[] ---@field enabled_languages string[]
@ -25,7 +20,6 @@
---@class PanelConfig ---@class PanelConfig
---@field diff_modes string[] ---@field diff_modes string[]
---@field max_output_lines integer ---@field max_output_lines integer
---@field precision number?
---@class DiffGitConfig ---@class DiffGitConfig
---@field args string[] ---@field args string[]
@ -33,23 +27,12 @@
---@class DiffConfig ---@class DiffConfig
---@field git DiffGitConfig ---@field git DiffGitConfig
---@class CpSetupIOHooks
---@field input? fun(bufnr: integer, state: cp.State)
---@field output? fun(bufnr: integer, state: cp.State)
---@class CpSetupHooks
---@field contest? fun(state: cp.State)
---@field code? fun(state: cp.State)
---@field io? CpSetupIOHooks
---@class CpOnHooks
---@field enter? fun(state: cp.State)
---@field run? fun(state: cp.State)
---@field debug? fun(state: cp.State)
---@class Hooks ---@class Hooks
---@field setup? CpSetupHooks ---@field before_run? fun(state: cp.State)
---@field on? CpOnHooks ---@field before_debug? fun(state: cp.State)
---@field setup_code? fun(state: cp.State)
---@field setup_io_input? fun(bufnr: integer, state: cp.State)
---@field setup_io_output? fun(bufnr: integer, state: cp.State)
---@class VerdictFormatData ---@class VerdictFormatData
---@field index integer ---@field index integer
@ -78,6 +61,8 @@
---@class RunConfig ---@class RunConfig
---@field width number ---@field width number
---@field next_test_key string|nil
---@field prev_test_key string|nil
---@field format_verdict VerdictFormatter ---@field format_verdict VerdictFormatter
---@class EditConfig ---@class EditConfig
@ -98,7 +83,6 @@
---@class cp.Config ---@class cp.Config
---@field languages table<string, CpLanguage> ---@field languages table<string, CpLanguage>
---@field platforms table<string, CpPlatform> ---@field platforms table<string, CpPlatform>
---@field templates? CpTemplatesConfig
---@field hooks Hooks ---@field hooks Hooks
---@field debug boolean ---@field debug boolean
---@field open_url boolean ---@field open_url boolean
@ -163,29 +147,13 @@ M.defaults = {
enabled_languages = { 'cpp', 'python' }, enabled_languages = { 'cpp', 'python' },
default_language = 'cpp', default_language = 'cpp',
}, },
kattis = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
usaco = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
}, },
hooks = { hooks = {
setup = { before_run = nil,
contest = nil, before_debug = nil,
code = nil, setup_code = nil,
io = { setup_io_input = helpers.clearcol,
input = helpers.clearcol, setup_io_output = helpers.clearcol,
output = helpers.clearcol,
},
},
on = {
enter = nil,
run = nil,
debug = nil,
},
}, },
debug = false, debug = false,
scrapers = constants.PLATFORMS, scrapers = constants.PLATFORMS,
@ -194,6 +162,8 @@ M.defaults = {
ansi = true, ansi = true,
run = { run = {
width = 0.3, width = 0.3,
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
format_verdict = helpers.default_verdict_formatter, format_verdict = helpers.default_verdict_formatter,
}, },
edit = { edit = {
@ -203,11 +173,7 @@ M.defaults = {
add_test_key = 'ga', add_test_key = 'ga',
save_and_exit_key = 'q', save_and_exit_key = 'q',
}, },
panel = { panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50 },
diff_modes = { 'side-by-side', 'git', 'vim' },
max_output_lines = 50,
precision = nil,
},
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' },
@ -249,10 +215,6 @@ local function validate_language(id, lang)
commands = { lang.commands, { 'table' } }, commands = { lang.commands, { 'table' } },
}) })
if lang.template ~= nil then
vim.validate({ template = { lang.template, 'string' } })
end
if not lang.commands.run then if not lang.commands.run then
error(('[cp.nvim] languages.%s.commands.run is required'):format(id)) error(('[cp.nvim] languages.%s.commands.run is required'):format(id))
end end
@ -291,9 +253,6 @@ local function merge_lang(base, ov)
if ov.commands then if ov.commands then
out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {}) out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {})
end end
if ov.template then
out.template = ov.template
end
return out return out
end end
@ -333,15 +292,7 @@ end
---@return cp.Config ---@return cp.Config
function M.setup(user_config) function M.setup(user_config)
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } }) vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
local defaults = vim.deepcopy(M.defaults) local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
if user_config and user_config.platforms then
for plat in pairs(defaults.platforms) do
if not user_config.platforms[plat] then
defaults.platforms[plat] = nil
end
end
end
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
if not next(cfg.languages) then if not next(cfg.languages) then
error('[cp.nvim] At least one language must be configured') error('[cp.nvim] At least one language must be configured')
@ -351,13 +302,6 @@ function M.setup(user_config)
error('[cp.nvim] At least one platform must be configured') error('[cp.nvim] At least one platform must be configured')
end end
if cfg.templates ~= nil then
vim.validate({ templates = { cfg.templates, 'table' } })
if cfg.templates.cursor_marker ~= nil then
vim.validate({ cursor_marker = { cfg.templates.cursor_marker, 'string' } })
end
end
vim.validate({ vim.validate({
hooks = { cfg.hooks, { 'table' } }, hooks = { cfg.hooks, { 'table' } },
ui = { cfg.ui, { 'table' } }, ui = { cfg.ui, { 'table' } },
@ -379,29 +323,12 @@ function M.setup(user_config)
end, end,
('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')), ('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')),
}, },
before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
setup_io_input = { cfg.hooks.setup_io_input, { 'function', 'nil' }, true },
setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true },
}) })
if cfg.hooks.setup ~= nil then
vim.validate({ setup = { cfg.hooks.setup, 'table' } })
vim.validate({
contest = { cfg.hooks.setup.contest, { 'function', 'nil' }, true },
code = { cfg.hooks.setup.code, { 'function', 'nil' }, true },
})
if cfg.hooks.setup.io ~= nil then
vim.validate({ io = { cfg.hooks.setup.io, 'table' } })
vim.validate({
input = { cfg.hooks.setup.io.input, { 'function', 'nil' }, true },
output = { cfg.hooks.setup.io.output, { 'function', 'nil' }, true },
})
end
end
if cfg.hooks.on ~= nil then
vim.validate({ on = { cfg.hooks.on, 'table' } })
vim.validate({
enter = { cfg.hooks.on.enter, { 'function', 'nil' }, true },
run = { cfg.hooks.on.run, { 'function', 'nil' }, true },
debug = { cfg.hooks.on.debug, { 'function', 'nil' }, true },
})
end
local layouts = require('cp.ui.layouts') local layouts = require('cp.ui.layouts')
vim.validate({ vim.validate({
@ -428,13 +355,6 @@ function M.setup(user_config)
end, end,
'positive integer', 'positive integer',
}, },
precision = {
cfg.ui.panel.precision,
function(v)
return v == nil or (type(v) == 'number' and v >= 0)
end,
'nil or non-negative number',
},
git = { cfg.ui.diff.git, { 'table' } }, git = { cfg.ui.diff.git, { 'table' } },
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' }, git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
width = { width = {
@ -444,6 +364,20 @@ function M.setup(user_config)
end, end,
'decimal between 0 and 1', 'decimal between 0 and 1',
}, },
next_test_key = {
cfg.ui.run.next_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
prev_test_key = {
cfg.ui.run.prev_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
format_verdict = { format_verdict = {
cfg.ui.run.format_verdict, cfg.ui.run.format_verdict,
'function', 'function',

View file

@ -1,28 +1,13 @@
local M = {} local M = {}
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses', 'kattis', 'usaco' } M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses' }
M.ACTIONS = { M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit' }
'run',
'panel',
'next',
'prev',
'pick',
'cache',
'interact',
'edit',
'race',
'stress',
'submit',
'credentials',
}
M.PLATFORM_DISPLAY_NAMES = { M.PLATFORM_DISPLAY_NAMES = {
atcoder = 'AtCoder', atcoder = 'AtCoder',
codechef = 'CodeChef', codechef = 'CodeChef',
codeforces = 'CodeForces', codeforces = 'CodeForces',
cses = 'CSES', cses = 'CSES',
kattis = 'Kattis',
usaco = 'USACO',
} }
M.CPP = 'cpp' M.CPP = 'cpp'

View file

@ -1,42 +0,0 @@
local M = {}
local cache = require('cp.cache')
local logger = require('cp.log')
local state = require('cp.state')
function M.set(platform)
platform = platform or state.get_platform()
if not platform then
logger.log('No platform specified. Usage: :CP credentials set <platform>', vim.log.levels.ERROR)
return
end
vim.ui.input({ prompt = platform .. ' username: ' }, function(username)
if not username or username == '' then
logger.log('Cancelled', vim.log.levels.WARN)
return
end
vim.fn.inputsave()
local password = vim.fn.inputsecret(platform .. ' password: ')
vim.fn.inputrestore()
if not password or password == '' then
logger.log('Cancelled', vim.log.levels.WARN)
return
end
cache.load()
cache.set_credentials(platform, { username = username, password = password })
logger.log(platform .. ' credentials saved', vim.log.levels.INFO, true)
end)
end
function M.clear(platform)
cache.load()
cache.clear_credentials(platform)
if platform then
logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true)
else
logger.log('all credentials cleared', vim.log.levels.INFO, true)
end
end
return M

View file

@ -5,50 +5,33 @@ local utils = require('cp.utils')
local function check() local function check()
vim.health.start('cp.nvim [required] ~') vim.health.start('cp.nvim [required] ~')
utils.setup_python_env()
if vim.fn.has('nvim-0.10.0') == 1 then if vim.fn.has('nvim-0.10.0') == 1 then
vim.health.ok('Neovim 0.10.0+ detected') vim.health.ok('Neovim 0.10.0+ detected')
else else
vim.health.error('cp.nvim requires Neovim 0.10.0+') vim.health.error('cp.nvim requires Neovim 0.10.0+')
end end
local uname = vim.uv.os_uname() local uname = vim.loop.os_uname()
if uname.sysname == 'Windows_NT' then if uname.sysname == 'Windows_NT' then
vim.health.error('Windows is not supported') vim.health.error('Windows is not supported')
end end
if utils.is_nix_build() then if vim.fn.executable('uv') == 1 then
local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install' vim.health.ok('uv executable found')
vim.health.ok('Nix Python environment detected (' .. source .. ')') local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
local py = utils.get_nix_python()
vim.health.info('Python: ' .. py)
local r = vim.system({ py, '--version' }, { text = true }):wait()
if r.code == 0 then if r.code == 0 then
vim.health.info('Python version: ' .. r.stdout:gsub('\n', '')) vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
end end
else else
if vim.fn.executable('uv') == 1 then vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
vim.health.ok('uv executable found') end
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
if r.code == 0 then
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
end
else
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end
if vim.fn.executable('nix') == 1 then local plugin_path = utils.get_plugin_path()
vim.health.info('nix available but Python environment not resolved via nix') local venv_dir = plugin_path .. '/.venv'
end if vim.fn.isdirectory(venv_dir) == 1 then
vim.health.ok('Python virtual environment found at ' .. venv_dir)
local plugin_path = utils.get_plugin_path() else
local venv_dir = plugin_path .. '/.venv' vim.health.info('Python virtual environment not set up (created on first scrape)')
if vim.fn.isdirectory(venv_dir) == 1 then
vim.health.ok('Python virtual environment found at ' .. venv_dir)
else
vim.health.info('Python virtual environment not set up (created on first scrape)')
end
end end
local time_cap = utils.time_capability() local time_cap = utils.time_capability()
@ -58,7 +41,7 @@ local function check()
vim.health.error('GNU time not found: ' .. (time_cap.reason or '')) vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
end end
local timeout_cap = utils.timeout_capability() local timeout_cap = utils.time_capability()
if timeout_cap.ok then if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path) vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else else

View file

@ -15,25 +15,17 @@ local initialized = false
local function ensure_initialized() local function ensure_initialized()
if initialized then if initialized then
return true return
end end
local user_config = vim.g.cp or {} local user_config = vim.g.cp_config or {}
local ok, result = pcall(config_module.setup, user_config) local config = config_module.setup(user_config)
if not ok then config_module.set_current_config(config)
local msg = tostring(result):gsub('^.+:%d+: ', '')
vim.notify(msg, vim.log.levels.ERROR)
return false
end
config_module.set_current_config(result)
initialized = true initialized = true
return true
end end
---@return nil ---@return nil
function M.handle_command(opts) function M.handle_command(opts)
if not ensure_initialized() then ensure_initialized()
return
end
local commands = require('cp.commands') local commands = require('cp.commands')
commands.handle_command(opts) commands.handle_command(opts)
end end
@ -42,13 +34,4 @@ function M.is_initialized()
return initialized return initialized
end end
---@deprecated Use `vim.g.cp` instead
function M.setup(user_config)
vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false)
if user_config then
vim.g.cp = vim.tbl_deep_extend('force', vim.g.cp or {}, user_config)
end
end
return M return M

View file

@ -1,144 +0,0 @@
local M = {}
local cache = require('cp.cache')
local constants = require('cp.constants')
local logger = require('cp.log')
local scraper = require('cp.scraper')
local race_state = {
timer = nil,
platform = nil,
contest_id = nil,
language = nil,
start_time = nil,
}
local function format_countdown(seconds)
local h = math.floor(seconds / 3600)
local m = math.floor((seconds % 3600) / 60)
local s = seconds % 60
return string.format('%02d:%02d:%02d', h, m, s)
end
function M.start(platform, contest_id, language)
if not platform or not vim.tbl_contains(constants.PLATFORMS, platform) then
logger.log('Invalid platform', vim.log.levels.ERROR)
return
end
if not contest_id or contest_id == '' then
logger.log('Contest ID required', vim.log.levels.ERROR)
return
end
if race_state.timer then
logger.log('Race already active. Use :CP race stop first.', vim.log.levels.WARN)
return
end
cache.load()
local start_time = cache.get_contest_start_time(platform, contest_id)
if not start_time then
logger.log('Fetching contest list...', vim.log.levels.INFO, true)
local contests = scraper.scrape_contest_list(platform)
if contests and #contests > 0 then
cache.set_contest_summaries(platform, contests)
start_time = cache.get_contest_start_time(platform, contest_id)
end
end
if not start_time then
logger.log(
('No start time found for %s contest %s'):format(
constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
contest_id
),
vim.log.levels.ERROR
)
return
end
local remaining = start_time - os.time()
if remaining <= 0 then
logger.log('Contest has already started, setting up...', vim.log.levels.INFO, true)
require('cp.setup').setup_contest(platform, contest_id, nil, language)
return
end
race_state.platform = platform
race_state.contest_id = contest_id
race_state.language = language
race_state.start_time = start_time
logger.log(
('Race started for %s %s — %s remaining'):format(
constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
contest_id,
format_countdown(remaining)
),
vim.log.levels.INFO,
true
)
local timer = vim.uv.new_timer()
race_state.timer = timer
timer:start(
1000,
1000,
vim.schedule_wrap(function()
local r = race_state.start_time - os.time()
if r <= 0 then
timer:stop()
timer:close()
race_state.timer = nil
local p = race_state.platform
local c = race_state.contest_id
local l = race_state.language
race_state.platform = nil
race_state.contest_id = nil
race_state.language = nil
race_state.start_time = nil
logger.log('Contest started!', vim.log.levels.INFO, true)
require('cp.setup').setup_contest(p, c, nil, l)
else
vim.notify(
('[cp.nvim] %s %s — %s'):format(
constants.PLATFORM_DISPLAY_NAMES[race_state.platform] or race_state.platform,
race_state.contest_id,
format_countdown(r)
),
vim.log.levels.INFO
)
end
end)
)
end
function M.stop()
local timer = race_state.timer
if not timer then
logger.log('No active race', vim.log.levels.WARN)
return
end
timer:stop()
timer:close()
race_state.timer = nil
race_state.platform = nil
race_state.contest_id = nil
race_state.language = nil
race_state.start_time = nil
logger.log('Race cancelled', vim.log.levels.INFO, true)
end
function M.status()
if not race_state.timer or not race_state.start_time then
return { active = false }
end
return {
active = true,
platform = race_state.platform,
contest_id = race_state.contest_id,
remaining_seconds = math.max(0, race_state.start_time - os.time()),
}
end
return M

View file

@ -43,7 +43,6 @@ end
function M.compile(compile_cmd, substitutions, on_complete) 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'
logger.log('compile: ' .. sh)
local t0 = vim.uv.hrtime() local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { text = false }, function(r) vim.system({ 'sh', '-c', sh }, { text = false }, function(r)
@ -120,7 +119,6 @@ function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete)
local sec = math.ceil(timeout_ms / 1000) local sec = math.ceil(timeout_ms / 1000)
local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec) local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec)
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)
logger.log('run: ' .. sh)
local t0 = vim.uv.hrtime() local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r) vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)

View file

@ -19,7 +19,6 @@
---@class ProblemConstraints ---@class ProblemConstraints
---@field timeout_ms number ---@field timeout_ms number
---@field memory_mb number ---@field memory_mb number
---@field precision number?
---@class PanelState ---@class PanelState
---@field test_cases RanTestCase[] ---@field test_cases RanTestCase[]
@ -57,8 +56,7 @@ local function load_constraints_from_cache(platform, contest_id, problem_id)
cache.load() cache.load()
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id) local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
if timeout_ms and memory_mb then if timeout_ms and memory_mb then
local precision = cache.get_precision(platform, contest_id, problem_id) return { timeout_ms = timeout_ms, memory_mb = memory_mb }
return { timeout_ms = timeout_ms, memory_mb = memory_mb, precision = precision }
end end
return nil return nil
end end
@ -101,53 +99,6 @@ local function build_command(cmd, substitutions)
return execute.build_command(cmd, substitutions) return execute.build_command(cmd, substitutions)
end end
---@param actual string
---@param expected string
---@param precision number?
---@return boolean
local function compare_outputs(actual, expected, precision)
local norm_actual = normalize_lines(actual)
local norm_expected = normalize_lines(expected)
if precision == nil or precision == 0 then
return norm_actual == norm_expected
end
local actual_lines = vim.split(norm_actual, '\n', { plain = true })
local expected_lines = vim.split(norm_expected, '\n', { plain = true })
if #actual_lines ~= #expected_lines then
return false
end
for i = 1, #actual_lines do
local a_tokens = vim.split(actual_lines[i], '%s+', { plain = false, trimempty = true })
local e_tokens = vim.split(expected_lines[i], '%s+', { plain = false, trimempty = true })
if #a_tokens ~= #e_tokens then
return false
end
for j = 1, #a_tokens do
local a_tok, e_tok = a_tokens[j], e_tokens[j]
local a_num = tonumber(a_tok)
local e_num = tonumber(e_tok)
if a_num ~= nil and e_num ~= nil then
if math.abs(a_num - e_num) > precision then
return false
end
else
if a_tok ~= e_tok then
return false
end
end
end
end
return true
end
---@param test_case RanTestCase ---@param test_case RanTestCase
---@param debug boolean? ---@param debug boolean?
---@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 }) ---@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 })
@ -192,9 +143,7 @@ local function run_single_test_case(test_case, debug, on_complete)
end end
local expected = test_case.expected or '' local expected = test_case.expected or ''
local precision = (panel_state.constraints and panel_state.constraints.precision) local ok = normalize_lines(out) == normalize_lines(expected)
or config.ui.panel.precision
local ok = compare_outputs(out, expected, precision)
local signal = r.signal local signal = r.signal
if not signal and r.code and r.code >= 128 then if not signal and r.code and r.code >= 128 then
@ -327,35 +276,26 @@ function M.run_all_test_cases(indices, debug, on_each, on_done)
end end
end end
if #to_run == 0 then local function run_next(pos)
logger.log( if pos > #to_run then
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0), logger.log(
vim.log.levels.INFO, ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run),
true vim.log.levels.INFO,
) true
on_done(panel_state.test_cases) )
return on_done(panel_state.test_cases)
end return
end
local total = #to_run M.run_test_case(to_run[pos], debug, function()
local remaining = total
for _, idx in ipairs(to_run) do
M.run_test_case(idx, debug, function()
if on_each then if on_each then
on_each(idx, total) on_each(pos, #to_run)
end
remaining = remaining - 1
if remaining == 0 then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
end end
run_next(pos + 1)
end) end)
end end
run_next(1)
end end
---@return PanelState ---@return PanelState

View file

@ -5,96 +5,67 @@ local logger = require('cp.log')
local utils = require('cp.utils') local utils = require('cp.utils')
local function syshandle(result) local function syshandle(result)
local ok, data = pcall(vim.json.decode, result.stdout or '')
if ok then
return { success = true, data = data }
end
if result.code ~= 0 then if result.code ~= 0 then
local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error') local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error')
return { success = false, error = msg } return { success = false, error = msg }
end end
local msg = 'Failed to parse scraper output: ' .. tostring(data) local ok, data = pcall(vim.json.decode, result.stdout)
logger.log(msg, vim.log.levels.ERROR) if not ok then
return { success = false, error = msg } local msg = 'Failed to parse scraper output: ' .. tostring(data)
end logger.log(msg, vim.log.levels.ERROR)
return { success = false, error = msg }
---@param env_map table<string, string>
---@return string[]
local function spawn_env_list(env_map)
local out = {}
for key, value in pairs(env_map) do
out[#out + 1] = tostring(key) .. '=' .. tostring(value)
end end
return out
return { success = true, data = data }
end end
---@param platform string ---@param platform string
---@param subcommand string ---@param subcommand string
---@param args string[] ---@param args string[]
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table), env_extra?: table<string, string>, stdin?: string } ---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) }
local function run_scraper(platform, subcommand, args, opts) local function run_scraper(platform, subcommand, args, opts)
if not utils.setup_python_env() then
local msg = 'no Python environment available (install uv or nix)'
logger.log(msg, vim.log.levels.ERROR)
if opts and opts.on_exit then
opts.on_exit({ success = false, error = msg })
end
return { success = false, error = msg }
end
local plugin_path = utils.get_plugin_path() local plugin_path = utils.get_plugin_path()
local cmd = utils.get_python_cmd(platform, plugin_path) local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand }
vim.list_extend(cmd, { subcommand })
vim.list_extend(cmd, args) vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ() local env = vim.fn.environ()
env.VIRTUAL_ENV = '' env.VIRTUAL_ENV = ''
env.PYTHONPATH = '' env.PYTHONPATH = ''
env.CONDA_PREFIX = '' env.CONDA_PREFIX = ''
if opts and opts.env_extra then
for k, v in pairs(opts.env_extra) do
env[k] = v
end
end
if opts and opts.ndjson then if opts and opts.ndjson then
local uv = vim.uv local uv = vim.loop
local stdout = uv.new_pipe(false) local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false) local stderr = uv.new_pipe(false)
local buf = '' local buf = ''
local handle local handle
handle = uv.spawn(cmd[1], { handle = uv.spawn(
args = vim.list_slice(cmd, 2), cmd[1],
stdio = { nil, stdout, stderr }, { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env },
env = spawn_env_list(env), function(code, signal)
cwd = plugin_path, if buf ~= '' and opts.on_event then
}, function(code, signal) local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if buf ~= '' and opts.on_event then if ok_tail then
local ok_tail, ev_tail = pcall(vim.json.decode, buf) opts.on_event(ev_tail)
if ok_tail then end
opts.on_event(ev_tail) buf = ''
end
if opts.on_exit then
opts.on_exit({ success = (code == 0), code = code, signal = signal })
end
if not stdout:is_closing() then
stdout:close()
end
if not stderr:is_closing() then
stderr:close()
end
if handle and not handle:is_closing() then
handle:close()
end end
buf = ''
end end
if opts.on_exit then )
opts.on_exit({ success = (code == 0), code = code, signal = signal })
end
if not stdout:is_closing() then
stdout:close()
end
if not stderr:is_closing() then
stderr:close()
end
if handle and not handle:is_closing() then
handle:close()
end
end)
if not handle then if not handle then
logger.log('Failed to start scraper process', vim.log.levels.ERROR) logger.log('Failed to start scraper process', vim.log.levels.ERROR)
@ -131,10 +102,7 @@ local function run_scraper(platform, subcommand, args, opts)
return return
end end
local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path } local sysopts = { text = true, timeout = 30000, env = env }
if opts and opts.stdin then
sysopts.stdin = opts.stdin
end
if opts and opts.sync then if opts and opts.sync then
local result = vim.system(cmd, sysopts):wait() local result = vim.system(cmd, sysopts):wait()
return syshandle(result) return syshandle(result)
@ -237,7 +205,6 @@ function M.scrape_all_tests(platform, contest_id, callback)
memory_mb = ev.memory_mb or 0, memory_mb = ev.memory_mb or 0,
interactive = ev.interactive or false, interactive = ev.interactive or false,
multi_test = ev.multi_test or false, multi_test = ev.multi_test or false,
precision = ev.precision ~= vim.NIL and ev.precision or nil,
problem_id = ev.problem_id, problem_id = ev.problem_id,
}) })
end end
@ -246,21 +213,4 @@ function M.scrape_all_tests(platform, contest_id, callback)
}) })
end end
function M.submit(platform, contest_id, problem_id, language, source_code, credentials, callback)
local creds_json = vim.json.encode(credentials)
run_scraper(platform, 'submit', { contest_id, problem_id, language }, {
stdin = source_code,
env_extra = { CP_CREDENTIALS = creds_json },
on_exit = function(result)
if type(callback) == 'function' then
if result and result.success then
callback(result.data or { success = true })
else
callback({ success = false, error = result and result.error or 'unknown' })
end
end
end,
})
end
return M return M

View file

@ -8,36 +8,6 @@ local logger = require('cp.log')
local scraper = require('cp.scraper') local scraper = require('cp.scraper')
local state = require('cp.state') local state = require('cp.state')
local function apply_template(bufnr, lang_id, platform)
local config = config_module.get_config()
local eff = config.runtime.effective[platform] and config.runtime.effective[platform][lang_id]
if not eff or not eff.template then
return
end
local path = vim.fn.expand(eff.template)
if vim.fn.filereadable(path) ~= 1 then
logger.log(('[cp.nvim] template not readable: %s'):format(path), vim.log.levels.WARN)
return
end
local lines = vim.fn.readfile(path)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
local marker = config.templates and config.templates.cursor_marker
if marker then
for lnum, line in ipairs(lines) do
local col = line:find(marker, 1, true)
if col then
local new_line = line:sub(1, col - 1) .. line:sub(col + #marker)
vim.api.nvim_buf_set_lines(bufnr, lnum - 1, lnum, false, { new_line })
local winid = vim.fn.bufwinid(bufnr)
if winid ~= -1 then
vim.api.nvim_win_set_cursor(winid, { lnum, col - 1 })
end
break
end
end
end
end
---Get the language of the current file from cache ---Get the language of the current file from cache
---@return string? ---@return string?
local function get_current_file_language() local function get_current_file_language()
@ -130,8 +100,7 @@ local function start_tests(platform, contest_id, problems)
ev.timeout_ms or 0, ev.timeout_ms or 0,
ev.memory_mb or 0, ev.memory_mb or 0,
ev.interactive, ev.interactive,
ev.multi_test, ev.multi_test
ev.precision
) )
local io_state = state.get_io_view_state() local io_state = state.get_io_view_state()
@ -152,7 +121,6 @@ end
---@param language? string ---@param language? string
function M.setup_contest(platform, contest_id, problem_id, language) function M.setup_contest(platform, contest_id, problem_id, language)
local old_platform, old_contest_id = state.get_platform(), state.get_contest_id() local old_platform, old_contest_id = state.get_platform(), state.get_contest_id()
local old_problem_id = state.get_problem_id()
state.set_platform(platform) state.set_platform(platform)
state.set_contest_id(contest_id) state.set_contest_id(contest_id)
@ -165,7 +133,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
end end
end end
local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id
cache.load() cache.load()
@ -175,10 +143,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
M.setup_problem(pid, language) M.setup_problem(pid, language)
start_tests(platform, contest_id, problems) start_tests(platform, contest_id, problems)
local is_new_problem = old_problem_id ~= pid if config_module.get_config().open_url and is_new_contest and contest_data.url then
local should_open_url = config_module.get_config().open_url
and (is_new_contest or is_new_problem)
if should_open_url and contest_data.url then
vim.ui.open(contest_data.url:format(pid)) vim.ui.open(contest_data.url:format(pid))
end end
end end
@ -195,7 +160,12 @@ function M.setup_contest(platform, contest_id, problem_id, language)
vim.bo[bufnr].buftype = '' vim.bo[bufnr].buftype = ''
vim.bo[bufnr].swapfile = false vim.bo[bufnr].swapfile = false
state.set_language(lang) if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
local ok = pcall(cfg.hooks.setup_code, state)
if ok then
vim.b[bufnr].cp_setup_done = true
end
end
state.set_provisional({ state.set_provisional({
bufnr = bufnr, bufnr = bufnr,
@ -203,7 +173,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
contest_id = contest_id, contest_id = contest_id,
language = lang, language = lang,
requested_problem_id = problem_id, requested_problem_id = problem_id,
token = vim.uv.hrtime(), token = vim.loop.hrtime(),
}) })
logger.log('Fetching contests problems...', vim.log.levels.INFO, true) logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
@ -275,15 +245,7 @@ function M.setup_problem(problem_id, language)
return return
end end
local contest_dir = vim.fn.fnamemodify(source_file, ':h') vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p')
local is_new_dir = vim.fn.isdirectory(contest_dir) == 0
vim.fn.mkdir(contest_dir, 'p')
if is_new_dir then
local s = config.hooks and config.hooks.setup
if s and s.contest then
pcall(s.contest, state)
end
end
local prov = state.get_provisional() local prov = state.get_provisional()
if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then
@ -302,29 +264,14 @@ function M.setup_problem(problem_id, language)
mods = { silent = true, noautocmd = true, keepalt = true }, mods = { silent = true, noautocmd = true, keepalt = true },
}) })
state.set_solution_win(vim.api.nvim_get_current_win()) state.set_solution_win(vim.api.nvim_get_current_win())
if not vim.b[prov.bufnr].cp_setup_done then if config.hooks and config.hooks.setup_code and not vim.b[prov.bufnr].cp_setup_done then
apply_template(prov.bufnr, lang, platform) local ok = pcall(config.hooks.setup_code, state)
local s = config.hooks and config.hooks.setup if ok then
if s and s.code then
local ok = pcall(s.code, state)
if ok then
vim.b[prov.bufnr].cp_setup_done = true
end
else
helpers.clearcol(prov.bufnr)
vim.b[prov.bufnr].cp_setup_done = true vim.b[prov.bufnr].cp_setup_done = true
end end
local o = config.hooks and config.hooks.on elseif not vim.b[prov.bufnr].cp_setup_done then
if o and o.enter then helpers.clearcol(prov.bufnr)
local bufnr = prov.bufnr vim.b[prov.bufnr].cp_setup_done = true
vim.api.nvim_create_autocmd('BufEnter', {
buffer = bufnr,
callback = function()
pcall(o.enter, state)
end,
})
pcall(o.enter, state)
end
end end
cache.set_file_state( cache.set_file_state(
vim.fn.fnamemodify(source_file, ':p'), vim.fn.fnamemodify(source_file, ':p'),
@ -347,32 +294,14 @@ function M.setup_problem(problem_id, language)
local bufnr = vim.api.nvim_get_current_buf() local bufnr = vim.api.nvim_get_current_buf()
state.set_solution_win(vim.api.nvim_get_current_win()) state.set_solution_win(vim.api.nvim_get_current_win())
require('cp.ui.views').ensure_io_view() require('cp.ui.views').ensure_io_view()
if not vim.b[bufnr].cp_setup_done then if config.hooks and config.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
local is_new = vim.api.nvim_buf_line_count(bufnr) == 1 local ok = pcall(config.hooks.setup_code, state)
and vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] == '' if ok then
if is_new then
apply_template(bufnr, lang, platform)
end
local s = config.hooks and config.hooks.setup
if s and s.code then
local ok = pcall(s.code, state)
if ok then
vim.b[bufnr].cp_setup_done = true
end
else
helpers.clearcol(bufnr)
vim.b[bufnr].cp_setup_done = true vim.b[bufnr].cp_setup_done = true
end end
local o = config.hooks and config.hooks.on elseif not vim.b[bufnr].cp_setup_done then
if o and o.enter then helpers.clearcol(bufnr)
vim.api.nvim_create_autocmd('BufEnter', { vim.b[bufnr].cp_setup_done = true
buffer = bufnr,
callback = function()
pcall(o.enter, state)
end,
})
pcall(o.enter, state)
end
end end
cache.set_file_state( cache.set_file_state(
vim.fn.expand('%:p'), vim.fn.expand('%:p'),

View file

@ -9,6 +9,7 @@
---@class cp.IoViewState ---@class cp.IoViewState
---@field output_buf integer ---@field output_buf integer
---@field input_buf integer ---@field input_buf integer
---@field current_test_index integer?
---@field source_buf integer? ---@field source_buf integer?
---@class cp.State ---@class cp.State

View file

@ -1,235 +0,0 @@
local M = {}
local logger = require('cp.log')
local state = require('cp.state')
local utils = require('cp.utils')
local GENERATOR_PATTERNS = {
'gen.py',
'gen.cc',
'gen.cpp',
'generator.py',
'generator.cc',
'generator.cpp',
}
local BRUTE_PATTERNS = {
'brute.py',
'brute.cc',
'brute.cpp',
'slow.py',
'slow.cc',
'slow.cpp',
}
local function find_file(patterns)
for _, pattern in ipairs(patterns) do
if vim.fn.filereadable(pattern) == 1 then
return pattern
end
end
return nil
end
local function compile_cpp(source, output)
local result = vim.system({ 'sh', '-c', 'g++ -O2 -o ' .. output .. ' ' .. source }):wait()
if result.code ~= 0 then
logger.log(
('Failed to compile %s: %s'):format(source, result.stderr or ''),
vim.log.levels.ERROR
)
return false
end
return true
end
local function build_run_cmd(file)
local ext = file:match('%.([^%.]+)$')
if ext == 'cc' or ext == 'cpp' then
local base = file:gsub('%.[^%.]+$', '')
local bin = base .. '_bin'
if not compile_cpp(file, bin) then
return nil
end
return './' .. bin
elseif ext == 'py' then
return 'python3 ' .. file
end
return './' .. file
end
function M.toggle(generator_cmd, brute_cmd)
if state.get_active_panel() == 'stress' then
if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then
local job = vim.b[state.stress_buf].terminal_job_id
if job then
vim.fn.jobstop(job)
end
end
if state.saved_stress_session then
vim.cmd.source(state.saved_stress_session)
vim.fn.delete(state.saved_stress_session)
state.saved_stress_session = nil
end
state.set_active_panel(nil)
return
end
if state.get_active_panel() then
logger.log('Another panel is already active.', vim.log.levels.WARN)
return
end
local gen_file = generator_cmd
local brute_file = brute_cmd
if not gen_file then
gen_file = find_file(GENERATOR_PATTERNS)
end
if not brute_file then
brute_file = find_file(BRUTE_PATTERNS)
end
if not gen_file then
logger.log(
'No generator found. Pass generator as first arg or add gen.{py,cc,cpp}.',
vim.log.levels.ERROR
)
return
end
if not brute_file then
logger.log(
'No brute solution found. Pass brute as second arg or add brute.{py,cc,cpp}.',
vim.log.levels.ERROR
)
return
end
local gen_cmd = build_run_cmd(gen_file)
if not gen_cmd then
return
end
local brute_run_cmd = build_run_cmd(brute_file)
if not brute_run_cmd then
return
end
state.saved_stress_session = vim.fn.tempname()
-- selene: allow(mixed_table)
vim.cmd.mksession({ state.saved_stress_session, bang = true })
vim.cmd.only({ mods = { silent = true } })
local execute = require('cp.runner.execute')
local function restore_session()
if state.saved_stress_session then
vim.cmd.source(state.saved_stress_session)
vim.fn.delete(state.saved_stress_session)
state.saved_stress_session = nil
end
end
execute.compile_problem(false, function(compile_result)
if not compile_result.success then
local run = require('cp.runner.run')
run.handle_compilation_failure(compile_result.output)
restore_session()
return
end
local binary = state.get_binary_file()
if not binary or binary == '' then
logger.log('No binary produced.', vim.log.levels.ERROR)
restore_session()
return
end
local script = vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/stress.py', ':p')
local cmdline
if utils.is_nix_build() then
cmdline = table.concat({
vim.fn.shellescape(utils.get_nix_python()),
vim.fn.shellescape(script),
vim.fn.shellescape(gen_cmd),
vim.fn.shellescape(brute_run_cmd),
vim.fn.shellescape(binary),
}, ' ')
else
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(script),
vim.fn.shellescape(gen_cmd),
vim.fn.shellescape(brute_run_cmd),
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
restore_session()
state.stress_buf = nil
state.stress_win = nil
state.set_active_panel(nil)
end
vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, {
buffer = term_buf,
callback = cleanup,
})
vim.api.nvim_create_autocmd('WinClosed', {
callback = function()
if cleaned then
return
end
local any = false
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then
any = true
break
end
end
if not any then
cleanup()
end
end,
})
vim.api.nvim_create_autocmd('TermClose', {
buffer = term_buf,
callback = function()
vim.b[term_buf].cp_stress_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.stress_buf = term_buf
state.stress_win = term_win
state.set_active_panel('stress')
end)
end
return M

View file

@ -1,74 +0,0 @@
local M = {}
local cache = require('cp.cache')
local logger = require('cp.log')
local state = require('cp.state')
local function prompt_credentials(platform, callback)
local saved = cache.get_credentials(platform)
if saved and saved.username and saved.password then
callback(saved)
return
end
vim.ui.input({ prompt = platform .. ' username: ' }, function(username)
if not username or username == '' then
logger.log('Submit cancelled', vim.log.levels.WARN)
return
end
vim.fn.inputsave()
local password = vim.fn.inputsecret(platform .. ' password: ')
vim.fn.inputrestore()
if not password or password == '' then
logger.log('Submit cancelled', vim.log.levels.WARN)
return
end
local creds = { username = username, password = password }
cache.set_credentials(platform, creds)
callback(creds)
end)
end
function M.submit(opts)
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
local language = (opts and opts.language) or state.get_language()
if not platform or not contest_id or not problem_id or not language then
logger.log('No active problem. Use :CP <platform> <contest> first.', vim.log.levels.ERROR)
return
end
local source_file = state.get_source_file()
if not source_file or vim.fn.filereadable(source_file) ~= 1 then
logger.log('Source file not found', vim.log.levels.ERROR)
return
end
prompt_credentials(platform, function(creds)
local source_lines = vim.fn.readfile(source_file)
local source_code = table.concat(source_lines, '\n')
require('cp.scraper').submit(
platform,
contest_id,
problem_id,
language,
source_code,
creds,
function(result)
vim.schedule(function()
if result and result.success then
logger.log('Submitted successfully', vim.log.levels.INFO, true)
else
logger.log(
'Submit failed: ' .. (result and result.error or 'unknown error'),
vim.log.levels.ERROR
)
end
end)
end
)
end)
end
return M

View file

@ -144,7 +144,7 @@ local function add_new_test()
vim.api.nvim_win_set_buf(input_win, input_buf) vim.api.nvim_win_set_buf(input_win, input_buf)
vim.bo[input_buf].modifiable = true vim.bo[input_buf].modifiable = true
vim.bo[input_buf].readonly = false vim.bo[input_buf].readonly = false
vim.bo[input_buf].buftype = 'acwrite' vim.bo[input_buf].buftype = 'nofile'
vim.bo[input_buf].buflisted = false vim.bo[input_buf].buflisted = false
helpers.clearcol(input_buf) helpers.clearcol(input_buf)
@ -155,7 +155,7 @@ local function add_new_test()
vim.api.nvim_win_set_buf(expected_win, expected_buf) vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.bo[expected_buf].modifiable = true vim.bo[expected_buf].modifiable = true
vim.bo[expected_buf].readonly = false vim.bo[expected_buf].readonly = false
vim.bo[expected_buf].buftype = 'acwrite' vim.bo[expected_buf].buftype = 'nofile'
vim.bo[expected_buf].buflisted = false vim.bo[expected_buf].buflisted = false
helpers.clearcol(expected_buf) helpers.clearcol(expected_buf)
@ -177,80 +177,6 @@ local function add_new_test()
logger.log(('Added test %d'):format(new_index)) logger.log(('Added test %d'):format(new_index))
end end
local function save_all_tests()
if not edit_state then
return
end
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
if not platform or not contest_id or not problem_id then
return
end
for i, pair in ipairs(edit_state.test_buffers) do
if
vim.api.nvim_buf_is_valid(pair.input_buf) and vim.api.nvim_buf_is_valid(pair.expected_buf)
then
local input_lines = vim.api.nvim_buf_get_lines(pair.input_buf, 0, -1, false)
local expected_lines = vim.api.nvim_buf_get_lines(pair.expected_buf, 0, -1, false)
edit_state.test_cases[i].input = table.concat(input_lines, '\n')
edit_state.test_cases[i].expected = table.concat(expected_lines, '\n')
end
end
local contest_data = cache.get_contest_data(platform, contest_id)
local is_multi_test = contest_data.problems[contest_data.index_map[problem_id]].multi_test
or false
local combined_input = table.concat(
vim.tbl_map(function(tc)
return tc.input
end, edit_state.test_cases),
'\n'
)
local combined_expected = table.concat(
vim.tbl_map(function(tc)
return tc.expected
end, edit_state.test_cases),
'\n'
)
cache.set_test_cases(
platform,
contest_id,
problem_id,
{ input = combined_input, expected = combined_expected },
edit_state.test_cases,
edit_state.constraints and edit_state.constraints.timeout_ms or 0,
edit_state.constraints and edit_state.constraints.memory_mb or 0,
false,
is_multi_test
)
local config = config_module.get_config()
local base_name = config.filename and config.filename(platform, contest_id, problem_id, config)
or config_module.default_filename(contest_id, problem_id)
vim.fn.mkdir('io', 'p')
for i, tc in ipairs(edit_state.test_cases) do
local input_file = string.format('io/%s.%d.cpin', base_name, i)
local expected_file = string.format('io/%s.%d.cpout', base_name, i)
local input_content = (tc.input or ''):gsub('\r', '')
local expected_content = (tc.expected or ''):gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n', { trimempty = true }), input_file)
vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file)
end
logger.log('Saved all test cases')
end
---@param buf integer ---@param buf integer
setup_keybindings = function(buf) setup_keybindings = function(buf)
local config = config_module.get_config() local config = config_module.get_config()
@ -317,30 +243,86 @@ setup_keybindings = function(buf)
end) end)
end, end,
}) })
end
vim.api.nvim_create_autocmd('BufWriteCmd', { local function save_all_tests()
group = augroup, if not edit_state then
buffer = buf, return
callback = function() end
save_all_tests()
vim.bo[buf].modified = false local platform = state.get_platform()
end, local contest_id = state.get_contest_id()
}) local problem_id = state.get_problem_id()
if not platform or not contest_id or not problem_id then
return
end
for i, pair in ipairs(edit_state.test_buffers) do
if
vim.api.nvim_buf_is_valid(pair.input_buf) and vim.api.nvim_buf_is_valid(pair.expected_buf)
then
local input_lines = vim.api.nvim_buf_get_lines(pair.input_buf, 0, -1, false)
local expected_lines = vim.api.nvim_buf_get_lines(pair.expected_buf, 0, -1, false)
edit_state.test_cases[i].input = table.concat(input_lines, '\n')
edit_state.test_cases[i].expected = table.concat(expected_lines, '\n')
end
end
local contest_data = cache.get_contest_data(platform, contest_id)
local is_multi_test = contest_data.problems[contest_data.index_map[problem_id]].multi_test
or false
-- Generate combined test from individual test cases
local combined_input = table.concat(
vim.tbl_map(function(tc)
return tc.input
end, edit_state.test_cases),
'\n'
)
local combined_expected = table.concat(
vim.tbl_map(function(tc)
return tc.expected
end, edit_state.test_cases),
'\n'
)
cache.set_test_cases(
platform,
contest_id,
problem_id,
{ input = combined_input, expected = combined_expected },
edit_state.test_cases,
edit_state.constraints and edit_state.constraints.timeout_ms or 0,
edit_state.constraints and edit_state.constraints.memory_mb or 0,
false,
is_multi_test
)
local config = config_module.get_config()
local base_name = config.filename and config.filename(platform, contest_id, problem_id, config)
or config_module.default_filename(contest_id, problem_id)
vim.fn.mkdir('io', 'p')
for i, tc in ipairs(edit_state.test_cases) do
local input_file = string.format('io/%s.%d.cpin', base_name, i)
local expected_file = string.format('io/%s.%d.cpout', base_name, i)
local input_content = (tc.input or ''):gsub('\r', '')
local expected_content = (tc.expected or ''):gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n', { trimempty = true }), input_file)
vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file)
end
logger.log('Saved all test cases')
end end
function M.toggle_edit(test_index) function M.toggle_edit(test_index)
if edit_state then if edit_state then
save_all_tests() save_all_tests()
for _, pair in ipairs(edit_state.test_buffers) do
if vim.api.nvim_buf_is_valid(pair.input_buf) then
vim.api.nvim_buf_delete(pair.input_buf, { force = true })
end
if vim.api.nvim_buf_is_valid(pair.expected_buf) then
vim.api.nvim_buf_delete(pair.expected_buf, { force = true })
end
end
edit_state = nil edit_state = nil
pcall(vim.api.nvim_clear_autocmds, { group = 'cp_edit_guard' }) pcall(vim.api.nvim_clear_autocmds, { group = 'cp_edit_guard' })
@ -429,7 +411,7 @@ function M.toggle_edit(test_index)
vim.api.nvim_win_set_buf(input_win, input_buf) vim.api.nvim_win_set_buf(input_win, input_buf)
vim.bo[input_buf].modifiable = true vim.bo[input_buf].modifiable = true
vim.bo[input_buf].readonly = false vim.bo[input_buf].readonly = false
vim.bo[input_buf].buftype = 'acwrite' vim.bo[input_buf].buftype = 'nofile'
vim.bo[input_buf].buflisted = false vim.bo[input_buf].buflisted = false
helpers.clearcol(input_buf) helpers.clearcol(input_buf)
@ -439,7 +421,7 @@ function M.toggle_edit(test_index)
vim.api.nvim_win_set_buf(expected_win, expected_buf) vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.bo[expected_buf].modifiable = true vim.bo[expected_buf].modifiable = true
vim.bo[expected_buf].readonly = false vim.bo[expected_buf].readonly = false
vim.bo[expected_buf].buftype = 'acwrite' vim.bo[expected_buf].buftype = 'nofile'
vim.bo[expected_buf].buflisted = false vim.bo[expected_buf].buflisted = false
helpers.clearcol(expected_buf) helpers.clearcol(expected_buf)

View file

@ -2,7 +2,6 @@ local M = {}
---@class PanelOpts ---@class PanelOpts
---@field debug? boolean ---@field debug? boolean
---@field test_index? integer
local cache = require('cp.cache') local cache = require('cp.cache')
local config_module = require('cp.config') local config_module = require('cp.config')
@ -27,8 +26,6 @@ function M.disable()
M.toggle_panel() M.toggle_panel()
elseif active_panel == 'interactive' then elseif active_panel == 'interactive' then
M.toggle_interactive() M.toggle_interactive()
elseif active_panel == 'stress' then
require('cp.stress').toggle()
else else
logger.log(('Unknown panel type: %s'):format(tostring(active_panel))) logger.log(('Unknown panel type: %s'):format(tostring(active_panel)))
end end
@ -70,11 +67,11 @@ function M.toggle_interactive(interactor_cmd)
cache.load() cache.load()
local contest_data = cache.get_contest_data(platform, contest_id) local contest_data = cache.get_contest_data(platform, contest_id)
if if
contest_data not contest_data
and contest_data.index_map or not contest_data.index_map
and not contest_data.problems[contest_data.index_map[problem_id]].interactive or not contest_data.problems[contest_data.index_map[problem_id]].interactive
then then
logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR) logger.log('This problem is interactive. Use :CP interact.', vim.log.levels.ERROR)
return return
end end
@ -124,22 +121,13 @@ function M.toggle_interactive(interactor_cmd)
end end
local orchestrator = local orchestrator =
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p') vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
if utils.is_nix_build() then cmdline = table.concat({
cmdline = table.concat({ 'uv',
vim.fn.shellescape(utils.get_nix_python()), 'run',
vim.fn.shellescape(orchestrator), vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor), vim.fn.shellescape(interactor),
vim.fn.shellescape(binary), vim.fn.shellescape(binary),
}, ' ') }, ' ')
else
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
end
else else
cmdline = vim.fn.shellescape(binary) cmdline = vim.fn.shellescape(binary)
end end
@ -241,6 +229,7 @@ local function get_or_create_io_buffers()
state.set_io_view_state({ state.set_io_view_state({
output_buf = output_buf, output_buf = output_buf,
input_buf = input_buf, input_buf = input_buf,
current_test_index = 1,
source_buf = current_source_buf, source_buf = current_source_buf,
}) })
@ -305,6 +294,49 @@ local function get_or_create_io_buffers()
end, end,
}) })
local cfg = config_module.get_config()
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
local function navigate_test(delta)
local io_view_state = state.get_io_view_state()
if not io_view_state then
return
end
if not platform or not contest_id or not problem_id then
return
end
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if not test_cases or #test_cases == 0 then
return
end
local new_index = (io_view_state.current_test_index or 1) + delta
if new_index < 1 or new_index > #test_cases then
return
end
io_view_state.current_test_index = new_index
M.run_io_view(new_index)
end
if cfg.ui.run.next_test_key then
vim.keymap.set('n', cfg.ui.run.next_test_key, function()
navigate_test(1)
end, { buffer = output_buf, silent = true, desc = 'Next test' })
vim.keymap.set('n', cfg.ui.run.next_test_key, function()
navigate_test(1)
end, { buffer = input_buf, silent = true, desc = 'Next test' })
end
if cfg.ui.run.prev_test_key then
vim.keymap.set('n', cfg.ui.run.prev_test_key, function()
navigate_test(-1)
end, { buffer = output_buf, silent = true, desc = 'Previous test' })
vim.keymap.set('n', cfg.ui.run.prev_test_key, function()
navigate_test(-1)
end, { buffer = input_buf, silent = true, desc = 'Previous test' })
end
return output_buf, input_buf return output_buf, input_buf
end end
@ -403,12 +435,12 @@ function M.ensure_io_view()
local cfg = config_module.get_config() local cfg = config_module.get_config()
local io = cfg.hooks and cfg.hooks.setup and cfg.hooks.setup.io if cfg.hooks and cfg.hooks.setup_io_output then
if io and io.output then pcall(cfg.hooks.setup_io_output, output_buf, state)
pcall(io.output, output_buf, state)
end end
if io and io.input then
pcall(io.input, input_buf, state) if cfg.hooks and cfg.hooks.setup_io_input then
pcall(cfg.hooks.setup_io_input, input_buf, state)
end end
local test_cases = cache.get_test_cases(platform, contest_id, problem_id) local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
@ -789,7 +821,6 @@ function M.toggle_panel(panel_opts)
local contest_data = cache.get_contest_data(platform, contest_id) local contest_data = cache.get_contest_data(platform, contest_id)
if if
contest_data contest_data
and contest_data.index_map
and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive
then then
logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN) logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN)
@ -807,13 +838,6 @@ function M.toggle_panel(panel_opts)
return return
end end
if panel_opts and panel_opts.test_index then
local test_state = run.get_panel_state()
if panel_opts.test_index >= 1 and panel_opts.test_index <= #test_state.test_cases then
test_state.current_index = panel_opts.test_index
end
end
local io_state = state.get_io_view_state() local io_state = state.get_io_view_state()
if io_state then if io_state then
for _, win in ipairs(vim.api.nvim_list_wins()) do for _, win in ipairs(vim.api.nvim_list_wins()) do
@ -925,15 +949,14 @@ function M.toggle_panel(panel_opts)
setup_keybindings_for_buffer(test_buffers.tab_buf) setup_keybindings_for_buffer(test_buffers.tab_buf)
local o = config.hooks and config.hooks.on if config.hooks and config.hooks.before_run then
if o and o.run then vim.schedule_wrap(function()
vim.schedule(function() config.hooks.before_run(state)
o.run(state)
end) end)
end end
if panel_opts and panel_opts.debug and o and o.debug then if panel_opts and panel_opts.debug and config.hooks and config.hooks.before_debug then
vim.schedule(function() vim.schedule_wrap(function()
o.debug(state) config.hooks.before_debug(state)
end) end)
end end

View file

@ -2,10 +2,7 @@ local M = {}
local logger = require('cp.log') local logger = require('cp.log')
local _nix_python = nil local uname = vim.loop.os_uname()
local _nix_discovered = false
local uname = vim.uv.os_uname()
local _time_cached = false local _time_cached = false
local _time_path = nil local _time_path = nil
@ -60,11 +57,7 @@ local function find_gnu_time()
_time_cached = true _time_cached = true
_time_path = nil _time_path = nil
if uname and uname.sysname == 'Darwin' then _time_reason = 'GNU time not found'
_time_reason = 'GNU time not found (install via: brew install coreutils)'
else
_time_reason = 'GNU time not found'
end
return _time_path, _time_reason return _time_path, _time_reason
end end
@ -86,103 +79,27 @@ function M.get_plugin_path()
return vim.fn.fnamemodify(plugin_path, ':h:h:h') return vim.fn.fnamemodify(plugin_path, ':h:h:h')
end end
---@return boolean
function M.is_nix_build()
return _nix_python ~= nil
end
---@return string|nil
function M.get_nix_python()
return _nix_python
end
---@return boolean
function M.is_nix_discovered()
return _nix_discovered
end
---@param module string
---@param plugin_path string
---@return string[]
function M.get_python_cmd(module, plugin_path)
if _nix_python then
return { _nix_python, '-m', 'scrapers.' .. module }
end
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
end
local python_env_setup = false local python_env_setup = false
---@return boolean
local function discover_nix_python()
local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim'
local cache_file = cache_dir .. '/nix-python'
local f = io.open(cache_file, 'r')
if f then
local cached = f:read('*l')
f:close()
if cached and vim.fn.executable(cached) == 1 then
_nix_python = cached
return true
end
end
local plugin_path = M.get_plugin_path()
vim.notify('[cp.nvim] Building Python environment with nix...', vim.log.levels.INFO)
vim.cmd.redraw()
local result = vim
.system(
{ 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' },
{ text = true }
)
:wait()
if result.code ~= 0 then
logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN)
return false
end
local store_path = result.stdout:gsub('%s+$', '')
local python_path = store_path .. '/bin/python3'
if vim.fn.executable(python_path) ~= 1 then
logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN)
return false
end
vim.fn.mkdir(cache_dir, 'p')
f = io.open(cache_file, 'w')
if f then
f:write(python_path)
f:close()
end
_nix_python = python_path
_nix_discovered = true
return true
end
---@return boolean success ---@return boolean success
function M.setup_python_env() function M.setup_python_env()
if python_env_setup then if python_env_setup then
return true return true
end end
if _nix_python then local plugin_path = M.get_plugin_path()
logger.log('Python env: nix (python=' .. _nix_python .. ')') local venv_dir = plugin_path .. '/.venv'
python_env_setup = true
return true if vim.fn.executable('uv') == 0 then
logger.log(
'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/',
vim.log.levels.WARN
)
return false
end end
local on_nixos = vim.fn.filereadable('/etc/NIXOS') == 1 if vim.fn.isdirectory(venv_dir) == 0 then
logger.log('Setting up Python environment for scrapers...')
if not on_nixos and vim.fn.executable('uv') == 1 then
local plugin_path = M.get_plugin_path()
logger.log('Python env: uv sync (dir=' .. plugin_path .. ')')
vim.notify('[cp.nvim] Setting up Python environment...', vim.log.levels.INFO)
vim.cmd.redraw()
local env = vim.fn.environ() local env = vim.fn.environ()
env.VIRTUAL_ENV = '' env.VIRTUAL_ENV = ''
env.PYTHONPATH = '' env.PYTHONPATH = ''
@ -191,33 +108,14 @@ function M.setup_python_env()
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
:wait() :wait()
if result.code ~= 0 then if result.code ~= 0 then
logger.log( logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
'Failed to setup Python environment: ' .. (result.stderr or ''),
vim.log.levels.ERROR
)
return false return false
end end
if result.stderr and result.stderr ~= '' then logger.log('Python environment setup complete.')
logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', ''))
end
python_env_setup = true
return true
end end
if vim.fn.executable('nix') == 1 then python_env_setup = true
logger.log('Python env: nix discovery') return true
if discover_nix_python() then
python_env_setup = true
return true
end
end
logger.log(
'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.',
vim.log.levels.WARN
)
return false
end end
--- Configure the buffer with good defaults --- Configure the buffer with good defaults
@ -264,12 +162,20 @@ function M.check_required_runtime()
local time = M.time_capability() local time = M.time_capability()
if not time.ok then if not time.ok then
return false, time.reason return false, 'GNU time not found: ' .. (time.reason or '')
end end
local timeout = M.timeout_capability() local timeout = M.timeout_capability()
if not timeout.ok then if not timeout.ok then
return false, timeout.reason return false, 'GNU timeout not found: ' .. (timeout.reason or '')
end
if vim.fn.executable('uv') ~= 1 then
return false, 'uv not found (https://docs.astral.sh/uv/)'
end
if not M.setup_python_env() then
return false, 'failed to set up Python virtual environment'
end end
return true return true
@ -319,11 +225,7 @@ local function find_gnu_timeout()
_timeout_cached = true _timeout_cached = true
_timeout_path = nil _timeout_path = nil
if uname and uname.sysname == 'Darwin' then _timeout_reason = 'GNU timeout not found'
_timeout_reason = 'GNU timeout not found (install via: brew install coreutils)'
else
_timeout_reason = 'GNU timeout not found'
end
return _timeout_path, _timeout_reason return _timeout_path, _timeout_reason
end end
@ -338,7 +240,7 @@ function M.timeout_capability()
end end
function M.cwd_executables() function M.cwd_executables()
local uv = vim.uv local uv = vim.uv or vim.loop
local req = uv.fs_scandir('.') local req = uv.fs_scandir('.')
if not req then if not req then
return {} return {}

0
new Normal file
View file

View file

@ -66,7 +66,7 @@ end, {
return filter_candidates(contests) return filter_candidates(contests)
elseif args[2] == 'cache' then elseif args[2] == 'cache' then
return filter_candidates({ 'clear', 'read' }) return filter_candidates({ 'clear', 'read' })
elseif args[2] == 'stress' or args[2] == 'interact' then elseif args[2] == 'interact' then
local utils = require('cp.utils') local utils = require('cp.utils')
return filter_candidates(utils.cwd_executables()) return filter_candidates(utils.cwd_executables())
elseif args[2] == 'edit' then elseif args[2] == 'edit' then
@ -103,12 +103,6 @@ end, {
end end
end end
return filter_candidates(candidates) return filter_candidates(candidates)
elseif args[2] == 'credentials' then
return filter_candidates({ 'set', 'clear' })
elseif args[2] == 'race' then
local candidates = { 'stop' }
vim.list_extend(candidates, platforms)
return filter_candidates(candidates)
elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then
return filter_candidates({ '--lang' }) return filter_candidates({ '--lang' })
else else
@ -118,17 +112,7 @@ end, {
end end
end end
elseif num_args == 4 then elseif num_args == 4 then
if args[2] == 'stress' then if args[2] == 'cache' and args[3] == 'clear' then
local utils = require('cp.utils')
return filter_candidates(utils.cwd_executables())
elseif args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
local cache = require('cp.cache')
cache.load()
local contests = cache.get_cached_contest_ids(args[3])
return filter_candidates(contests)
elseif args[2] == 'credentials' and vim.tbl_contains({ 'set', 'clear' }, args[3]) then
return filter_candidates(platforms)
elseif args[2] == 'cache' and args[3] == 'clear' then
local candidates = vim.list_extend({}, platforms) local candidates = vim.list_extend({}, platforms)
table.insert(candidates, '') table.insert(candidates, '')
return filter_candidates(candidates) return filter_candidates(candidates)
@ -150,9 +134,7 @@ end, {
return filter_candidates(candidates) return filter_candidates(candidates)
end end
elseif num_args == 5 then elseif num_args == 5 then
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then if args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
return filter_candidates({ '--lang' })
elseif args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
local cache = require('cp.cache') local cache = require('cp.cache')
cache.load() cache.load()
local contests = cache.get_cached_contest_ids(args[4]) local contests = cache.get_cached_contest_ids(args[4])
@ -165,31 +147,10 @@ end, {
end end
end end
elseif num_args == 6 then elseif num_args == 6 then
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) and args[5] == '--lang' then if vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
return filter_candidates(get_enabled_languages(args[3]))
elseif vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
return filter_candidates(get_enabled_languages(args[2])) return filter_candidates(get_enabled_languages(args[2]))
end end
end end
return {} return {}
end, end,
}) })
local function cp_action(action)
return function()
require('cp').handle_command({ fargs = { action } })
end
end
vim.keymap.set('n', '<Plug>(cp-run)', cp_action('run'), { desc = 'CP run tests' })
vim.keymap.set('n', '<Plug>(cp-panel)', cp_action('panel'), { desc = 'CP open panel' })
vim.keymap.set('n', '<Plug>(cp-edit)', cp_action('edit'), { desc = 'CP edit test cases' })
vim.keymap.set('n', '<Plug>(cp-next)', cp_action('next'), { desc = 'CP next problem' })
vim.keymap.set('n', '<Plug>(cp-prev)', cp_action('prev'), { desc = 'CP previous problem' })
vim.keymap.set('n', '<Plug>(cp-pick)', cp_action('pick'), { desc = 'CP pick contest' })
vim.keymap.set('n', '<Plug>(cp-interact)', cp_action('interact'), { desc = 'CP interactive mode' })
vim.keymap.set('n', '<Plug>(cp-stress)', cp_action('stress'), { desc = 'CP stress test' })
vim.keymap.set('n', '<Plug>(cp-submit)', cp_action('submit'), { desc = 'CP submit solution' })
vim.keymap.set('n', '<Plug>(cp-race-stop)', function()
require('cp.race').stop()
end, { desc = 'CP stop race countdown' })

View file

@ -12,6 +12,8 @@ dependencies = [
"ndjson>=0.3.1", "ndjson>=0.3.1",
"pydantic>=2.11.10", "pydantic>=2.11.10",
"requests>=2.32.5", "requests>=2.32.5",
"scrapling[fetchers]>=0.3.5",
"types-requests>=2.32.4.20250913",
] ]
[dependency-groups] [dependency-groups]

View file

@ -2,7 +2,6 @@
import asyncio import asyncio
import json import json
import os
import re import re
import sys import sys
import time import time
@ -15,11 +14,16 @@ from bs4 import BeautifulSoup, Tag
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
from .base import BaseScraper, extract_precision from .base import BaseScraper
from .language_ids import get_language_id from .models import (
from .models import (CombinedTest, ContestListResult, ContestSummary, CombinedTest,
MetadataResult, ProblemSummary, SubmitResult, TestCase, ContestListResult,
TestsResult) ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
TestsResult,
)
MIB_TO_MB = 1.048576 MIB_TO_MB = 1.048576
BASE_URL = "https://atcoder.jp" BASE_URL = "https://atcoder.jp"
@ -117,23 +121,6 @@ def _parse_last_page(html: str) -> int:
return max(nums) if nums else 1 return max(nums) if nums else 1
def _parse_start_time(tr: Tag) -> int | None:
tds = tr.select("td")
if not tds:
return None
time_el = tds[0].select_one("time.fixtime-full")
if not time_el:
return None
text = time_el.get_text(strip=True)
try:
from datetime import datetime
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S%z")
return int(dt.timestamp())
except (ValueError, TypeError):
return None
def _parse_archive_contests(html: str) -> list[ContestSummary]: def _parse_archive_contests(html: str) -> list[ContestSummary]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
tbody = soup.select_one("table.table-default tbody") or soup.select_one("tbody") tbody = soup.select_one("table.table-default tbody") or soup.select_one("tbody")
@ -152,10 +139,7 @@ def _parse_archive_contests(html: str) -> list[ContestSummary]:
continue continue
cid = m.group(1) cid = m.group(1)
name = a.get_text(strip=True) name = a.get_text(strip=True)
start_time = _parse_start_time(tr) out.append(ContestSummary(id=cid, name=name, display_name=name))
out.append(
ContestSummary(id=cid, name=name, display_name=name, start_time=start_time)
)
return out return out
@ -185,7 +169,7 @@ def _parse_tasks_list(html: str) -> list[dict[str, str]]:
return rows return rows
def _extract_problem_info(html: str) -> tuple[int, float, bool, float | None]: def _extract_problem_info(html: str) -> tuple[int, float, bool]:
soup = BeautifulSoup(html, "html.parser") soup = BeautifulSoup(html, "html.parser")
txt = soup.get_text(" ", strip=True) txt = soup.get_text(" ", strip=True)
timeout_ms = 0 timeout_ms = 0
@ -197,10 +181,9 @@ def _extract_problem_info(html: str) -> tuple[int, float, bool, float | None]:
if ms: if ms:
memory_mb = float(ms.group(1)) * MIB_TO_MB memory_mb = float(ms.group(1)) * MIB_TO_MB
div = soup.select_one("#problem-statement") div = soup.select_one("#problem-statement")
body = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True) txt = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
interactive = "This is an interactive" in body interactive = "This is an interactive" in txt
precision = extract_precision(body) return timeout_ms, memory_mb, interactive
return timeout_ms, memory_mb, interactive, precision
def _extract_samples(html: str) -> list[TestCase]: def _extract_samples(html: str) -> list[TestCase]:
@ -237,13 +220,12 @@ def _scrape_problem_page_sync(contest_id: str, slug: str) -> dict[str, Any]:
tests = _extract_samples(html) tests = _extract_samples(html)
except Exception: except Exception:
tests = [] tests = []
timeout_ms, memory_mb, interactive, precision = _extract_problem_info(html) timeout_ms, memory_mb, interactive = _extract_problem_info(html)
return { return {
"tests": tests, "tests": tests,
"timeout_ms": timeout_ms, "timeout_ms": timeout_ms,
"memory_mb": memory_mb, "memory_mb": memory_mb,
"interactive": interactive, "interactive": interactive,
"precision": precision,
} }
@ -259,29 +241,14 @@ def _to_problem_summaries(rows: list[dict[str, str]]) -> list[ProblemSummary]:
return out return out
async def _fetch_upcoming_contests_async(
client: httpx.AsyncClient,
) -> list[ContestSummary]:
try:
html = await _get_async(client, f"{BASE_URL}/contests/")
return _parse_archive_contests(html)
except Exception:
return []
async def _fetch_all_contests_async() -> list[ContestSummary]: async def _fetch_all_contests_async() -> list[ContestSummary]:
async with httpx.AsyncClient( async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=100, max_keepalive_connections=100), limits=httpx.Limits(max_connections=100, max_keepalive_connections=100),
) as client: ) as client:
upcoming = await _fetch_upcoming_contests_async(client)
first_html = await _get_async(client, ARCHIVE_URL) first_html = await _get_async(client, ARCHIVE_URL)
last = _parse_last_page(first_html) last = _parse_last_page(first_html)
out = _parse_archive_contests(first_html) out = _parse_archive_contests(first_html)
if last <= 1: if last <= 1:
seen = {c.id for c in out}
for c in upcoming:
if c.id not in seen:
out.append(c)
return out return out
tasks = [ tasks = [
asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}")) asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}"))
@ -290,10 +257,6 @@ async def _fetch_all_contests_async() -> list[ContestSummary]:
for coro in asyncio.as_completed(tasks): for coro in asyncio.as_completed(tasks):
html = await coro html = await coro
out.extend(_parse_archive_contests(html)) out.extend(_parse_archive_contests(html))
seen = {c.id for c in out}
for c in upcoming:
if c.id not in seen:
out.append(c)
return out return out
@ -356,7 +319,6 @@ class AtcoderScraper(BaseScraper):
"memory_mb": data.get("memory_mb", 0), "memory_mb": data.get("memory_mb", 0),
"interactive": bool(data.get("interactive")), "interactive": bool(data.get("interactive")),
"multi_test": False, "multi_test": False,
"precision": data.get("precision"),
} }
), ),
flush=True, flush=True,
@ -364,99 +326,6 @@ class AtcoderScraper(BaseScraper):
await asyncio.gather(*(emit(r) for r in rows)) await asyncio.gather(*(emit(r) for r in rows))
async def submit(
self,
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> SubmitResult:
def _submit_sync() -> SubmitResult:
from curl_cffi import requests as curl_requests
try:
session = curl_requests.Session(impersonate="chrome")
login_page = session.get(f"{BASE_URL}/login", timeout=TIMEOUT_SECONDS)
login_page.raise_for_status()
soup = BeautifulSoup(login_page.text, "html.parser")
csrf_input = soup.find("input", {"name": "csrf_token"})
if not csrf_input or not hasattr(csrf_input, "get"):
return SubmitResult(
success=False, error="Could not find CSRF token on login page"
)
csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr]
login_resp = session.post(
f"{BASE_URL}/login",
data={
"username": credentials.get("username", ""),
"password": credentials.get("password", ""),
"csrf_token": csrf_token,
},
timeout=TIMEOUT_SECONDS,
allow_redirects=False,
)
if login_resp.status_code in (301, 302):
location = login_resp.headers.get("Location", "")
if "/login" in location:
return SubmitResult(
success=False,
error="Login failed: incorrect username or password",
)
session.get(BASE_URL + location, timeout=TIMEOUT_SECONDS)
else:
login_resp.raise_for_status()
submit_page = session.get(
f"{BASE_URL}/contests/{contest_id}/submit",
timeout=TIMEOUT_SECONDS,
)
submit_page.raise_for_status()
soup = BeautifulSoup(submit_page.text, "html.parser")
csrf_input = soup.find("input", {"name": "csrf_token"})
if not csrf_input or not hasattr(csrf_input, "get"):
return SubmitResult(
success=False, error="Could not find CSRF token on submit page"
)
csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr]
task_screen_name = f"{contest_id}_{problem_id}"
submit_resp = session.post(
f"{BASE_URL}/contests/{contest_id}/submit",
data={
"data.TaskScreenName": task_screen_name,
"data.LanguageId": language_id,
"sourceCode": source_code,
"csrf_token": csrf_token,
},
timeout=TIMEOUT_SECONDS,
allow_redirects=False,
)
if submit_resp.status_code in (301, 302):
location = submit_resp.headers.get("Location", "")
if "/submissions/me" in location:
return SubmitResult(
success=True,
error="",
submission_id="",
verdict="submitted",
)
return SubmitResult(
success=False,
error=f"Submit may have failed: redirected to {location}",
)
submit_resp.raise_for_status()
return SubmitResult(
success=False,
error="Unexpected response from submit (expected redirect)",
)
except Exception as e:
return SubmitResult(success=False, error=str(e))
return await asyncio.to_thread(_submit_sync)
async def main_async() -> int: async def main_async() -> int:
if len(sys.argv) < 2: if len(sys.argv) < 2:
@ -513,31 +382,9 @@ async def main_async() -> int:
print(contest_result.model_dump_json()) print(contest_result.model_dump_json())
return 0 if contest_result.success else 1 return 0 if contest_result.success else 1
if mode == "submit":
if len(sys.argv) != 5:
print(
SubmitResult(
success=False,
error="Usage: atcoder.py submit <contest_id> <problem_id> <language>",
).model_dump_json()
)
return 1
source_code = sys.stdin.read()
creds_raw = os.environ.get("CP_CREDENTIALS", "{}")
try:
credentials = json.loads(creds_raw)
except json.JSONDecodeError:
credentials = {}
language_id = get_language_id("atcoder", sys.argv[4]) or sys.argv[4]
submit_result = await scraper.submit(
sys.argv[2], sys.argv[3], source_code, language_id, credentials
)
print(submit_result.model_dump_json())
return 0 if submit_result.success else 1
result = MetadataResult( result = MetadataResult(
success=False, success=False,
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', 'contests', or 'submit <contest_id> <problem_id> <language>'", error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
url="", url="",
) )
print(result.model_dump_json()) print(result.model_dump_json())

View file

@ -1,32 +1,8 @@
import asyncio import asyncio
import json
import os
import re
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from .language_ids import get_language_id from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
from .models import (CombinedTest, ContestListResult, MetadataResult,
SubmitResult, TestsResult)
_PRECISION_ABS_REL_RE = re.compile(
r"(?:absolute|relative)\s+error[^.]*?10\s*[\^{]\s*\{?\s*[-\u2212]\s*(\d+)\s*\}?",
re.IGNORECASE,
)
_PRECISION_DECIMAL_RE = re.compile(
r"round(?:ed)?\s+to\s+(\d+)\s+decimal\s+place",
re.IGNORECASE,
)
def extract_precision(text: str) -> float | None:
m = _PRECISION_ABS_REL_RE.search(text)
if m:
return 10 ** -int(m.group(1))
m = _PRECISION_DECIMAL_RE.search(text)
if m:
return 10 ** -int(m.group(1))
return None
class BaseScraper(ABC): class BaseScraper(ABC):
@ -43,16 +19,6 @@ 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: ...
@abstractmethod
async def submit(
self,
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> SubmitResult: ...
def _usage(self) -> str: def _usage(self) -> str:
name = self.platform_name name = self.platform_name
return f"Usage: {name}.py metadata <id> | tests <id> | contests" return f"Usage: {name}.py metadata <id> | tests <id> | contests"
@ -74,9 +40,6 @@ class BaseScraper(ABC):
def _contests_error(self, msg: str) -> ContestListResult: def _contests_error(self, msg: str) -> ContestListResult:
return ContestListResult(success=False, error=msg) return ContestListResult(success=False, error=msg)
def _submit_error(self, msg: str) -> SubmitResult:
return SubmitResult(success=False, error=msg)
async def _run_cli_async(self, args: list[str]) -> int: async def _run_cli_async(self, args: list[str]) -> int:
if len(args) < 2: if len(args) < 2:
print(self._metadata_error(self._usage()).model_dump_json()) print(self._metadata_error(self._usage()).model_dump_json())
@ -108,27 +71,6 @@ class BaseScraper(ABC):
print(result.model_dump_json()) print(result.model_dump_json())
return 0 if result.success else 1 return 0 if result.success else 1
case "submit":
if len(args) != 5:
print(
self._submit_error(
"Usage: <platform> submit <contest_id> <problem_id> <language_id>"
).model_dump_json()
)
return 1
source_code = sys.stdin.read()
creds_raw = os.environ.get("CP_CREDENTIALS", "{}")
try:
credentials = json.loads(creds_raw)
except json.JSONDecodeError:
credentials = {}
language_id = get_language_id(self.platform_name, args[4]) or args[4]
result = await self.submit(
args[2], args[3], source_code, language_id, credentials
)
print(result.model_dump_json())
return 0 if result.success else 1
case _: case _:
print( print(
self._metadata_error( self._metadata_error(

View file

@ -6,11 +6,16 @@ import re
from typing import Any from typing import Any
import httpx import httpx
from curl_cffi import requests as curl_requests from scrapling.fetchers import Fetcher
from .base import BaseScraper, extract_precision from .base import BaseScraper
from .models import (ContestListResult, ContestSummary, MetadataResult, from .models import (
ProblemSummary, SubmitResult, TestCase) ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
)
BASE_URL = "https://www.codechef.com" BASE_URL = "https://www.codechef.com"
API_CONTESTS_ALL = "/api/list/contests/all" API_CONTESTS_ALL = "/api/list/contests/all"
@ -45,9 +50,8 @@ def _extract_memory_limit(html: str) -> float:
def _fetch_html_sync(url: str) -> str: def _fetch_html_sync(url: str) -> str:
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S) response = Fetcher.get(url)
response.raise_for_status() return str(response.body)
return response.text
class CodeChefScraper(BaseScraper): class CodeChefScraper(BaseScraper):
@ -214,13 +218,11 @@ class CodeChefScraper(BaseScraper):
) )
memory_mb = _extract_memory_limit(html) memory_mb = _extract_memory_limit(html)
interactive = False interactive = False
precision = extract_precision(html)
except Exception: except Exception:
tests = [] tests = []
timeout_ms = 1000 timeout_ms = 1000
memory_mb = 256.0 memory_mb = 256.0
interactive = False interactive = False
precision = None
combined_input = "\n".join(t.input for t in tests) if tests else "" combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = ( combined_expected = (
"\n".join(t.expected for t in tests) if tests else "" "\n".join(t.expected for t in tests) if tests else ""
@ -238,7 +240,6 @@ class CodeChefScraper(BaseScraper):
"memory_mb": memory_mb, "memory_mb": memory_mb,
"interactive": interactive, "interactive": interactive,
"multi_test": False, "multi_test": False,
"precision": precision,
} }
tasks = [run_one(problem_code) for problem_code in problems.keys()] tasks = [run_one(problem_code) for problem_code in problems.keys()]
@ -246,21 +247,6 @@ class CodeChefScraper(BaseScraper):
payload = await coro payload = await coro
print(json.dumps(payload), flush=True) print(json.dumps(payload), flush=True)
async def submit(
self,
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> SubmitResult:
return SubmitResult(
success=False,
error="CodeChef submit not yet implemented",
submission_id="",
verdict="",
)
if __name__ == "__main__": if __name__ == "__main__":
CodeChefScraper().run_cli() CodeChefScraper().run_cli()

View file

@ -2,16 +2,26 @@
import asyncio import asyncio
import json import json
import logging
import re import re
from typing import Any from typing import Any
import requests import requests
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from curl_cffi import requests as curl_requests from scrapling.fetchers import Fetcher
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
)
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
from .base import BaseScraper, extract_precision
from .models import (ContestListResult, ContestSummary, MetadataResult,
ProblemSummary, SubmitResult, TestCase)
BASE_URL = "https://codeforces.com" BASE_URL = "https://codeforces.com"
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list" API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
@ -73,7 +83,7 @@ def _extract_title(block: Tag) -> tuple[str, str]:
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]: def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
st = block.find("div", class_="sample-test") st = block.find("div", class_="sample-test")
if not isinstance(st, Tag): if not st:
return [], False return [], False
input_pres: list[Tag] = [ input_pres: list[Tag] = [
@ -130,9 +140,10 @@ def _is_interactive(block: Tag) -> bool:
def _fetch_problems_html(contest_id: str) -> str: def _fetch_problems_html(contest_id: str) -> str:
url = f"{BASE_URL}/contest/{contest_id}/problems" url = f"{BASE_URL}/contest/{contest_id}/problems"
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS) page = Fetcher.get(
response.raise_for_status() url,
return response.text )
return page.html_content
def _parse_all_blocks(html: str) -> list[dict[str, Any]]: def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
@ -148,7 +159,6 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
raw_samples, is_grouped = _extract_samples(b) raw_samples, is_grouped = _extract_samples(b)
timeout_ms, memory_mb = _extract_limits(b) timeout_ms, memory_mb = _extract_limits(b)
interactive = _is_interactive(b) interactive = _is_interactive(b)
precision = extract_precision(b.get_text(" ", strip=True))
if is_grouped and raw_samples: if is_grouped and raw_samples:
combined_input = f"{len(raw_samples)}\n" + "\n".join( combined_input = f"{len(raw_samples)}\n" + "\n".join(
@ -175,7 +185,6 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
"memory_mb": memory_mb, "memory_mb": memory_mb,
"interactive": interactive, "interactive": interactive,
"multi_test": is_grouped, "multi_test": is_grouped,
"precision": precision,
} }
) )
return out return out
@ -225,20 +234,11 @@ class CodeforcesScraper(BaseScraper):
contests: list[ContestSummary] = [] contests: list[ContestSummary] = []
for c in data["result"]: for c in data["result"]:
phase = c.get("phase") if c.get("phase") != "FINISHED":
if phase not in ("FINISHED", "BEFORE", "CODING"):
continue continue
cid = str(c["id"]) cid = str(c["id"])
name = c["name"] name = c["name"]
start_time = c.get("startTimeSeconds") if phase != "FINISHED" else None contests.append(ContestSummary(id=cid, name=name, display_name=name))
contests.append(
ContestSummary(
id=cid,
name=name,
display_name=name,
start_time=start_time,
)
)
if not contests: if not contests:
return self._contests_error("No contests found") return self._contests_error("No contests found")
@ -269,27 +269,11 @@ class CodeforcesScraper(BaseScraper):
"memory_mb": b.get("memory_mb", 0), "memory_mb": b.get("memory_mb", 0),
"interactive": bool(b.get("interactive")), "interactive": bool(b.get("interactive")),
"multi_test": bool(b.get("multi_test", False)), "multi_test": bool(b.get("multi_test", False)),
"precision": b.get("precision"),
} }
), ),
flush=True, flush=True,
) )
async def submit(
self,
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> SubmitResult:
return SubmitResult(
success=False,
error="Codeforces submit not yet implemented",
submission_id="",
verdict="",
)
if __name__ == "__main__": if __name__ == "__main__":
CodeforcesScraper().run_cli() CodeforcesScraper().run_cli()

View file

@ -7,9 +7,14 @@ from typing import Any
import httpx import httpx
from .base import BaseScraper, extract_precision from .base import BaseScraper
from .models import (ContestListResult, ContestSummary, MetadataResult, from .models import (
ProblemSummary, SubmitResult, TestCase) ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
)
BASE_URL = "https://cses.fi" BASE_URL = "https://cses.fi"
INDEX_PATH = "/problemset" INDEX_PATH = "/problemset"
@ -124,21 +129,17 @@ def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary]
return [] return []
def _extract_problem_info(html: str) -> tuple[int, int, bool, float | None]: def _extract_problem_info(html: str) -> tuple[int, int, bool]:
tm = TIME_RE.search(html) tm = TIME_RE.search(html)
mm = MEM_RE.search(html) mm = MEM_RE.search(html)
t = int(round(float(tm.group(1)) * 1000)) if tm else 0 t = int(round(float(tm.group(1)) * 1000)) if tm else 0
m = int(mm.group(1)) if mm else 0 m = int(mm.group(1)) if mm else 0
md = MD_BLOCK_RE.search(html) md = MD_BLOCK_RE.search(html)
interactive = False interactive = False
precision = None
if md: if md:
body = md.group(1) body = md.group(1)
interactive = "This is an interactive problem." in body interactive = "This is an interactive problem." in body
from bs4 import BeautifulSoup return t, m, interactive
precision = extract_precision(BeautifulSoup(body, "html.parser").get_text(" "))
return t, m, interactive, precision
def parse_title(html: str) -> str: def parse_title(html: str) -> str:
@ -226,17 +227,10 @@ class CSESScraper(BaseScraper):
try: try:
html = await fetch_text(client, task_path(pid)) html = await fetch_text(client, task_path(pid))
tests = parse_tests(html) tests = parse_tests(html)
timeout_ms, memory_mb, interactive, precision = ( timeout_ms, memory_mb, interactive = _extract_problem_info(html)
_extract_problem_info(html)
)
except Exception: except Exception:
tests = [] tests = []
timeout_ms, memory_mb, interactive, precision = ( timeout_ms, memory_mb, interactive = 0, 0, False
0,
0,
False,
None,
)
combined_input = "\n".join(t.input for t in tests) if tests else "" combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = ( combined_expected = (
@ -256,7 +250,6 @@ class CSESScraper(BaseScraper):
"memory_mb": memory_mb, "memory_mb": memory_mb,
"interactive": interactive, "interactive": interactive,
"multi_test": False, "multi_test": False,
"precision": precision,
} }
tasks = [run_one(p.id) for p in problems] tasks = [run_one(p.id) for p in problems]
@ -264,21 +257,6 @@ class CSESScraper(BaseScraper):
payload = await coro payload = await coro
print(json.dumps(payload), flush=True) print(json.dumps(payload), flush=True)
async def submit(
self,
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> SubmitResult:
return SubmitResult(
success=False,
error="CSES submit not yet implemented",
submission_id="",
verdict="",
)
if __name__ == "__main__": if __name__ == "__main__":
CSESScraper().run_cli() CSESScraper().run_cli()

View file

@ -1,283 +0,0 @@
#!/usr/bin/env python3
import asyncio
import io
import json
import re
import zipfile
from datetime import datetime
import httpx
from .base import BaseScraper
from .models import (ContestListResult, ContestSummary, MetadataResult,
ProblemSummary, SubmitResult, TestCase)
BASE_URL = "https://open.kattis.com"
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
TIMEOUT_S = 15.0
CONNECTIONS = 8
TIME_RE = re.compile(
r"CPU Time limit</span>\s*<span[^>]*>\s*(\d+)\s*seconds?\s*</span>",
re.DOTALL,
)
MEM_RE = re.compile(
r"Memory limit</span>\s*<span[^>]*>\s*(\d+)\s*MB\s*</span>",
re.DOTALL,
)
async def _fetch_text(client: httpx.AsyncClient, url: str) -> str:
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S)
r.raise_for_status()
return r.text
async def _fetch_bytes(client: httpx.AsyncClient, url: str) -> bytes:
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S)
r.raise_for_status()
return r.content
def _parse_limits(html: str) -> tuple[int, int]:
tm = TIME_RE.search(html)
mm = MEM_RE.search(html)
timeout_ms = int(tm.group(1)) * 1000 if tm else 1000
memory_mb = int(mm.group(1)) if mm else 1024
return timeout_ms, memory_mb
def _parse_samples_html(html: str) -> list[TestCase]:
tests: list[TestCase] = []
tables = re.finditer(r'<table\s+class="sample"[^>]*>.*?</table>', html, re.DOTALL)
for table_match in tables:
table_html = table_match.group(0)
pres = re.findall(r"<pre>(.*?)</pre>", table_html, re.DOTALL)
if len(pres) >= 2:
inp = pres[0].strip()
out = pres[1].strip()
tests.append(TestCase(input=inp, expected=out))
return tests
def _parse_samples_zip(data: bytes) -> list[TestCase]:
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile:
return []
inputs: dict[str, str] = {}
outputs: dict[str, str] = {}
for name in zf.namelist():
content = zf.read(name).decode("utf-8").strip()
if name.endswith(".in"):
key = name[: -len(".in")]
inputs[key] = content
elif name.endswith(".ans"):
key = name[: -len(".ans")]
outputs[key] = content
tests: list[TestCase] = []
for key in sorted(set(inputs) & set(outputs)):
tests.append(TestCase(input=inputs[key], expected=outputs[key]))
return tests
def _is_interactive(html: str) -> bool:
return "This is an interactive problem" in html
def _parse_contests_page(html: str) -> list[ContestSummary]:
results: list[ContestSummary] = []
seen: set[str] = set()
for row_m in re.finditer(r"<tr[^>]*>(.*?)</tr>", html, re.DOTALL):
row = row_m.group(1)
link_m = re.search(r'href="/contests/([a-z0-9]+)"[^>]*>([^<]+)</a>', row)
if not link_m:
continue
cid = link_m.group(1)
name = link_m.group(2).strip()
if cid in seen:
continue
seen.add(cid)
start_time: int | None = None
ts_m = re.search(r'data-timestamp="(\d+)"', row)
if ts_m:
start_time = int(ts_m.group(1))
else:
time_m = re.search(r'<time[^>]+datetime="([^"]+)"', row)
if time_m:
try:
dt = datetime.fromisoformat(time_m.group(1).replace("Z", "+00:00"))
start_time = int(dt.timestamp())
except Exception:
pass
results.append(ContestSummary(id=cid, name=name, start_time=start_time))
return results
def _parse_contest_problem_list(html: str) -> list[tuple[str, str]]:
if "The problems will become available when the contest starts" in html:
return []
results: list[tuple[str, str]] = []
seen: set[str] = set()
for row_m in re.finditer(r"<tr[^>]*>(.*?)</tr>", html, re.DOTALL):
row = row_m.group(1)
link_m = re.search(
r'href="/contests/[^/]+/problems/([^"]+)"[^>]*>([^<]+)</a>', row
)
if not link_m:
continue
slug = link_m.group(1)
name = link_m.group(2).strip()
if slug in seen:
continue
seen.add(slug)
label_m = re.search(r"<td[^>]*>\s*([A-Z])\s*</td>", row)
label = label_m.group(1) if label_m else ""
display = f"{label} - {name}" if label else name
results.append((slug, display))
return results
async def _fetch_contest_slugs(
client: httpx.AsyncClient, contest_id: str
) -> list[tuple[str, str]]:
try:
html = await _fetch_text(client, f"{BASE_URL}/contests/{contest_id}/problems")
return _parse_contest_problem_list(html)
except httpx.HTTPStatusError:
return []
except Exception:
return []
async def _stream_single_problem(client: httpx.AsyncClient, slug: str) -> None:
try:
html = await _fetch_text(client, f"{BASE_URL}/problems/{slug}")
except Exception:
return
timeout_ms, memory_mb = _parse_limits(html)
interactive = _is_interactive(html)
tests: list[TestCase] = []
try:
zip_data = await _fetch_bytes(
client,
f"{BASE_URL}/problems/{slug}/file/statement/samples.zip",
)
tests = _parse_samples_zip(zip_data)
except Exception:
tests = _parse_samples_html(html)
combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = "\n".join(t.expected for t in tests) if tests else ""
print(
json.dumps(
{
"problem_id": slug,
"combined": {
"input": combined_input,
"expected": combined_expected,
},
"tests": [{"input": t.input, "expected": t.expected} for t in tests],
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": False,
}
),
flush=True,
)
class KattisScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "kattis"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
try:
async with httpx.AsyncClient() as client:
slugs = await _fetch_contest_slugs(client, contest_id)
if slugs:
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=[
ProblemSummary(id=slug, name=name) for slug, name in slugs
],
url=f"{BASE_URL}/problems/%s",
)
try:
html = await _fetch_text(
client, f"{BASE_URL}/problems/{contest_id}"
)
except Exception as e:
return self._metadata_error(str(e))
title_m = re.search(r"<title>([^<]+)</title>", html)
name = (
title_m.group(1).split("\u2013")[0].strip()
if title_m
else contest_id
)
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=[ProblemSummary(id=contest_id, name=name)],
url=f"{BASE_URL}/problems/%s",
)
except Exception as e:
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
try:
async with httpx.AsyncClient() as client:
html = await _fetch_text(client, f"{BASE_URL}/contests")
contests = _parse_contests_page(html)
if not contests:
return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests)
except Exception as e:
return self._contests_error(str(e))
async def stream_tests_for_category_async(self, category_id: str) -> None:
async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=CONNECTIONS)
) as client:
slugs = await _fetch_contest_slugs(client, category_id)
if slugs:
sem = asyncio.Semaphore(CONNECTIONS)
async def emit_one(slug: str, _name: str) -> None:
async with sem:
await _stream_single_problem(client, slug)
await asyncio.gather(*(emit_one(s, n) for s, n in slugs))
return
await _stream_single_problem(client, category_id)
async def submit(
self,
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> SubmitResult:
return SubmitResult(
success=False,
error="Kattis submit not yet implemented",
submission_id="",
verdict="",
)
if __name__ == "__main__":
KattisScraper().run_cli()

View file

@ -1,18 +0,0 @@
LANGUAGE_IDS = {
"atcoder": {
"cpp": "5028",
"python": "5078",
},
"codeforces": {
"cpp": "89",
"python": "70",
},
"cses": {
"cpp": "C++17",
"python": "Python3",
},
}
def get_language_id(platform: str, language: str) -> str | None:
return LANGUAGE_IDS.get(platform, {}).get(language)

View file

@ -26,7 +26,6 @@ class ContestSummary(BaseModel):
id: str id: str
name: str name: str
display_name: str | None = None display_name: str | None = None
start_time: int | None = None
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -64,13 +63,6 @@ class TestsResult(ScrapingResult):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
class SubmitResult(ScrapingResult):
submission_id: str = ""
verdict: str = ""
model_config = ConfigDict(extra="forbid")
class ScraperConfig(BaseModel): class ScraperConfig(BaseModel):
timeout_seconds: int = 30 timeout_seconds: int = 30
max_retries: int = 3 max_retries: int = 3

View file

@ -1,292 +0,0 @@
#!/usr/bin/env python3
import asyncio
import json
import re
from typing import Any, cast
import httpx
from .base import BaseScraper
from .models import (ContestListResult, ContestSummary, MetadataResult,
ProblemSummary, SubmitResult, TestCase)
BASE_URL = "http://www.usaco.org"
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
TIMEOUT_S = 15.0
CONNECTIONS = 4
MONTHS = [
"dec",
"jan",
"feb",
"mar",
"open",
]
DIVISION_HEADING_RE = re.compile(
r"<h2>.*?USACO\s+(\d{4})\s+(\w+)\s+Contest,\s+(\w+)\s*</h2>",
re.IGNORECASE,
)
PROBLEM_BLOCK_RE = re.compile(
r"<b>([^<]+)</b>\s*<br\s*/?>.*?" r"viewproblem2&cpid=(\d+)",
re.DOTALL,
)
SAMPLE_IN_RE = re.compile(r"<pre\s+class=['\"]in['\"]>(.*?)</pre>", re.DOTALL)
SAMPLE_OUT_RE = re.compile(r"<pre\s+class=['\"]out['\"]>(.*?)</pre>", re.DOTALL)
TIME_NOTE_RE = re.compile(
r"time\s+limit\s+(?:for\s+this\s+problem\s+is\s+)?(\d+)s",
re.IGNORECASE,
)
MEMORY_NOTE_RE = re.compile(
r"memory\s+limit\s+(?:for\s+this\s+problem\s+is\s+)?(\d+)\s*MB",
re.IGNORECASE,
)
RESULTS_PAGE_RE = re.compile(
r'href="index\.php\?page=([a-z]+\d{2,4}results)"',
re.IGNORECASE,
)
async def _fetch_text(client: httpx.AsyncClient, url: str) -> str:
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S, follow_redirects=True)
r.raise_for_status()
return r.text
def _parse_results_page(html: str) -> dict[str, list[tuple[str, str]]]:
sections: dict[str, list[tuple[str, str]]] = {}
current_div: str | None = None
parts = re.split(r"(<h2>.*?</h2>)", html, flags=re.DOTALL)
for part in parts:
heading_m = DIVISION_HEADING_RE.search(part)
if heading_m:
current_div = heading_m.group(3).lower()
sections.setdefault(current_div, [])
continue
if current_div is not None:
for m in PROBLEM_BLOCK_RE.finditer(part):
name = m.group(1).strip()
cpid = m.group(2)
sections[current_div].append((cpid, name))
return sections
def _parse_contest_id(contest_id: str) -> tuple[str, str]:
parts = contest_id.rsplit("_", 1)
if len(parts) != 2:
return contest_id, ""
return parts[0], parts[1].lower()
def _results_page_slug(month_year: str) -> str:
return f"{month_year}results"
def _parse_problem_page(html: str) -> dict[str, Any]:
inputs = SAMPLE_IN_RE.findall(html)
outputs = SAMPLE_OUT_RE.findall(html)
tests: list[TestCase] = []
for inp, out in zip(inputs, outputs):
tests.append(
TestCase(
input=inp.strip().replace("\r", ""),
expected=out.strip().replace("\r", ""),
)
)
tm = TIME_NOTE_RE.search(html)
mm = MEMORY_NOTE_RE.search(html)
timeout_ms = int(tm.group(1)) * 1000 if tm else 4000
memory_mb = int(mm.group(1)) if mm else 256
interactive = "interactive problem" in html.lower()
return {
"tests": tests,
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
}
class USACOScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "usaco"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
try:
month_year, division = _parse_contest_id(contest_id)
if not division:
return self._metadata_error(
f"Invalid contest ID '{contest_id}'. "
"Expected format: <monthYY>_<division> (e.g. dec24_gold)"
)
slug = _results_page_slug(month_year)
async with httpx.AsyncClient() as client:
html = await _fetch_text(client, f"{BASE_URL}/index.php?page={slug}")
sections = _parse_results_page(html)
problems_raw = sections.get(division, [])
if not problems_raw:
return self._metadata_error(
f"No problems found for {contest_id} (division: {division})"
)
problems = [
ProblemSummary(id=cpid, name=name) for cpid, name in problems_raw
]
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"{BASE_URL}/index.php?page=viewproblem2&cpid=%s",
)
except Exception as e:
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
try:
async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=CONNECTIONS)
) as client:
html = await _fetch_text(client, f"{BASE_URL}/index.php?page=contests")
page_slugs: set[str] = set()
for m in RESULTS_PAGE_RE.finditer(html):
page_slugs.add(m.group(1))
recent_patterns = []
for year in range(15, 27):
for month in MONTHS:
recent_patterns.append(f"{month}{year:02d}results")
page_slugs.update(recent_patterns)
contests: list[ContestSummary] = []
sem = asyncio.Semaphore(CONNECTIONS)
async def check_page(slug: str) -> list[ContestSummary]:
async with sem:
try:
page_html = await _fetch_text(
client, f"{BASE_URL}/index.php?page={slug}"
)
except Exception:
return []
sections = _parse_results_page(page_html)
if not sections:
return []
month_year = slug.replace("results", "")
out: list[ContestSummary] = []
for div in sections:
cid = f"{month_year}_{div}"
year_m = re.search(r"\d{2,4}", month_year)
month_m = re.search(r"[a-z]+", month_year)
year_str = year_m.group() if year_m else ""
month_str = month_m.group().capitalize() if month_m else ""
if len(year_str) == 2:
year_str = f"20{year_str}"
display = (
f"USACO {year_str} {month_str} - {div.capitalize()}"
)
out.append(
ContestSummary(id=cid, name=cid, display_name=display)
)
return out
tasks = [check_page(slug) for slug in sorted(page_slugs)]
for coro in asyncio.as_completed(tasks):
contests.extend(await coro)
if not contests:
return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests)
except Exception as e:
return self._contests_error(str(e))
async def stream_tests_for_category_async(self, category_id: str) -> None:
month_year, division = _parse_contest_id(category_id)
if not division:
return
slug = _results_page_slug(month_year)
async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=CONNECTIONS)
) as client:
try:
html = await _fetch_text(client, f"{BASE_URL}/index.php?page={slug}")
except Exception:
return
sections = _parse_results_page(html)
problems_raw = sections.get(division, [])
if not problems_raw:
return
sem = asyncio.Semaphore(CONNECTIONS)
async def run_one(cpid: str) -> dict[str, Any]:
async with sem:
try:
problem_html = await _fetch_text(
client,
f"{BASE_URL}/index.php?page=viewproblem2&cpid={cpid}",
)
info = _parse_problem_page(problem_html)
except Exception:
info = {
"tests": [],
"timeout_ms": 4000,
"memory_mb": 256,
"interactive": False,
}
tests = cast(list[TestCase], info["tests"])
combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = (
"\n".join(t.expected for t in tests) if tests else ""
)
return {
"problem_id": cpid,
"combined": {
"input": combined_input,
"expected": combined_expected,
},
"tests": [
{"input": t.input, "expected": t.expected} for t in tests
],
"timeout_ms": info["timeout_ms"],
"memory_mb": info["memory_mb"],
"interactive": info["interactive"],
"multi_test": False,
}
tasks = [run_one(cpid) for cpid, _ in problems_raw]
for coro in asyncio.as_completed(tasks):
payload = await coro
print(json.dumps(payload), flush=True)
async def submit(
self,
contest_id: str,
problem_id: str,
source_code: str,
language_id: str,
credentials: dict[str, str],
) -> SubmitResult:
return SubmitResult(
success=False,
error="USACO submit not yet implemented",
submission_id="",
verdict="",
)
if __name__ == "__main__":
USACOScraper().run_cli()

View file

@ -1,113 +0,0 @@
#!/usr/bin/env python3
import subprocess
import sys
def main() -> None:
argv = sys.argv[1:]
max_iterations = 1000
timeout = 10
positional: list[str] = []
i = 0
while i < len(argv):
if argv[i] == "--max-iterations" and i + 1 < len(argv):
max_iterations = int(argv[i + 1])
i += 2
elif argv[i] == "--timeout" and i + 1 < len(argv):
timeout = int(argv[i + 1])
i += 2
else:
positional.append(argv[i])
i += 1
if len(positional) != 3:
print(
"Usage: stress.py <generator> <brute> <candidate> "
"[--max-iterations N] [--timeout S]",
file=sys.stderr,
)
sys.exit(1)
generator, brute, candidate = positional
for iteration in range(1, max_iterations + 1):
try:
gen_result = subprocess.run(
generator,
capture_output=True,
text=True,
shell=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
print(
f"[stress] generator timed out on iteration {iteration}",
file=sys.stderr,
)
sys.exit(1)
if gen_result.returncode != 0:
print(
f"[stress] generator failed on iteration {iteration} "
f"(exit code {gen_result.returncode})",
file=sys.stderr,
)
if gen_result.stderr:
print(gen_result.stderr, file=sys.stderr, end="")
sys.exit(1)
test_input = gen_result.stdout
try:
brute_result = subprocess.run(
brute,
input=test_input,
capture_output=True,
text=True,
shell=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
print(f"[stress] brute timed out on iteration {iteration}", file=sys.stderr)
print(f"\n--- input ---\n{test_input}", end="")
sys.exit(1)
try:
cand_result = subprocess.run(
candidate,
input=test_input,
capture_output=True,
text=True,
shell=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
print(
f"[stress] candidate timed out on iteration {iteration}",
file=sys.stderr,
)
print(f"\n--- input ---\n{test_input}", end="")
sys.exit(1)
brute_out = brute_result.stdout.strip()
cand_out = cand_result.stdout.strip()
if brute_out != cand_out:
print(f"[stress] mismatch on iteration {iteration}", file=sys.stderr)
print(f"\n--- input ---\n{test_input}", end="")
print(f"\n--- expected (brute) ---\n{brute_out}")
print(f"\n--- actual (candidate) ---\n{cand_out}")
sys.exit(1)
print(f"[stress] iteration {iteration} OK", file=sys.stderr)
print(
f"[stress] all {max_iterations} iterations passed",
file=sys.stderr,
)
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -1,4 +1 @@
std = 'vim' std = 'vim'
[lints]
bad_string_escape = 'allow'

View file

@ -10,7 +10,7 @@ from typing import Any
import httpx import httpx
import pytest import pytest
import requests import requests
from curl_cffi import requests as curl_requests from scrapling import fetchers
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
FIX = Path(__file__).resolve().parent / "fixtures" FIX = Path(__file__).resolve().parent / "fixtures"
@ -136,15 +136,12 @@ def run_scraper_offline(fixture_text):
case "codeforces": case "codeforces":
class MockCurlResponse: class MockCodeForcesPage:
def __init__(self, html: str): def __init__(self, html: str):
self.text = html self.html_content = html
def raise_for_status(self): def _mock_stealthy_fetch(url: str, **kwargs):
pass return MockCodeForcesPage(_router_codeforces(url=url))
def _mock_curl_get(url: str, **kwargs):
return MockCurlResponse(_router_codeforces(url=url))
def _mock_requests_get(url: str, **kwargs): def _mock_requests_get(url: str, **kwargs):
if "api/contest.list" in url: if "api/contest.list" in url:
@ -175,7 +172,7 @@ def run_scraper_offline(fixture_text):
raise AssertionError(f"Unexpected requests.get call: {url}") raise AssertionError(f"Unexpected requests.get call: {url}")
return { return {
"curl_requests.get": _mock_curl_get, "Fetcher.get": _mock_stealthy_fetch,
"requests.get": _mock_requests_get, "requests.get": _mock_requests_get,
} }
@ -215,23 +212,21 @@ def run_scraper_offline(fixture_text):
return MockResponse(data) return MockResponse(data)
raise AssertionError(f"No fixture for CodeChef url={url!r}") raise AssertionError(f"No fixture for CodeChef url={url!r}")
class MockCodeChefCurlResponse: class MockCodeChefPage:
def __init__(self, html: str): def __init__(self, html: str):
self.text = html self.body = html
self.status = 200
def raise_for_status(self): def _mock_stealthy_fetch(url: str, **kwargs):
pass
def _mock_curl_get(url: str, **kwargs):
if "/problems/" in url: if "/problems/" in url:
problem_id = url.rstrip("/").split("/")[-1] problem_id = url.rstrip("/").split("/")[-1]
html = fixture_text(f"codechef/{problem_id}.html") html = fixture_text(f"codechef/{problem_id}.html")
return MockCodeChefCurlResponse(html) return MockCodeChefPage(html)
raise AssertionError(f"No fixture for CodeChef url={url!r}") raise AssertionError(f"No fixture for CodeChef url={url!r}")
return { return {
"__offline_get_async": __offline_get_async, "__offline_get_async": __offline_get_async,
"curl_requests.get": _mock_curl_get, "Fetcher.get": _mock_stealthy_fetch,
} }
case _: case _:
@ -250,7 +245,7 @@ def run_scraper_offline(fixture_text):
offline_fetches = _make_offline_fetches(scraper_name) offline_fetches = _make_offline_fetches(scraper_name)
if scraper_name == "codeforces": if scraper_name == "codeforces":
curl_requests.get = offline_fetches["curl_requests.get"] 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"]
@ -259,7 +254,7 @@ def run_scraper_offline(fixture_text):
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
elif scraper_name == "codechef": elif scraper_name == "codechef":
httpx.AsyncClient.get = offline_fetches["__offline_get_async"] httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
curl_requests.get = offline_fetches["curl_requests.get"] fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
scraper_class = getattr(ns, scraper_classes[scraper_name]) scraper_class = getattr(ns, scraper_classes[scraper_name])
scraper = scraper_class() scraper = scraper_class()

View file

@ -1,6 +1,15 @@
import pytest import pytest
from scrapers.models import ContestListResult, MetadataResult, TestsResult from scrapers.models import (
ContestListResult,
MetadataResult,
TestsResult,
)
MODEL_FOR_MODE = {
"metadata": MetadataResult,
"contests": ContestListResult,
}
MATRIX = { MATRIX = {
"cses": { "cses": {
@ -34,16 +43,17 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
assert rc in (0, 1), f"Bad exit code {rc}" assert rc in (0, 1), f"Bad exit code {rc}"
assert objs, f"No JSON output for {scraper}:{mode}" assert objs, f"No JSON output for {scraper}:{mode}"
if mode == "metadata": if mode in ("metadata", "contests"):
model = MetadataResult.model_validate(objs[-1]) Model = MODEL_FOR_MODE[mode]
model = Model.model_validate(objs[-1])
assert model is not None
assert model.success is True assert model.success is True
assert model.url if mode == "metadata":
assert len(model.problems) >= 1 assert model.url
assert all(isinstance(p.id, str) and p.id for p in model.problems) assert len(model.problems) >= 1
elif mode == "contests": assert all(isinstance(p.id, str) and p.id for p in model.problems)
model = ContestListResult.model_validate(objs[-1]) else:
assert model.success is True assert len(model.contests) >= 1
assert len(model.contests) >= 1
else: else:
assert len(objs) >= 1, "No test objects returned" assert len(objs) >= 1, "No test objects returned"
validated_any = False validated_any = False
@ -57,9 +67,9 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
assert hasattr(tr.combined, "input"), "combined missing input" assert hasattr(tr.combined, "input"), "combined missing input"
assert hasattr(tr.combined, "expected"), "combined missing expected" assert hasattr(tr.combined, "expected"), "combined missing expected"
assert isinstance(tr.combined.input, str), "combined.input not string" assert isinstance(tr.combined.input, str), "combined.input not string"
assert isinstance( assert isinstance(tr.combined.expected, str), (
tr.combined.expected, str "combined.expected not string"
), "combined.expected not string" )
assert hasattr(tr, "multi_test"), "Missing multi_test field" assert hasattr(tr, "multi_test"), "Missing multi_test field"
assert isinstance(tr.multi_test, bool), "multi_test not boolean" assert isinstance(tr.multi_test, bool), "multi_test not boolean"
validated_any = True validated_any = True
@ -73,12 +83,12 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
assert isinstance(obj["combined"], dict), "combined not a dict" assert isinstance(obj["combined"], dict), "combined not a dict"
assert "input" in obj["combined"], "combined missing input key" assert "input" in obj["combined"], "combined missing input key"
assert "expected" in obj["combined"], "combined missing expected key" assert "expected" in obj["combined"], "combined missing expected key"
assert isinstance( assert isinstance(obj["combined"]["input"], str), (
obj["combined"]["input"], str "combined.input not string"
), "combined.input not string" )
assert isinstance( assert isinstance(obj["combined"]["expected"], str), (
obj["combined"]["expected"], str "combined.expected not string"
), "combined.expected not string" )
assert "multi_test" in obj, "Missing multi_test field in raw JSON" assert "multi_test" in obj, "Missing multi_test field in raw JSON"
assert isinstance(obj["multi_test"], bool), "multi_test not boolean" assert isinstance(obj["multi_test"], bool), "multi_test not boolean"
validated_any = True validated_any = True

1269
uv.lock generated

File diff suppressed because it is too large Load diff

30
vim.toml Normal file
View file

@ -0,0 +1,30 @@
[selene]
base = "lua51"
name = "vim"
[vim]
any = true
[jit]
any = true
[assert]
any = true
[describe]
any = true
[it]
any = true
[before_each]
any = true
[after_each]
any = true
[spy]
any = true
[stub]
any = true

View file

@ -1,26 +0,0 @@
---
base: lua51
name: vim
lua_versions:
- luajit
globals:
vim:
any: true
jit:
any: true
assert:
any: true
describe:
any: true
it:
any: true
before_each:
any: true
after_each:
any: true
spy:
any: true
stub:
any: true
bit:
any: true