Compare commits

..

No commits in common. "fix/scraper-nil-run-nav" and "chore/add-issue-templates" have entirely different histories.

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

View file

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

3
.gitignore vendored
View file

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

View file

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

View file

@ -28,12 +28,11 @@ Install using your package manager of choice or via
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
[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

View file

@ -9,7 +9,7 @@ INTRODUCTION *cp.nvim*
cp.nvim is a competitive programming plugin that automates problem setup,
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*
@ -19,20 +19,195 @@ REQUIREMENTS *cp-requirements*
- 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
{ 'barrettruth/cp.nvim' }
: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.
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 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
vim.g.cp = {
vim.g.cp_config = {
languages = {
cpp = {
extension = 'cc',
@ -68,18 +243,6 @@ Configuration is done via `vim.g.cp`. Set this before using the plugin:
enabled_languages = { 'cpp', 'python' },
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,
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:
>lua
vim.g.cp = {
vim.g.cp_config = {
platforms = {
codeforces = {
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
run CSES problems with Rust using the single schema:
>lua
vim.g.cp = {
vim.g.cp_config = {
languages = {
rust = {
extension = 'rs',
@ -155,7 +318,7 @@ run CSES problems with Rust using the single schema:
(default: concatenates contest_id and problem_id, lowercased)
{ui} (|CpUI|) UI settings: panel, diff backend, picker.
{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*
Fields: ~
@ -232,340 +395,42 @@ run CSES problems with Rust using the single schema:
*cp.Hooks*
Fields: ~
{setup} (|cp.CpSetupHooks|, optional) One-time initialization hooks.
{on} (|cp.CpOnHooks|, optional) Recurring event hooks.
{before_run} (function, optional) Called before test panel opens.
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*
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
Hook functions receive the cp.nvim state object (|cp.State|). See
|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:
>lua
hooks = {
setup = {
contest = function(state)
local dir = vim.fn.fnamemodify(
state.get_source_file(state.get_language()), ':h')
vim.fn.system({ 'cp', '~/.clang-format', dir .. '/.clang-format' })
end,
code = function(state)
vim.opt_local.foldmethod = 'marker'
vim.diagnostic.enable(false)
end,
},
on = {
enter = function(state) vim.opt_local.winbar = '' end,
run = function(state) require('config.lsp').format() end,
},
setup_code = function(state)
print("Setting up " .. state.get_base_name())
print("Source file: " .. state.get_source_file())
end,
setup_io_input = function(bufnr, state)
-- Custom setup for input buffer
vim.api.nvim_set_option_value('number', false, { buf = bufnr })
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*
@ -643,41 +508,6 @@ URL format: https://cses.fi/problemset/task/{problem_id}
Usage examples: >
: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*
@ -712,9 +542,7 @@ Example: Setting up and solving AtCoder contest ABC324
:CP
< Automatically restores abc323 contest context
8. Submit solution: >
:CP submit
< Prompts for credentials on first use and submits to AtCoder.
8. Submit solutions on AtCoder website
==============================================================================
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 ~
Use the hooks.setup.io.input and hooks.setup.io.output hooks (see |cp.Hooks|)
to customize buffer appearance. By default, line numbers and columns are
removed via helpers.clearcol (see |cp-helpers|).
Use the setup_io_input and setup_io_output hooks (see |cp.Hooks|) to customize
buffer appearance. By default, line numbers and columns are removed via
helpers.clearcol (see |cp-helpers|).
==============================================================================
VERDICT FORMATTING *cp-verdict-format*
@ -925,72 +753,6 @@ When using :CP interact {interactor}, the interactor must be executable
Keymaps ~
<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*
@ -1081,124 +843,6 @@ Functions ~
Parameters: ~
{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*

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

View file

@ -16,8 +16,6 @@ local actions = constants.ACTIONS
---@field platform? string
---@field problem_id? string
---@field interactor_cmd? string
---@field generator_cmd? string
---@field brute_cmd? string
---@field test_index? integer
---@field test_indices? integer[]
---@field mode? string
@ -55,27 +53,6 @@ local function parse_command(args)
else
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
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
local inter = args[2]
if inter and inter ~= '' then
@ -83,27 +60,6 @@ local function parse_command(args)
else
return { type = 'action', action = 'interact' }
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
local test_index = nil
if #args >= 2 then
@ -329,14 +285,6 @@ function M.handle_command(opts)
elseif cmd.action == 'edit' then
local edit = require('cp.ui.edit')
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
elseif cmd.type == 'problem_jump' then
local platform = state.get_platform()
@ -366,13 +314,6 @@ function M.handle_command(opts)
local setup = require('cp.setup')
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
local cache_commands = require('cp.commands.cache')
cache_commands.handle_cache_command(cmd)

View file

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

View file

@ -1,28 +1,13 @@
local M = {}
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses', 'kattis', 'usaco' }
M.ACTIONS = {
'run',
'panel',
'next',
'prev',
'pick',
'cache',
'interact',
'edit',
'race',
'stress',
'submit',
'credentials',
}
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses' }
M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit' }
M.PLATFORM_DISPLAY_NAMES = {
atcoder = 'AtCoder',
codechef = 'CodeChef',
codeforces = 'CodeForces',
cses = 'CSES',
kattis = 'Kattis',
usaco = 'USACO',
}
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()
vim.health.start('cp.nvim [required] ~')
utils.setup_python_env()
if vim.fn.has('nvim-0.10.0') == 1 then
vim.health.ok('Neovim 0.10.0+ detected')
else
vim.health.error('cp.nvim requires Neovim 0.10.0+')
end
local uname = vim.uv.os_uname()
local uname = vim.loop.os_uname()
if uname.sysname == 'Windows_NT' then
vim.health.error('Windows is not supported')
end
if utils.is_nix_build() then
local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install'
vim.health.ok('Nix Python environment detected (' .. source .. ')')
local py = utils.get_nix_python()
vim.health.info('Python: ' .. py)
local r = vim.system({ py, '--version' }, { text = true }):wait()
if vim.fn.executable('uv') == 1 then
vim.health.ok('uv executable found')
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
if r.code == 0 then
vim.health.info('Python version: ' .. r.stdout:gsub('\n', ''))
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
end
else
if vim.fn.executable('uv') == 1 then
vim.health.ok('uv executable found')
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
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end
if vim.fn.executable('nix') == 1 then
vim.health.info('nix available but Python environment not resolved via nix')
end
local plugin_path = utils.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
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
local plugin_path = utils.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
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
local time_cap = utils.time_capability()
@ -58,7 +41,7 @@ local function check()
vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
end
local timeout_cap = utils.timeout_capability()
local timeout_cap = utils.time_capability()
if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else

View file

@ -15,25 +15,17 @@ local initialized = false
local function ensure_initialized()
if initialized then
return true
return
end
local user_config = vim.g.cp or {}
local ok, result = pcall(config_module.setup, user_config)
if not ok then
local msg = tostring(result):gsub('^.+:%d+: ', '')
vim.notify(msg, vim.log.levels.ERROR)
return false
end
config_module.set_current_config(result)
local user_config = vim.g.cp_config or {}
local config = config_module.setup(user_config)
config_module.set_current_config(config)
initialized = true
return true
end
---@return nil
function M.handle_command(opts)
if not ensure_initialized() then
return
end
ensure_initialized()
local commands = require('cp.commands')
commands.handle_command(opts)
end
@ -42,13 +34,4 @@ function M.is_initialized()
return initialized
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

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)
local cmd = substitute_template(compile_cmd, substitutions)
local sh = table.concat(cmd, ' ') .. ' 2>&1'
logger.log('compile: ' .. sh)
local t0 = vim.uv.hrtime()
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 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)
logger.log('run: ' .. sh)
local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)

View file

@ -19,7 +19,6 @@
---@class ProblemConstraints
---@field timeout_ms number
---@field memory_mb number
---@field precision number?
---@class PanelState
---@field test_cases RanTestCase[]
@ -57,8 +56,7 @@ local function load_constraints_from_cache(platform, contest_id, problem_id)
cache.load()
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
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, precision = precision }
return { timeout_ms = timeout_ms, memory_mb = memory_mb }
end
return nil
end
@ -101,53 +99,6 @@ local function build_command(cmd, substitutions)
return execute.build_command(cmd, substitutions)
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 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 })
@ -192,9 +143,7 @@ local function run_single_test_case(test_case, debug, on_complete)
end
local expected = test_case.expected or ''
local precision = (panel_state.constraints and panel_state.constraints.precision)
or config.ui.panel.precision
local ok = compare_outputs(out, expected, precision)
local ok = normalize_lines(out) == normalize_lines(expected)
local signal = r.signal
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
if #to_run == 0 then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
return
end
local function run_next(pos)
if pos > #to_run then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
return
end
local total = #to_run
local remaining = total
for _, idx in ipairs(to_run) do
M.run_test_case(idx, debug, function()
M.run_test_case(to_run[pos], debug, function()
if on_each then
on_each(idx, total)
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)
on_each(pos, #to_run)
end
run_next(pos + 1)
end)
end
run_next(1)
end
---@return PanelState

View file

@ -20,81 +20,52 @@ local function syshandle(result)
return { success = true, data = data }
end
---@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
return out
end
---@param platform string
---@param subcommand 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)
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 cmd = utils.get_python_cmd(platform, plugin_path)
vim.list_extend(cmd, { subcommand })
local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand }
vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
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
local uv = vim.uv
local uv = vim.loop
local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false)
local buf = ''
local handle
handle = uv.spawn(cmd[1], {
args = vim.list_slice(cmd, 2),
stdio = { nil, stdout, stderr },
env = spawn_env_list(env),
cwd = plugin_path,
}, function(code, signal)
if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if ok_tail then
opts.on_event(ev_tail)
handle = uv.spawn(
cmd[1],
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env },
function(code, signal)
if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if ok_tail then
opts.on_event(ev_tail)
end
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
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)
)
if not handle then
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
@ -131,10 +102,7 @@ local function run_scraper(platform, subcommand, args, opts)
return
end
local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path }
if opts and opts.stdin then
sysopts.stdin = opts.stdin
end
local sysopts = { text = true, timeout = 30000, env = env }
if opts and opts.sync then
local result = vim.system(cmd, sysopts):wait()
return syshandle(result)
@ -237,7 +205,6 @@ function M.scrape_all_tests(platform, contest_id, callback)
memory_mb = ev.memory_mb or 0,
interactive = ev.interactive or false,
multi_test = ev.multi_test or false,
precision = ev.precision ~= vim.NIL and ev.precision or nil,
problem_id = ev.problem_id,
})
end
@ -246,21 +213,4 @@ function M.scrape_all_tests(platform, contest_id, callback)
})
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

View file

@ -8,36 +8,6 @@ local logger = require('cp.log')
local scraper = require('cp.scraper')
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
---@return string?
local function get_current_file_language()
@ -130,8 +100,7 @@ local function start_tests(platform, contest_id, problems)
ev.timeout_ms or 0,
ev.memory_mb or 0,
ev.interactive,
ev.multi_test,
ev.precision
ev.multi_test
)
local io_state = state.get_io_view_state()
@ -152,7 +121,6 @@ end
---@param language? string
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_problem_id = state.get_problem_id()
state.set_platform(platform)
state.set_contest_id(contest_id)
@ -165,7 +133,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
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()
@ -175,10 +143,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
M.setup_problem(pid, language)
start_tests(platform, contest_id, problems)
local is_new_problem = old_problem_id ~= pid
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
if config_module.get_config().open_url and is_new_contest and contest_data.url then
vim.ui.open(contest_data.url:format(pid))
end
end
@ -195,7 +160,12 @@ function M.setup_contest(platform, contest_id, problem_id, language)
vim.bo[bufnr].buftype = ''
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({
bufnr = bufnr,
@ -203,7 +173,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
contest_id = contest_id,
language = lang,
requested_problem_id = problem_id,
token = vim.uv.hrtime(),
token = vim.loop.hrtime(),
})
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
@ -275,15 +245,7 @@ function M.setup_problem(problem_id, language)
return
end
local contest_dir = vim.fn.fnamemodify(source_file, ':h')
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
vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p')
local prov = state.get_provisional()
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 },
})
state.set_solution_win(vim.api.nvim_get_current_win())
if not vim.b[prov.bufnr].cp_setup_done then
apply_template(prov.bufnr, lang, platform)
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[prov.bufnr].cp_setup_done = true
end
else
helpers.clearcol(prov.bufnr)
if config.hooks and config.hooks.setup_code and not vim.b[prov.bufnr].cp_setup_done then
local ok = pcall(config.hooks.setup_code, state)
if ok then
vim.b[prov.bufnr].cp_setup_done = true
end
local o = config.hooks and config.hooks.on
if o and o.enter then
local bufnr = prov.bufnr
vim.api.nvim_create_autocmd('BufEnter', {
buffer = bufnr,
callback = function()
pcall(o.enter, state)
end,
})
pcall(o.enter, state)
end
elseif not vim.b[prov.bufnr].cp_setup_done then
helpers.clearcol(prov.bufnr)
vim.b[prov.bufnr].cp_setup_done = true
end
cache.set_file_state(
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()
state.set_solution_win(vim.api.nvim_get_current_win())
require('cp.ui.views').ensure_io_view()
if not vim.b[bufnr].cp_setup_done then
local is_new = vim.api.nvim_buf_line_count(bufnr) == 1
and vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] == ''
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)
if config.hooks and config.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
local ok = pcall(config.hooks.setup_code, state)
if ok then
vim.b[bufnr].cp_setup_done = true
end
local o = config.hooks and config.hooks.on
if o and o.enter then
vim.api.nvim_create_autocmd('BufEnter', {
buffer = bufnr,
callback = function()
pcall(o.enter, state)
end,
})
pcall(o.enter, state)
end
elseif not vim.b[bufnr].cp_setup_done then
helpers.clearcol(bufnr)
vim.b[bufnr].cp_setup_done = true
end
cache.set_file_state(
vim.fn.expand('%:p'),

View file

@ -9,6 +9,7 @@
---@class cp.IoViewState
---@field output_buf integer
---@field input_buf integer
---@field current_test_index integer?
---@field source_buf integer?
---@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

@ -26,8 +26,6 @@ function M.disable()
M.toggle_panel()
elseif active_panel == 'interactive' then
M.toggle_interactive()
elseif active_panel == 'stress' then
require('cp.stress').toggle()
else
logger.log(('Unknown panel type: %s'):format(tostring(active_panel)))
end
@ -69,11 +67,11 @@ function M.toggle_interactive(interactor_cmd)
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if
contest_data
and contest_data.index_map
and not contest_data.problems[contest_data.index_map[problem_id]].interactive
not contest_data
or not contest_data.index_map
or not contest_data.problems[contest_data.index_map[problem_id]].interactive
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
end
@ -123,22 +121,13 @@ function M.toggle_interactive(interactor_cmd)
end
local orchestrator =
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
if utils.is_nix_build() then
cmdline = table.concat({
vim.fn.shellescape(utils.get_nix_python()),
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
else
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
end
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
else
cmdline = vim.fn.shellescape(binary)
end
@ -240,6 +229,7 @@ local function get_or_create_io_buffers()
state.set_io_view_state({
output_buf = output_buf,
input_buf = input_buf,
current_test_index = 1,
source_buf = current_source_buf,
})
@ -304,6 +294,49 @@ local function get_or_create_io_buffers()
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
end
@ -402,12 +435,12 @@ function M.ensure_io_view()
local cfg = config_module.get_config()
local io = cfg.hooks and cfg.hooks.setup and cfg.hooks.setup.io
if io and io.output then
pcall(io.output, output_buf, state)
if cfg.hooks and cfg.hooks.setup_io_output then
pcall(cfg.hooks.setup_io_output, output_buf, state)
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
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
@ -788,7 +821,6 @@ function M.toggle_panel(panel_opts)
local contest_data = cache.get_contest_data(platform, contest_id)
if
contest_data
and contest_data.index_map
and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive
then
logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN)
@ -917,15 +949,14 @@ function M.toggle_panel(panel_opts)
setup_keybindings_for_buffer(test_buffers.tab_buf)
local o = config.hooks and config.hooks.on
if o and o.run then
vim.schedule(function()
o.run(state)
if config.hooks and config.hooks.before_run then
vim.schedule_wrap(function()
config.hooks.before_run(state)
end)
end
if panel_opts and panel_opts.debug and o and o.debug then
vim.schedule(function()
o.debug(state)
if panel_opts and panel_opts.debug and config.hooks and config.hooks.before_debug then
vim.schedule_wrap(function()
config.hooks.before_debug(state)
end)
end

View file

@ -2,10 +2,7 @@ local M = {}
local logger = require('cp.log')
local _nix_python = nil
local _nix_discovered = false
local uname = vim.uv.os_uname()
local uname = vim.loop.os_uname()
local _time_cached = false
local _time_path = nil
@ -60,11 +57,7 @@ local function find_gnu_time()
_time_cached = true
_time_path = nil
if uname and uname.sysname == 'Darwin' then
_time_reason = 'GNU time not found (install via: brew install coreutils)'
else
_time_reason = 'GNU time not found'
end
_time_reason = 'GNU time not found'
return _time_path, _time_reason
end
@ -86,103 +79,27 @@ function M.get_plugin_path()
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
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
---@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
function M.setup_python_env()
if python_env_setup then
return true
end
if _nix_python then
logger.log('Python env: nix (python=' .. _nix_python .. ')')
python_env_setup = true
return true
local plugin_path = M.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
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
local on_nixos = vim.fn.filereadable('/etc/NIXOS') == 1
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()
if vim.fn.isdirectory(venv_dir) == 0 then
logger.log('Setting up Python environment for scrapers...')
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
@ -191,33 +108,14 @@ function M.setup_python_env()
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
:wait()
if result.code ~= 0 then
logger.log(
'Failed to setup Python environment: ' .. (result.stderr or ''),
vim.log.levels.ERROR
)
logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
return false
end
if result.stderr and result.stderr ~= '' then
logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', ''))
end
python_env_setup = true
return true
logger.log('Python environment setup complete.')
end
if vim.fn.executable('nix') == 1 then
logger.log('Python env: nix discovery')
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
python_env_setup = true
return true
end
--- Configure the buffer with good defaults
@ -264,12 +162,20 @@ function M.check_required_runtime()
local time = M.time_capability()
if not time.ok then
return false, time.reason
return false, 'GNU time not found: ' .. (time.reason or '')
end
local timeout = M.timeout_capability()
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
return true
@ -319,11 +225,7 @@ local function find_gnu_timeout()
_timeout_cached = true
_timeout_path = nil
if uname and uname.sysname == 'Darwin' then
_timeout_reason = 'GNU timeout not found (install via: brew install coreutils)'
else
_timeout_reason = 'GNU timeout not found'
end
_timeout_reason = 'GNU timeout not found'
return _timeout_path, _timeout_reason
end
@ -338,7 +240,7 @@ function M.timeout_capability()
end
function M.cwd_executables()
local uv = vim.uv
local uv = vim.uv or vim.loop
local req = uv.fs_scandir('.')
if not req then
return {}

0
new Normal file
View file

View file

@ -66,7 +66,7 @@ end, {
return filter_candidates(contests)
elseif args[2] == 'cache' then
return filter_candidates({ 'clear', 'read' })
elseif args[2] == 'stress' or args[2] == 'interact' then
elseif args[2] == 'interact' then
local utils = require('cp.utils')
return filter_candidates(utils.cwd_executables())
elseif args[2] == 'edit' then
@ -103,12 +103,6 @@ end, {
end
end
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
return filter_candidates({ '--lang' })
else
@ -118,17 +112,7 @@ end, {
end
end
elseif num_args == 4 then
if args[2] == 'stress' 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
if args[2] == 'cache' and args[3] == 'clear' then
local candidates = vim.list_extend({}, platforms)
table.insert(candidates, '')
return filter_candidates(candidates)
@ -150,9 +134,7 @@ end, {
return filter_candidates(candidates)
end
elseif num_args == 5 then
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
return filter_candidates({ '--lang' })
elseif args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
if args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
local cache = require('cp.cache')
cache.load()
local contests = cache.get_cached_contest_ids(args[4])
@ -165,31 +147,10 @@ end, {
end
end
elseif num_args == 6 then
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) 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
if vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
return filter_candidates(get_enabled_languages(args[2]))
end
end
return {}
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",
"pydantic>=2.11.10",
"requests>=2.32.5",
"scrapling[fetchers]>=0.3.5",
"types-requests>=2.32.4.20250913",
]
[dependency-groups]

View file

@ -14,14 +14,13 @@ from bs4 import BeautifulSoup, Tag
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .base import BaseScraper, extract_precision
from .base import BaseScraper
from .models import (
CombinedTest,
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
SubmitResult,
TestCase,
TestsResult,
)
@ -122,23 +121,6 @@ def _parse_last_page(html: str) -> int:
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]:
soup = BeautifulSoup(html, "html.parser")
tbody = soup.select_one("table.table-default tbody") or soup.select_one("tbody")
@ -157,10 +139,7 @@ def _parse_archive_contests(html: str) -> list[ContestSummary]:
continue
cid = m.group(1)
name = a.get_text(strip=True)
start_time = _parse_start_time(tr)
out.append(
ContestSummary(id=cid, name=name, display_name=name, start_time=start_time)
)
out.append(ContestSummary(id=cid, name=name, display_name=name))
return out
@ -190,7 +169,7 @@ def _parse_tasks_list(html: str) -> list[dict[str, str]]:
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")
txt = soup.get_text(" ", strip=True)
timeout_ms = 0
@ -202,10 +181,9 @@ def _extract_problem_info(html: str) -> tuple[int, float, bool, float | None]:
if ms:
memory_mb = float(ms.group(1)) * MIB_TO_MB
div = soup.select_one("#problem-statement")
body = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
interactive = "This is an interactive" in body
precision = extract_precision(body)
return timeout_ms, memory_mb, interactive, precision
txt = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
interactive = "This is an interactive" in txt
return timeout_ms, memory_mb, interactive
def _extract_samples(html: str) -> list[TestCase]:
@ -242,13 +220,12 @@ def _scrape_problem_page_sync(contest_id: str, slug: str) -> dict[str, Any]:
tests = _extract_samples(html)
except Exception:
tests = []
timeout_ms, memory_mb, interactive, precision = _extract_problem_info(html)
timeout_ms, memory_mb, interactive = _extract_problem_info(html)
return {
"tests": tests,
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
"precision": precision,
}
@ -264,29 +241,14 @@ def _to_problem_summaries(rows: list[dict[str, str]]) -> list[ProblemSummary]:
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 with httpx.AsyncClient(
limits=httpx.Limits(max_connections=100, max_keepalive_connections=100),
) as client:
upcoming = await _fetch_upcoming_contests_async(client)
first_html = await _get_async(client, ARCHIVE_URL)
last = _parse_last_page(first_html)
out = _parse_archive_contests(first_html)
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
tasks = [
asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}"))
@ -295,10 +257,6 @@ async def _fetch_all_contests_async() -> list[ContestSummary]:
for coro in asyncio.as_completed(tasks):
html = await coro
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
@ -361,7 +319,6 @@ class AtcoderScraper(BaseScraper):
"memory_mb": data.get("memory_mb", 0),
"interactive": bool(data.get("interactive")),
"multi_test": False,
"precision": data.get("precision"),
}
),
flush=True,
@ -369,76 +326,6 @@ class AtcoderScraper(BaseScraper):
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:
try:
login_page = _session.get(
f"{BASE_URL}/login", headers=HEADERS, 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,
},
headers=HEADERS,
timeout=TIMEOUT_SECONDS,
)
login_resp.raise_for_status()
submit_page = _session.get(
f"{BASE_URL}/contests/{contest_id}/submit",
headers=HEADERS,
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,
},
headers=HEADERS,
timeout=TIMEOUT_SECONDS,
)
submit_resp.raise_for_status()
return SubmitResult(
success=True, error="", submission_id="", verdict="submitted"
)
except Exception as e:
return SubmitResult(success=False, error=str(e))
return await asyncio.to_thread(_submit_sync)
async def main_async() -> int:
if len(sys.argv) < 2:

View file

@ -1,37 +1,8 @@
import asyncio
import json
import os
import re
import sys
from abc import ABC, abstractmethod
from .language_ids import get_language_id
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
from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
class BaseScraper(ABC):
@ -48,16 +19,6 @@ class BaseScraper(ABC):
@abstractmethod
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:
name = self.platform_name
return f"Usage: {name}.py metadata <id> | tests <id> | contests"
@ -79,9 +40,6 @@ class BaseScraper(ABC):
def _contests_error(self, msg: str) -> ContestListResult:
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:
if len(args) < 2:
print(self._metadata_error(self._usage()).model_dump_json())
@ -113,27 +71,6 @@ class BaseScraper(ABC):
print(result.model_dump_json())
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 _:
print(
self._metadata_error(

View file

@ -6,15 +6,14 @@ import re
from typing import Any
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,
ProblemSummary,
SubmitResult,
TestCase,
)
@ -51,9 +50,8 @@ def _extract_memory_limit(html: str) -> float:
def _fetch_html_sync(url: str) -> str:
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S)
response.raise_for_status()
return response.text
response = Fetcher.get(url)
return str(response.body)
class CodeChefScraper(BaseScraper):
@ -220,13 +218,11 @@ class CodeChefScraper(BaseScraper):
)
memory_mb = _extract_memory_limit(html)
interactive = False
precision = extract_precision(html)
except Exception:
tests = []
timeout_ms = 1000
memory_mb = 256.0
interactive = False
precision = None
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 ""
@ -244,7 +240,6 @@ class CodeChefScraper(BaseScraper):
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": False,
"precision": precision,
}
tasks = [run_one(problem_code) for problem_code in problems.keys()]
@ -252,21 +247,6 @@ class CodeChefScraper(BaseScraper):
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="CodeChef submit not yet implemented",
submission_id="",
verdict="",
)
if __name__ == "__main__":
CodeChefScraper().run_cli()

View file

@ -2,23 +2,27 @@
import asyncio
import json
import logging
import re
from typing import Any
import requests
from bs4 import BeautifulSoup, Tag
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,
ProblemSummary,
SubmitResult,
TestCase,
)
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
BASE_URL = "https://codeforces.com"
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
TIMEOUT_SECONDS = 30
@ -79,7 +83,7 @@ def _extract_title(block: Tag) -> tuple[str, str]:
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
st = block.find("div", class_="sample-test")
if not isinstance(st, Tag):
if not st:
return [], False
input_pres: list[Tag] = [
@ -136,9 +140,10 @@ def _is_interactive(block: Tag) -> bool:
def _fetch_problems_html(contest_id: str) -> str:
url = f"{BASE_URL}/contest/{contest_id}/problems"
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS)
response.raise_for_status()
return response.text
page = Fetcher.get(
url,
)
return page.html_content
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
@ -154,7 +159,6 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
raw_samples, is_grouped = _extract_samples(b)
timeout_ms, memory_mb = _extract_limits(b)
interactive = _is_interactive(b)
precision = extract_precision(b.get_text(" ", strip=True))
if is_grouped and raw_samples:
combined_input = f"{len(raw_samples)}\n" + "\n".join(
@ -181,7 +185,6 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": is_grouped,
"precision": precision,
}
)
return out
@ -231,20 +234,11 @@ class CodeforcesScraper(BaseScraper):
contests: list[ContestSummary] = []
for c in data["result"]:
phase = c.get("phase")
if phase not in ("FINISHED", "BEFORE", "CODING"):
if c.get("phase") != "FINISHED":
continue
cid = str(c["id"])
name = c["name"]
start_time = c.get("startTimeSeconds") if phase != "FINISHED" else None
contests.append(
ContestSummary(
id=cid,
name=name,
display_name=name,
start_time=start_time,
)
)
contests.append(ContestSummary(id=cid, name=name, display_name=name))
if not contests:
return self._contests_error("No contests found")
@ -275,27 +269,11 @@ class CodeforcesScraper(BaseScraper):
"memory_mb": b.get("memory_mb", 0),
"interactive": bool(b.get("interactive")),
"multi_test": bool(b.get("multi_test", False)),
"precision": b.get("precision"),
}
),
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__":
CodeforcesScraper().run_cli()

View file

@ -7,13 +7,12 @@ from typing import Any
import httpx
from .base import BaseScraper, extract_precision
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
SubmitResult,
TestCase,
)
@ -130,21 +129,17 @@ def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary]
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)
mm = MEM_RE.search(html)
t = int(round(float(tm.group(1)) * 1000)) if tm else 0
m = int(mm.group(1)) if mm else 0
md = MD_BLOCK_RE.search(html)
interactive = False
precision = None
if md:
body = md.group(1)
interactive = "This is an interactive problem." in body
from bs4 import BeautifulSoup
precision = extract_precision(BeautifulSoup(body, "html.parser").get_text(" "))
return t, m, interactive, precision
return t, m, interactive
def parse_title(html: str) -> str:
@ -232,17 +227,10 @@ class CSESScraper(BaseScraper):
try:
html = await fetch_text(client, task_path(pid))
tests = parse_tests(html)
timeout_ms, memory_mb, interactive, precision = (
_extract_problem_info(html)
)
timeout_ms, memory_mb, interactive = _extract_problem_info(html)
except Exception:
tests = []
timeout_ms, memory_mb, interactive, precision = (
0,
0,
False,
None,
)
timeout_ms, memory_mb, interactive = 0, 0, False
combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = (
@ -262,7 +250,6 @@ class CSESScraper(BaseScraper):
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": False,
"precision": precision,
}
tasks = [run_one(p.id) for p in problems]
@ -270,21 +257,6 @@ class CSESScraper(BaseScraper):
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="CSES submit not yet implemented",
submission_id="",
verdict="",
)
if __name__ == "__main__":
CSESScraper().run_cli()

