Compare commits
No commits in common. "fix/scraper-nil-run-nav" and "chore/add-issue-templates" have entirely different histories.
fix/scrape
...
chore/add-
46 changed files with 1954 additions and 3055 deletions
3
.envrc
Normal file
3
.envrc
Normal 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
112
.github/workflows/ci.yaml
vendored
Normal 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
|
||||
29
.github/workflows/quality.yaml
vendored
29
.github/workflows/quality.yaml
vendored
|
|
@ -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 .
|
||||
|
|
|
|||
4
.github/workflows/test.yaml
vendored
4
.github/workflows/test.yaml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -14,6 +14,3 @@ __pycache__
|
|||
.claude/
|
||||
|
||||
node_modules/
|
||||
|
||||
.envrc
|
||||
.direnv/
|
||||
|
|
|
|||
20
.luarc.json
20
.luarc.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
790
doc/cp.nvim.txt
790
doc/cp.nvim.txt
|
|
@ -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, A–H 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
43
flake.lock
generated
|
|
@ -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
|
||||
}
|
||||
76
flake.nix
76
flake.nix
|
|
@ -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
|
||||
];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
144
lua/cp/race.lua
144
lua/cp/race.lua
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
117
lua/cp/setup.lua
117
lua/cp/setup.lua
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
156
lua/cp/utils.lua
156
lua/cp/utils.lua
|
|
@ -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
0
new
Normal 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' })
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -1,4 +1 @@
|
|||
std = 'vim'
|
||||
|
||||
[lints]
|
||||
bad_string_escape = 'allow'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
vim.toml
Normal file
30
vim.toml
Normal 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
|
||||
26
vim.yaml
26
vim.yaml
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue