Compare commits
No commits in common. "fix/edit-buffer-cleanup-acwrite" and "chore/add-issue-templates" have entirely different histories.
fix/edit-b
...
chore/add-
47 changed files with 2084 additions and 3211 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'
|
- '*.lua'
|
||||||
- '.luarc.json'
|
- '.luarc.json'
|
||||||
- '*.toml'
|
- '*.toml'
|
||||||
- 'vim.yaml'
|
|
||||||
python:
|
python:
|
||||||
- 'scripts/**/.py'
|
- 'scripts/**/.py'
|
||||||
- 'scrapers/**/*.py'
|
- 'scrapers/**/*.py'
|
||||||
|
|
@ -46,8 +45,11 @@ jobs:
|
||||||
if: ${{ needs.changes.outputs.lua == 'true' }}
|
if: ${{ needs.changes.outputs.lua == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: cachix/install-nix-action@v31
|
- uses: JohnnyMorganz/stylua-action@v4
|
||||||
- run: nix develop --command stylua --check .
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
version: 2.1.0
|
||||||
|
args: --check .
|
||||||
|
|
||||||
lua-lint:
|
lua-lint:
|
||||||
name: Lua Lint Check
|
name: Lua Lint Check
|
||||||
|
|
@ -56,8 +58,11 @@ jobs:
|
||||||
if: ${{ needs.changes.outputs.lua == 'true' }}
|
if: ${{ needs.changes.outputs.lua == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: cachix/install-nix-action@v31
|
- name: Lint with Selene
|
||||||
- run: nix develop --command selene --display-style quiet .
|
uses: NTBBloodbath/selene-action@v1.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
args: --display-style quiet .
|
||||||
|
|
||||||
lua-typecheck:
|
lua-typecheck:
|
||||||
name: Lua Type Check
|
name: Lua Type Check
|
||||||
|
|
@ -122,5 +127,15 @@ jobs:
|
||||||
if: ${{ needs.changes.outputs.markdown == 'true' }}
|
if: ${{ needs.changes.outputs.markdown == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: cachix/install-nix-action@v31
|
- name: Setup pnpm
|
||||||
- run: nix develop --command prettier --check .
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Install prettier
|
||||||
|
run: pnpm add -g prettier@3.1.0
|
||||||
|
- name: Check markdown formatting with prettier
|
||||||
|
run: prettier --check .
|
||||||
|
|
|
||||||
4
.github/workflows/test.yaml
vendored
4
.github/workflows/test.yaml
vendored
|
|
@ -44,7 +44,9 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v4
|
uses: astral-sh/setup-uv@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies with pytest
|
||||||
run: uv sync --dev
|
run: uv sync --dev
|
||||||
|
- name: Fetch camoufox data
|
||||||
|
run: uv run camoufox fetch
|
||||||
- name: Run Python tests
|
- name: Run Python tests
|
||||||
run: uv run pytest tests/ -v
|
run: uv run pytest tests/ -v
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -14,6 +14,3 @@ __pycache__
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
.envrc
|
|
||||||
.direnv/
|
|
||||||
|
|
|
||||||
20
.luarc.json
20
.luarc.json
|
|
@ -1,16 +1,8 @@
|
||||||
{
|
{
|
||||||
"runtime": {
|
"runtime.version": "Lua 5.1",
|
||||||
"version": "LuaJIT",
|
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
||||||
"path": ["lua/?.lua", "lua/?/init.lua"]
|
"diagnostics.globals": ["vim"],
|
||||||
},
|
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
|
||||||
"diagnostics": {
|
"workspace.checkThirdParty": false,
|
||||||
"globals": ["vim"]
|
"completion.callSnippet": "Replace"
|
||||||
},
|
|
||||||
"workspace": {
|
|
||||||
"library": ["$VIMRUNTIME/lua", "${3rd}/luv/library", "${3rd}/busted/library"],
|
|
||||||
"checkThirdParty": false
|
|
||||||
},
|
|
||||||
"completion": {
|
|
||||||
"callSnippet": "Replace"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,11 @@ Install using your package manager of choice or via
|
||||||
luarocks install cp.nvim
|
luarocks install cp.nvim
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Optional Dependencies
|
||||||
|
|
||||||
|
- [uv](https://docs.astral.sh/uv/) for problem scraping
|
||||||
- GNU [time](https://www.gnu.org/software/time/) and
|
- GNU [time](https://www.gnu.org/software/time/) and
|
||||||
[timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html)
|
[timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html)
|
||||||
- [uv](https://docs.astral.sh/uv/) or [nix](https://nixos.org/) for problem
|
|
||||||
scraping
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
cp.nvim is a competitive programming plugin that automates problem setup,
|
||||||
compilation, and testing workflow for online judges.
|
compilation, and testing workflow for online judges.
|
||||||
|
|
||||||
Supported platforms: AtCoder, CodeChef, Codeforces, CSES, Kattis, USACO
|
Supported platforms (for now!): AtCoder, Codeforces, CSES
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
REQUIREMENTS *cp-requirements*
|
REQUIREMENTS *cp-requirements*
|
||||||
|
|
@ -19,20 +19,195 @@ REQUIREMENTS *cp-requirements*
|
||||||
- uv package manager (https://docs.astral.sh/uv/)
|
- uv package manager (https://docs.astral.sh/uv/)
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
SETUP *cp-setup*
|
COMMANDS *cp-commands*
|
||||||
|
|
||||||
Load cp.nvim with your package manager. For example, with lazy.nvim: >lua
|
:CP *:CP*
|
||||||
{ 'barrettruth/cp.nvim' }
|
cp.nvim uses a single :CP command with intelligent argument parsing:
|
||||||
|
|
||||||
|
Setup Commands ~
|
||||||
|
:CP {platform} {contest_id} [--lang {language}]
|
||||||
|
Full setup: set platform and load contest metadata.
|
||||||
|
Scrapes test cases and creates source file.
|
||||||
|
--lang: Use specific language (default: platform default)
|
||||||
|
Examples: >
|
||||||
|
:CP codeforces 1933
|
||||||
|
:CP codeforces 1933 --lang python
|
||||||
|
<
|
||||||
|
View Commands ~
|
||||||
|
:CP run [all|n|n,m,...] [--debug]
|
||||||
|
Run tests in I/O view (see |cp-io-view|).
|
||||||
|
Lightweight split showing test verdicts.
|
||||||
|
|
||||||
|
Execution modes:
|
||||||
|
• :CP run Combined: single execution with all tests
|
||||||
|
(auto-switches to individual when multiple samples)
|
||||||
|
• :CP run all Individual: N separate executions
|
||||||
|
• :CP run n Individual: run test n only
|
||||||
|
• :CP run n,m,... Individual: run specific tests (e.g. nth and mth)
|
||||||
|
|
||||||
|
--debug: Use debug build (builds to build/<name>.dbg)
|
||||||
|
|
||||||
|
Combined mode runs all test inputs in one execution (matching
|
||||||
|
platform behavior for multi-test problems). When a problem has
|
||||||
|
multiple independent sample test cases, :CP run auto-switches to
|
||||||
|
individual mode to run each sample separately.
|
||||||
|
|
||||||
|
Examples: >
|
||||||
|
:CP run " Combined: all tests, one execution
|
||||||
|
:CP run all " Individual: all tests, N executions
|
||||||
|
:CP run 2 " Individual: test 2 only
|
||||||
|
:CP run 1,3,5 " Individual: tests 1, 3, and 5
|
||||||
|
:CP run all --debug " Individual with debug build
|
||||||
|
<
|
||||||
|
:CP panel [--debug] [n]
|
||||||
|
Open full-screen test panel (see |cp-panel|).
|
||||||
|
Aggregate table with diff modes for detailed analysis.
|
||||||
|
Optional [n] focuses on specific test.
|
||||||
|
--debug: Use debug build (with sanitizers, etc.)
|
||||||
|
Examples: >
|
||||||
|
:CP panel " All tests
|
||||||
|
:CP panel --debug 3 " Test 3, debug build
|
||||||
|
<
|
||||||
|
|
||||||
|
:CP pick [--lang {language}]
|
||||||
|
Launch configured picker for interactive
|
||||||
|
platform/contest selection.
|
||||||
|
--lang: Pre-select language for chosen contest.
|
||||||
|
Example: >
|
||||||
|
:CP pick
|
||||||
|
:CP pick --lang python
|
||||||
|
<
|
||||||
|
|
||||||
|
:CP interact [script]
|
||||||
|
Open an interactive terminal for the current problem.
|
||||||
|
If an executable interactor is provided, runs the compiled
|
||||||
|
binary against the source file (see
|
||||||
|
*cp-interact*). Otherwise, runs the source
|
||||||
|
file. Only valid for interactive problems.
|
||||||
|
|
||||||
|
Navigation Commands ~
|
||||||
|
:CP next [--lang {language}]
|
||||||
|
Navigate to next problem in current contest.
|
||||||
|
Stops at last problem (no wrapping).
|
||||||
|
--lang: Use specific language for next problem.
|
||||||
|
By default, preserves current file's language if
|
||||||
|
enabled for the new problem, otherwise uses platform
|
||||||
|
default.
|
||||||
|
Examples: >
|
||||||
|
:CP next
|
||||||
|
:CP next --lang python
|
||||||
|
<
|
||||||
|
:CP prev [--lang {language}]
|
||||||
|
Navigate to previous problem in current contest.
|
||||||
|
Stops at first problem (no wrapping).
|
||||||
|
--lang: Use specific language for previous problem.
|
||||||
|
By default, preserves current file's language if
|
||||||
|
enabled for the new problem, otherwise uses platform
|
||||||
|
default.
|
||||||
|
Examples: >
|
||||||
|
:CP prev
|
||||||
|
:CP prev --lang cpp
|
||||||
|
<
|
||||||
|
:CP {problem_id} [--lang {language}]
|
||||||
|
Jump to problem {problem_id} in a contest.
|
||||||
|
Requires that a contest has already been set up.
|
||||||
|
--lang: Use specific language for this problem.
|
||||||
|
Examples: >
|
||||||
|
:CP B
|
||||||
|
:CP C --lang python
|
||||||
|
<
|
||||||
|
|
||||||
|
Edit Commands ~
|
||||||
|
:CP edit [n]
|
||||||
|
Open grid test editor showing all test cases.
|
||||||
|
Tests displayed as 2×N grid (2 rows, N columns):
|
||||||
|
• Top row: Test inputs (editable)
|
||||||
|
• Bottom row: Expected outputs (editable)
|
||||||
|
|
||||||
|
Optional [n]: Jump cursor to test n's input buffer
|
||||||
|
|
||||||
|
Changes saved to both cache and disk on exit,
|
||||||
|
taking effect immediately in :CP run and CLI.
|
||||||
|
|
||||||
|
Keybindings (configurable via |EditConfig|):
|
||||||
|
q Save all and exit editor
|
||||||
|
]t Jump to next test column
|
||||||
|
[t Jump to previous test column
|
||||||
|
gd Delete current test column
|
||||||
|
ga Add new test column at end
|
||||||
|
<c-w> Normal window navigation
|
||||||
|
|
||||||
|
Examples: >
|
||||||
|
:CP edit " Edit all tests
|
||||||
|
:CP edit 3 " Edit all, start at test 3
|
||||||
|
<
|
||||||
|
|
||||||
|
State Restoration ~
|
||||||
|
:CP Restore state from current file.
|
||||||
|
Automatically detects platform, contest, problem,
|
||||||
|
and language from cached state. Use this after
|
||||||
|
switching files to restore your CP environment.
|
||||||
|
|
||||||
|
Cache Commands ~
|
||||||
|
:CP cache clear [platform] [contest]
|
||||||
|
Clear cache data at different granularities:
|
||||||
|
• No args: Clear all cached data
|
||||||
|
• [platform]: Clear all data for a platform
|
||||||
|
• [platform] [contest]: Clear specific contest
|
||||||
|
Examples: >
|
||||||
|
:CP cache clear
|
||||||
|
:CP cache clear codeforces
|
||||||
|
:CP cache clear codeforces 1848
|
||||||
|
<
|
||||||
|
:CP cache read
|
||||||
|
View the cache in a pretty-printed lua buffer.
|
||||||
|
Exit with q.
|
||||||
|
|
||||||
|
Template Variables ~
|
||||||
|
*cp-template-vars*
|
||||||
|
Command templates support variable substitution using {variable} syntax:
|
||||||
|
|
||||||
|
• {source} Source file path (e.g. "abc324a.cpp")
|
||||||
|
• {binary} Output binary path (e.g. "build/abc324a.run" or
|
||||||
|
"build/abc324a.dbg" for debug builds)
|
||||||
|
|
||||||
|
Example template: >
|
||||||
|
build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
|
||||||
|
< Would expand to: >
|
||||||
|
g++ abc324a.cpp -o build/abc324a.run -std=c++17
|
||||||
|
<
|
||||||
|
Debug Builds ~
|
||||||
|
*cp-debug-builds*
|
||||||
|
The --debug flag uses the debug command configuration instead of build:
|
||||||
|
|
||||||
|
• Normal build: commands.build → outputs to build/<name>.run
|
||||||
|
• Debug build: commands.debug → outputs to build/<name>.dbg
|
||||||
|
|
||||||
|
Debug builds typically include sanitizers (address, undefined behavior) to
|
||||||
|
catch memory errors, buffer overflows, and other issues. Both binaries
|
||||||
|
coexist, so you can switch between normal and debug mode without
|
||||||
|
recompiling.
|
||||||
|
|
||||||
|
Example debug configuration: >
|
||||||
|
languages = {
|
||||||
|
cpp = {
|
||||||
|
extension = 'cc',
|
||||||
|
commands = {
|
||||||
|
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
|
||||||
|
run = { '{binary}' },
|
||||||
|
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
|
||||||
|
'{source}', '-o', '{binary}' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
<
|
<
|
||||||
The plugin works automatically with no configuration required. For
|
|
||||||
customization, see |cp-config|.
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
CONFIGURATION *cp-config*
|
CONFIGURATION *cp-config*
|
||||||
|
|
||||||
Configuration is done via `vim.g.cp`. Set this before using the plugin:
|
Configuration is done via `vim.g.cp_config`. Set this before using the plugin:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp = {
|
vim.g.cp_config = {
|
||||||
languages = {
|
languages = {
|
||||||
cpp = {
|
cpp = {
|
||||||
extension = 'cc',
|
extension = 'cc',
|
||||||
|
|
@ -68,18 +243,6 @@ Configuration is done via `vim.g.cp`. Set this before using the plugin:
|
||||||
enabled_languages = { 'cpp', 'python' },
|
enabled_languages = { 'cpp', 'python' },
|
||||||
default_language = 'cpp',
|
default_language = 'cpp',
|
||||||
},
|
},
|
||||||
codechef = {
|
|
||||||
enabled_languages = { 'cpp', 'python' },
|
|
||||||
default_language = 'cpp',
|
|
||||||
},
|
|
||||||
usaco = {
|
|
||||||
enabled_languages = { 'cpp', 'python' },
|
|
||||||
default_language = 'cpp',
|
|
||||||
},
|
|
||||||
kattis = {
|
|
||||||
enabled_languages = { 'cpp', 'python' },
|
|
||||||
default_language = 'cpp',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
open_url = true,
|
open_url = true,
|
||||||
debug = false,
|
debug = false,
|
||||||
|
|
@ -111,7 +274,7 @@ the default; per-platform overrides can tweak 'extension' or 'commands'.
|
||||||
|
|
||||||
For example, to run CodeForces contests with Python by default:
|
For example, to run CodeForces contests with Python by default:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp = {
|
vim.g.cp_config = {
|
||||||
platforms = {
|
platforms = {
|
||||||
codeforces = {
|
codeforces = {
|
||||||
default_language = 'python',
|
default_language = 'python',
|
||||||
|
|
@ -122,7 +285,7 @@ For example, to run CodeForces contests with Python by default:
|
||||||
Any language is supported provided the proper configuration. For example, to
|
Any language is supported provided the proper configuration. For example, to
|
||||||
run CSES problems with Rust using the single schema:
|
run CSES problems with Rust using the single schema:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp = {
|
vim.g.cp_config = {
|
||||||
languages = {
|
languages = {
|
||||||
rust = {
|
rust = {
|
||||||
extension = 'rs',
|
extension = 'rs',
|
||||||
|
|
@ -155,7 +318,7 @@ run CSES problems with Rust using the single schema:
|
||||||
(default: concatenates contest_id and problem_id, lowercased)
|
(default: concatenates contest_id and problem_id, lowercased)
|
||||||
{ui} (|CpUI|) UI settings: panel, diff backend, picker.
|
{ui} (|CpUI|) UI settings: panel, diff backend, picker.
|
||||||
{open_url} (boolean) Open the contest & problem url in the browser
|
{open_url} (boolean) Open the contest & problem url in the browser
|
||||||
when a new contest is opened or the active problem changes.
|
when the contest is first opened.
|
||||||
|
|
||||||
*CpPlatform*
|
*CpPlatform*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
|
|
@ -232,340 +395,42 @@ run CSES problems with Rust using the single schema:
|
||||||
|
|
||||||
*cp.Hooks*
|
*cp.Hooks*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
{setup} (|cp.CpSetupHooks|, optional) One-time initialization hooks.
|
{before_run} (function, optional) Called before test panel opens.
|
||||||
{on} (|cp.CpOnHooks|, optional) Recurring event hooks.
|
function(state: cp.State)
|
||||||
|
{before_debug} (function, optional) Called before debug build/run.
|
||||||
|
function(state: cp.State)
|
||||||
|
{setup_code} (function, optional) Called after source file is opened.
|
||||||
|
function(state: cp.State)
|
||||||
|
{setup_io_input} (function, optional) Called when I/O input buffer created.
|
||||||
|
function(bufnr: integer, state: cp.State)
|
||||||
|
Default: helpers.clearcol (removes line numbers/columns)
|
||||||
|
{setup_io_output} (function, optional) Called when I/O output buffer created.
|
||||||
|
function(bufnr: integer, state: cp.State)
|
||||||
|
Default: helpers.clearcol (removes line numbers/columns)
|
||||||
|
|
||||||
*cp.CpSetupHooks*
|
Hook functions receive the cp.nvim state object (|cp.State|). See
|
||||||
Fields: ~
|
|
||||||
{contest} (function, optional) Called once when a contest directory
|
|
||||||
is first created (not on subsequent visits).
|
|
||||||
function(state: cp.State)
|
|
||||||
{code} (function, optional) Called after the source buffer is
|
|
||||||
opened for the first time (guarded by cp_setup_done).
|
|
||||||
function(state: cp.State)
|
|
||||||
{io} (|cp.CpSetupIOHooks|, optional) I/O buffer hooks.
|
|
||||||
|
|
||||||
*cp.CpSetupIOHooks*
|
|
||||||
Fields: ~
|
|
||||||
{input} (function, optional) Called when the I/O input buffer is
|
|
||||||
created. function(bufnr: integer, state: cp.State)
|
|
||||||
Default: helpers.clearcol
|
|
||||||
{output} (function, optional) Called when the I/O output buffer is
|
|
||||||
created. function(bufnr: integer, state: cp.State)
|
|
||||||
Default: helpers.clearcol
|
|
||||||
|
|
||||||
*cp.CpOnHooks*
|
|
||||||
Fields: ~
|
|
||||||
{enter} (function, optional) Called on every BufEnter on the
|
|
||||||
solution buffer. Registered as a buffer-scoped autocmd and
|
|
||||||
fired immediately after setup.code.
|
|
||||||
function(state: cp.State)
|
|
||||||
{run} (function, optional) Called before the test panel opens.
|
|
||||||
function(state: cp.State)
|
|
||||||
{debug} (function, optional) Called before a debug run.
|
|
||||||
function(state: cp.State)
|
|
||||||
|
|
||||||
All hook functions receive the cp.nvim state object (|cp.State|). See
|
|
||||||
|lua/cp/state.lua| for available methods and fields.
|
|lua/cp/state.lua| for available methods and fields.
|
||||||
|
|
||||||
|
The I/O buffer hooks are called once when the buffers are first created
|
||||||
|
during problem setup. Use these to customize buffer appearance (e.g.,
|
||||||
|
remove line numbers, set custom options). Access helpers via:
|
||||||
|
>lua
|
||||||
|
local helpers = require('cp').helpers
|
||||||
|
<
|
||||||
Example usage:
|
Example usage:
|
||||||
>lua
|
>lua
|
||||||
hooks = {
|
hooks = {
|
||||||
setup = {
|
setup_code = function(state)
|
||||||
contest = function(state)
|
print("Setting up " .. state.get_base_name())
|
||||||
local dir = vim.fn.fnamemodify(
|
print("Source file: " .. state.get_source_file())
|
||||||
state.get_source_file(state.get_language()), ':h')
|
end,
|
||||||
vim.fn.system({ 'cp', '~/.clang-format', dir .. '/.clang-format' })
|
setup_io_input = function(bufnr, state)
|
||||||
end,
|
-- Custom setup for input buffer
|
||||||
code = function(state)
|
vim.api.nvim_set_option_value('number', false, { buf = bufnr })
|
||||||
vim.opt_local.foldmethod = 'marker'
|
end
|
||||||
vim.diagnostic.enable(false)
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
on = {
|
|
||||||
enter = function(state) vim.opt_local.winbar = '' end,
|
|
||||||
run = function(state) require('config.lsp').format() end,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
COMMANDS *cp-commands*
|
|
||||||
|
|
||||||
:CP *:CP*
|
|
||||||
cp.nvim uses a single :CP command with intelligent argument parsing:
|
|
||||||
|
|
||||||
Setup Commands ~
|
|
||||||
:CP {platform} {contest_id} [--lang {language}]
|
|
||||||
Full setup: set platform and load contest metadata.
|
|
||||||
Scrapes test cases and creates source file.
|
|
||||||
--lang: Use specific language (default: platform default)
|
|
||||||
Examples: >
|
|
||||||
:CP codeforces 1933
|
|
||||||
:CP codeforces 1933 --lang python
|
|
||||||
<
|
|
||||||
View Commands ~
|
|
||||||
:CP run [all|n|n,m,...] [--debug]
|
|
||||||
Run tests in I/O view (see |cp-io-view|).
|
|
||||||
Lightweight split showing test verdicts.
|
|
||||||
|
|
||||||
Execution modes:
|
|
||||||
• :CP run Combined: single execution with all tests
|
|
||||||
(auto-switches to individual when multiple samples)
|
|
||||||
• :CP run all Individual: N separate executions
|
|
||||||
• :CP run n Individual: run test n only
|
|
||||||
• :CP run n,m,... Individual: run specific tests (e.g. nth and mth)
|
|
||||||
|
|
||||||
--debug: Use debug build (builds to build/<name>.dbg)
|
|
||||||
|
|
||||||
Combined mode runs all test inputs in one execution (matching
|
|
||||||
platform behavior for multi-test problems). When a problem has
|
|
||||||
multiple independent sample test cases, :CP run auto-switches to
|
|
||||||
individual mode to run each sample separately.
|
|
||||||
|
|
||||||
Examples: >
|
|
||||||
:CP run " Combined: all tests, one execution
|
|
||||||
:CP run all " Individual: all tests, N executions
|
|
||||||
:CP run 2 " Individual: test 2 only
|
|
||||||
:CP run 1,3,5 " Individual: tests 1, 3, and 5
|
|
||||||
:CP run all --debug " Individual with debug build
|
|
||||||
<
|
|
||||||
:CP panel [--debug] [n]
|
|
||||||
Open full-screen test panel (see |cp-panel|).
|
|
||||||
Aggregate table with diff modes for detailed analysis.
|
|
||||||
Optional [n] focuses on specific test.
|
|
||||||
--debug: Use debug build (with sanitizers, etc.)
|
|
||||||
Examples: >
|
|
||||||
:CP panel " All tests
|
|
||||||
:CP panel --debug 3 " Test 3, debug build
|
|
||||||
<
|
|
||||||
|
|
||||||
:CP pick [--lang {language}]
|
|
||||||
Launch configured picker for interactive
|
|
||||||
platform/contest selection.
|
|
||||||
--lang: Pre-select language for chosen contest.
|
|
||||||
Example: >
|
|
||||||
:CP pick
|
|
||||||
:CP pick --lang python
|
|
||||||
<
|
|
||||||
|
|
||||||
:CP interact [script]
|
|
||||||
Open an interactive terminal for the current problem.
|
|
||||||
If an executable interactor is provided, runs the compiled
|
|
||||||
binary against the source file (see
|
|
||||||
*cp-interact*). Otherwise, runs the source
|
|
||||||
file. Only valid for interactive problems.
|
|
||||||
|
|
||||||
:CP stress [generator] [brute]
|
|
||||||
Start an automated stress test loop against a
|
|
||||||
brute-force reference. Toggles off if already
|
|
||||||
running. Without arguments, auto-detects a
|
|
||||||
generator and brute script in the working
|
|
||||||
directory. See |cp-stress|.
|
|
||||||
|
|
||||||
Navigation Commands ~
|
|
||||||
:CP next [--lang {language}]
|
|
||||||
Navigate to next problem in current contest.
|
|
||||||
Stops at last problem (no wrapping).
|
|
||||||
--lang: Use specific language for next problem.
|
|
||||||
By default, preserves current file's language if
|
|
||||||
enabled for the new problem, otherwise uses platform
|
|
||||||
default.
|
|
||||||
Examples: >
|
|
||||||
:CP next
|
|
||||||
:CP next --lang python
|
|
||||||
<
|
|
||||||
:CP prev [--lang {language}]
|
|
||||||
Navigate to previous problem in current contest.
|
|
||||||
Stops at first problem (no wrapping).
|
|
||||||
--lang: Use specific language for previous problem.
|
|
||||||
By default, preserves current file's language if
|
|
||||||
enabled for the new problem, otherwise uses platform
|
|
||||||
default.
|
|
||||||
Examples: >
|
|
||||||
:CP prev
|
|
||||||
:CP prev --lang cpp
|
|
||||||
<
|
|
||||||
:CP {problem_id} [--lang {language}]
|
|
||||||
Jump to problem {problem_id} in a contest.
|
|
||||||
Requires that a contest has already been set up.
|
|
||||||
--lang: Use specific language for this problem.
|
|
||||||
Examples: >
|
|
||||||
:CP B
|
|
||||||
:CP C --lang python
|
|
||||||
<
|
|
||||||
|
|
||||||
Edit Commands ~
|
|
||||||
:CP edit [n]
|
|
||||||
Open grid test editor showing all test cases.
|
|
||||||
Tests displayed as 2×N grid (2 rows, N columns):
|
|
||||||
• Top row: Test inputs (editable)
|
|
||||||
• Bottom row: Expected outputs (editable)
|
|
||||||
|
|
||||||
Optional [n]: Jump cursor to test n's input buffer
|
|
||||||
|
|
||||||
Changes saved to both cache and disk on exit,
|
|
||||||
taking effect immediately in :CP run and CLI.
|
|
||||||
|
|
||||||
Keybindings (configurable via |EditConfig|):
|
|
||||||
q Save all and exit editor
|
|
||||||
]t Jump to next test column
|
|
||||||
[t Jump to previous test column
|
|
||||||
gd Delete current test column
|
|
||||||
ga Add new test column at end
|
|
||||||
<c-w> Normal window navigation
|
|
||||||
|
|
||||||
Examples: >
|
|
||||||
:CP edit " Edit all tests
|
|
||||||
:CP edit 3 " Edit all, start at test 3
|
|
||||||
<
|
|
||||||
|
|
||||||
Race Commands ~
|
|
||||||
:CP race {platform} {contest_id} [--lang {language}]
|
|
||||||
Start a countdown to the contest's scheduled
|
|
||||||
start time. At T=0, automatically runs:
|
|
||||||
:CP {platform} {contest_id} [--lang ...]
|
|
||||||
Examples: >
|
|
||||||
:CP race atcoder abc400
|
|
||||||
:CP race codeforces 2100 --lang python
|
|
||||||
<
|
|
||||||
:CP race stop
|
|
||||||
Cancel an active race countdown.
|
|
||||||
|
|
||||||
Credential Commands ~
|
|
||||||
:CP credentials set [platform]
|
|
||||||
Set or update stored credentials for a platform.
|
|
||||||
Always prompts for username and password,
|
|
||||||
overwriting any previously saved credentials.
|
|
||||||
If [platform] is omitted, uses the active platform.
|
|
||||||
Examples: >
|
|
||||||
:CP credentials set atcoder
|
|
||||||
:CP credentials set codeforces
|
|
||||||
<
|
|
||||||
:CP credentials clear [platform]
|
|
||||||
Remove stored credentials. Without [platform],
|
|
||||||
clears credentials for all platforms.
|
|
||||||
Examples: >
|
|
||||||
:CP credentials clear atcoder
|
|
||||||
:CP credentials clear
|
|
||||||
<
|
|
||||||
Submit Commands ~
|
|
||||||
:CP submit [--lang {language}]
|
|
||||||
Submit the current solution to the online
|
|
||||||
judge. Uses stored credentials (set via
|
|
||||||
:CP credentials set). Prompts on first use
|
|
||||||
if no credentials are saved.
|
|
||||||
--lang: Submit solution for a specific language.
|
|
||||||
|
|
||||||
State Restoration ~
|
|
||||||
:CP Restore state from current file.
|
|
||||||
Automatically detects platform, contest, problem,
|
|
||||||
and language from cached state. Use this after
|
|
||||||
switching files to restore your CP environment.
|
|
||||||
|
|
||||||
Cache Commands ~
|
|
||||||
:CP cache clear [platform] [contest]
|
|
||||||
Clear cache data at different granularities:
|
|
||||||
• No args: Clear all cached data
|
|
||||||
• [platform]: Clear all data for a platform
|
|
||||||
• [platform] [contest]: Clear specific contest
|
|
||||||
Examples: >
|
|
||||||
:CP cache clear
|
|
||||||
:CP cache clear codeforces
|
|
||||||
:CP cache clear codeforces 1848
|
|
||||||
<
|
|
||||||
:CP cache read
|
|
||||||
View the cache in a pretty-printed lua buffer.
|
|
||||||
Exit with q.
|
|
||||||
|
|
||||||
Template Variables ~
|
|
||||||
*cp-template-vars*
|
|
||||||
Command templates support variable substitution using {variable} syntax:
|
|
||||||
|
|
||||||
• {source} Source file path (e.g. "abc324a.cpp")
|
|
||||||
• {binary} Output binary path (e.g. "build/abc324a.run" or
|
|
||||||
"build/abc324a.dbg" for debug builds)
|
|
||||||
|
|
||||||
Example template: >
|
|
||||||
build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
|
|
||||||
< Would expand to: >
|
|
||||||
g++ abc324a.cpp -o build/abc324a.run -std=c++17
|
|
||||||
<
|
|
||||||
Debug Builds ~
|
|
||||||
*cp-debug-builds*
|
|
||||||
The --debug flag uses the debug command configuration instead of build:
|
|
||||||
|
|
||||||
• Normal build: commands.build → outputs to build/<name>.run
|
|
||||||
• Debug build: commands.debug → outputs to build/<name>.dbg
|
|
||||||
|
|
||||||
Debug builds typically include sanitizers (address, undefined behavior) to
|
|
||||||
catch memory errors, buffer overflows, and other issues. Both binaries
|
|
||||||
coexist, so you can switch between normal and debug mode without
|
|
||||||
recompiling.
|
|
||||||
|
|
||||||
Example debug configuration: >
|
|
||||||
languages = {
|
|
||||||
cpp = {
|
|
||||||
extension = 'cc',
|
|
||||||
commands = {
|
|
||||||
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
|
|
||||||
run = { '{binary}' },
|
|
||||||
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
|
|
||||||
'{source}', '-o', '{binary}' },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
MAPPINGS *cp-mappings*
|
|
||||||
|
|
||||||
cp.nvim provides <Plug> mappings for all primary actions. These dispatch
|
|
||||||
through the same code path as |:CP|.
|
|
||||||
|
|
||||||
*<Plug>(cp-run)*
|
|
||||||
<Plug>(cp-run) Run tests in I/O view. Equivalent to :CP run.
|
|
||||||
|
|
||||||
*<Plug>(cp-panel)*
|
|
||||||
<Plug>(cp-panel) Open full-screen test panel. Equivalent to :CP panel.
|
|
||||||
|
|
||||||
*<Plug>(cp-edit)*
|
|
||||||
<Plug>(cp-edit) Open the test case editor. Equivalent to :CP edit.
|
|
||||||
|
|
||||||
*<Plug>(cp-next)*
|
|
||||||
<Plug>(cp-next) Navigate to the next problem. Equivalent to :CP next.
|
|
||||||
|
|
||||||
*<Plug>(cp-prev)*
|
|
||||||
<Plug>(cp-prev) Navigate to the previous problem. Equivalent to :CP prev.
|
|
||||||
|
|
||||||
*<Plug>(cp-pick)*
|
|
||||||
<Plug>(cp-pick) Launch the contest picker. Equivalent to :CP pick.
|
|
||||||
|
|
||||||
*<Plug>(cp-interact)*
|
|
||||||
<Plug>(cp-interact) Open interactive mode. Equivalent to :CP interact.
|
|
||||||
|
|
||||||
*<Plug>(cp-stress)*
|
|
||||||
<Plug>(cp-stress) Run stress test loop. Equivalent to :CP stress.
|
|
||||||
|
|
||||||
*<Plug>(cp-submit)*
|
|
||||||
<Plug>(cp-submit) Submit current solution. Equivalent to :CP submit.
|
|
||||||
|
|
||||||
*<Plug>(cp-race-stop)*
|
|
||||||
<Plug>(cp-race-stop) Cancel active race countdown. Equivalent to :CP race stop.
|
|
||||||
|
|
||||||
Example configuration: >lua
|
|
||||||
vim.keymap.set('n', '<leader>cr', '<Plug>(cp-run)')
|
|
||||||
vim.keymap.set('n', '<leader>cp', '<Plug>(cp-panel)')
|
|
||||||
vim.keymap.set('n', '<leader>ce', '<Plug>(cp-edit)')
|
|
||||||
vim.keymap.set('n', '<leader>cn', '<Plug>(cp-next)')
|
|
||||||
vim.keymap.set('n', '<leader>cN', '<Plug>(cp-prev)')
|
|
||||||
vim.keymap.set('n', '<leader>cc', '<Plug>(cp-pick)')
|
|
||||||
vim.keymap.set('n', '<leader>ci', '<Plug>(cp-interact)')
|
|
||||||
vim.keymap.set('n', '<leader>cs', '<Plug>(cp-stress)')
|
|
||||||
vim.keymap.set('n', '<leader>cu', '<Plug>(cp-submit)')
|
|
||||||
vim.keymap.set('n', '<leader>cR', '<Plug>(cp-race-stop)')
|
|
||||||
<
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
LANGUAGE SELECTION *cp-lang-selection*
|
LANGUAGE SELECTION *cp-lang-selection*
|
||||||
|
|
||||||
|
|
@ -643,41 +508,6 @@ URL format: https://cses.fi/problemset/task/{problem_id}
|
||||||
Usage examples: >
|
Usage examples: >
|
||||||
:CP cses dynamic_programming " Set up all problems in dp category
|
:CP cses dynamic_programming " Set up all problems in dp category
|
||||||
|
|
||||||
CodeChef ~
|
|
||||||
*cp-codechef*
|
|
||||||
URL format: https://www.codechef.com/{contest_id}/problems/{problem_id}
|
|
||||||
|
|
||||||
The contest_id is the contest code from the URL (e.g. START209).
|
|
||||||
|
|
||||||
Usage examples: >
|
|
||||||
:CP codechef START209 " Set up codechef.com/START209
|
|
||||||
|
|
||||||
USACO ~
|
|
||||||
*cp-usaco*
|
|
||||||
URL format: https://usaco.org/index.php?page=viewproblem2&cpid={cpid}
|
|
||||||
|
|
||||||
The contest_id combines the abbreviated month, two-digit year, and division
|
|
||||||
in lowercase, joined by underscores (e.g. dec24_gold, feb23_silver).
|
|
||||||
|
|
||||||
Usage examples: >
|
|
||||||
:CP usaco dec24_gold " Set up December 2024 Gold division
|
|
||||||
:CP usaco feb23_silver " Set up February 2023 Silver division
|
|
||||||
|
|
||||||
Kattis ~
|
|
||||||
*cp-kattis*
|
|
||||||
Kattis supports single-problem and full-contest modes.
|
|
||||||
|
|
||||||
Single problem — the contest_id is the problem slug from the URL:
|
|
||||||
URL format: https://open.kattis.com/problems/{slug}
|
|
||||||
|
|
||||||
Full contest — the contest_id is the contest ID from the URL. All problems
|
|
||||||
are set up at once with :CP next/:CP prev navigation:
|
|
||||||
URL format: https://open.kattis.com/contests/{id}
|
|
||||||
|
|
||||||
Usage examples: >
|
|
||||||
:CP kattis primesieve " Single problem
|
|
||||||
:CP kattis t8tnpe " Full contest (all problems, A–H navigation)
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
|
|
||||||
COMPLETE WORKFLOW EXAMPLE *cp-example*
|
COMPLETE WORKFLOW EXAMPLE *cp-example*
|
||||||
|
|
@ -712,9 +542,7 @@ Example: Setting up and solving AtCoder contest ABC324
|
||||||
:CP
|
:CP
|
||||||
< Automatically restores abc323 contest context
|
< Automatically restores abc323 contest context
|
||||||
|
|
||||||
8. Submit solution: >
|
8. Submit solutions on AtCoder website
|
||||||
:CP submit
|
|
||||||
< Prompts for credentials on first use and submits to AtCoder.
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
I/O VIEW *cp-io-view*
|
I/O VIEW *cp-io-view*
|
||||||
|
|
@ -784,9 +612,9 @@ While in the I/O view buffers, use the configured keymaps to cycle through tests
|
||||||
|
|
||||||
Buffer Customization ~
|
Buffer Customization ~
|
||||||
|
|
||||||
Use the hooks.setup.io.input and hooks.setup.io.output hooks (see |cp.Hooks|)
|
Use the setup_io_input and setup_io_output hooks (see |cp.Hooks|) to customize
|
||||||
to customize buffer appearance. By default, line numbers and columns are
|
buffer appearance. By default, line numbers and columns are removed via
|
||||||
removed via helpers.clearcol (see |cp-helpers|).
|
helpers.clearcol (see |cp-helpers|).
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
VERDICT FORMATTING *cp-verdict-format*
|
VERDICT FORMATTING *cp-verdict-format*
|
||||||
|
|
@ -925,72 +753,6 @@ When using :CP interact {interactor}, the interactor must be executable
|
||||||
Keymaps ~
|
Keymaps ~
|
||||||
<c-q> Close the terminal and restore the previous layout.
|
<c-q> Close the terminal and restore the previous layout.
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
STRESS TESTING *cp-stress*
|
|
||||||
|
|
||||||
Start an automated stress test loop to find inputs where your solution
|
|
||||||
disagrees with a brute-force reference.
|
|
||||||
|
|
||||||
:CP stress [generator] [brute]
|
|
||||||
Start the stress loop. Toggles off if the loop is already running.
|
|
||||||
{generator} Generator script path (default: auto-detected).
|
|
||||||
{brute} Brute-force solution path (default: auto-detected).
|
|
||||||
Auto-detection looks for files named gen.* and brute.* in the CWD.
|
|
||||||
|
|
||||||
The stress panel opens and streams results for each iteration.
|
|
||||||
On a mismatch, the failing input is displayed in the panel.
|
|
||||||
|
|
||||||
Keymaps ~
|
|
||||||
<c-q> Close the stress panel and restore the previous layout.
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
RACE *cp-race*
|
|
||||||
|
|
||||||
Count down to a contest's start time and automatically run setup at T=0.
|
|
||||||
|
|
||||||
:CP race {platform} {contest_id} [--lang {language}]
|
|
||||||
Start a countdown timer. At T=0, automatically runs:
|
|
||||||
:CP {platform} {contest_id} [--lang {language}]
|
|
||||||
Examples: >
|
|
||||||
:CP race atcoder abc400
|
|
||||||
:CP race codeforces 2100 --lang python
|
|
||||||
<
|
|
||||||
:CP race stop
|
|
||||||
Cancel an active race countdown.
|
|
||||||
|
|
||||||
Statusline integration: see |cp-race-status|.
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
CREDENTIALS *cp-credentials*
|
|
||||||
|
|
||||||
Manage stored login credentials for platform submission.
|
|
||||||
|
|
||||||
Credentials are stored under _credentials in the main cache file
|
|
||||||
(stdpath('data')/cp-nvim.json). Use :CP cache read to inspect them.
|
|
||||||
|
|
||||||
:CP credentials set [platform]
|
|
||||||
Set or update credentials for a platform. Always prompts for
|
|
||||||
username and password, overwriting any previously saved values.
|
|
||||||
Omit [platform] to use the currently active platform.
|
|
||||||
|
|
||||||
:CP credentials clear [platform]
|
|
||||||
Remove stored credentials. Without [platform], clears all platforms.
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
SUBMIT *cp-submit*
|
|
||||||
|
|
||||||
Submit the current solution to the online judge.
|
|
||||||
|
|
||||||
:CP submit [--lang {language}]
|
|
||||||
Submit the current solution. Uses stored credentials (set via
|
|
||||||
:CP credentials set). Prompts on first use if no credentials
|
|
||||||
are saved.
|
|
||||||
--lang: Override the language to submit.
|
|
||||||
|
|
||||||
Platform support:
|
|
||||||
AtCoder Fully implemented.
|
|
||||||
Others Not yet implemented.
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
|
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
|
||||||
|
|
||||||
|
|
@ -1081,124 +843,6 @@ Functions ~
|
||||||
Parameters: ~
|
Parameters: ~
|
||||||
{bufnr} (integer) Buffer handle
|
{bufnr} (integer) Buffer handle
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
STATUSLINE INTEGRATION *cp-statusline*
|
|
||||||
|
|
||||||
cp.nvim exposes its runtime state through a public module that can be queried
|
|
||||||
from any statusline plugin. Import it with: >lua
|
|
||||||
local state = require('cp.state')
|
|
||||||
<
|
|
||||||
All getters return nil when no problem is active, so guard every value before
|
|
||||||
use. Calling any getter outside a CP context is safe and has no side effects.
|
|
||||||
|
|
||||||
State API ~
|
|
||||||
*cp.State*
|
|
||||||
The following getters are available for statusline use:
|
|
||||||
|
|
||||||
get_platform() (string?) Platform id. e.g. "codeforces", "atcoder"
|
|
||||||
get_contest_id() (string?) Contest id. e.g. "1933", "abc324"
|
|
||||||
get_problem_id() (string?) Problem id. e.g. "A", "B"
|
|
||||||
get_language() (string?) Language id. e.g. "cpp", "python"
|
|
||||||
get_base_name() (string?) Derived filename stem. e.g. "1933a"
|
|
||||||
get_source_file() (string?) Full source filename. e.g. "1933a.cc"
|
|
||||||
get_active_panel() (string?) One of 'run', 'interactive', 'stress', or
|
|
||||||
nil when no panel is open.
|
|
||||||
|
|
||||||
Race API ~
|
|
||||||
*cp-race-status*
|
|
||||||
require('cp.race').status() returns a table describing the race state:
|
|
||||||
{ active = false }
|
|
||||||
{ active = true, platform = string, contest_id = string,
|
|
||||||
remaining_seconds = number }
|
|
||||||
|
|
||||||
Recipe: vanilla statusline ~
|
|
||||||
|
|
||||||
Set vim.o.statusline from an autocommand so it is recalculated on every
|
|
||||||
BufEnter: >lua
|
|
||||||
local function cp_component()
|
|
||||||
local state = require('cp.state')
|
|
||||||
local platform = state.get_platform()
|
|
||||||
if not platform then
|
|
||||||
return ''
|
|
||||||
end
|
|
||||||
local parts = {
|
|
||||||
platform,
|
|
||||||
state.get_contest_id(),
|
|
||||||
state.get_problem_id(),
|
|
||||||
state.get_language(),
|
|
||||||
}
|
|
||||||
local filtered = {}
|
|
||||||
for _, v in ipairs(parts) do
|
|
||||||
if v then filtered[#filtered + 1] = v end
|
|
||||||
end
|
|
||||||
return '[' .. table.concat(filtered, ' · ') .. ']'
|
|
||||||
end
|
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd({ 'BufEnter', 'User' }, {
|
|
||||||
callback = function()
|
|
||||||
vim.o.statusline = cp_component() .. ' %f %=%l:%c'
|
|
||||||
end
|
|
||||||
})
|
|
||||||
<
|
|
||||||
|
|
||||||
Recipe: lualine ~
|
|
||||||
|
|
||||||
Add a custom component to any lualine section. The cond field hides the
|
|
||||||
component entirely when no problem is active: >lua
|
|
||||||
local function cp_lualine()
|
|
||||||
local state = require('cp.state')
|
|
||||||
local parts = {
|
|
||||||
state.get_platform(),
|
|
||||||
state.get_contest_id(),
|
|
||||||
state.get_problem_id(),
|
|
||||||
state.get_language(),
|
|
||||||
}
|
|
||||||
local filtered = {}
|
|
||||||
for _, v in ipairs(parts) do
|
|
||||||
if v then filtered[#filtered + 1] = v end
|
|
||||||
end
|
|
||||||
return table.concat(filtered, ' · ')
|
|
||||||
end
|
|
||||||
|
|
||||||
require('lualine').setup({
|
|
||||||
sections = {
|
|
||||||
lualine_c = {
|
|
||||||
{
|
|
||||||
cp_lualine,
|
|
||||||
cond = function()
|
|
||||||
return require('cp.state').get_platform() ~= nil
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
<
|
|
||||||
|
|
||||||
Recipe: heirline ~
|
|
||||||
|
|
||||||
Build a heirline component using a provider and condition: >lua
|
|
||||||
local CpComponent = {
|
|
||||||
condition = function()
|
|
||||||
return require('cp.state').get_platform() ~= nil
|
|
||||||
end,
|
|
||||||
provider = function()
|
|
||||||
local state = require('cp.state')
|
|
||||||
local parts = {
|
|
||||||
state.get_platform(),
|
|
||||||
state.get_contest_id(),
|
|
||||||
state.get_problem_id(),
|
|
||||||
state.get_language(),
|
|
||||||
}
|
|
||||||
local filtered = {}
|
|
||||||
for _, v in ipairs(parts) do
|
|
||||||
if v then filtered[#filtered + 1] = v end
|
|
||||||
end
|
|
||||||
return '[' .. table.concat(filtered, ' · ') .. ']'
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
<
|
|
||||||
Include CpComponent in your heirline StatusLine spec wherever desired.
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
PANEL KEYMAPS *cp-panel-keys*
|
PANEL KEYMAPS *cp-panel-keys*
|
||||||
|
|
||||||
|
|
|
||||||
43
flake.lock
generated
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 display_name string
|
||||||
---@field name string
|
---@field name string
|
||||||
---@field id string
|
---@field id string
|
||||||
---@field start_time? integer
|
|
||||||
|
|
||||||
---@class CombinedTest
|
---@class CombinedTest
|
||||||
---@field input string
|
---@field input string
|
||||||
|
|
@ -28,7 +27,6 @@
|
||||||
---@field multi_test? boolean
|
---@field multi_test? boolean
|
||||||
---@field memory_mb? number
|
---@field memory_mb? number
|
||||||
---@field timeout_ms? number
|
---@field timeout_ms? number
|
||||||
---@field precision? number
|
|
||||||
---@field combined_test? CombinedTest
|
---@field combined_test? CombinedTest
|
||||||
---@field test_cases TestCase[]
|
---@field test_cases TestCase[]
|
||||||
|
|
||||||
|
|
@ -40,8 +38,7 @@
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local CACHE_VERSION = 1
|
local logger = require('cp.log')
|
||||||
|
|
||||||
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
|
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
|
||||||
local cache_data = {}
|
local cache_data = {}
|
||||||
local loaded = false
|
local loaded = false
|
||||||
|
|
@ -68,15 +65,9 @@ function M.load()
|
||||||
|
|
||||||
local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n'))
|
local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n'))
|
||||||
if ok then
|
if ok then
|
||||||
if decoded._version ~= CACHE_VERSION then
|
cache_data = decoded
|
||||||
cache_data = {}
|
|
||||||
M.save()
|
|
||||||
else
|
|
||||||
cache_data = decoded
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
cache_data = {}
|
logger.log('Could not decode json in cache file', vim.log.levels.ERROR)
|
||||||
M.save()
|
|
||||||
end
|
end
|
||||||
loaded = true
|
loaded = true
|
||||||
end
|
end
|
||||||
|
|
@ -87,7 +78,6 @@ function M.save()
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
|
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
|
||||||
|
|
||||||
cache_data._version = CACHE_VERSION
|
|
||||||
local encoded = vim.json.encode(cache_data)
|
local encoded = vim.json.encode(cache_data)
|
||||||
local lines = vim.split(encoded, '\n')
|
local lines = vim.split(encoded, '\n')
|
||||||
vim.fn.writefile(lines, cache_file)
|
vim.fn.writefile(lines, cache_file)
|
||||||
|
|
@ -232,8 +222,7 @@ function M.set_test_cases(
|
||||||
timeout_ms,
|
timeout_ms,
|
||||||
memory_mb,
|
memory_mb,
|
||||||
interactive,
|
interactive,
|
||||||
multi_test,
|
multi_test
|
||||||
precision
|
|
||||||
)
|
)
|
||||||
vim.validate({
|
vim.validate({
|
||||||
platform = { platform, 'string' },
|
platform = { platform, 'string' },
|
||||||
|
|
@ -245,7 +234,6 @@ function M.set_test_cases(
|
||||||
memory_mb = { memory_mb, { 'number', 'nil' }, true },
|
memory_mb = { memory_mb, { 'number', 'nil' }, true },
|
||||||
interactive = { interactive, { 'boolean', 'nil' }, true },
|
interactive = { interactive, { 'boolean', 'nil' }, true },
|
||||||
multi_test = { multi_test, { 'boolean', 'nil' }, true },
|
multi_test = { multi_test, { 'boolean', 'nil' }, true },
|
||||||
precision = { precision, { 'number', 'nil' }, true },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
local index = cache_data[platform][contest_id].index_map[problem_id]
|
local index = cache_data[platform][contest_id].index_map[problem_id]
|
||||||
|
|
@ -256,7 +244,6 @@ function M.set_test_cases(
|
||||||
cache_data[platform][contest_id].problems[index].memory_mb = memory_mb
|
cache_data[platform][contest_id].problems[index].memory_mb = memory_mb
|
||||||
cache_data[platform][contest_id].problems[index].interactive = interactive
|
cache_data[platform][contest_id].problems[index].interactive = interactive
|
||||||
cache_data[platform][contest_id].problems[index].multi_test = multi_test
|
cache_data[platform][contest_id].problems[index].multi_test = multi_test
|
||||||
cache_data[platform][contest_id].problems[index].precision = precision
|
|
||||||
|
|
||||||
M.save()
|
M.save()
|
||||||
end
|
end
|
||||||
|
|
@ -278,34 +265,6 @@ function M.get_constraints(platform, contest_id, problem_id)
|
||||||
return problem_data.timeout_ms, problem_data.memory_mb
|
return problem_data.timeout_ms, problem_data.memory_mb
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param platform string
|
|
||||||
---@param contest_id string
|
|
||||||
---@param problem_id? string
|
|
||||||
---@return number?
|
|
||||||
function M.get_precision(platform, contest_id, problem_id)
|
|
||||||
vim.validate({
|
|
||||||
platform = { platform, 'string' },
|
|
||||||
contest_id = { contest_id, 'string' },
|
|
||||||
problem_id = { problem_id, { 'string', 'nil' }, true },
|
|
||||||
})
|
|
||||||
|
|
||||||
if
|
|
||||||
not cache_data[platform]
|
|
||||||
or not cache_data[platform][contest_id]
|
|
||||||
or not cache_data[platform][contest_id].index_map
|
|
||||||
then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local index = cache_data[platform][contest_id].index_map[problem_id]
|
|
||||||
if not index then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local problem_data = cache_data[platform][contest_id].problems[index]
|
|
||||||
return problem_data and problem_data.precision or nil
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param file_path string
|
---@param file_path string
|
||||||
---@return FileState|nil
|
---@return FileState|nil
|
||||||
function M.get_file_state(file_path)
|
function M.get_file_state(file_path)
|
||||||
|
|
@ -353,59 +312,13 @@ function M.set_contest_summaries(platform, contests)
|
||||||
cache_data[platform][contest.id] = cache_data[platform][contest.id] or {}
|
cache_data[platform][contest.id] = cache_data[platform][contest.id] or {}
|
||||||
cache_data[platform][contest.id].display_name = contest.display_name
|
cache_data[platform][contest.id].display_name = contest.display_name
|
||||||
cache_data[platform][contest.id].name = contest.name
|
cache_data[platform][contest.id].name = contest.name
|
||||||
if contest.start_time then
|
|
||||||
cache_data[platform][contest.id].start_time = contest.start_time
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
M.save()
|
M.save()
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param platform string
|
|
||||||
---@param contest_id string
|
|
||||||
---@return integer?
|
|
||||||
function M.get_contest_start_time(platform, contest_id)
|
|
||||||
if not cache_data[platform] or not cache_data[platform][contest_id] then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
return cache_data[platform][contest_id].start_time
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param platform string
|
|
||||||
---@return table?
|
|
||||||
function M.get_credentials(platform)
|
|
||||||
if not cache_data._credentials then
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
return cache_data._credentials[platform]
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param platform string
|
|
||||||
---@param creds table
|
|
||||||
function M.set_credentials(platform, creds)
|
|
||||||
cache_data._credentials = cache_data._credentials or {}
|
|
||||||
cache_data._credentials[platform] = creds
|
|
||||||
M.save()
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param platform string?
|
|
||||||
function M.clear_credentials(platform)
|
|
||||||
if platform then
|
|
||||||
if cache_data._credentials then
|
|
||||||
cache_data._credentials[platform] = nil
|
|
||||||
end
|
|
||||||
else
|
|
||||||
cache_data._credentials = nil
|
|
||||||
end
|
|
||||||
M.save()
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.clear_all()
|
function M.clear_all()
|
||||||
local creds = cache_data._credentials
|
|
||||||
cache_data = {}
|
cache_data = {}
|
||||||
if creds then
|
|
||||||
cache_data._credentials = creds
|
|
||||||
end
|
|
||||||
M.save()
|
M.save()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -425,8 +338,6 @@ function M.get_data_pretty()
|
||||||
return vim.inspect(cache_data)
|
return vim.inspect(cache_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.get_raw_cache()
|
M._cache = cache_data
|
||||||
return cache_data
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ local actions = constants.ACTIONS
|
||||||
---@field platform? string
|
---@field platform? string
|
||||||
---@field problem_id? string
|
---@field problem_id? string
|
||||||
---@field interactor_cmd? string
|
---@field interactor_cmd? string
|
||||||
---@field generator_cmd? string
|
|
||||||
---@field brute_cmd? string
|
|
||||||
---@field test_index? integer
|
---@field test_index? integer
|
||||||
---@field test_indices? integer[]
|
---@field test_indices? integer[]
|
||||||
---@field mode? string
|
---@field mode? string
|
||||||
|
|
@ -55,27 +53,6 @@ local function parse_command(args)
|
||||||
else
|
else
|
||||||
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
|
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
|
||||||
end
|
end
|
||||||
elseif first == 'race' then
|
|
||||||
if args[2] == 'stop' then
|
|
||||||
return { type = 'action', action = 'race_stop' }
|
|
||||||
end
|
|
||||||
if not args[2] or not args[3] then
|
|
||||||
return {
|
|
||||||
type = 'error',
|
|
||||||
message = 'Usage: :CP race <platform> <contest_id> [--lang <lang>]',
|
|
||||||
}
|
|
||||||
end
|
|
||||||
local language = nil
|
|
||||||
if args[4] == '--lang' and args[5] then
|
|
||||||
language = args[5]
|
|
||||||
end
|
|
||||||
return {
|
|
||||||
type = 'action',
|
|
||||||
action = 'race',
|
|
||||||
platform = args[2],
|
|
||||||
contest = args[3],
|
|
||||||
language = language,
|
|
||||||
}
|
|
||||||
elseif first == 'interact' then
|
elseif first == 'interact' then
|
||||||
local inter = args[2]
|
local inter = args[2]
|
||||||
if inter and inter ~= '' then
|
if inter and inter ~= '' then
|
||||||
|
|
@ -83,27 +60,6 @@ local function parse_command(args)
|
||||||
else
|
else
|
||||||
return { type = 'action', action = 'interact' }
|
return { type = 'action', action = 'interact' }
|
||||||
end
|
end
|
||||||
elseif first == 'credentials' then
|
|
||||||
local subcommand = args[2]
|
|
||||||
if not subcommand then
|
|
||||||
return { type = 'error', message = 'credentials command requires subcommand (set, clear)' }
|
|
||||||
end
|
|
||||||
if vim.tbl_contains({ 'set', 'clear' }, subcommand) then
|
|
||||||
return {
|
|
||||||
type = 'credentials',
|
|
||||||
subcommand = subcommand,
|
|
||||||
platform = args[3],
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return { type = 'error', message = 'unknown credentials subcommand: ' .. subcommand }
|
|
||||||
end
|
|
||||||
elseif first == 'stress' then
|
|
||||||
return {
|
|
||||||
type = 'action',
|
|
||||||
action = 'stress',
|
|
||||||
generator_cmd = args[2],
|
|
||||||
brute_cmd = args[3],
|
|
||||||
}
|
|
||||||
elseif first == 'edit' then
|
elseif first == 'edit' then
|
||||||
local test_index = nil
|
local test_index = nil
|
||||||
if #args >= 2 then
|
if #args >= 2 then
|
||||||
|
|
@ -329,14 +285,6 @@ function M.handle_command(opts)
|
||||||
elseif cmd.action == 'edit' then
|
elseif cmd.action == 'edit' then
|
||||||
local edit = require('cp.ui.edit')
|
local edit = require('cp.ui.edit')
|
||||||
edit.toggle_edit(cmd.test_index)
|
edit.toggle_edit(cmd.test_index)
|
||||||
elseif cmd.action == 'stress' then
|
|
||||||
require('cp.stress').toggle(cmd.generator_cmd, cmd.brute_cmd)
|
|
||||||
elseif cmd.action == 'submit' then
|
|
||||||
require('cp.submit').submit({ language = cmd.language })
|
|
||||||
elseif cmd.action == 'race' then
|
|
||||||
require('cp.race').start(cmd.platform, cmd.contest, cmd.language)
|
|
||||||
elseif cmd.action == 'race_stop' then
|
|
||||||
require('cp.race').stop()
|
|
||||||
end
|
end
|
||||||
elseif cmd.type == 'problem_jump' then
|
elseif cmd.type == 'problem_jump' then
|
||||||
local platform = state.get_platform()
|
local platform = state.get_platform()
|
||||||
|
|
@ -366,13 +314,6 @@ function M.handle_command(opts)
|
||||||
|
|
||||||
local setup = require('cp.setup')
|
local setup = require('cp.setup')
|
||||||
setup.setup_contest(platform, contest_id, problem_id, cmd.language)
|
setup.setup_contest(platform, contest_id, problem_id, cmd.language)
|
||||||
elseif cmd.type == 'credentials' then
|
|
||||||
local creds = require('cp.credentials')
|
|
||||||
if cmd.subcommand == 'set' then
|
|
||||||
creds.set(cmd.platform)
|
|
||||||
elseif cmd.subcommand == 'clear' then
|
|
||||||
creds.clear(cmd.platform)
|
|
||||||
end
|
|
||||||
elseif cmd.type == 'cache' then
|
elseif cmd.type == 'cache' then
|
||||||
local cache_commands = require('cp.commands.cache')
|
local cache_commands = require('cp.commands.cache')
|
||||||
cache_commands.handle_cache_command(cmd)
|
cache_commands.handle_cache_command(cmd)
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,10 @@
|
||||||
---@class CpLanguage
|
---@class CpLanguage
|
||||||
---@field extension string
|
---@field extension string
|
||||||
---@field commands CpLangCommands
|
---@field commands CpLangCommands
|
||||||
---@field template? string
|
|
||||||
|
|
||||||
---@class CpTemplatesConfig
|
|
||||||
---@field cursor_marker? string
|
|
||||||
|
|
||||||
---@class CpPlatformOverrides
|
---@class CpPlatformOverrides
|
||||||
---@field extension? string
|
---@field extension? string
|
||||||
---@field commands? CpLangCommands
|
---@field commands? CpLangCommands
|
||||||
---@field template? string
|
|
||||||
|
|
||||||
---@class CpPlatform
|
---@class CpPlatform
|
||||||
---@field enabled_languages string[]
|
---@field enabled_languages string[]
|
||||||
|
|
@ -25,7 +20,6 @@
|
||||||
---@class PanelConfig
|
---@class PanelConfig
|
||||||
---@field diff_modes string[]
|
---@field diff_modes string[]
|
||||||
---@field max_output_lines integer
|
---@field max_output_lines integer
|
||||||
---@field precision number?
|
|
||||||
|
|
||||||
---@class DiffGitConfig
|
---@class DiffGitConfig
|
||||||
---@field args string[]
|
---@field args string[]
|
||||||
|
|
@ -33,23 +27,12 @@
|
||||||
---@class DiffConfig
|
---@class DiffConfig
|
||||||
---@field git DiffGitConfig
|
---@field git DiffGitConfig
|
||||||
|
|
||||||
---@class CpSetupIOHooks
|
|
||||||
---@field input? fun(bufnr: integer, state: cp.State)
|
|
||||||
---@field output? fun(bufnr: integer, state: cp.State)
|
|
||||||
|
|
||||||
---@class CpSetupHooks
|
|
||||||
---@field contest? fun(state: cp.State)
|
|
||||||
---@field code? fun(state: cp.State)
|
|
||||||
---@field io? CpSetupIOHooks
|
|
||||||
|
|
||||||
---@class CpOnHooks
|
|
||||||
---@field enter? fun(state: cp.State)
|
|
||||||
---@field run? fun(state: cp.State)
|
|
||||||
---@field debug? fun(state: cp.State)
|
|
||||||
|
|
||||||
---@class Hooks
|
---@class Hooks
|
||||||
---@field setup? CpSetupHooks
|
---@field before_run? fun(state: cp.State)
|
||||||
---@field on? CpOnHooks
|
---@field before_debug? fun(state: cp.State)
|
||||||
|
---@field setup_code? fun(state: cp.State)
|
||||||
|
---@field setup_io_input? fun(bufnr: integer, state: cp.State)
|
||||||
|
---@field setup_io_output? fun(bufnr: integer, state: cp.State)
|
||||||
|
|
||||||
---@class VerdictFormatData
|
---@class VerdictFormatData
|
||||||
---@field index integer
|
---@field index integer
|
||||||
|
|
@ -78,6 +61,8 @@
|
||||||
|
|
||||||
---@class RunConfig
|
---@class RunConfig
|
||||||
---@field width number
|
---@field width number
|
||||||
|
---@field next_test_key string|nil
|
||||||
|
---@field prev_test_key string|nil
|
||||||
---@field format_verdict VerdictFormatter
|
---@field format_verdict VerdictFormatter
|
||||||
|
|
||||||
---@class EditConfig
|
---@class EditConfig
|
||||||
|
|
@ -98,7 +83,6 @@
|
||||||
---@class cp.Config
|
---@class cp.Config
|
||||||
---@field languages table<string, CpLanguage>
|
---@field languages table<string, CpLanguage>
|
||||||
---@field platforms table<string, CpPlatform>
|
---@field platforms table<string, CpPlatform>
|
||||||
---@field templates? CpTemplatesConfig
|
|
||||||
---@field hooks Hooks
|
---@field hooks Hooks
|
||||||
---@field debug boolean
|
---@field debug boolean
|
||||||
---@field open_url boolean
|
---@field open_url boolean
|
||||||
|
|
@ -163,29 +147,13 @@ M.defaults = {
|
||||||
enabled_languages = { 'cpp', 'python' },
|
enabled_languages = { 'cpp', 'python' },
|
||||||
default_language = 'cpp',
|
default_language = 'cpp',
|
||||||
},
|
},
|
||||||
kattis = {
|
|
||||||
enabled_languages = { 'cpp', 'python' },
|
|
||||||
default_language = 'cpp',
|
|
||||||
},
|
|
||||||
usaco = {
|
|
||||||
enabled_languages = { 'cpp', 'python' },
|
|
||||||
default_language = 'cpp',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
hooks = {
|
hooks = {
|
||||||
setup = {
|
before_run = nil,
|
||||||
contest = nil,
|
before_debug = nil,
|
||||||
code = nil,
|
setup_code = nil,
|
||||||
io = {
|
setup_io_input = helpers.clearcol,
|
||||||
input = helpers.clearcol,
|
setup_io_output = helpers.clearcol,
|
||||||
output = helpers.clearcol,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
on = {
|
|
||||||
enter = nil,
|
|
||||||
run = nil,
|
|
||||||
debug = nil,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
debug = false,
|
debug = false,
|
||||||
scrapers = constants.PLATFORMS,
|
scrapers = constants.PLATFORMS,
|
||||||
|
|
@ -194,6 +162,8 @@ M.defaults = {
|
||||||
ansi = true,
|
ansi = true,
|
||||||
run = {
|
run = {
|
||||||
width = 0.3,
|
width = 0.3,
|
||||||
|
next_test_key = '<c-n>',
|
||||||
|
prev_test_key = '<c-p>',
|
||||||
format_verdict = helpers.default_verdict_formatter,
|
format_verdict = helpers.default_verdict_formatter,
|
||||||
},
|
},
|
||||||
edit = {
|
edit = {
|
||||||
|
|
@ -203,11 +173,7 @@ M.defaults = {
|
||||||
add_test_key = 'ga',
|
add_test_key = 'ga',
|
||||||
save_and_exit_key = 'q',
|
save_and_exit_key = 'q',
|
||||||
},
|
},
|
||||||
panel = {
|
panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50 },
|
||||||
diff_modes = { 'side-by-side', 'git', 'vim' },
|
|
||||||
max_output_lines = 50,
|
|
||||||
precision = nil,
|
|
||||||
},
|
|
||||||
diff = {
|
diff = {
|
||||||
git = {
|
git = {
|
||||||
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
|
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
|
||||||
|
|
@ -249,10 +215,6 @@ local function validate_language(id, lang)
|
||||||
commands = { lang.commands, { 'table' } },
|
commands = { lang.commands, { 'table' } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if lang.template ~= nil then
|
|
||||||
vim.validate({ template = { lang.template, 'string' } })
|
|
||||||
end
|
|
||||||
|
|
||||||
if not lang.commands.run then
|
if not lang.commands.run then
|
||||||
error(('[cp.nvim] languages.%s.commands.run is required'):format(id))
|
error(('[cp.nvim] languages.%s.commands.run is required'):format(id))
|
||||||
end
|
end
|
||||||
|
|
@ -291,9 +253,6 @@ local function merge_lang(base, ov)
|
||||||
if ov.commands then
|
if ov.commands then
|
||||||
out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {})
|
out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {})
|
||||||
end
|
end
|
||||||
if ov.template then
|
|
||||||
out.template = ov.template
|
|
||||||
end
|
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -333,15 +292,7 @@ end
|
||||||
---@return cp.Config
|
---@return cp.Config
|
||||||
function M.setup(user_config)
|
function M.setup(user_config)
|
||||||
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
|
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
|
||||||
local defaults = vim.deepcopy(M.defaults)
|
local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
|
||||||
if user_config and user_config.platforms then
|
|
||||||
for plat in pairs(defaults.platforms) do
|
|
||||||
if not user_config.platforms[plat] then
|
|
||||||
defaults.platforms[plat] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
|
|
||||||
|
|
||||||
if not next(cfg.languages) then
|
if not next(cfg.languages) then
|
||||||
error('[cp.nvim] At least one language must be configured')
|
error('[cp.nvim] At least one language must be configured')
|
||||||
|
|
@ -351,13 +302,6 @@ function M.setup(user_config)
|
||||||
error('[cp.nvim] At least one platform must be configured')
|
error('[cp.nvim] At least one platform must be configured')
|
||||||
end
|
end
|
||||||
|
|
||||||
if cfg.templates ~= nil then
|
|
||||||
vim.validate({ templates = { cfg.templates, 'table' } })
|
|
||||||
if cfg.templates.cursor_marker ~= nil then
|
|
||||||
vim.validate({ cursor_marker = { cfg.templates.cursor_marker, 'string' } })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
vim.validate({
|
vim.validate({
|
||||||
hooks = { cfg.hooks, { 'table' } },
|
hooks = { cfg.hooks, { 'table' } },
|
||||||
ui = { cfg.ui, { 'table' } },
|
ui = { cfg.ui, { 'table' } },
|
||||||
|
|
@ -379,29 +323,12 @@ function M.setup(user_config)
|
||||||
end,
|
end,
|
||||||
('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')),
|
('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')),
|
||||||
},
|
},
|
||||||
|
before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
|
||||||
|
before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
|
||||||
|
setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
|
||||||
|
setup_io_input = { cfg.hooks.setup_io_input, { 'function', 'nil' }, true },
|
||||||
|
setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true },
|
||||||
})
|
})
|
||||||
if cfg.hooks.setup ~= nil then
|
|
||||||
vim.validate({ setup = { cfg.hooks.setup, 'table' } })
|
|
||||||
vim.validate({
|
|
||||||
contest = { cfg.hooks.setup.contest, { 'function', 'nil' }, true },
|
|
||||||
code = { cfg.hooks.setup.code, { 'function', 'nil' }, true },
|
|
||||||
})
|
|
||||||
if cfg.hooks.setup.io ~= nil then
|
|
||||||
vim.validate({ io = { cfg.hooks.setup.io, 'table' } })
|
|
||||||
vim.validate({
|
|
||||||
input = { cfg.hooks.setup.io.input, { 'function', 'nil' }, true },
|
|
||||||
output = { cfg.hooks.setup.io.output, { 'function', 'nil' }, true },
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if cfg.hooks.on ~= nil then
|
|
||||||
vim.validate({ on = { cfg.hooks.on, 'table' } })
|
|
||||||
vim.validate({
|
|
||||||
enter = { cfg.hooks.on.enter, { 'function', 'nil' }, true },
|
|
||||||
run = { cfg.hooks.on.run, { 'function', 'nil' }, true },
|
|
||||||
debug = { cfg.hooks.on.debug, { 'function', 'nil' }, true },
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
local layouts = require('cp.ui.layouts')
|
local layouts = require('cp.ui.layouts')
|
||||||
vim.validate({
|
vim.validate({
|
||||||
|
|
@ -428,13 +355,6 @@ function M.setup(user_config)
|
||||||
end,
|
end,
|
||||||
'positive integer',
|
'positive integer',
|
||||||
},
|
},
|
||||||
precision = {
|
|
||||||
cfg.ui.panel.precision,
|
|
||||||
function(v)
|
|
||||||
return v == nil or (type(v) == 'number' and v >= 0)
|
|
||||||
end,
|
|
||||||
'nil or non-negative number',
|
|
||||||
},
|
|
||||||
git = { cfg.ui.diff.git, { 'table' } },
|
git = { cfg.ui.diff.git, { 'table' } },
|
||||||
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
|
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
|
||||||
width = {
|
width = {
|
||||||
|
|
@ -444,6 +364,20 @@ function M.setup(user_config)
|
||||||
end,
|
end,
|
||||||
'decimal between 0 and 1',
|
'decimal between 0 and 1',
|
||||||
},
|
},
|
||||||
|
next_test_key = {
|
||||||
|
cfg.ui.run.next_test_key,
|
||||||
|
function(v)
|
||||||
|
return v == nil or (type(v) == 'string' and #v > 0)
|
||||||
|
end,
|
||||||
|
'nil or non-empty string',
|
||||||
|
},
|
||||||
|
prev_test_key = {
|
||||||
|
cfg.ui.run.prev_test_key,
|
||||||
|
function(v)
|
||||||
|
return v == nil or (type(v) == 'string' and #v > 0)
|
||||||
|
end,
|
||||||
|
'nil or non-empty string',
|
||||||
|
},
|
||||||
format_verdict = {
|
format_verdict = {
|
||||||
cfg.ui.run.format_verdict,
|
cfg.ui.run.format_verdict,
|
||||||
'function',
|
'function',
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,13 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses', 'kattis', 'usaco' }
|
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses' }
|
||||||
M.ACTIONS = {
|
M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit' }
|
||||||
'run',
|
|
||||||
'panel',
|
|
||||||
'next',
|
|
||||||
'prev',
|
|
||||||
'pick',
|
|
||||||
'cache',
|
|
||||||
'interact',
|
|
||||||
'edit',
|
|
||||||
'race',
|
|
||||||
'stress',
|
|
||||||
'submit',
|
|
||||||
'credentials',
|
|
||||||
}
|
|
||||||
|
|
||||||
M.PLATFORM_DISPLAY_NAMES = {
|
M.PLATFORM_DISPLAY_NAMES = {
|
||||||
atcoder = 'AtCoder',
|
atcoder = 'AtCoder',
|
||||||
codechef = 'CodeChef',
|
codechef = 'CodeChef',
|
||||||
codeforces = 'CodeForces',
|
codeforces = 'CodeForces',
|
||||||
cses = 'CSES',
|
cses = 'CSES',
|
||||||
kattis = 'Kattis',
|
|
||||||
usaco = 'USACO',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CPP = 'cpp'
|
M.CPP = 'cpp'
|
||||||
|
|
|
||||||
|
|
@ -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()
|
local function check()
|
||||||
vim.health.start('cp.nvim [required] ~')
|
vim.health.start('cp.nvim [required] ~')
|
||||||
|
|
||||||
utils.setup_python_env()
|
|
||||||
|
|
||||||
if vim.fn.has('nvim-0.10.0') == 1 then
|
if vim.fn.has('nvim-0.10.0') == 1 then
|
||||||
vim.health.ok('Neovim 0.10.0+ detected')
|
vim.health.ok('Neovim 0.10.0+ detected')
|
||||||
else
|
else
|
||||||
vim.health.error('cp.nvim requires Neovim 0.10.0+')
|
vim.health.error('cp.nvim requires Neovim 0.10.0+')
|
||||||
end
|
end
|
||||||
|
|
||||||
local uname = vim.uv.os_uname()
|
local uname = vim.loop.os_uname()
|
||||||
if uname.sysname == 'Windows_NT' then
|
if uname.sysname == 'Windows_NT' then
|
||||||
vim.health.error('Windows is not supported')
|
vim.health.error('Windows is not supported')
|
||||||
end
|
end
|
||||||
|
|
||||||
if utils.is_nix_build() then
|
if vim.fn.executable('uv') == 1 then
|
||||||
local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install'
|
vim.health.ok('uv executable found')
|
||||||
vim.health.ok('Nix Python environment detected (' .. source .. ')')
|
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
|
||||||
local py = utils.get_nix_python()
|
|
||||||
vim.health.info('Python: ' .. py)
|
|
||||||
local r = vim.system({ py, '--version' }, { text = true }):wait()
|
|
||||||
if r.code == 0 then
|
if r.code == 0 then
|
||||||
vim.health.info('Python version: ' .. r.stdout:gsub('\n', ''))
|
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
if vim.fn.executable('uv') == 1 then
|
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
|
||||||
vim.health.ok('uv executable found')
|
end
|
||||||
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
|
|
||||||
if r.code == 0 then
|
|
||||||
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
|
|
||||||
end
|
|
||||||
else
|
|
||||||
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
|
|
||||||
end
|
|
||||||
|
|
||||||
if vim.fn.executable('nix') == 1 then
|
local plugin_path = utils.get_plugin_path()
|
||||||
vim.health.info('nix available but Python environment not resolved via nix')
|
local venv_dir = plugin_path .. '/.venv'
|
||||||
end
|
if vim.fn.isdirectory(venv_dir) == 1 then
|
||||||
|
vim.health.ok('Python virtual environment found at ' .. venv_dir)
|
||||||
local plugin_path = utils.get_plugin_path()
|
else
|
||||||
local venv_dir = plugin_path .. '/.venv'
|
vim.health.info('Python virtual environment not set up (created on first scrape)')
|
||||||
if vim.fn.isdirectory(venv_dir) == 1 then
|
|
||||||
vim.health.ok('Python virtual environment found at ' .. venv_dir)
|
|
||||||
else
|
|
||||||
vim.health.info('Python virtual environment not set up (created on first scrape)')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local time_cap = utils.time_capability()
|
local time_cap = utils.time_capability()
|
||||||
|
|
@ -58,7 +41,7 @@ local function check()
|
||||||
vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
|
vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
|
||||||
end
|
end
|
||||||
|
|
||||||
local timeout_cap = utils.timeout_capability()
|
local timeout_cap = utils.time_capability()
|
||||||
if timeout_cap.ok then
|
if timeout_cap.ok then
|
||||||
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
|
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -15,25 +15,17 @@ local initialized = false
|
||||||
|
|
||||||
local function ensure_initialized()
|
local function ensure_initialized()
|
||||||
if initialized then
|
if initialized then
|
||||||
return true
|
return
|
||||||
end
|
end
|
||||||
local user_config = vim.g.cp or {}
|
local user_config = vim.g.cp_config or {}
|
||||||
local ok, result = pcall(config_module.setup, user_config)
|
local config = config_module.setup(user_config)
|
||||||
if not ok then
|
config_module.set_current_config(config)
|
||||||
local msg = tostring(result):gsub('^.+:%d+: ', '')
|
|
||||||
vim.notify(msg, vim.log.levels.ERROR)
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
config_module.set_current_config(result)
|
|
||||||
initialized = true
|
initialized = true
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.handle_command(opts)
|
function M.handle_command(opts)
|
||||||
if not ensure_initialized() then
|
ensure_initialized()
|
||||||
return
|
|
||||||
end
|
|
||||||
local commands = require('cp.commands')
|
local commands = require('cp.commands')
|
||||||
commands.handle_command(opts)
|
commands.handle_command(opts)
|
||||||
end
|
end
|
||||||
|
|
@ -42,13 +34,4 @@ function M.is_initialized()
|
||||||
return initialized
|
return initialized
|
||||||
end
|
end
|
||||||
|
|
||||||
---@deprecated Use `vim.g.cp` instead
|
|
||||||
function M.setup(user_config)
|
|
||||||
vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false)
|
|
||||||
|
|
||||||
if user_config then
|
|
||||||
vim.g.cp = vim.tbl_deep_extend('force', vim.g.cp or {}, user_config)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
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)
|
function M.compile(compile_cmd, substitutions, on_complete)
|
||||||
local cmd = substitute_template(compile_cmd, substitutions)
|
local cmd = substitute_template(compile_cmd, substitutions)
|
||||||
local sh = table.concat(cmd, ' ') .. ' 2>&1'
|
local sh = table.concat(cmd, ' ') .. ' 2>&1'
|
||||||
logger.log('compile: ' .. sh)
|
|
||||||
|
|
||||||
local t0 = vim.uv.hrtime()
|
local t0 = vim.uv.hrtime()
|
||||||
vim.system({ 'sh', '-c', sh }, { text = false }, function(r)
|
vim.system({ 'sh', '-c', sh }, { text = false }, function(r)
|
||||||
|
|
@ -120,7 +119,6 @@ function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete)
|
||||||
local sec = math.ceil(timeout_ms / 1000)
|
local sec = math.ceil(timeout_ms / 1000)
|
||||||
local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec)
|
local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec)
|
||||||
local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog)
|
local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog)
|
||||||
logger.log('run: ' .. sh)
|
|
||||||
|
|
||||||
local t0 = vim.uv.hrtime()
|
local t0 = vim.uv.hrtime()
|
||||||
vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)
|
vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
---@class ProblemConstraints
|
---@class ProblemConstraints
|
||||||
---@field timeout_ms number
|
---@field timeout_ms number
|
||||||
---@field memory_mb number
|
---@field memory_mb number
|
||||||
---@field precision number?
|
|
||||||
|
|
||||||
---@class PanelState
|
---@class PanelState
|
||||||
---@field test_cases RanTestCase[]
|
---@field test_cases RanTestCase[]
|
||||||
|
|
@ -57,8 +56,7 @@ local function load_constraints_from_cache(platform, contest_id, problem_id)
|
||||||
cache.load()
|
cache.load()
|
||||||
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
|
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
|
||||||
if timeout_ms and memory_mb then
|
if timeout_ms and memory_mb then
|
||||||
local precision = cache.get_precision(platform, contest_id, problem_id)
|
return { timeout_ms = timeout_ms, memory_mb = memory_mb }
|
||||||
return { timeout_ms = timeout_ms, memory_mb = memory_mb, precision = precision }
|
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
@ -101,53 +99,6 @@ local function build_command(cmd, substitutions)
|
||||||
return execute.build_command(cmd, substitutions)
|
return execute.build_command(cmd, substitutions)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param actual string
|
|
||||||
---@param expected string
|
|
||||||
---@param precision number?
|
|
||||||
---@return boolean
|
|
||||||
local function compare_outputs(actual, expected, precision)
|
|
||||||
local norm_actual = normalize_lines(actual)
|
|
||||||
local norm_expected = normalize_lines(expected)
|
|
||||||
|
|
||||||
if precision == nil or precision == 0 then
|
|
||||||
return norm_actual == norm_expected
|
|
||||||
end
|
|
||||||
|
|
||||||
local actual_lines = vim.split(norm_actual, '\n', { plain = true })
|
|
||||||
local expected_lines = vim.split(norm_expected, '\n', { plain = true })
|
|
||||||
|
|
||||||
if #actual_lines ~= #expected_lines then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
for i = 1, #actual_lines do
|
|
||||||
local a_tokens = vim.split(actual_lines[i], '%s+', { plain = false, trimempty = true })
|
|
||||||
local e_tokens = vim.split(expected_lines[i], '%s+', { plain = false, trimempty = true })
|
|
||||||
|
|
||||||
if #a_tokens ~= #e_tokens then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
for j = 1, #a_tokens do
|
|
||||||
local a_tok, e_tok = a_tokens[j], e_tokens[j]
|
|
||||||
local a_num = tonumber(a_tok)
|
|
||||||
local e_num = tonumber(e_tok)
|
|
||||||
|
|
||||||
if a_num ~= nil and e_num ~= nil then
|
|
||||||
if math.abs(a_num - e_num) > precision then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
else
|
|
||||||
if a_tok ~= e_tok then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param test_case RanTestCase
|
---@param test_case RanTestCase
|
||||||
---@param debug boolean?
|
---@param debug boolean?
|
||||||
---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string?, tled: boolean, mled: boolean, rss_mb: number })
|
---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string?, tled: boolean, mled: boolean, rss_mb: number })
|
||||||
|
|
@ -192,9 +143,7 @@ local function run_single_test_case(test_case, debug, on_complete)
|
||||||
end
|
end
|
||||||
|
|
||||||
local expected = test_case.expected or ''
|
local expected = test_case.expected or ''
|
||||||
local precision = (panel_state.constraints and panel_state.constraints.precision)
|
local ok = normalize_lines(out) == normalize_lines(expected)
|
||||||
or config.ui.panel.precision
|
|
||||||
local ok = compare_outputs(out, expected, precision)
|
|
||||||
|
|
||||||
local signal = r.signal
|
local signal = r.signal
|
||||||
if not signal and r.code and r.code >= 128 then
|
if not signal and r.code and r.code >= 128 then
|
||||||
|
|
@ -327,35 +276,26 @@ function M.run_all_test_cases(indices, debug, on_each, on_done)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if #to_run == 0 then
|
local function run_next(pos)
|
||||||
logger.log(
|
if pos > #to_run then
|
||||||
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0),
|
logger.log(
|
||||||
vim.log.levels.INFO,
|
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run),
|
||||||
true
|
vim.log.levels.INFO,
|
||||||
)
|
true
|
||||||
on_done(panel_state.test_cases)
|
)
|
||||||
return
|
on_done(panel_state.test_cases)
|
||||||
end
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local total = #to_run
|
M.run_test_case(to_run[pos], debug, function()
|
||||||
local remaining = total
|
|
||||||
|
|
||||||
for _, idx in ipairs(to_run) do
|
|
||||||
M.run_test_case(idx, debug, function()
|
|
||||||
if on_each then
|
if on_each then
|
||||||
on_each(idx, total)
|
on_each(pos, #to_run)
|
||||||
end
|
|
||||||
remaining = remaining - 1
|
|
||||||
if remaining == 0 then
|
|
||||||
logger.log(
|
|
||||||
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total),
|
|
||||||
vim.log.levels.INFO,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
on_done(panel_state.test_cases)
|
|
||||||
end
|
end
|
||||||
|
run_next(pos + 1)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
run_next(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return PanelState
|
---@return PanelState
|
||||||
|
|
|
||||||
|
|
@ -5,96 +5,67 @@ local logger = require('cp.log')
|
||||||
local utils = require('cp.utils')
|
local utils = require('cp.utils')
|
||||||
|
|
||||||
local function syshandle(result)
|
local function syshandle(result)
|
||||||
local ok, data = pcall(vim.json.decode, result.stdout or '')
|
|
||||||
if ok then
|
|
||||||
return { success = true, data = data }
|
|
||||||
end
|
|
||||||
|
|
||||||
if result.code ~= 0 then
|
if result.code ~= 0 then
|
||||||
local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error')
|
local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error')
|
||||||
return { success = false, error = msg }
|
return { success = false, error = msg }
|
||||||
end
|
end
|
||||||
|
|
||||||
local msg = 'Failed to parse scraper output: ' .. tostring(data)
|
local ok, data = pcall(vim.json.decode, result.stdout)
|
||||||
logger.log(msg, vim.log.levels.ERROR)
|
if not ok then
|
||||||
return { success = false, error = msg }
|
local msg = 'Failed to parse scraper output: ' .. tostring(data)
|
||||||
end
|
logger.log(msg, vim.log.levels.ERROR)
|
||||||
|
return { success = false, error = msg }
|
||||||
---@param env_map table<string, string>
|
|
||||||
---@return string[]
|
|
||||||
local function spawn_env_list(env_map)
|
|
||||||
local out = {}
|
|
||||||
for key, value in pairs(env_map) do
|
|
||||||
out[#out + 1] = tostring(key) .. '=' .. tostring(value)
|
|
||||||
end
|
end
|
||||||
return out
|
|
||||||
|
return { success = true, data = data }
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param platform string
|
---@param platform string
|
||||||
---@param subcommand string
|
---@param subcommand string
|
||||||
---@param args string[]
|
---@param args string[]
|
||||||
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table), env_extra?: table<string, string>, stdin?: string }
|
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) }
|
||||||
local function run_scraper(platform, subcommand, args, opts)
|
local function run_scraper(platform, subcommand, args, opts)
|
||||||
if not utils.setup_python_env() then
|
|
||||||
local msg = 'no Python environment available (install uv or nix)'
|
|
||||||
logger.log(msg, vim.log.levels.ERROR)
|
|
||||||
if opts and opts.on_exit then
|
|
||||||
opts.on_exit({ success = false, error = msg })
|
|
||||||
end
|
|
||||||
return { success = false, error = msg }
|
|
||||||
end
|
|
||||||
|
|
||||||
local plugin_path = utils.get_plugin_path()
|
local plugin_path = utils.get_plugin_path()
|
||||||
local cmd = utils.get_python_cmd(platform, plugin_path)
|
local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand }
|
||||||
vim.list_extend(cmd, { subcommand })
|
|
||||||
vim.list_extend(cmd, args)
|
vim.list_extend(cmd, args)
|
||||||
|
|
||||||
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
|
|
||||||
|
|
||||||
local env = vim.fn.environ()
|
local env = vim.fn.environ()
|
||||||
env.VIRTUAL_ENV = ''
|
env.VIRTUAL_ENV = ''
|
||||||
env.PYTHONPATH = ''
|
env.PYTHONPATH = ''
|
||||||
env.CONDA_PREFIX = ''
|
env.CONDA_PREFIX = ''
|
||||||
|
|
||||||
if opts and opts.env_extra then
|
|
||||||
for k, v in pairs(opts.env_extra) do
|
|
||||||
env[k] = v
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if opts and opts.ndjson then
|
if opts and opts.ndjson then
|
||||||
local uv = vim.uv
|
local uv = vim.loop
|
||||||
local stdout = uv.new_pipe(false)
|
local stdout = uv.new_pipe(false)
|
||||||
local stderr = uv.new_pipe(false)
|
local stderr = uv.new_pipe(false)
|
||||||
local buf = ''
|
local buf = ''
|
||||||
|
|
||||||
local handle
|
local handle
|
||||||
handle = uv.spawn(cmd[1], {
|
handle = uv.spawn(
|
||||||
args = vim.list_slice(cmd, 2),
|
cmd[1],
|
||||||
stdio = { nil, stdout, stderr },
|
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env },
|
||||||
env = spawn_env_list(env),
|
function(code, signal)
|
||||||
cwd = plugin_path,
|
if buf ~= '' and opts.on_event then
|
||||||
}, function(code, signal)
|
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
|
||||||
if buf ~= '' and opts.on_event then
|
if ok_tail then
|
||||||
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
|
opts.on_event(ev_tail)
|
||||||
if ok_tail then
|
end
|
||||||
opts.on_event(ev_tail)
|
buf = ''
|
||||||
|
end
|
||||||
|
if opts.on_exit then
|
||||||
|
opts.on_exit({ success = (code == 0), code = code, signal = signal })
|
||||||
|
end
|
||||||
|
if not stdout:is_closing() then
|
||||||
|
stdout:close()
|
||||||
|
end
|
||||||
|
if not stderr:is_closing() then
|
||||||
|
stderr:close()
|
||||||
|
end
|
||||||
|
if handle and not handle:is_closing() then
|
||||||
|
handle:close()
|
||||||
end
|
end
|
||||||
buf = ''
|
|
||||||
end
|
end
|
||||||
if opts.on_exit then
|
)
|
||||||
opts.on_exit({ success = (code == 0), code = code, signal = signal })
|
|
||||||
end
|
|
||||||
if not stdout:is_closing() then
|
|
||||||
stdout:close()
|
|
||||||
end
|
|
||||||
if not stderr:is_closing() then
|
|
||||||
stderr:close()
|
|
||||||
end
|
|
||||||
if handle and not handle:is_closing() then
|
|
||||||
handle:close()
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
if not handle then
|
if not handle then
|
||||||
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
|
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
|
||||||
|
|
@ -131,10 +102,7 @@ local function run_scraper(platform, subcommand, args, opts)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path }
|
local sysopts = { text = true, timeout = 30000, env = env }
|
||||||
if opts and opts.stdin then
|
|
||||||
sysopts.stdin = opts.stdin
|
|
||||||
end
|
|
||||||
if opts and opts.sync then
|
if opts and opts.sync then
|
||||||
local result = vim.system(cmd, sysopts):wait()
|
local result = vim.system(cmd, sysopts):wait()
|
||||||
return syshandle(result)
|
return syshandle(result)
|
||||||
|
|
@ -237,7 +205,6 @@ function M.scrape_all_tests(platform, contest_id, callback)
|
||||||
memory_mb = ev.memory_mb or 0,
|
memory_mb = ev.memory_mb or 0,
|
||||||
interactive = ev.interactive or false,
|
interactive = ev.interactive or false,
|
||||||
multi_test = ev.multi_test or false,
|
multi_test = ev.multi_test or false,
|
||||||
precision = ev.precision ~= vim.NIL and ev.precision or nil,
|
|
||||||
problem_id = ev.problem_id,
|
problem_id = ev.problem_id,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
@ -246,21 +213,4 @@ function M.scrape_all_tests(platform, contest_id, callback)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.submit(platform, contest_id, problem_id, language, source_code, credentials, callback)
|
|
||||||
local creds_json = vim.json.encode(credentials)
|
|
||||||
run_scraper(platform, 'submit', { contest_id, problem_id, language }, {
|
|
||||||
stdin = source_code,
|
|
||||||
env_extra = { CP_CREDENTIALS = creds_json },
|
|
||||||
on_exit = function(result)
|
|
||||||
if type(callback) == 'function' then
|
|
||||||
if result and result.success then
|
|
||||||
callback(result.data or { success = true })
|
|
||||||
else
|
|
||||||
callback({ success = false, error = result and result.error or 'unknown' })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
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 scraper = require('cp.scraper')
|
||||||
local state = require('cp.state')
|
local state = require('cp.state')
|
||||||
|
|
||||||
local function apply_template(bufnr, lang_id, platform)
|
|
||||||
local config = config_module.get_config()
|
|
||||||
local eff = config.runtime.effective[platform] and config.runtime.effective[platform][lang_id]
|
|
||||||
if not eff or not eff.template then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local path = vim.fn.expand(eff.template)
|
|
||||||
if vim.fn.filereadable(path) ~= 1 then
|
|
||||||
logger.log(('[cp.nvim] template not readable: %s'):format(path), vim.log.levels.WARN)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local lines = vim.fn.readfile(path)
|
|
||||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
|
||||||
local marker = config.templates and config.templates.cursor_marker
|
|
||||||
if marker then
|
|
||||||
for lnum, line in ipairs(lines) do
|
|
||||||
local col = line:find(marker, 1, true)
|
|
||||||
if col then
|
|
||||||
local new_line = line:sub(1, col - 1) .. line:sub(col + #marker)
|
|
||||||
vim.api.nvim_buf_set_lines(bufnr, lnum - 1, lnum, false, { new_line })
|
|
||||||
local winid = vim.fn.bufwinid(bufnr)
|
|
||||||
if winid ~= -1 then
|
|
||||||
vim.api.nvim_win_set_cursor(winid, { lnum, col - 1 })
|
|
||||||
end
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
---Get the language of the current file from cache
|
---Get the language of the current file from cache
|
||||||
---@return string?
|
---@return string?
|
||||||
local function get_current_file_language()
|
local function get_current_file_language()
|
||||||
|
|
@ -130,8 +100,7 @@ local function start_tests(platform, contest_id, problems)
|
||||||
ev.timeout_ms or 0,
|
ev.timeout_ms or 0,
|
||||||
ev.memory_mb or 0,
|
ev.memory_mb or 0,
|
||||||
ev.interactive,
|
ev.interactive,
|
||||||
ev.multi_test,
|
ev.multi_test
|
||||||
ev.precision
|
|
||||||
)
|
)
|
||||||
|
|
||||||
local io_state = state.get_io_view_state()
|
local io_state = state.get_io_view_state()
|
||||||
|
|
@ -152,7 +121,6 @@ end
|
||||||
---@param language? string
|
---@param language? string
|
||||||
function M.setup_contest(platform, contest_id, problem_id, language)
|
function M.setup_contest(platform, contest_id, problem_id, language)
|
||||||
local old_platform, old_contest_id = state.get_platform(), state.get_contest_id()
|
local old_platform, old_contest_id = state.get_platform(), state.get_contest_id()
|
||||||
local old_problem_id = state.get_problem_id()
|
|
||||||
|
|
||||||
state.set_platform(platform)
|
state.set_platform(platform)
|
||||||
state.set_contest_id(contest_id)
|
state.set_contest_id(contest_id)
|
||||||
|
|
@ -165,7 +133,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id
|
local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id
|
||||||
|
|
||||||
cache.load()
|
cache.load()
|
||||||
|
|
||||||
|
|
@ -175,10 +143,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
|
||||||
M.setup_problem(pid, language)
|
M.setup_problem(pid, language)
|
||||||
start_tests(platform, contest_id, problems)
|
start_tests(platform, contest_id, problems)
|
||||||
|
|
||||||
local is_new_problem = old_problem_id ~= pid
|
if config_module.get_config().open_url and is_new_contest and contest_data.url then
|
||||||
local should_open_url = config_module.get_config().open_url
|
|
||||||
and (is_new_contest or is_new_problem)
|
|
||||||
if should_open_url and contest_data.url then
|
|
||||||
vim.ui.open(contest_data.url:format(pid))
|
vim.ui.open(contest_data.url:format(pid))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -195,7 +160,12 @@ function M.setup_contest(platform, contest_id, problem_id, language)
|
||||||
vim.bo[bufnr].buftype = ''
|
vim.bo[bufnr].buftype = ''
|
||||||
vim.bo[bufnr].swapfile = false
|
vim.bo[bufnr].swapfile = false
|
||||||
|
|
||||||
state.set_language(lang)
|
if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
|
||||||
|
local ok = pcall(cfg.hooks.setup_code, state)
|
||||||
|
if ok then
|
||||||
|
vim.b[bufnr].cp_setup_done = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
state.set_provisional({
|
state.set_provisional({
|
||||||
bufnr = bufnr,
|
bufnr = bufnr,
|
||||||
|
|
@ -203,7 +173,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
|
||||||
contest_id = contest_id,
|
contest_id = contest_id,
|
||||||
language = lang,
|
language = lang,
|
||||||
requested_problem_id = problem_id,
|
requested_problem_id = problem_id,
|
||||||
token = vim.uv.hrtime(),
|
token = vim.loop.hrtime(),
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
|
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
|
||||||
|
|
@ -275,15 +245,7 @@ function M.setup_problem(problem_id, language)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local contest_dir = vim.fn.fnamemodify(source_file, ':h')
|
vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p')
|
||||||
local is_new_dir = vim.fn.isdirectory(contest_dir) == 0
|
|
||||||
vim.fn.mkdir(contest_dir, 'p')
|
|
||||||
if is_new_dir then
|
|
||||||
local s = config.hooks and config.hooks.setup
|
|
||||||
if s and s.contest then
|
|
||||||
pcall(s.contest, state)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local prov = state.get_provisional()
|
local prov = state.get_provisional()
|
||||||
if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then
|
if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then
|
||||||
|
|
@ -302,29 +264,14 @@ function M.setup_problem(problem_id, language)
|
||||||
mods = { silent = true, noautocmd = true, keepalt = true },
|
mods = { silent = true, noautocmd = true, keepalt = true },
|
||||||
})
|
})
|
||||||
state.set_solution_win(vim.api.nvim_get_current_win())
|
state.set_solution_win(vim.api.nvim_get_current_win())
|
||||||
if not vim.b[prov.bufnr].cp_setup_done then
|
if config.hooks and config.hooks.setup_code and not vim.b[prov.bufnr].cp_setup_done then
|
||||||
apply_template(prov.bufnr, lang, platform)
|
local ok = pcall(config.hooks.setup_code, state)
|
||||||
local s = config.hooks and config.hooks.setup
|
if ok then
|
||||||
if s and s.code then
|
|
||||||
local ok = pcall(s.code, state)
|
|
||||||
if ok then
|
|
||||||
vim.b[prov.bufnr].cp_setup_done = true
|
|
||||||
end
|
|
||||||
else
|
|
||||||
helpers.clearcol(prov.bufnr)
|
|
||||||
vim.b[prov.bufnr].cp_setup_done = true
|
vim.b[prov.bufnr].cp_setup_done = true
|
||||||
end
|
end
|
||||||
local o = config.hooks and config.hooks.on
|
elseif not vim.b[prov.bufnr].cp_setup_done then
|
||||||
if o and o.enter then
|
helpers.clearcol(prov.bufnr)
|
||||||
local bufnr = prov.bufnr
|
vim.b[prov.bufnr].cp_setup_done = true
|
||||||
vim.api.nvim_create_autocmd('BufEnter', {
|
|
||||||
buffer = bufnr,
|
|
||||||
callback = function()
|
|
||||||
pcall(o.enter, state)
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
pcall(o.enter, state)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
cache.set_file_state(
|
cache.set_file_state(
|
||||||
vim.fn.fnamemodify(source_file, ':p'),
|
vim.fn.fnamemodify(source_file, ':p'),
|
||||||
|
|
@ -347,32 +294,14 @@ function M.setup_problem(problem_id, language)
|
||||||
local bufnr = vim.api.nvim_get_current_buf()
|
local bufnr = vim.api.nvim_get_current_buf()
|
||||||
state.set_solution_win(vim.api.nvim_get_current_win())
|
state.set_solution_win(vim.api.nvim_get_current_win())
|
||||||
require('cp.ui.views').ensure_io_view()
|
require('cp.ui.views').ensure_io_view()
|
||||||
if not vim.b[bufnr].cp_setup_done then
|
if config.hooks and config.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
|
||||||
local is_new = vim.api.nvim_buf_line_count(bufnr) == 1
|
local ok = pcall(config.hooks.setup_code, state)
|
||||||
and vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] == ''
|
if ok then
|
||||||
if is_new then
|
|
||||||
apply_template(bufnr, lang, platform)
|
|
||||||
end
|
|
||||||
local s = config.hooks and config.hooks.setup
|
|
||||||
if s and s.code then
|
|
||||||
local ok = pcall(s.code, state)
|
|
||||||
if ok then
|
|
||||||
vim.b[bufnr].cp_setup_done = true
|
|
||||||
end
|
|
||||||
else
|
|
||||||
helpers.clearcol(bufnr)
|
|
||||||
vim.b[bufnr].cp_setup_done = true
|
vim.b[bufnr].cp_setup_done = true
|
||||||
end
|
end
|
||||||
local o = config.hooks and config.hooks.on
|
elseif not vim.b[bufnr].cp_setup_done then
|
||||||
if o and o.enter then
|
helpers.clearcol(bufnr)
|
||||||
vim.api.nvim_create_autocmd('BufEnter', {
|
vim.b[bufnr].cp_setup_done = true
|
||||||
buffer = bufnr,
|
|
||||||
callback = function()
|
|
||||||
pcall(o.enter, state)
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
pcall(o.enter, state)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
cache.set_file_state(
|
cache.set_file_state(
|
||||||
vim.fn.expand('%:p'),
|
vim.fn.expand('%:p'),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
---@class cp.IoViewState
|
---@class cp.IoViewState
|
||||||
---@field output_buf integer
|
---@field output_buf integer
|
||||||
---@field input_buf integer
|
---@field input_buf integer
|
||||||
|
---@field current_test_index integer?
|
||||||
---@field source_buf integer?
|
---@field source_buf integer?
|
||||||
|
|
||||||
---@class cp.State
|
---@class cp.State
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -144,7 +144,7 @@ local function add_new_test()
|
||||||
vim.api.nvim_win_set_buf(input_win, input_buf)
|
vim.api.nvim_win_set_buf(input_win, input_buf)
|
||||||
vim.bo[input_buf].modifiable = true
|
vim.bo[input_buf].modifiable = true
|
||||||
vim.bo[input_buf].readonly = false
|
vim.bo[input_buf].readonly = false
|
||||||
vim.bo[input_buf].buftype = 'acwrite'
|
vim.bo[input_buf].buftype = 'nofile'
|
||||||
vim.bo[input_buf].buflisted = false
|
vim.bo[input_buf].buflisted = false
|
||||||
helpers.clearcol(input_buf)
|
helpers.clearcol(input_buf)
|
||||||
|
|
||||||
|
|
@ -155,7 +155,7 @@ local function add_new_test()
|
||||||
vim.api.nvim_win_set_buf(expected_win, expected_buf)
|
vim.api.nvim_win_set_buf(expected_win, expected_buf)
|
||||||
vim.bo[expected_buf].modifiable = true
|
vim.bo[expected_buf].modifiable = true
|
||||||
vim.bo[expected_buf].readonly = false
|
vim.bo[expected_buf].readonly = false
|
||||||
vim.bo[expected_buf].buftype = 'acwrite'
|
vim.bo[expected_buf].buftype = 'nofile'
|
||||||
vim.bo[expected_buf].buflisted = false
|
vim.bo[expected_buf].buflisted = false
|
||||||
helpers.clearcol(expected_buf)
|
helpers.clearcol(expected_buf)
|
||||||
|
|
||||||
|
|
@ -177,80 +177,6 @@ local function add_new_test()
|
||||||
logger.log(('Added test %d'):format(new_index))
|
logger.log(('Added test %d'):format(new_index))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function save_all_tests()
|
|
||||||
if not edit_state then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local platform = state.get_platform()
|
|
||||||
local contest_id = state.get_contest_id()
|
|
||||||
local problem_id = state.get_problem_id()
|
|
||||||
|
|
||||||
if not platform or not contest_id or not problem_id then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
for i, pair in ipairs(edit_state.test_buffers) do
|
|
||||||
if
|
|
||||||
vim.api.nvim_buf_is_valid(pair.input_buf) and vim.api.nvim_buf_is_valid(pair.expected_buf)
|
|
||||||
then
|
|
||||||
local input_lines = vim.api.nvim_buf_get_lines(pair.input_buf, 0, -1, false)
|
|
||||||
local expected_lines = vim.api.nvim_buf_get_lines(pair.expected_buf, 0, -1, false)
|
|
||||||
|
|
||||||
edit_state.test_cases[i].input = table.concat(input_lines, '\n')
|
|
||||||
edit_state.test_cases[i].expected = table.concat(expected_lines, '\n')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local contest_data = cache.get_contest_data(platform, contest_id)
|
|
||||||
local is_multi_test = contest_data.problems[contest_data.index_map[problem_id]].multi_test
|
|
||||||
or false
|
|
||||||
|
|
||||||
local combined_input = table.concat(
|
|
||||||
vim.tbl_map(function(tc)
|
|
||||||
return tc.input
|
|
||||||
end, edit_state.test_cases),
|
|
||||||
'\n'
|
|
||||||
)
|
|
||||||
local combined_expected = table.concat(
|
|
||||||
vim.tbl_map(function(tc)
|
|
||||||
return tc.expected
|
|
||||||
end, edit_state.test_cases),
|
|
||||||
'\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
cache.set_test_cases(
|
|
||||||
platform,
|
|
||||||
contest_id,
|
|
||||||
problem_id,
|
|
||||||
{ input = combined_input, expected = combined_expected },
|
|
||||||
edit_state.test_cases,
|
|
||||||
edit_state.constraints and edit_state.constraints.timeout_ms or 0,
|
|
||||||
edit_state.constraints and edit_state.constraints.memory_mb or 0,
|
|
||||||
false,
|
|
||||||
is_multi_test
|
|
||||||
)
|
|
||||||
|
|
||||||
local config = config_module.get_config()
|
|
||||||
local base_name = config.filename and config.filename(platform, contest_id, problem_id, config)
|
|
||||||
or config_module.default_filename(contest_id, problem_id)
|
|
||||||
|
|
||||||
vim.fn.mkdir('io', 'p')
|
|
||||||
|
|
||||||
for i, tc in ipairs(edit_state.test_cases) do
|
|
||||||
local input_file = string.format('io/%s.%d.cpin', base_name, i)
|
|
||||||
local expected_file = string.format('io/%s.%d.cpout', base_name, i)
|
|
||||||
|
|
||||||
local input_content = (tc.input or ''):gsub('\r', '')
|
|
||||||
local expected_content = (tc.expected or ''):gsub('\r', '')
|
|
||||||
|
|
||||||
vim.fn.writefile(vim.split(input_content, '\n', { trimempty = true }), input_file)
|
|
||||||
vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file)
|
|
||||||
end
|
|
||||||
|
|
||||||
logger.log('Saved all test cases')
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param buf integer
|
---@param buf integer
|
||||||
setup_keybindings = function(buf)
|
setup_keybindings = function(buf)
|
||||||
local config = config_module.get_config()
|
local config = config_module.get_config()
|
||||||
|
|
@ -317,30 +243,86 @@ setup_keybindings = function(buf)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
end
|
||||||
|
|
||||||
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
local function save_all_tests()
|
||||||
group = augroup,
|
if not edit_state then
|
||||||
buffer = buf,
|
return
|
||||||
callback = function()
|
end
|
||||||
save_all_tests()
|
|
||||||
vim.bo[buf].modified = false
|
local platform = state.get_platform()
|
||||||
end,
|
local contest_id = state.get_contest_id()
|
||||||
})
|
local problem_id = state.get_problem_id()
|
||||||
|
|
||||||
|
if not platform or not contest_id or not problem_id then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for i, pair in ipairs(edit_state.test_buffers) do
|
||||||
|
if
|
||||||
|
vim.api.nvim_buf_is_valid(pair.input_buf) and vim.api.nvim_buf_is_valid(pair.expected_buf)
|
||||||
|
then
|
||||||
|
local input_lines = vim.api.nvim_buf_get_lines(pair.input_buf, 0, -1, false)
|
||||||
|
local expected_lines = vim.api.nvim_buf_get_lines(pair.expected_buf, 0, -1, false)
|
||||||
|
|
||||||
|
edit_state.test_cases[i].input = table.concat(input_lines, '\n')
|
||||||
|
edit_state.test_cases[i].expected = table.concat(expected_lines, '\n')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||||
|
local is_multi_test = contest_data.problems[contest_data.index_map[problem_id]].multi_test
|
||||||
|
or false
|
||||||
|
|
||||||
|
-- Generate combined test from individual test cases
|
||||||
|
local combined_input = table.concat(
|
||||||
|
vim.tbl_map(function(tc)
|
||||||
|
return tc.input
|
||||||
|
end, edit_state.test_cases),
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
local combined_expected = table.concat(
|
||||||
|
vim.tbl_map(function(tc)
|
||||||
|
return tc.expected
|
||||||
|
end, edit_state.test_cases),
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.set_test_cases(
|
||||||
|
platform,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
{ input = combined_input, expected = combined_expected },
|
||||||
|
edit_state.test_cases,
|
||||||
|
edit_state.constraints and edit_state.constraints.timeout_ms or 0,
|
||||||
|
edit_state.constraints and edit_state.constraints.memory_mb or 0,
|
||||||
|
false,
|
||||||
|
is_multi_test
|
||||||
|
)
|
||||||
|
|
||||||
|
local config = config_module.get_config()
|
||||||
|
local base_name = config.filename and config.filename(platform, contest_id, problem_id, config)
|
||||||
|
or config_module.default_filename(contest_id, problem_id)
|
||||||
|
|
||||||
|
vim.fn.mkdir('io', 'p')
|
||||||
|
|
||||||
|
for i, tc in ipairs(edit_state.test_cases) do
|
||||||
|
local input_file = string.format('io/%s.%d.cpin', base_name, i)
|
||||||
|
local expected_file = string.format('io/%s.%d.cpout', base_name, i)
|
||||||
|
|
||||||
|
local input_content = (tc.input or ''):gsub('\r', '')
|
||||||
|
local expected_content = (tc.expected or ''):gsub('\r', '')
|
||||||
|
|
||||||
|
vim.fn.writefile(vim.split(input_content, '\n', { trimempty = true }), input_file)
|
||||||
|
vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file)
|
||||||
|
end
|
||||||
|
|
||||||
|
logger.log('Saved all test cases')
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.toggle_edit(test_index)
|
function M.toggle_edit(test_index)
|
||||||
if edit_state then
|
if edit_state then
|
||||||
save_all_tests()
|
save_all_tests()
|
||||||
|
|
||||||
for _, pair in ipairs(edit_state.test_buffers) do
|
|
||||||
if vim.api.nvim_buf_is_valid(pair.input_buf) then
|
|
||||||
vim.api.nvim_buf_delete(pair.input_buf, { force = true })
|
|
||||||
end
|
|
||||||
if vim.api.nvim_buf_is_valid(pair.expected_buf) then
|
|
||||||
vim.api.nvim_buf_delete(pair.expected_buf, { force = true })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
edit_state = nil
|
edit_state = nil
|
||||||
|
|
||||||
pcall(vim.api.nvim_clear_autocmds, { group = 'cp_edit_guard' })
|
pcall(vim.api.nvim_clear_autocmds, { group = 'cp_edit_guard' })
|
||||||
|
|
@ -429,7 +411,7 @@ function M.toggle_edit(test_index)
|
||||||
vim.api.nvim_win_set_buf(input_win, input_buf)
|
vim.api.nvim_win_set_buf(input_win, input_buf)
|
||||||
vim.bo[input_buf].modifiable = true
|
vim.bo[input_buf].modifiable = true
|
||||||
vim.bo[input_buf].readonly = false
|
vim.bo[input_buf].readonly = false
|
||||||
vim.bo[input_buf].buftype = 'acwrite'
|
vim.bo[input_buf].buftype = 'nofile'
|
||||||
vim.bo[input_buf].buflisted = false
|
vim.bo[input_buf].buflisted = false
|
||||||
helpers.clearcol(input_buf)
|
helpers.clearcol(input_buf)
|
||||||
|
|
||||||
|
|
@ -439,7 +421,7 @@ function M.toggle_edit(test_index)
|
||||||
vim.api.nvim_win_set_buf(expected_win, expected_buf)
|
vim.api.nvim_win_set_buf(expected_win, expected_buf)
|
||||||
vim.bo[expected_buf].modifiable = true
|
vim.bo[expected_buf].modifiable = true
|
||||||
vim.bo[expected_buf].readonly = false
|
vim.bo[expected_buf].readonly = false
|
||||||
vim.bo[expected_buf].buftype = 'acwrite'
|
vim.bo[expected_buf].buftype = 'nofile'
|
||||||
vim.bo[expected_buf].buflisted = false
|
vim.bo[expected_buf].buflisted = false
|
||||||
helpers.clearcol(expected_buf)
|
helpers.clearcol(expected_buf)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ local M = {}
|
||||||
|
|
||||||
---@class PanelOpts
|
---@class PanelOpts
|
||||||
---@field debug? boolean
|
---@field debug? boolean
|
||||||
---@field test_index? integer
|
|
||||||
|
|
||||||
local cache = require('cp.cache')
|
local cache = require('cp.cache')
|
||||||
local config_module = require('cp.config')
|
local config_module = require('cp.config')
|
||||||
|
|
@ -27,8 +26,6 @@ function M.disable()
|
||||||
M.toggle_panel()
|
M.toggle_panel()
|
||||||
elseif active_panel == 'interactive' then
|
elseif active_panel == 'interactive' then
|
||||||
M.toggle_interactive()
|
M.toggle_interactive()
|
||||||
elseif active_panel == 'stress' then
|
|
||||||
require('cp.stress').toggle()
|
|
||||||
else
|
else
|
||||||
logger.log(('Unknown panel type: %s'):format(tostring(active_panel)))
|
logger.log(('Unknown panel type: %s'):format(tostring(active_panel)))
|
||||||
end
|
end
|
||||||
|
|
@ -70,11 +67,11 @@ function M.toggle_interactive(interactor_cmd)
|
||||||
cache.load()
|
cache.load()
|
||||||
local contest_data = cache.get_contest_data(platform, contest_id)
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||||
if
|
if
|
||||||
contest_data
|
not contest_data
|
||||||
and contest_data.index_map
|
or not contest_data.index_map
|
||||||
and not contest_data.problems[contest_data.index_map[problem_id]].interactive
|
or not contest_data.problems[contest_data.index_map[problem_id]].interactive
|
||||||
then
|
then
|
||||||
logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR)
|
logger.log('This problem is interactive. Use :CP interact.', vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -124,22 +121,13 @@ function M.toggle_interactive(interactor_cmd)
|
||||||
end
|
end
|
||||||
local orchestrator =
|
local orchestrator =
|
||||||
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
|
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
|
||||||
if utils.is_nix_build() then
|
cmdline = table.concat({
|
||||||
cmdline = table.concat({
|
'uv',
|
||||||
vim.fn.shellescape(utils.get_nix_python()),
|
'run',
|
||||||
vim.fn.shellescape(orchestrator),
|
vim.fn.shellescape(orchestrator),
|
||||||
vim.fn.shellescape(interactor),
|
vim.fn.shellescape(interactor),
|
||||||
vim.fn.shellescape(binary),
|
vim.fn.shellescape(binary),
|
||||||
}, ' ')
|
}, ' ')
|
||||||
else
|
|
||||||
cmdline = table.concat({
|
|
||||||
'uv',
|
|
||||||
'run',
|
|
||||||
vim.fn.shellescape(orchestrator),
|
|
||||||
vim.fn.shellescape(interactor),
|
|
||||||
vim.fn.shellescape(binary),
|
|
||||||
}, ' ')
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
cmdline = vim.fn.shellescape(binary)
|
cmdline = vim.fn.shellescape(binary)
|
||||||
end
|
end
|
||||||
|
|
@ -241,6 +229,7 @@ local function get_or_create_io_buffers()
|
||||||
state.set_io_view_state({
|
state.set_io_view_state({
|
||||||
output_buf = output_buf,
|
output_buf = output_buf,
|
||||||
input_buf = input_buf,
|
input_buf = input_buf,
|
||||||
|
current_test_index = 1,
|
||||||
source_buf = current_source_buf,
|
source_buf = current_source_buf,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -305,6 +294,49 @@ local function get_or_create_io_buffers()
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
local cfg = config_module.get_config()
|
||||||
|
local platform = state.get_platform()
|
||||||
|
local contest_id = state.get_contest_id()
|
||||||
|
local problem_id = state.get_problem_id()
|
||||||
|
|
||||||
|
local function navigate_test(delta)
|
||||||
|
local io_view_state = state.get_io_view_state()
|
||||||
|
if not io_view_state then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not platform or not contest_id or not problem_id then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
|
||||||
|
if not test_cases or #test_cases == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local new_index = (io_view_state.current_test_index or 1) + delta
|
||||||
|
if new_index < 1 or new_index > #test_cases then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
io_view_state.current_test_index = new_index
|
||||||
|
M.run_io_view(new_index)
|
||||||
|
end
|
||||||
|
|
||||||
|
if cfg.ui.run.next_test_key then
|
||||||
|
vim.keymap.set('n', cfg.ui.run.next_test_key, function()
|
||||||
|
navigate_test(1)
|
||||||
|
end, { buffer = output_buf, silent = true, desc = 'Next test' })
|
||||||
|
vim.keymap.set('n', cfg.ui.run.next_test_key, function()
|
||||||
|
navigate_test(1)
|
||||||
|
end, { buffer = input_buf, silent = true, desc = 'Next test' })
|
||||||
|
end
|
||||||
|
|
||||||
|
if cfg.ui.run.prev_test_key then
|
||||||
|
vim.keymap.set('n', cfg.ui.run.prev_test_key, function()
|
||||||
|
navigate_test(-1)
|
||||||
|
end, { buffer = output_buf, silent = true, desc = 'Previous test' })
|
||||||
|
vim.keymap.set('n', cfg.ui.run.prev_test_key, function()
|
||||||
|
navigate_test(-1)
|
||||||
|
end, { buffer = input_buf, silent = true, desc = 'Previous test' })
|
||||||
|
end
|
||||||
|
|
||||||
return output_buf, input_buf
|
return output_buf, input_buf
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -403,12 +435,12 @@ function M.ensure_io_view()
|
||||||
|
|
||||||
local cfg = config_module.get_config()
|
local cfg = config_module.get_config()
|
||||||
|
|
||||||
local io = cfg.hooks and cfg.hooks.setup and cfg.hooks.setup.io
|
if cfg.hooks and cfg.hooks.setup_io_output then
|
||||||
if io and io.output then
|
pcall(cfg.hooks.setup_io_output, output_buf, state)
|
||||||
pcall(io.output, output_buf, state)
|
|
||||||
end
|
end
|
||||||
if io and io.input then
|
|
||||||
pcall(io.input, input_buf, state)
|
if cfg.hooks and cfg.hooks.setup_io_input then
|
||||||
|
pcall(cfg.hooks.setup_io_input, input_buf, state)
|
||||||
end
|
end
|
||||||
|
|
||||||
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
|
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
|
||||||
|
|
@ -789,7 +821,6 @@ function M.toggle_panel(panel_opts)
|
||||||
local contest_data = cache.get_contest_data(platform, contest_id)
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||||
if
|
if
|
||||||
contest_data
|
contest_data
|
||||||
and contest_data.index_map
|
|
||||||
and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive
|
and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive
|
||||||
then
|
then
|
||||||
logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN)
|
logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN)
|
||||||
|
|
@ -807,13 +838,6 @@ function M.toggle_panel(panel_opts)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if panel_opts and panel_opts.test_index then
|
|
||||||
local test_state = run.get_panel_state()
|
|
||||||
if panel_opts.test_index >= 1 and panel_opts.test_index <= #test_state.test_cases then
|
|
||||||
test_state.current_index = panel_opts.test_index
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local io_state = state.get_io_view_state()
|
local io_state = state.get_io_view_state()
|
||||||
if io_state then
|
if io_state then
|
||||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||||
|
|
@ -925,15 +949,14 @@ function M.toggle_panel(panel_opts)
|
||||||
|
|
||||||
setup_keybindings_for_buffer(test_buffers.tab_buf)
|
setup_keybindings_for_buffer(test_buffers.tab_buf)
|
||||||
|
|
||||||
local o = config.hooks and config.hooks.on
|
if config.hooks and config.hooks.before_run then
|
||||||
if o and o.run then
|
vim.schedule_wrap(function()
|
||||||
vim.schedule(function()
|
config.hooks.before_run(state)
|
||||||
o.run(state)
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
if panel_opts and panel_opts.debug and o and o.debug then
|
if panel_opts and panel_opts.debug and config.hooks and config.hooks.before_debug then
|
||||||
vim.schedule(function()
|
vim.schedule_wrap(function()
|
||||||
o.debug(state)
|
config.hooks.before_debug(state)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
156
lua/cp/utils.lua
156
lua/cp/utils.lua
|
|
@ -2,10 +2,7 @@ local M = {}
|
||||||
|
|
||||||
local logger = require('cp.log')
|
local logger = require('cp.log')
|
||||||
|
|
||||||
local _nix_python = nil
|
local uname = vim.loop.os_uname()
|
||||||
local _nix_discovered = false
|
|
||||||
|
|
||||||
local uname = vim.uv.os_uname()
|
|
||||||
|
|
||||||
local _time_cached = false
|
local _time_cached = false
|
||||||
local _time_path = nil
|
local _time_path = nil
|
||||||
|
|
@ -60,11 +57,7 @@ local function find_gnu_time()
|
||||||
|
|
||||||
_time_cached = true
|
_time_cached = true
|
||||||
_time_path = nil
|
_time_path = nil
|
||||||
if uname and uname.sysname == 'Darwin' then
|
_time_reason = 'GNU time not found'
|
||||||
_time_reason = 'GNU time not found (install via: brew install coreutils)'
|
|
||||||
else
|
|
||||||
_time_reason = 'GNU time not found'
|
|
||||||
end
|
|
||||||
return _time_path, _time_reason
|
return _time_path, _time_reason
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -86,103 +79,27 @@ function M.get_plugin_path()
|
||||||
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return boolean
|
|
||||||
function M.is_nix_build()
|
|
||||||
return _nix_python ~= nil
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return string|nil
|
|
||||||
function M.get_nix_python()
|
|
||||||
return _nix_python
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return boolean
|
|
||||||
function M.is_nix_discovered()
|
|
||||||
return _nix_discovered
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param module string
|
|
||||||
---@param plugin_path string
|
|
||||||
---@return string[]
|
|
||||||
function M.get_python_cmd(module, plugin_path)
|
|
||||||
if _nix_python then
|
|
||||||
return { _nix_python, '-m', 'scrapers.' .. module }
|
|
||||||
end
|
|
||||||
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
|
|
||||||
end
|
|
||||||
|
|
||||||
local python_env_setup = false
|
local python_env_setup = false
|
||||||
|
|
||||||
---@return boolean
|
|
||||||
local function discover_nix_python()
|
|
||||||
local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim'
|
|
||||||
local cache_file = cache_dir .. '/nix-python'
|
|
||||||
|
|
||||||
local f = io.open(cache_file, 'r')
|
|
||||||
if f then
|
|
||||||
local cached = f:read('*l')
|
|
||||||
f:close()
|
|
||||||
if cached and vim.fn.executable(cached) == 1 then
|
|
||||||
_nix_python = cached
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local plugin_path = M.get_plugin_path()
|
|
||||||
vim.notify('[cp.nvim] Building Python environment with nix...', vim.log.levels.INFO)
|
|
||||||
vim.cmd.redraw()
|
|
||||||
local result = vim
|
|
||||||
.system(
|
|
||||||
{ 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' },
|
|
||||||
{ text = true }
|
|
||||||
)
|
|
||||||
:wait()
|
|
||||||
|
|
||||||
if result.code ~= 0 then
|
|
||||||
logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN)
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
local store_path = result.stdout:gsub('%s+$', '')
|
|
||||||
local python_path = store_path .. '/bin/python3'
|
|
||||||
|
|
||||||
if vim.fn.executable(python_path) ~= 1 then
|
|
||||||
logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN)
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
vim.fn.mkdir(cache_dir, 'p')
|
|
||||||
f = io.open(cache_file, 'w')
|
|
||||||
if f then
|
|
||||||
f:write(python_path)
|
|
||||||
f:close()
|
|
||||||
end
|
|
||||||
|
|
||||||
_nix_python = python_path
|
|
||||||
_nix_discovered = true
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
---@return boolean success
|
---@return boolean success
|
||||||
function M.setup_python_env()
|
function M.setup_python_env()
|
||||||
if python_env_setup then
|
if python_env_setup then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
if _nix_python then
|
local plugin_path = M.get_plugin_path()
|
||||||
logger.log('Python env: nix (python=' .. _nix_python .. ')')
|
local venv_dir = plugin_path .. '/.venv'
|
||||||
python_env_setup = true
|
|
||||||
return true
|
if vim.fn.executable('uv') == 0 then
|
||||||
|
logger.log(
|
||||||
|
'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/',
|
||||||
|
vim.log.levels.WARN
|
||||||
|
)
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local on_nixos = vim.fn.filereadable('/etc/NIXOS') == 1
|
if vim.fn.isdirectory(venv_dir) == 0 then
|
||||||
|
logger.log('Setting up Python environment for scrapers...')
|
||||||
if not on_nixos and vim.fn.executable('uv') == 1 then
|
|
||||||
local plugin_path = M.get_plugin_path()
|
|
||||||
logger.log('Python env: uv sync (dir=' .. plugin_path .. ')')
|
|
||||||
vim.notify('[cp.nvim] Setting up Python environment...', vim.log.levels.INFO)
|
|
||||||
vim.cmd.redraw()
|
|
||||||
|
|
||||||
local env = vim.fn.environ()
|
local env = vim.fn.environ()
|
||||||
env.VIRTUAL_ENV = ''
|
env.VIRTUAL_ENV = ''
|
||||||
env.PYTHONPATH = ''
|
env.PYTHONPATH = ''
|
||||||
|
|
@ -191,33 +108,14 @@ function M.setup_python_env()
|
||||||
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
|
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
|
||||||
:wait()
|
:wait()
|
||||||
if result.code ~= 0 then
|
if result.code ~= 0 then
|
||||||
logger.log(
|
logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
|
||||||
'Failed to setup Python environment: ' .. (result.stderr or ''),
|
|
||||||
vim.log.levels.ERROR
|
|
||||||
)
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
if result.stderr and result.stderr ~= '' then
|
logger.log('Python environment setup complete.')
|
||||||
logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', ''))
|
|
||||||
end
|
|
||||||
|
|
||||||
python_env_setup = true
|
|
||||||
return true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if vim.fn.executable('nix') == 1 then
|
python_env_setup = true
|
||||||
logger.log('Python env: nix discovery')
|
return true
|
||||||
if discover_nix_python() then
|
|
||||||
python_env_setup = true
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
logger.log(
|
|
||||||
'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.',
|
|
||||||
vim.log.levels.WARN
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Configure the buffer with good defaults
|
--- Configure the buffer with good defaults
|
||||||
|
|
@ -264,12 +162,20 @@ function M.check_required_runtime()
|
||||||
|
|
||||||
local time = M.time_capability()
|
local time = M.time_capability()
|
||||||
if not time.ok then
|
if not time.ok then
|
||||||
return false, time.reason
|
return false, 'GNU time not found: ' .. (time.reason or '')
|
||||||
end
|
end
|
||||||
|
|
||||||
local timeout = M.timeout_capability()
|
local timeout = M.timeout_capability()
|
||||||
if not timeout.ok then
|
if not timeout.ok then
|
||||||
return false, timeout.reason
|
return false, 'GNU timeout not found: ' .. (timeout.reason or '')
|
||||||
|
end
|
||||||
|
|
||||||
|
if vim.fn.executable('uv') ~= 1 then
|
||||||
|
return false, 'uv not found (https://docs.astral.sh/uv/)'
|
||||||
|
end
|
||||||
|
|
||||||
|
if not M.setup_python_env() then
|
||||||
|
return false, 'failed to set up Python virtual environment'
|
||||||
end
|
end
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
@ -319,11 +225,7 @@ local function find_gnu_timeout()
|
||||||
|
|
||||||
_timeout_cached = true
|
_timeout_cached = true
|
||||||
_timeout_path = nil
|
_timeout_path = nil
|
||||||
if uname and uname.sysname == 'Darwin' then
|
_timeout_reason = 'GNU timeout not found'
|
||||||
_timeout_reason = 'GNU timeout not found (install via: brew install coreutils)'
|
|
||||||
else
|
|
||||||
_timeout_reason = 'GNU timeout not found'
|
|
||||||
end
|
|
||||||
return _timeout_path, _timeout_reason
|
return _timeout_path, _timeout_reason
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -338,7 +240,7 @@ function M.timeout_capability()
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.cwd_executables()
|
function M.cwd_executables()
|
||||||
local uv = vim.uv
|
local uv = vim.uv or vim.loop
|
||||||
local req = uv.fs_scandir('.')
|
local req = uv.fs_scandir('.')
|
||||||
if not req then
|
if not req then
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
0
new
Normal file
0
new
Normal file
|
|
@ -66,7 +66,7 @@ end, {
|
||||||
return filter_candidates(contests)
|
return filter_candidates(contests)
|
||||||
elseif args[2] == 'cache' then
|
elseif args[2] == 'cache' then
|
||||||
return filter_candidates({ 'clear', 'read' })
|
return filter_candidates({ 'clear', 'read' })
|
||||||
elseif args[2] == 'stress' or args[2] == 'interact' then
|
elseif args[2] == 'interact' then
|
||||||
local utils = require('cp.utils')
|
local utils = require('cp.utils')
|
||||||
return filter_candidates(utils.cwd_executables())
|
return filter_candidates(utils.cwd_executables())
|
||||||
elseif args[2] == 'edit' then
|
elseif args[2] == 'edit' then
|
||||||
|
|
@ -103,12 +103,6 @@ end, {
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return filter_candidates(candidates)
|
return filter_candidates(candidates)
|
||||||
elseif args[2] == 'credentials' then
|
|
||||||
return filter_candidates({ 'set', 'clear' })
|
|
||||||
elseif args[2] == 'race' then
|
|
||||||
local candidates = { 'stop' }
|
|
||||||
vim.list_extend(candidates, platforms)
|
|
||||||
return filter_candidates(candidates)
|
|
||||||
elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then
|
elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then
|
||||||
return filter_candidates({ '--lang' })
|
return filter_candidates({ '--lang' })
|
||||||
else
|
else
|
||||||
|
|
@ -118,17 +112,7 @@ end, {
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif num_args == 4 then
|
elseif num_args == 4 then
|
||||||
if args[2] == 'stress' then
|
if args[2] == 'cache' and args[3] == 'clear' then
|
||||||
local utils = require('cp.utils')
|
|
||||||
return filter_candidates(utils.cwd_executables())
|
|
||||||
elseif args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
|
|
||||||
local cache = require('cp.cache')
|
|
||||||
cache.load()
|
|
||||||
local contests = cache.get_cached_contest_ids(args[3])
|
|
||||||
return filter_candidates(contests)
|
|
||||||
elseif args[2] == 'credentials' and vim.tbl_contains({ 'set', 'clear' }, args[3]) then
|
|
||||||
return filter_candidates(platforms)
|
|
||||||
elseif args[2] == 'cache' and args[3] == 'clear' then
|
|
||||||
local candidates = vim.list_extend({}, platforms)
|
local candidates = vim.list_extend({}, platforms)
|
||||||
table.insert(candidates, '')
|
table.insert(candidates, '')
|
||||||
return filter_candidates(candidates)
|
return filter_candidates(candidates)
|
||||||
|
|
@ -150,9 +134,7 @@ end, {
|
||||||
return filter_candidates(candidates)
|
return filter_candidates(candidates)
|
||||||
end
|
end
|
||||||
elseif num_args == 5 then
|
elseif num_args == 5 then
|
||||||
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
|
if args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
|
||||||
return filter_candidates({ '--lang' })
|
|
||||||
elseif args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
|
|
||||||
local cache = require('cp.cache')
|
local cache = require('cp.cache')
|
||||||
cache.load()
|
cache.load()
|
||||||
local contests = cache.get_cached_contest_ids(args[4])
|
local contests = cache.get_cached_contest_ids(args[4])
|
||||||
|
|
@ -165,31 +147,10 @@ end, {
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif num_args == 6 then
|
elseif num_args == 6 then
|
||||||
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) and args[5] == '--lang' then
|
if vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
|
||||||
return filter_candidates(get_enabled_languages(args[3]))
|
|
||||||
elseif vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
|
|
||||||
return filter_candidates(get_enabled_languages(args[2]))
|
return filter_candidates(get_enabled_languages(args[2]))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return {}
|
return {}
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
|
||||||
local function cp_action(action)
|
|
||||||
return function()
|
|
||||||
require('cp').handle_command({ fargs = { action } })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-run)', cp_action('run'), { desc = 'CP run tests' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-panel)', cp_action('panel'), { desc = 'CP open panel' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-edit)', cp_action('edit'), { desc = 'CP edit test cases' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-next)', cp_action('next'), { desc = 'CP next problem' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-prev)', cp_action('prev'), { desc = 'CP previous problem' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-pick)', cp_action('pick'), { desc = 'CP pick contest' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-interact)', cp_action('interact'), { desc = 'CP interactive mode' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-stress)', cp_action('stress'), { desc = 'CP stress test' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-submit)', cp_action('submit'), { desc = 'CP submit solution' })
|
|
||||||
vim.keymap.set('n', '<Plug>(cp-race-stop)', function()
|
|
||||||
require('cp.race').stop()
|
|
||||||
end, { desc = 'CP stop race countdown' })
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ dependencies = [
|
||||||
"ndjson>=0.3.1",
|
"ndjson>=0.3.1",
|
||||||
"pydantic>=2.11.10",
|
"pydantic>=2.11.10",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
|
"scrapling[fetchers]>=0.3.5",
|
||||||
|
"types-requests>=2.32.4.20250913",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
@ -15,11 +14,16 @@ from bs4 import BeautifulSoup, Tag
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util.retry import Retry
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
from .base import BaseScraper, extract_precision
|
from .base import BaseScraper
|
||||||
from .language_ids import get_language_id
|
from .models import (
|
||||||
from .models import (CombinedTest, ContestListResult, ContestSummary,
|
CombinedTest,
|
||||||
MetadataResult, ProblemSummary, SubmitResult, TestCase,
|
ContestListResult,
|
||||||
TestsResult)
|
ContestSummary,
|
||||||
|
MetadataResult,
|
||||||
|
ProblemSummary,
|
||||||
|
TestCase,
|
||||||
|
TestsResult,
|
||||||
|
)
|
||||||
|
|
||||||
MIB_TO_MB = 1.048576
|
MIB_TO_MB = 1.048576
|
||||||
BASE_URL = "https://atcoder.jp"
|
BASE_URL = "https://atcoder.jp"
|
||||||
|
|
@ -117,23 +121,6 @@ def _parse_last_page(html: str) -> int:
|
||||||
return max(nums) if nums else 1
|
return max(nums) if nums else 1
|
||||||
|
|
||||||
|
|
||||||
def _parse_start_time(tr: Tag) -> int | None:
|
|
||||||
tds = tr.select("td")
|
|
||||||
if not tds:
|
|
||||||
return None
|
|
||||||
time_el = tds[0].select_one("time.fixtime-full")
|
|
||||||
if not time_el:
|
|
||||||
return None
|
|
||||||
text = time_el.get_text(strip=True)
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S%z")
|
|
||||||
return int(dt.timestamp())
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_archive_contests(html: str) -> list[ContestSummary]:
|
def _parse_archive_contests(html: str) -> list[ContestSummary]:
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
tbody = soup.select_one("table.table-default tbody") or soup.select_one("tbody")
|
tbody = soup.select_one("table.table-default tbody") or soup.select_one("tbody")
|
||||||
|
|
@ -152,10 +139,7 @@ def _parse_archive_contests(html: str) -> list[ContestSummary]:
|
||||||
continue
|
continue
|
||||||
cid = m.group(1)
|
cid = m.group(1)
|
||||||
name = a.get_text(strip=True)
|
name = a.get_text(strip=True)
|
||||||
start_time = _parse_start_time(tr)
|
out.append(ContestSummary(id=cid, name=name, display_name=name))
|
||||||
out.append(
|
|
||||||
ContestSummary(id=cid, name=name, display_name=name, start_time=start_time)
|
|
||||||
)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -185,7 +169,7 @@ def _parse_tasks_list(html: str) -> list[dict[str, str]]:
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _extract_problem_info(html: str) -> tuple[int, float, bool, float | None]:
|
def _extract_problem_info(html: str) -> tuple[int, float, bool]:
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
txt = soup.get_text(" ", strip=True)
|
txt = soup.get_text(" ", strip=True)
|
||||||
timeout_ms = 0
|
timeout_ms = 0
|
||||||
|
|
@ -197,10 +181,9 @@ def _extract_problem_info(html: str) -> tuple[int, float, bool, float | None]:
|
||||||
if ms:
|
if ms:
|
||||||
memory_mb = float(ms.group(1)) * MIB_TO_MB
|
memory_mb = float(ms.group(1)) * MIB_TO_MB
|
||||||
div = soup.select_one("#problem-statement")
|
div = soup.select_one("#problem-statement")
|
||||||
body = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
|
txt = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
|
||||||
interactive = "This is an interactive" in body
|
interactive = "This is an interactive" in txt
|
||||||
precision = extract_precision(body)
|
return timeout_ms, memory_mb, interactive
|
||||||
return timeout_ms, memory_mb, interactive, precision
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_samples(html: str) -> list[TestCase]:
|
def _extract_samples(html: str) -> list[TestCase]:
|
||||||
|
|
@ -237,13 +220,12 @@ def _scrape_problem_page_sync(contest_id: str, slug: str) -> dict[str, Any]:
|
||||||
tests = _extract_samples(html)
|
tests = _extract_samples(html)
|
||||||
except Exception:
|
except Exception:
|
||||||
tests = []
|
tests = []
|
||||||
timeout_ms, memory_mb, interactive, precision = _extract_problem_info(html)
|
timeout_ms, memory_mb, interactive = _extract_problem_info(html)
|
||||||
return {
|
return {
|
||||||
"tests": tests,
|
"tests": tests,
|
||||||
"timeout_ms": timeout_ms,
|
"timeout_ms": timeout_ms,
|
||||||
"memory_mb": memory_mb,
|
"memory_mb": memory_mb,
|
||||||
"interactive": interactive,
|
"interactive": interactive,
|
||||||
"precision": precision,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -259,29 +241,14 @@ def _to_problem_summaries(rows: list[dict[str, str]]) -> list[ProblemSummary]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_upcoming_contests_async(
|
|
||||||
client: httpx.AsyncClient,
|
|
||||||
) -> list[ContestSummary]:
|
|
||||||
try:
|
|
||||||
html = await _get_async(client, f"{BASE_URL}/contests/")
|
|
||||||
return _parse_archive_contests(html)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_all_contests_async() -> list[ContestSummary]:
|
async def _fetch_all_contests_async() -> list[ContestSummary]:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
limits=httpx.Limits(max_connections=100, max_keepalive_connections=100),
|
limits=httpx.Limits(max_connections=100, max_keepalive_connections=100),
|
||||||
) as client:
|
) as client:
|
||||||
upcoming = await _fetch_upcoming_contests_async(client)
|
|
||||||
first_html = await _get_async(client, ARCHIVE_URL)
|
first_html = await _get_async(client, ARCHIVE_URL)
|
||||||
last = _parse_last_page(first_html)
|
last = _parse_last_page(first_html)
|
||||||
out = _parse_archive_contests(first_html)
|
out = _parse_archive_contests(first_html)
|
||||||
if last <= 1:
|
if last <= 1:
|
||||||
seen = {c.id for c in out}
|
|
||||||
for c in upcoming:
|
|
||||||
if c.id not in seen:
|
|
||||||
out.append(c)
|
|
||||||
return out
|
return out
|
||||||
tasks = [
|
tasks = [
|
||||||
asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}"))
|
asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}"))
|
||||||
|
|
@ -290,10 +257,6 @@ async def _fetch_all_contests_async() -> list[ContestSummary]:
|
||||||
for coro in asyncio.as_completed(tasks):
|
for coro in asyncio.as_completed(tasks):
|
||||||
html = await coro
|
html = await coro
|
||||||
out.extend(_parse_archive_contests(html))
|
out.extend(_parse_archive_contests(html))
|
||||||
seen = {c.id for c in out}
|
|
||||||
for c in upcoming:
|
|
||||||
if c.id not in seen:
|
|
||||||
out.append(c)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -356,7 +319,6 @@ class AtcoderScraper(BaseScraper):
|
||||||
"memory_mb": data.get("memory_mb", 0),
|
"memory_mb": data.get("memory_mb", 0),
|
||||||
"interactive": bool(data.get("interactive")),
|
"interactive": bool(data.get("interactive")),
|
||||||
"multi_test": False,
|
"multi_test": False,
|
||||||
"precision": data.get("precision"),
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
flush=True,
|
flush=True,
|
||||||
|
|
@ -364,99 +326,6 @@ class AtcoderScraper(BaseScraper):
|
||||||
|
|
||||||
await asyncio.gather(*(emit(r) for r in rows))
|
await asyncio.gather(*(emit(r) for r in rows))
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
contest_id: str,
|
|
||||||
problem_id: str,
|
|
||||||
source_code: str,
|
|
||||||
language_id: str,
|
|
||||||
credentials: dict[str, str],
|
|
||||||
) -> SubmitResult:
|
|
||||||
def _submit_sync() -> SubmitResult:
|
|
||||||
from curl_cffi import requests as curl_requests
|
|
||||||
|
|
||||||
try:
|
|
||||||
session = curl_requests.Session(impersonate="chrome")
|
|
||||||
|
|
||||||
login_page = session.get(f"{BASE_URL}/login", timeout=TIMEOUT_SECONDS)
|
|
||||||
login_page.raise_for_status()
|
|
||||||
soup = BeautifulSoup(login_page.text, "html.parser")
|
|
||||||
csrf_input = soup.find("input", {"name": "csrf_token"})
|
|
||||||
if not csrf_input or not hasattr(csrf_input, "get"):
|
|
||||||
return SubmitResult(
|
|
||||||
success=False, error="Could not find CSRF token on login page"
|
|
||||||
)
|
|
||||||
csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr]
|
|
||||||
|
|
||||||
login_resp = session.post(
|
|
||||||
f"{BASE_URL}/login",
|
|
||||||
data={
|
|
||||||
"username": credentials.get("username", ""),
|
|
||||||
"password": credentials.get("password", ""),
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
},
|
|
||||||
timeout=TIMEOUT_SECONDS,
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
if login_resp.status_code in (301, 302):
|
|
||||||
location = login_resp.headers.get("Location", "")
|
|
||||||
if "/login" in location:
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="Login failed: incorrect username or password",
|
|
||||||
)
|
|
||||||
session.get(BASE_URL + location, timeout=TIMEOUT_SECONDS)
|
|
||||||
else:
|
|
||||||
login_resp.raise_for_status()
|
|
||||||
|
|
||||||
submit_page = session.get(
|
|
||||||
f"{BASE_URL}/contests/{contest_id}/submit",
|
|
||||||
timeout=TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
submit_page.raise_for_status()
|
|
||||||
soup = BeautifulSoup(submit_page.text, "html.parser")
|
|
||||||
csrf_input = soup.find("input", {"name": "csrf_token"})
|
|
||||||
if not csrf_input or not hasattr(csrf_input, "get"):
|
|
||||||
return SubmitResult(
|
|
||||||
success=False, error="Could not find CSRF token on submit page"
|
|
||||||
)
|
|
||||||
csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr]
|
|
||||||
|
|
||||||
task_screen_name = f"{contest_id}_{problem_id}"
|
|
||||||
submit_resp = session.post(
|
|
||||||
f"{BASE_URL}/contests/{contest_id}/submit",
|
|
||||||
data={
|
|
||||||
"data.TaskScreenName": task_screen_name,
|
|
||||||
"data.LanguageId": language_id,
|
|
||||||
"sourceCode": source_code,
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
},
|
|
||||||
timeout=TIMEOUT_SECONDS,
|
|
||||||
allow_redirects=False,
|
|
||||||
)
|
|
||||||
if submit_resp.status_code in (301, 302):
|
|
||||||
location = submit_resp.headers.get("Location", "")
|
|
||||||
if "/submissions/me" in location:
|
|
||||||
return SubmitResult(
|
|
||||||
success=True,
|
|
||||||
error="",
|
|
||||||
submission_id="",
|
|
||||||
verdict="submitted",
|
|
||||||
)
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error=f"Submit may have failed: redirected to {location}",
|
|
||||||
)
|
|
||||||
submit_resp.raise_for_status()
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="Unexpected response from submit (expected redirect)",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return SubmitResult(success=False, error=str(e))
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_submit_sync)
|
|
||||||
|
|
||||||
|
|
||||||
async def main_async() -> int:
|
async def main_async() -> int:
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
|
|
@ -513,31 +382,9 @@ async def main_async() -> int:
|
||||||
print(contest_result.model_dump_json())
|
print(contest_result.model_dump_json())
|
||||||
return 0 if contest_result.success else 1
|
return 0 if contest_result.success else 1
|
||||||
|
|
||||||
if mode == "submit":
|
|
||||||
if len(sys.argv) != 5:
|
|
||||||
print(
|
|
||||||
SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: atcoder.py submit <contest_id> <problem_id> <language>",
|
|
||||||
).model_dump_json()
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
source_code = sys.stdin.read()
|
|
||||||
creds_raw = os.environ.get("CP_CREDENTIALS", "{}")
|
|
||||||
try:
|
|
||||||
credentials = json.loads(creds_raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
credentials = {}
|
|
||||||
language_id = get_language_id("atcoder", sys.argv[4]) or sys.argv[4]
|
|
||||||
submit_result = await scraper.submit(
|
|
||||||
sys.argv[2], sys.argv[3], source_code, language_id, credentials
|
|
||||||
)
|
|
||||||
print(submit_result.model_dump_json())
|
|
||||||
return 0 if submit_result.success else 1
|
|
||||||
|
|
||||||
result = MetadataResult(
|
result = MetadataResult(
|
||||||
success=False,
|
success=False,
|
||||||
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', 'contests', or 'submit <contest_id> <problem_id> <language>'",
|
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
|
||||||
url="",
|
url="",
|
||||||
)
|
)
|
||||||
print(result.model_dump_json())
|
print(result.model_dump_json())
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,8 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from .language_ids import get_language_id
|
from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
|
||||||
from .models import (CombinedTest, ContestListResult, MetadataResult,
|
|
||||||
SubmitResult, TestsResult)
|
|
||||||
|
|
||||||
_PRECISION_ABS_REL_RE = re.compile(
|
|
||||||
r"(?:absolute|relative)\s+error[^.]*?10\s*[\^{]\s*\{?\s*[-\u2212]\s*(\d+)\s*\}?",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_PRECISION_DECIMAL_RE = re.compile(
|
|
||||||
r"round(?:ed)?\s+to\s+(\d+)\s+decimal\s+place",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_precision(text: str) -> float | None:
|
|
||||||
m = _PRECISION_ABS_REL_RE.search(text)
|
|
||||||
if m:
|
|
||||||
return 10 ** -int(m.group(1))
|
|
||||||
m = _PRECISION_DECIMAL_RE.search(text)
|
|
||||||
if m:
|
|
||||||
return 10 ** -int(m.group(1))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class BaseScraper(ABC):
|
class BaseScraper(ABC):
|
||||||
|
|
@ -43,16 +19,6 @@ class BaseScraper(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def stream_tests_for_category_async(self, category_id: str) -> None: ...
|
async def stream_tests_for_category_async(self, category_id: str) -> None: ...
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
contest_id: str,
|
|
||||||
problem_id: str,
|
|
||||||
source_code: str,
|
|
||||||
language_id: str,
|
|
||||||
credentials: dict[str, str],
|
|
||||||
) -> SubmitResult: ...
|
|
||||||
|
|
||||||
def _usage(self) -> str:
|
def _usage(self) -> str:
|
||||||
name = self.platform_name
|
name = self.platform_name
|
||||||
return f"Usage: {name}.py metadata <id> | tests <id> | contests"
|
return f"Usage: {name}.py metadata <id> | tests <id> | contests"
|
||||||
|
|
@ -74,9 +40,6 @@ class BaseScraper(ABC):
|
||||||
def _contests_error(self, msg: str) -> ContestListResult:
|
def _contests_error(self, msg: str) -> ContestListResult:
|
||||||
return ContestListResult(success=False, error=msg)
|
return ContestListResult(success=False, error=msg)
|
||||||
|
|
||||||
def _submit_error(self, msg: str) -> SubmitResult:
|
|
||||||
return SubmitResult(success=False, error=msg)
|
|
||||||
|
|
||||||
async def _run_cli_async(self, args: list[str]) -> int:
|
async def _run_cli_async(self, args: list[str]) -> int:
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
print(self._metadata_error(self._usage()).model_dump_json())
|
print(self._metadata_error(self._usage()).model_dump_json())
|
||||||
|
|
@ -108,27 +71,6 @@ class BaseScraper(ABC):
|
||||||
print(result.model_dump_json())
|
print(result.model_dump_json())
|
||||||
return 0 if result.success else 1
|
return 0 if result.success else 1
|
||||||
|
|
||||||
case "submit":
|
|
||||||
if len(args) != 5:
|
|
||||||
print(
|
|
||||||
self._submit_error(
|
|
||||||
"Usage: <platform> submit <contest_id> <problem_id> <language_id>"
|
|
||||||
).model_dump_json()
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
source_code = sys.stdin.read()
|
|
||||||
creds_raw = os.environ.get("CP_CREDENTIALS", "{}")
|
|
||||||
try:
|
|
||||||
credentials = json.loads(creds_raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
credentials = {}
|
|
||||||
language_id = get_language_id(self.platform_name, args[4]) or args[4]
|
|
||||||
result = await self.submit(
|
|
||||||
args[2], args[3], source_code, language_id, credentials
|
|
||||||
)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 0 if result.success else 1
|
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
print(
|
print(
|
||||||
self._metadata_error(
|
self._metadata_error(
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,16 @@ import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from curl_cffi import requests as curl_requests
|
from scrapling.fetchers import Fetcher
|
||||||
|
|
||||||
from .base import BaseScraper, extract_precision
|
from .base import BaseScraper
|
||||||
from .models import (ContestListResult, ContestSummary, MetadataResult,
|
from .models import (
|
||||||
ProblemSummary, SubmitResult, TestCase)
|
ContestListResult,
|
||||||
|
ContestSummary,
|
||||||
|
MetadataResult,
|
||||||
|
ProblemSummary,
|
||||||
|
TestCase,
|
||||||
|
)
|
||||||
|
|
||||||
BASE_URL = "https://www.codechef.com"
|
BASE_URL = "https://www.codechef.com"
|
||||||
API_CONTESTS_ALL = "/api/list/contests/all"
|
API_CONTESTS_ALL = "/api/list/contests/all"
|
||||||
|
|
@ -45,9 +50,8 @@ def _extract_memory_limit(html: str) -> float:
|
||||||
|
|
||||||
|
|
||||||
def _fetch_html_sync(url: str) -> str:
|
def _fetch_html_sync(url: str) -> str:
|
||||||
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S)
|
response = Fetcher.get(url)
|
||||||
response.raise_for_status()
|
return str(response.body)
|
||||||
return response.text
|
|
||||||
|
|
||||||
|
|
||||||
class CodeChefScraper(BaseScraper):
|
class CodeChefScraper(BaseScraper):
|
||||||
|
|
@ -214,13 +218,11 @@ class CodeChefScraper(BaseScraper):
|
||||||
)
|
)
|
||||||
memory_mb = _extract_memory_limit(html)
|
memory_mb = _extract_memory_limit(html)
|
||||||
interactive = False
|
interactive = False
|
||||||
precision = extract_precision(html)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
tests = []
|
tests = []
|
||||||
timeout_ms = 1000
|
timeout_ms = 1000
|
||||||
memory_mb = 256.0
|
memory_mb = 256.0
|
||||||
interactive = False
|
interactive = False
|
||||||
precision = None
|
|
||||||
combined_input = "\n".join(t.input for t in tests) if tests else ""
|
combined_input = "\n".join(t.input for t in tests) if tests else ""
|
||||||
combined_expected = (
|
combined_expected = (
|
||||||
"\n".join(t.expected for t in tests) if tests else ""
|
"\n".join(t.expected for t in tests) if tests else ""
|
||||||
|
|
@ -238,7 +240,6 @@ class CodeChefScraper(BaseScraper):
|
||||||
"memory_mb": memory_mb,
|
"memory_mb": memory_mb,
|
||||||
"interactive": interactive,
|
"interactive": interactive,
|
||||||
"multi_test": False,
|
"multi_test": False,
|
||||||
"precision": precision,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks = [run_one(problem_code) for problem_code in problems.keys()]
|
tasks = [run_one(problem_code) for problem_code in problems.keys()]
|
||||||
|
|
@ -246,21 +247,6 @@ class CodeChefScraper(BaseScraper):
|
||||||
payload = await coro
|
payload = await coro
|
||||||
print(json.dumps(payload), flush=True)
|
print(json.dumps(payload), flush=True)
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
contest_id: str,
|
|
||||||
problem_id: str,
|
|
||||||
source_code: str,
|
|
||||||
language_id: str,
|
|
||||||
credentials: dict[str, str],
|
|
||||||
) -> SubmitResult:
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="CodeChef submit not yet implemented",
|
|
||||||
submission_id="",
|
|
||||||
verdict="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
CodeChefScraper().run_cli()
|
CodeChefScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,26 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup, Tag
|
from bs4 import BeautifulSoup, Tag
|
||||||
from curl_cffi import requests as curl_requests
|
from scrapling.fetchers import Fetcher
|
||||||
|
|
||||||
|
from .base import BaseScraper
|
||||||
|
from .models import (
|
||||||
|
ContestListResult,
|
||||||
|
ContestSummary,
|
||||||
|
MetadataResult,
|
||||||
|
ProblemSummary,
|
||||||
|
TestCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
|
||||||
|
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
from .base import BaseScraper, extract_precision
|
|
||||||
from .models import (ContestListResult, ContestSummary, MetadataResult,
|
|
||||||
ProblemSummary, SubmitResult, TestCase)
|
|
||||||
|
|
||||||
BASE_URL = "https://codeforces.com"
|
BASE_URL = "https://codeforces.com"
|
||||||
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
|
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
|
||||||
|
|
@ -73,7 +83,7 @@ def _extract_title(block: Tag) -> tuple[str, str]:
|
||||||
|
|
||||||
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
|
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
|
||||||
st = block.find("div", class_="sample-test")
|
st = block.find("div", class_="sample-test")
|
||||||
if not isinstance(st, Tag):
|
if not st:
|
||||||
return [], False
|
return [], False
|
||||||
|
|
||||||
input_pres: list[Tag] = [
|
input_pres: list[Tag] = [
|
||||||
|
|
@ -130,9 +140,10 @@ def _is_interactive(block: Tag) -> bool:
|
||||||
|
|
||||||
def _fetch_problems_html(contest_id: str) -> str:
|
def _fetch_problems_html(contest_id: str) -> str:
|
||||||
url = f"{BASE_URL}/contest/{contest_id}/problems"
|
url = f"{BASE_URL}/contest/{contest_id}/problems"
|
||||||
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS)
|
page = Fetcher.get(
|
||||||
response.raise_for_status()
|
url,
|
||||||
return response.text
|
)
|
||||||
|
return page.html_content
|
||||||
|
|
||||||
|
|
||||||
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
|
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
|
||||||
|
|
@ -148,7 +159,6 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
|
||||||
raw_samples, is_grouped = _extract_samples(b)
|
raw_samples, is_grouped = _extract_samples(b)
|
||||||
timeout_ms, memory_mb = _extract_limits(b)
|
timeout_ms, memory_mb = _extract_limits(b)
|
||||||
interactive = _is_interactive(b)
|
interactive = _is_interactive(b)
|
||||||
precision = extract_precision(b.get_text(" ", strip=True))
|
|
||||||
|
|
||||||
if is_grouped and raw_samples:
|
if is_grouped and raw_samples:
|
||||||
combined_input = f"{len(raw_samples)}\n" + "\n".join(
|
combined_input = f"{len(raw_samples)}\n" + "\n".join(
|
||||||
|
|
@ -175,7 +185,6 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
|
||||||
"memory_mb": memory_mb,
|
"memory_mb": memory_mb,
|
||||||
"interactive": interactive,
|
"interactive": interactive,
|
||||||
"multi_test": is_grouped,
|
"multi_test": is_grouped,
|
||||||
"precision": precision,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
@ -225,20 +234,11 @@ class CodeforcesScraper(BaseScraper):
|
||||||
|
|
||||||
contests: list[ContestSummary] = []
|
contests: list[ContestSummary] = []
|
||||||
for c in data["result"]:
|
for c in data["result"]:
|
||||||
phase = c.get("phase")
|
if c.get("phase") != "FINISHED":
|
||||||
if phase not in ("FINISHED", "BEFORE", "CODING"):
|
|
||||||
continue
|
continue
|
||||||
cid = str(c["id"])
|
cid = str(c["id"])
|
||||||
name = c["name"]
|
name = c["name"]
|
||||||
start_time = c.get("startTimeSeconds") if phase != "FINISHED" else None
|
contests.append(ContestSummary(id=cid, name=name, display_name=name))
|
||||||
contests.append(
|
|
||||||
ContestSummary(
|
|
||||||
id=cid,
|
|
||||||
name=name,
|
|
||||||
display_name=name,
|
|
||||||
start_time=start_time,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not contests:
|
if not contests:
|
||||||
return self._contests_error("No contests found")
|
return self._contests_error("No contests found")
|
||||||
|
|
@ -269,27 +269,11 @@ class CodeforcesScraper(BaseScraper):
|
||||||
"memory_mb": b.get("memory_mb", 0),
|
"memory_mb": b.get("memory_mb", 0),
|
||||||
"interactive": bool(b.get("interactive")),
|
"interactive": bool(b.get("interactive")),
|
||||||
"multi_test": bool(b.get("multi_test", False)),
|
"multi_test": bool(b.get("multi_test", False)),
|
||||||
"precision": b.get("precision"),
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
contest_id: str,
|
|
||||||
problem_id: str,
|
|
||||||
source_code: str,
|
|
||||||
language_id: str,
|
|
||||||
credentials: dict[str, str],
|
|
||||||
) -> SubmitResult:
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="Codeforces submit not yet implemented",
|
|
||||||
submission_id="",
|
|
||||||
verdict="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
CodeforcesScraper().run_cli()
|
CodeforcesScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .base import BaseScraper, extract_precision
|
from .base import BaseScraper
|
||||||
from .models import (ContestListResult, ContestSummary, MetadataResult,
|
from .models import (
|
||||||
ProblemSummary, SubmitResult, TestCase)
|
ContestListResult,
|
||||||
|
ContestSummary,
|
||||||
|
MetadataResult,
|
||||||
|
ProblemSummary,
|
||||||
|
TestCase,
|
||||||
|
)
|
||||||
|
|
||||||
BASE_URL = "https://cses.fi"
|
BASE_URL = "https://cses.fi"
|
||||||
INDEX_PATH = "/problemset"
|
INDEX_PATH = "/problemset"
|
||||||
|
|
@ -124,21 +129,17 @@ def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _extract_problem_info(html: str) -> tuple[int, int, bool, float | None]:
|
def _extract_problem_info(html: str) -> tuple[int, int, bool]:
|
||||||
tm = TIME_RE.search(html)
|
tm = TIME_RE.search(html)
|
||||||
mm = MEM_RE.search(html)
|
mm = MEM_RE.search(html)
|
||||||
t = int(round(float(tm.group(1)) * 1000)) if tm else 0
|
t = int(round(float(tm.group(1)) * 1000)) if tm else 0
|
||||||
m = int(mm.group(1)) if mm else 0
|
m = int(mm.group(1)) if mm else 0
|
||||||
md = MD_BLOCK_RE.search(html)
|
md = MD_BLOCK_RE.search(html)
|
||||||
interactive = False
|
interactive = False
|
||||||
precision = None
|
|
||||||
if md:
|
if md:
|
||||||
body = md.group(1)
|
body = md.group(1)
|
||||||
interactive = "This is an interactive problem." in body
|
interactive = "This is an interactive problem." in body
|
||||||
from bs4 import BeautifulSoup
|
return t, m, interactive
|
||||||
|
|
||||||
precision = extract_precision(BeautifulSoup(body, "html.parser").get_text(" "))
|
|
||||||
return t, m, interactive, precision
|
|
||||||
|
|
||||||
|
|
||||||
def parse_title(html: str) -> str:
|
def parse_title(html: str) -> str:
|
||||||
|
|
@ -226,17 +227,10 @@ class CSESScraper(BaseScraper):
|
||||||
try:
|
try:
|
||||||
html = await fetch_text(client, task_path(pid))
|
html = await fetch_text(client, task_path(pid))
|
||||||
tests = parse_tests(html)
|
tests = parse_tests(html)
|
||||||
timeout_ms, memory_mb, interactive, precision = (
|
timeout_ms, memory_mb, interactive = _extract_problem_info(html)
|
||||||
_extract_problem_info(html)
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
tests = []
|
tests = []
|
||||||
timeout_ms, memory_mb, interactive, precision = (
|
timeout_ms, memory_mb, interactive = 0, 0, False
|
||||||
0,
|
|
||||||
0,
|
|
||||||
False,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
combined_input = "\n".join(t.input for t in tests) if tests else ""
|
combined_input = "\n".join(t.input for t in tests) if tests else ""
|
||||||
combined_expected = (
|
combined_expected = (
|
||||||
|
|
@ -256,7 +250,6 @@ class CSESScraper(BaseScraper):
|
||||||
"memory_mb": memory_mb,
|
"memory_mb": memory_mb,
|
||||||
"interactive": interactive,
|
"interactive": interactive,
|
||||||
"multi_test": False,
|
"multi_test": False,
|
||||||
"precision": precision,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks = [run_one(p.id) for p in problems]
|
tasks = [run_one(p.id) for p in problems]
|
||||||
|
|
@ -264,21 +257,6 @@ class CSESScraper(BaseScraper):
|
||||||
payload = await coro
|
payload = await coro
|
||||||
print(json.dumps(payload), flush=True)
|
print(json.dumps(payload), flush=True)
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
contest_id: str,
|
|
||||||
problem_id: str,
|
|
||||||
source_code: str,
|
|
||||||
language_id: str,
|
|
||||||
credentials: dict[str, str],
|
|
||||||
) -> SubmitResult:
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="CSES submit not yet implemented",
|
|
||||||
submission_id="",
|
|
||||||
verdict="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
CSESScraper().run_cli()
|
CSESScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .base import BaseScraper
|
|
||||||
from .models import (ContestListResult, ContestSummary, MetadataResult,
|
|
||||||
ProblemSummary, SubmitResult, TestCase)
|
|
||||||
|
|
||||||
BASE_URL = "https://open.kattis.com"
|
|
||||||
HEADERS = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
}
|
|
||||||
TIMEOUT_S = 15.0
|
|
||||||
CONNECTIONS = 8
|
|
||||||
|
|
||||||
TIME_RE = re.compile(
|
|
||||||
r"CPU Time limit</span>\s*<span[^>]*>\s*(\d+)\s*seconds?\s*</span>",
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
MEM_RE = re.compile(
|
|
||||||
r"Memory limit</span>\s*<span[^>]*>\s*(\d+)\s*MB\s*</span>",
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_text(client: httpx.AsyncClient, url: str) -> str:
|
|
||||||
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.text
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_bytes(client: httpx.AsyncClient, url: str) -> bytes:
|
|
||||||
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.content
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_limits(html: str) -> tuple[int, int]:
|
|
||||||
tm = TIME_RE.search(html)
|
|
||||||
mm = MEM_RE.search(html)
|
|
||||||
timeout_ms = int(tm.group(1)) * 1000 if tm else 1000
|
|
||||||
memory_mb = int(mm.group(1)) if mm else 1024
|
|
||||||
return timeout_ms, memory_mb
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_samples_html(html: str) -> list[TestCase]:
|
|
||||||
tests: list[TestCase] = []
|
|
||||||
tables = re.finditer(r'<table\s+class="sample"[^>]*>.*?</table>', html, re.DOTALL)
|
|
||||||
for table_match in tables:
|
|
||||||
table_html = table_match.group(0)
|
|
||||||
pres = re.findall(r"<pre>(.*?)</pre>", table_html, re.DOTALL)
|
|
||||||
if len(pres) >= 2:
|
|
||||||
inp = pres[0].strip()
|
|
||||||
out = pres[1].strip()
|
|
||||||
tests.append(TestCase(input=inp, expected=out))
|
|
||||||
return tests
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_samples_zip(data: bytes) -> list[TestCase]:
|
|
||||||
try:
|
|
||||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
|
||||||
except zipfile.BadZipFile:
|
|
||||||
return []
|
|
||||||
inputs: dict[str, str] = {}
|
|
||||||
outputs: dict[str, str] = {}
|
|
||||||
for name in zf.namelist():
|
|
||||||
content = zf.read(name).decode("utf-8").strip()
|
|
||||||
if name.endswith(".in"):
|
|
||||||
key = name[: -len(".in")]
|
|
||||||
inputs[key] = content
|
|
||||||
elif name.endswith(".ans"):
|
|
||||||
key = name[: -len(".ans")]
|
|
||||||
outputs[key] = content
|
|
||||||
tests: list[TestCase] = []
|
|
||||||
for key in sorted(set(inputs) & set(outputs)):
|
|
||||||
tests.append(TestCase(input=inputs[key], expected=outputs[key]))
|
|
||||||
return tests
|
|
||||||
|
|
||||||
|
|
||||||
def _is_interactive(html: str) -> bool:
|
|
||||||
return "This is an interactive problem" in html
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_contests_page(html: str) -> list[ContestSummary]:
|
|
||||||
results: list[ContestSummary] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for row_m in re.finditer(r"<tr[^>]*>(.*?)</tr>", html, re.DOTALL):
|
|
||||||
row = row_m.group(1)
|
|
||||||
link_m = re.search(r'href="/contests/([a-z0-9]+)"[^>]*>([^<]+)</a>', row)
|
|
||||||
if not link_m:
|
|
||||||
continue
|
|
||||||
cid = link_m.group(1)
|
|
||||||
name = link_m.group(2).strip()
|
|
||||||
if cid in seen:
|
|
||||||
continue
|
|
||||||
seen.add(cid)
|
|
||||||
start_time: int | None = None
|
|
||||||
ts_m = re.search(r'data-timestamp="(\d+)"', row)
|
|
||||||
if ts_m:
|
|
||||||
start_time = int(ts_m.group(1))
|
|
||||||
else:
|
|
||||||
time_m = re.search(r'<time[^>]+datetime="([^"]+)"', row)
|
|
||||||
if time_m:
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(time_m.group(1).replace("Z", "+00:00"))
|
|
||||||
start_time = int(dt.timestamp())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
results.append(ContestSummary(id=cid, name=name, start_time=start_time))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_contest_problem_list(html: str) -> list[tuple[str, str]]:
|
|
||||||
if "The problems will become available when the contest starts" in html:
|
|
||||||
return []
|
|
||||||
results: list[tuple[str, str]] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for row_m in re.finditer(r"<tr[^>]*>(.*?)</tr>", html, re.DOTALL):
|
|
||||||
row = row_m.group(1)
|
|
||||||
link_m = re.search(
|
|
||||||
r'href="/contests/[^/]+/problems/([^"]+)"[^>]*>([^<]+)</a>', row
|
|
||||||
)
|
|
||||||
if not link_m:
|
|
||||||
continue
|
|
||||||
slug = link_m.group(1)
|
|
||||||
name = link_m.group(2).strip()
|
|
||||||
if slug in seen:
|
|
||||||
continue
|
|
||||||
seen.add(slug)
|
|
||||||
label_m = re.search(r"<td[^>]*>\s*([A-Z])\s*</td>", row)
|
|
||||||
label = label_m.group(1) if label_m else ""
|
|
||||||
display = f"{label} - {name}" if label else name
|
|
||||||
results.append((slug, display))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_contest_slugs(
|
|
||||||
client: httpx.AsyncClient, contest_id: str
|
|
||||||
) -> list[tuple[str, str]]:
|
|
||||||
try:
|
|
||||||
html = await _fetch_text(client, f"{BASE_URL}/contests/{contest_id}/problems")
|
|
||||||
return _parse_contest_problem_list(html)
|
|
||||||
except httpx.HTTPStatusError:
|
|
||||||
return []
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def _stream_single_problem(client: httpx.AsyncClient, slug: str) -> None:
|
|
||||||
try:
|
|
||||||
html = await _fetch_text(client, f"{BASE_URL}/problems/{slug}")
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
timeout_ms, memory_mb = _parse_limits(html)
|
|
||||||
interactive = _is_interactive(html)
|
|
||||||
|
|
||||||
tests: list[TestCase] = []
|
|
||||||
try:
|
|
||||||
zip_data = await _fetch_bytes(
|
|
||||||
client,
|
|
||||||
f"{BASE_URL}/problems/{slug}/file/statement/samples.zip",
|
|
||||||
)
|
|
||||||
tests = _parse_samples_zip(zip_data)
|
|
||||||
except Exception:
|
|
||||||
tests = _parse_samples_html(html)
|
|
||||||
|
|
||||||
combined_input = "\n".join(t.input for t in tests) if tests else ""
|
|
||||||
combined_expected = "\n".join(t.expected for t in tests) if tests else ""
|
|
||||||
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"problem_id": slug,
|
|
||||||
"combined": {
|
|
||||||
"input": combined_input,
|
|
||||||
"expected": combined_expected,
|
|
||||||
},
|
|
||||||
"tests": [{"input": t.input, "expected": t.expected} for t in tests],
|
|
||||||
"timeout_ms": timeout_ms,
|
|
||||||
"memory_mb": memory_mb,
|
|
||||||
"interactive": interactive,
|
|
||||||
"multi_test": False,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class KattisScraper(BaseScraper):
|
|
||||||
@property
|
|
||||||
def platform_name(self) -> str:
|
|
||||||
return "kattis"
|
|
||||||
|
|
||||||
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
slugs = await _fetch_contest_slugs(client, contest_id)
|
|
||||||
if slugs:
|
|
||||||
return MetadataResult(
|
|
||||||
success=True,
|
|
||||||
error="",
|
|
||||||
contest_id=contest_id,
|
|
||||||
problems=[
|
|
||||||
ProblemSummary(id=slug, name=name) for slug, name in slugs
|
|
||||||
],
|
|
||||||
url=f"{BASE_URL}/problems/%s",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
html = await _fetch_text(
|
|
||||||
client, f"{BASE_URL}/problems/{contest_id}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return self._metadata_error(str(e))
|
|
||||||
title_m = re.search(r"<title>([^<]+)</title>", html)
|
|
||||||
name = (
|
|
||||||
title_m.group(1).split("\u2013")[0].strip()
|
|
||||||
if title_m
|
|
||||||
else contest_id
|
|
||||||
)
|
|
||||||
return MetadataResult(
|
|
||||||
success=True,
|
|
||||||
error="",
|
|
||||||
contest_id=contest_id,
|
|
||||||
problems=[ProblemSummary(id=contest_id, name=name)],
|
|
||||||
url=f"{BASE_URL}/problems/%s",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return self._metadata_error(str(e))
|
|
||||||
|
|
||||||
async def scrape_contest_list(self) -> ContestListResult:
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
html = await _fetch_text(client, f"{BASE_URL}/contests")
|
|
||||||
contests = _parse_contests_page(html)
|
|
||||||
if not contests:
|
|
||||||
return self._contests_error("No contests found")
|
|
||||||
return ContestListResult(success=True, error="", contests=contests)
|
|
||||||
except Exception as e:
|
|
||||||
return self._contests_error(str(e))
|
|
||||||
|
|
||||||
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
limits=httpx.Limits(max_connections=CONNECTIONS)
|
|
||||||
) as client:
|
|
||||||
slugs = await _fetch_contest_slugs(client, category_id)
|
|
||||||
if slugs:
|
|
||||||
sem = asyncio.Semaphore(CONNECTIONS)
|
|
||||||
|
|
||||||
async def emit_one(slug: str, _name: str) -> None:
|
|
||||||
async with sem:
|
|
||||||
await _stream_single_problem(client, slug)
|
|
||||||
|
|
||||||
await asyncio.gather(*(emit_one(s, n) for s, n in slugs))
|
|
||||||
return
|
|
||||||
|
|
||||||
await _stream_single_problem(client, category_id)
|
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
contest_id: str,
|
|
||||||
problem_id: str,
|
|
||||||
source_code: str,
|
|
||||||
language_id: str,
|
|
||||||
credentials: dict[str, str],
|
|
||||||
) -> SubmitResult:
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="Kattis submit not yet implemented",
|
|
||||||
submission_id="",
|
|
||||||
verdict="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
KattisScraper().run_cli()
|
|
||||||
|
|
@ -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
|
id: str
|
||||||
name: str
|
name: str
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
start_time: int | None = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -64,13 +63,6 @@ class TestsResult(ScrapingResult):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class SubmitResult(ScrapingResult):
|
|
||||||
submission_id: str = ""
|
|
||||||
verdict: str = ""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class ScraperConfig(BaseModel):
|
class ScraperConfig(BaseModel):
|
||||||
timeout_seconds: int = 30
|
timeout_seconds: int = 30
|
||||||
max_retries: int = 3
|
max_retries: int = 3
|
||||||
|
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from .base import BaseScraper
|
|
||||||
from .models import (ContestListResult, ContestSummary, MetadataResult,
|
|
||||||
ProblemSummary, SubmitResult, TestCase)
|
|
||||||
|
|
||||||
BASE_URL = "http://www.usaco.org"
|
|
||||||
HEADERS = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
}
|
|
||||||
TIMEOUT_S = 15.0
|
|
||||||
CONNECTIONS = 4
|
|
||||||
|
|
||||||
MONTHS = [
|
|
||||||
"dec",
|
|
||||||
"jan",
|
|
||||||
"feb",
|
|
||||||
"mar",
|
|
||||||
"open",
|
|
||||||
]
|
|
||||||
|
|
||||||
DIVISION_HEADING_RE = re.compile(
|
|
||||||
r"<h2>.*?USACO\s+(\d{4})\s+(\w+)\s+Contest,\s+(\w+)\s*</h2>",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
PROBLEM_BLOCK_RE = re.compile(
|
|
||||||
r"<b>([^<]+)</b>\s*<br\s*/?>.*?" r"viewproblem2&cpid=(\d+)",
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
SAMPLE_IN_RE = re.compile(r"<pre\s+class=['\"]in['\"]>(.*?)</pre>", re.DOTALL)
|
|
||||||
SAMPLE_OUT_RE = re.compile(r"<pre\s+class=['\"]out['\"]>(.*?)</pre>", re.DOTALL)
|
|
||||||
TIME_NOTE_RE = re.compile(
|
|
||||||
r"time\s+limit\s+(?:for\s+this\s+problem\s+is\s+)?(\d+)s",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
MEMORY_NOTE_RE = re.compile(
|
|
||||||
r"memory\s+limit\s+(?:for\s+this\s+problem\s+is\s+)?(\d+)\s*MB",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
RESULTS_PAGE_RE = re.compile(
|
|
||||||
r'href="index\.php\?page=([a-z]+\d{2,4}results)"',
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_text(client: httpx.AsyncClient, url: str) -> str:
|
|
||||||
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S, follow_redirects=True)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.text
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_results_page(html: str) -> dict[str, list[tuple[str, str]]]:
|
|
||||||
sections: dict[str, list[tuple[str, str]]] = {}
|
|
||||||
current_div: str | None = None
|
|
||||||
|
|
||||||
parts = re.split(r"(<h2>.*?</h2>)", html, flags=re.DOTALL)
|
|
||||||
for part in parts:
|
|
||||||
heading_m = DIVISION_HEADING_RE.search(part)
|
|
||||||
if heading_m:
|
|
||||||
current_div = heading_m.group(3).lower()
|
|
||||||
sections.setdefault(current_div, [])
|
|
||||||
continue
|
|
||||||
if current_div is not None:
|
|
||||||
for m in PROBLEM_BLOCK_RE.finditer(part):
|
|
||||||
name = m.group(1).strip()
|
|
||||||
cpid = m.group(2)
|
|
||||||
sections[current_div].append((cpid, name))
|
|
||||||
|
|
||||||
return sections
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_contest_id(contest_id: str) -> tuple[str, str]:
|
|
||||||
parts = contest_id.rsplit("_", 1)
|
|
||||||
if len(parts) != 2:
|
|
||||||
return contest_id, ""
|
|
||||||
return parts[0], parts[1].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _results_page_slug(month_year: str) -> str:
|
|
||||||
return f"{month_year}results"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_problem_page(html: str) -> dict[str, Any]:
|
|
||||||
inputs = SAMPLE_IN_RE.findall(html)
|
|
||||||
outputs = SAMPLE_OUT_RE.findall(html)
|
|
||||||
tests: list[TestCase] = []
|
|
||||||
for inp, out in zip(inputs, outputs):
|
|
||||||
tests.append(
|
|
||||||
TestCase(
|
|
||||||
input=inp.strip().replace("\r", ""),
|
|
||||||
expected=out.strip().replace("\r", ""),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
tm = TIME_NOTE_RE.search(html)
|
|
||||||
mm = MEMORY_NOTE_RE.search(html)
|
|
||||||
timeout_ms = int(tm.group(1)) * 1000 if tm else 4000
|
|
||||||
memory_mb = int(mm.group(1)) if mm else 256
|
|
||||||
|
|
||||||
interactive = "interactive problem" in html.lower()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tests": tests,
|
|
||||||
"timeout_ms": timeout_ms,
|
|
||||||
"memory_mb": memory_mb,
|
|
||||||
"interactive": interactive,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class USACOScraper(BaseScraper):
|
|
||||||
@property
|
|
||||||
def platform_name(self) -> str:
|
|
||||||
return "usaco"
|
|
||||||
|
|
||||||
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
|
||||||
try:
|
|
||||||
month_year, division = _parse_contest_id(contest_id)
|
|
||||||
if not division:
|
|
||||||
return self._metadata_error(
|
|
||||||
f"Invalid contest ID '{contest_id}'. "
|
|
||||||
"Expected format: <monthYY>_<division> (e.g. dec24_gold)"
|
|
||||||
)
|
|
||||||
|
|
||||||
slug = _results_page_slug(month_year)
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
html = await _fetch_text(client, f"{BASE_URL}/index.php?page={slug}")
|
|
||||||
sections = _parse_results_page(html)
|
|
||||||
problems_raw = sections.get(division, [])
|
|
||||||
if not problems_raw:
|
|
||||||
return self._metadata_error(
|
|
||||||
f"No problems found for {contest_id} (division: {division})"
|
|
||||||
)
|
|
||||||
problems = [
|
|
||||||
ProblemSummary(id=cpid, name=name) for cpid, name in problems_raw
|
|
||||||
]
|
|
||||||
return MetadataResult(
|
|
||||||
success=True,
|
|
||||||
error="",
|
|
||||||
contest_id=contest_id,
|
|
||||||
problems=problems,
|
|
||||||
url=f"{BASE_URL}/index.php?page=viewproblem2&cpid=%s",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return self._metadata_error(str(e))
|
|
||||||
|
|
||||||
async def scrape_contest_list(self) -> ContestListResult:
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
limits=httpx.Limits(max_connections=CONNECTIONS)
|
|
||||||
) as client:
|
|
||||||
html = await _fetch_text(client, f"{BASE_URL}/index.php?page=contests")
|
|
||||||
|
|
||||||
page_slugs: set[str] = set()
|
|
||||||
for m in RESULTS_PAGE_RE.finditer(html):
|
|
||||||
page_slugs.add(m.group(1))
|
|
||||||
|
|
||||||
recent_patterns = []
|
|
||||||
for year in range(15, 27):
|
|
||||||
for month in MONTHS:
|
|
||||||
recent_patterns.append(f"{month}{year:02d}results")
|
|
||||||
page_slugs.update(recent_patterns)
|
|
||||||
|
|
||||||
contests: list[ContestSummary] = []
|
|
||||||
sem = asyncio.Semaphore(CONNECTIONS)
|
|
||||||
|
|
||||||
async def check_page(slug: str) -> list[ContestSummary]:
|
|
||||||
async with sem:
|
|
||||||
try:
|
|
||||||
page_html = await _fetch_text(
|
|
||||||
client, f"{BASE_URL}/index.php?page={slug}"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
sections = _parse_results_page(page_html)
|
|
||||||
if not sections:
|
|
||||||
return []
|
|
||||||
month_year = slug.replace("results", "")
|
|
||||||
out: list[ContestSummary] = []
|
|
||||||
for div in sections:
|
|
||||||
cid = f"{month_year}_{div}"
|
|
||||||
year_m = re.search(r"\d{2,4}", month_year)
|
|
||||||
month_m = re.search(r"[a-z]+", month_year)
|
|
||||||
year_str = year_m.group() if year_m else ""
|
|
||||||
month_str = month_m.group().capitalize() if month_m else ""
|
|
||||||
if len(year_str) == 2:
|
|
||||||
year_str = f"20{year_str}"
|
|
||||||
display = (
|
|
||||||
f"USACO {year_str} {month_str} - {div.capitalize()}"
|
|
||||||
)
|
|
||||||
out.append(
|
|
||||||
ContestSummary(id=cid, name=cid, display_name=display)
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
tasks = [check_page(slug) for slug in sorted(page_slugs)]
|
|
||||||
for coro in asyncio.as_completed(tasks):
|
|
||||||
contests.extend(await coro)
|
|
||||||
|
|
||||||
if not contests:
|
|
||||||
return self._contests_error("No contests found")
|
|
||||||
return ContestListResult(success=True, error="", contests=contests)
|
|
||||||
except Exception as e:
|
|
||||||
return self._contests_error(str(e))
|
|
||||||
|
|
||||||
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
|
||||||
month_year, division = _parse_contest_id(category_id)
|
|
||||||
if not division:
|
|
||||||
return
|
|
||||||
|
|
||||||
slug = _results_page_slug(month_year)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
limits=httpx.Limits(max_connections=CONNECTIONS)
|
|
||||||
) as client:
|
|
||||||
try:
|
|
||||||
html = await _fetch_text(client, f"{BASE_URL}/index.php?page={slug}")
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
sections = _parse_results_page(html)
|
|
||||||
problems_raw = sections.get(division, [])
|
|
||||||
if not problems_raw:
|
|
||||||
return
|
|
||||||
|
|
||||||
sem = asyncio.Semaphore(CONNECTIONS)
|
|
||||||
|
|
||||||
async def run_one(cpid: str) -> dict[str, Any]:
|
|
||||||
async with sem:
|
|
||||||
try:
|
|
||||||
problem_html = await _fetch_text(
|
|
||||||
client,
|
|
||||||
f"{BASE_URL}/index.php?page=viewproblem2&cpid={cpid}",
|
|
||||||
)
|
|
||||||
info = _parse_problem_page(problem_html)
|
|
||||||
except Exception:
|
|
||||||
info = {
|
|
||||||
"tests": [],
|
|
||||||
"timeout_ms": 4000,
|
|
||||||
"memory_mb": 256,
|
|
||||||
"interactive": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
tests = cast(list[TestCase], info["tests"])
|
|
||||||
combined_input = "\n".join(t.input for t in tests) if tests else ""
|
|
||||||
combined_expected = (
|
|
||||||
"\n".join(t.expected for t in tests) if tests else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"problem_id": cpid,
|
|
||||||
"combined": {
|
|
||||||
"input": combined_input,
|
|
||||||
"expected": combined_expected,
|
|
||||||
},
|
|
||||||
"tests": [
|
|
||||||
{"input": t.input, "expected": t.expected} for t in tests
|
|
||||||
],
|
|
||||||
"timeout_ms": info["timeout_ms"],
|
|
||||||
"memory_mb": info["memory_mb"],
|
|
||||||
"interactive": info["interactive"],
|
|
||||||
"multi_test": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks = [run_one(cpid) for cpid, _ in problems_raw]
|
|
||||||
for coro in asyncio.as_completed(tasks):
|
|
||||||
payload = await coro
|
|
||||||
print(json.dumps(payload), flush=True)
|
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
contest_id: str,
|
|
||||||
problem_id: str,
|
|
||||||
source_code: str,
|
|
||||||
language_id: str,
|
|
||||||
credentials: dict[str, str],
|
|
||||||
) -> SubmitResult:
|
|
||||||
return SubmitResult(
|
|
||||||
success=False,
|
|
||||||
error="USACO submit not yet implemented",
|
|
||||||
submission_id="",
|
|
||||||
verdict="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
USACOScraper().run_cli()
|
|
||||||
|
|
@ -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'
|
std = 'vim'
|
||||||
|
|
||||||
[lints]
|
|
||||||
bad_string_escape = 'allow'
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from curl_cffi import requests as curl_requests
|
from scrapling import fetchers
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
FIX = Path(__file__).resolve().parent / "fixtures"
|
FIX = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
|
@ -136,15 +136,12 @@ def run_scraper_offline(fixture_text):
|
||||||
|
|
||||||
case "codeforces":
|
case "codeforces":
|
||||||
|
|
||||||
class MockCurlResponse:
|
class MockCodeForcesPage:
|
||||||
def __init__(self, html: str):
|
def __init__(self, html: str):
|
||||||
self.text = html
|
self.html_content = html
|
||||||
|
|
||||||
def raise_for_status(self):
|
def _mock_stealthy_fetch(url: str, **kwargs):
|
||||||
pass
|
return MockCodeForcesPage(_router_codeforces(url=url))
|
||||||
|
|
||||||
def _mock_curl_get(url: str, **kwargs):
|
|
||||||
return MockCurlResponse(_router_codeforces(url=url))
|
|
||||||
|
|
||||||
def _mock_requests_get(url: str, **kwargs):
|
def _mock_requests_get(url: str, **kwargs):
|
||||||
if "api/contest.list" in url:
|
if "api/contest.list" in url:
|
||||||
|
|
@ -175,7 +172,7 @@ def run_scraper_offline(fixture_text):
|
||||||
raise AssertionError(f"Unexpected requests.get call: {url}")
|
raise AssertionError(f"Unexpected requests.get call: {url}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"curl_requests.get": _mock_curl_get,
|
"Fetcher.get": _mock_stealthy_fetch,
|
||||||
"requests.get": _mock_requests_get,
|
"requests.get": _mock_requests_get,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,23 +212,21 @@ def run_scraper_offline(fixture_text):
|
||||||
return MockResponse(data)
|
return MockResponse(data)
|
||||||
raise AssertionError(f"No fixture for CodeChef url={url!r}")
|
raise AssertionError(f"No fixture for CodeChef url={url!r}")
|
||||||
|
|
||||||
class MockCodeChefCurlResponse:
|
class MockCodeChefPage:
|
||||||
def __init__(self, html: str):
|
def __init__(self, html: str):
|
||||||
self.text = html
|
self.body = html
|
||||||
|
self.status = 200
|
||||||
|
|
||||||
def raise_for_status(self):
|
def _mock_stealthy_fetch(url: str, **kwargs):
|
||||||
pass
|
|
||||||
|
|
||||||
def _mock_curl_get(url: str, **kwargs):
|
|
||||||
if "/problems/" in url:
|
if "/problems/" in url:
|
||||||
problem_id = url.rstrip("/").split("/")[-1]
|
problem_id = url.rstrip("/").split("/")[-1]
|
||||||
html = fixture_text(f"codechef/{problem_id}.html")
|
html = fixture_text(f"codechef/{problem_id}.html")
|
||||||
return MockCodeChefCurlResponse(html)
|
return MockCodeChefPage(html)
|
||||||
raise AssertionError(f"No fixture for CodeChef url={url!r}")
|
raise AssertionError(f"No fixture for CodeChef url={url!r}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"__offline_get_async": __offline_get_async,
|
"__offline_get_async": __offline_get_async,
|
||||||
"curl_requests.get": _mock_curl_get,
|
"Fetcher.get": _mock_stealthy_fetch,
|
||||||
}
|
}
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
|
|
@ -250,7 +245,7 @@ def run_scraper_offline(fixture_text):
|
||||||
offline_fetches = _make_offline_fetches(scraper_name)
|
offline_fetches = _make_offline_fetches(scraper_name)
|
||||||
|
|
||||||
if scraper_name == "codeforces":
|
if scraper_name == "codeforces":
|
||||||
curl_requests.get = offline_fetches["curl_requests.get"]
|
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
|
||||||
requests.get = offline_fetches["requests.get"]
|
requests.get = offline_fetches["requests.get"]
|
||||||
elif scraper_name == "atcoder":
|
elif scraper_name == "atcoder":
|
||||||
ns._fetch = offline_fetches["_fetch"]
|
ns._fetch = offline_fetches["_fetch"]
|
||||||
|
|
@ -259,7 +254,7 @@ def run_scraper_offline(fixture_text):
|
||||||
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
|
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
|
||||||
elif scraper_name == "codechef":
|
elif scraper_name == "codechef":
|
||||||
httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
|
httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
|
||||||
curl_requests.get = offline_fetches["curl_requests.get"]
|
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
|
||||||
|
|
||||||
scraper_class = getattr(ns, scraper_classes[scraper_name])
|
scraper_class = getattr(ns, scraper_classes[scraper_name])
|
||||||
scraper = scraper_class()
|
scraper = scraper_class()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from scrapers.models import ContestListResult, MetadataResult, TestsResult
|
from scrapers.models import (
|
||||||
|
ContestListResult,
|
||||||
|
MetadataResult,
|
||||||
|
TestsResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
MODEL_FOR_MODE = {
|
||||||
|
"metadata": MetadataResult,
|
||||||
|
"contests": ContestListResult,
|
||||||
|
}
|
||||||
|
|
||||||
MATRIX = {
|
MATRIX = {
|
||||||
"cses": {
|
"cses": {
|
||||||
|
|
@ -34,16 +43,17 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
|
||||||
assert rc in (0, 1), f"Bad exit code {rc}"
|
assert rc in (0, 1), f"Bad exit code {rc}"
|
||||||
assert objs, f"No JSON output for {scraper}:{mode}"
|
assert objs, f"No JSON output for {scraper}:{mode}"
|
||||||
|
|
||||||
if mode == "metadata":
|
if mode in ("metadata", "contests"):
|
||||||
model = MetadataResult.model_validate(objs[-1])
|
Model = MODEL_FOR_MODE[mode]
|
||||||
|
model = Model.model_validate(objs[-1])
|
||||||
|
assert model is not None
|
||||||
assert model.success is True
|
assert model.success is True
|
||||||
assert model.url
|
if mode == "metadata":
|
||||||
assert len(model.problems) >= 1
|
assert model.url
|
||||||
assert all(isinstance(p.id, str) and p.id for p in model.problems)
|
assert len(model.problems) >= 1
|
||||||
elif mode == "contests":
|
assert all(isinstance(p.id, str) and p.id for p in model.problems)
|
||||||
model = ContestListResult.model_validate(objs[-1])
|
else:
|
||||||
assert model.success is True
|
assert len(model.contests) >= 1
|
||||||
assert len(model.contests) >= 1
|
|
||||||
else:
|
else:
|
||||||
assert len(objs) >= 1, "No test objects returned"
|
assert len(objs) >= 1, "No test objects returned"
|
||||||
validated_any = False
|
validated_any = False
|
||||||
|
|
@ -57,9 +67,9 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
|
||||||
assert hasattr(tr.combined, "input"), "combined missing input"
|
assert hasattr(tr.combined, "input"), "combined missing input"
|
||||||
assert hasattr(tr.combined, "expected"), "combined missing expected"
|
assert hasattr(tr.combined, "expected"), "combined missing expected"
|
||||||
assert isinstance(tr.combined.input, str), "combined.input not string"
|
assert isinstance(tr.combined.input, str), "combined.input not string"
|
||||||
assert isinstance(
|
assert isinstance(tr.combined.expected, str), (
|
||||||
tr.combined.expected, str
|
"combined.expected not string"
|
||||||
), "combined.expected not string"
|
)
|
||||||
assert hasattr(tr, "multi_test"), "Missing multi_test field"
|
assert hasattr(tr, "multi_test"), "Missing multi_test field"
|
||||||
assert isinstance(tr.multi_test, bool), "multi_test not boolean"
|
assert isinstance(tr.multi_test, bool), "multi_test not boolean"
|
||||||
validated_any = True
|
validated_any = True
|
||||||
|
|
@ -73,12 +83,12 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
|
||||||
assert isinstance(obj["combined"], dict), "combined not a dict"
|
assert isinstance(obj["combined"], dict), "combined not a dict"
|
||||||
assert "input" in obj["combined"], "combined missing input key"
|
assert "input" in obj["combined"], "combined missing input key"
|
||||||
assert "expected" in obj["combined"], "combined missing expected key"
|
assert "expected" in obj["combined"], "combined missing expected key"
|
||||||
assert isinstance(
|
assert isinstance(obj["combined"]["input"], str), (
|
||||||
obj["combined"]["input"], str
|
"combined.input not string"
|
||||||
), "combined.input not string"
|
)
|
||||||
assert isinstance(
|
assert isinstance(obj["combined"]["expected"], str), (
|
||||||
obj["combined"]["expected"], str
|
"combined.expected not string"
|
||||||
), "combined.expected not string"
|
)
|
||||||
assert "multi_test" in obj, "Missing multi_test field in raw JSON"
|
assert "multi_test" in obj, "Missing multi_test field in raw JSON"
|
||||||
assert isinstance(obj["multi_test"], bool), "multi_test not boolean"
|
assert isinstance(obj["multi_test"], bool), "multi_test not boolean"
|
||||||
validated_any = True
|
validated_any = True
|
||||||
|
|
|
||||||
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