View file

@ -1,289 +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
name: str
display_name: str | None = None
start_time: int | None = None
model_config = ConfigDict(extra="forbid")
@ -64,13 +63,6 @@ class TestsResult(ScrapingResult):
model_config = ConfigDict(extra="forbid")
class SubmitResult(ScrapingResult):
submission_id: str = ""
verdict: str = ""
model_config = ConfigDict(extra="forbid")
class ScraperConfig(BaseModel):
timeout_seconds: int = 30
max_retries: int = 3

View file

@ -1,299 +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'
[lints]
bad_string_escape = 'allow'

View file

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

View file

@ -6,6 +6,11 @@ from scrapers.models import (
TestsResult,
)
MODEL_FOR_MODE = {
"metadata": MetadataResult,
"contests": ContestListResult,
}
MATRIX = {
"cses": {
"metadata": ("introductory_problems",),
@ -38,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 objs, f"No JSON output for {scraper}:{mode}"
if mode == "metadata":
model = MetadataResult.model_validate(objs[-1])
if mode in ("metadata", "contests"):
Model = MODEL_FOR_MODE[mode]
model = Model.model_validate(objs[-1])
assert model is not None
assert model.success is True
assert model.url
assert len(model.problems) >= 1
assert all(isinstance(p.id, str) and p.id for p in model.problems)
elif mode == "contests":
model = ContestListResult.model_validate(objs[-1])
assert model.success is True
assert len(model.contests) >= 1
if mode == "metadata":
assert model.url
assert len(model.problems) >= 1
assert all(isinstance(p.id, str) and p.id for p in model.problems)
else:
assert len(model.contests) >= 1
else:
assert len(objs) >= 1, "No test objects returned"
validated_any = False

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