Compare commits
90 commits
chore/add-
...
feat/codef
| Author | SHA1 | Date | |
|---|---|---|---|
| c95f7f4c53 | |||
| 38cd0482f0 | |||
| 3ecd200da7 | |||
| 7368747946 | |||
| 1afe41103f | |||
| 2cdde85d36 | |||
| f4055b071b | |||
| 027fae65a4 | |||
| 08593d828d | |||
|
|
e9f72dfbbc | ||
|
|
e674265527 | ||
|
|
c194f12eee | ||
|
|
1bc0aa41b6 | ||
|
|
49e0ae3885 | ||
|
|
98ac0aa7a7 | ||
|
|
18a60da2d8 | ||
| baaaa95b27 | |||
| 900fd70935 | |||
| f17eb32e8c | |||
| 4f88b19a82 | |||
| 217476f5f3 | |||
| 488260f769 | |||
| a04702d87c | |||
| 3e0b7beabf | |||
| a08d1f0c5e | |||
| 52cf54d05c | |||
| 7e48ba05cf | |||
| e79f992e0b | |||
| de5a20c567 | |||
| bad219e578 | |||
| ad90d564ca | |||
| bfa2cf893c | |||
| a75694e9e0 | |||
| 39b7b3d83f | |||
| f5c1b978a2 | |||
| 4e8da84882 | |||
| 90bd13580b | |||
| 865e3b5928 | |||
| dc9cb10f3a | |||
| 72ea6249f4 | |||
| 0e88e0f182 | |||
| add022af8c | |||
| 6a395af98f | |||
| d3324aafa3 | |||
| 24b088e8e9 | |||
| e685a8089f | |||
| 84d12758c2 | |||
| 2c25ec616a | |||
| ce5648f9cf | |||
| 585cf2a077 | |||
| 81f5273840 | |||
| d274e0c117 | |||
| 3cb872a65f | |||
| 4ccab9ee1f | |||
| 48c08825b2 | |||
| 4d58db8520 | |||
| 591f70a237 | |||
|
|
e989897c77 | ||
|
|
1c31abe3d6 | ||
| d3ac300ea0 | |||
| db5bd791f9 | |||
|
|
9fc34cb6fd | ||
|
|
484a4a56d0 | ||
| ff5ba39a59 | |||
| 760e7d7731 | |||
| 49e4233b3f | |||
| 622620f6d0 | |||
| 976838d981 | |||
| 06f72bbe2b | |||
| 6045042dfb | |||
| c192afc5d7 | |||
| b6f3398bbc | |||
| e02a29bd40 | |||
| 0f9715298e | |||
| 2148d9bd07 | |||
| 1162e7046b | |||
| b36ffba63a | |||
| 04d0c124cf | |||
| da433068ef | |||
| 51504b0121 | |||
| 49df7e015d | |||
| 029ea125b9 | |||
|
|
43193c3762 | ||
| de2bc07532 | |||
|
|
041e09ac04 | ||
| d23b4e59d1 | |||
|
|
19e71ac7fa | ||
| a54a06f939 | |||
|
|
b2c7f16890 | ||
| 276241447c |
50 changed files with 4131 additions and 1802 deletions
3
.envrc
3
.envrc
|
|
@ -1,3 +0,0 @@
|
||||||
VIRTUAL_ENV="$PWD/.venv"
|
|
||||||
PATH_add "$VIRTUAL_ENV/bin"
|
|
||||||
export VIRTUAL_ENV
|
|
||||||
112
.github/workflows/ci.yaml
vendored
112
.github/workflows/ci.yaml
vendored
|
|
@ -1,112 +0,0 @@
|
||||||
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,6 +28,7 @@ jobs:
|
||||||
- '*.lua'
|
- '*.lua'
|
||||||
- '.luarc.json'
|
- '.luarc.json'
|
||||||
- '*.toml'
|
- '*.toml'
|
||||||
|
- 'vim.yaml'
|
||||||
python:
|
python:
|
||||||
- 'scripts/**/.py'
|
- 'scripts/**/.py'
|
||||||
- 'scrapers/**/*.py'
|
- 'scrapers/**/*.py'
|
||||||
|
|
@ -45,11 +46,8 @@ jobs:
|
||||||
if: ${{ needs.changes.outputs.lua == 'true' }}
|
if: ${{ needs.changes.outputs.lua == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: JohnnyMorganz/stylua-action@v4
|
- uses: cachix/install-nix-action@v31
|
||||||
with:
|
- run: nix develop --command stylua --check .
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
version: 2.1.0
|
|
||||||
args: --check .
|
|
||||||
|
|
||||||
lua-lint:
|
lua-lint:
|
||||||
name: Lua Lint Check
|
name: Lua Lint Check
|
||||||
|
|
@ -58,11 +56,8 @@ jobs:
|
||||||
if: ${{ needs.changes.outputs.lua == 'true' }}
|
if: ${{ needs.changes.outputs.lua == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Lint with Selene
|
- uses: cachix/install-nix-action@v31
|
||||||
uses: NTBBloodbath/selene-action@v1.0.0
|
- run: nix develop --command selene --display-style quiet .
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
args: --display-style quiet .
|
|
||||||
|
|
||||||
lua-typecheck:
|
lua-typecheck:
|
||||||
name: Lua Type Check
|
name: Lua Type Check
|
||||||
|
|
@ -127,15 +122,5 @@ jobs:
|
||||||
if: ${{ needs.changes.outputs.markdown == 'true' }}
|
if: ${{ needs.changes.outputs.markdown == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Setup pnpm
|
- uses: cachix/install-nix-action@v31
|
||||||
uses: pnpm/action-setup@v4
|
- run: nix develop --command prettier --check .
|
||||||
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,9 +44,7 @@ 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 with pytest
|
- name: Install dependencies
|
||||||
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,3 +14,6 @@ __pycache__
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
.envrc
|
||||||
|
.direnv/
|
||||||
|
|
|
||||||
25
.luarc.json
25
.luarc.json
|
|
@ -1,8 +1,21 @@
|
||||||
{
|
{
|
||||||
"runtime.version": "Lua 5.1",
|
"runtime": {
|
||||||
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
|
"version": "LuaJIT",
|
||||||
"diagnostics.globals": ["vim"],
|
"path": ["lua/?.lua", "lua/?/init.lua"]
|
||||||
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
|
},
|
||||||
"workspace.checkThirdParty": false,
|
"diagnostics": {
|
||||||
"completion.callSnippet": "Replace"
|
"globals": ["vim"]
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"library": [
|
||||||
|
"$VIMRUNTIME/lua",
|
||||||
|
"${3rd}/luv/library",
|
||||||
|
"${3rd}/busted/library"
|
||||||
|
],
|
||||||
|
"checkThirdParty": false,
|
||||||
|
"ignoreDir": [".direnv"]
|
||||||
|
},
|
||||||
|
"completion": {
|
||||||
|
"callSnippet": "Replace"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
.styluaignore
Normal file
1
.styluaignore
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.direnv/
|
||||||
|
|
@ -28,11 +28,12 @@ Install using your package manager of choice or via
|
||||||
luarocks install cp.nvim
|
luarocks install cp.nvim
|
||||||
```
|
```
|
||||||
|
|
||||||
## Optional Dependencies
|
## 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
|
||||||
|
|
||||||
|
|
|
||||||
820
doc/cp.nvim.txt
820
doc/cp.nvim.txt
|
|
@ -3,13 +3,44 @@
|
||||||
Author: Barrett Ruth <br.barrettruth@gmail.com>
|
Author: Barrett Ruth <br.barrettruth@gmail.com>
|
||||||
License: Same terms as Vim itself (see |license|)
|
License: Same terms as Vim itself (see |license|)
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
CONTENTS *cp-contents*
|
||||||
|
|
||||||
|
1. Introduction .................................................. |cp.nvim|
|
||||||
|
2. Requirements ........................................ |cp-requirements|
|
||||||
|
3. Setup ........................................................ |cp-setup|
|
||||||
|
4. Configuration ................................................ |cp-config|
|
||||||
|
5. Commands .................................................. |cp-commands|
|
||||||
|
6. Mappings .................................................. |cp-mappings|
|
||||||
|
7. Language Selection .................................. |cp-lang-selection|
|
||||||
|
8. Workflow .................................................. |cp-workflow|
|
||||||
|
9. Workflow Example ............................................ |cp-example|
|
||||||
|
10. Verdict Formatting ................................. |cp-verdict-format|
|
||||||
|
11. Picker Integration .......................................... |cp-picker|
|
||||||
|
12. Picker Keymaps ........................................ |cp-picker-keys|
|
||||||
|
13. Panel ........................................................ |cp-panel|
|
||||||
|
14. Interactive Mode .......................................... |cp-interact|
|
||||||
|
15. Stress Testing .............................................. |cp-stress|
|
||||||
|
16. Race .......................................................... |cp-race|
|
||||||
|
17. Credentials ............................................ |cp-credentials|
|
||||||
|
18. Submit ...................................................... |cp-submit|
|
||||||
|
19. ANSI Colors ................................................... |cp-ansi|
|
||||||
|
20. Highlight Groups ........................................ |cp-highlights|
|
||||||
|
21. Terminal Colors .................................... |cp-terminal-colors|
|
||||||
|
22. Highlight Customization .......................... |cp-highlight-custom|
|
||||||
|
23. Helpers .................................................... |cp-helpers|
|
||||||
|
24. Statusline Integration .................................. |cp-statusline|
|
||||||
|
25. Panel Keymaps .......................................... |cp-panel-keys|
|
||||||
|
26. File Structure ................................................ |cp-files|
|
||||||
|
27. Health Check ................................................ |cp-health|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
INTRODUCTION *cp.nvim*
|
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 (for now!): AtCoder, Codeforces, CSES
|
Supported platforms: AtCoder, CodeChef, Codeforces, CSES, Kattis, USACO
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
REQUIREMENTS *cp-requirements*
|
REQUIREMENTS *cp-requirements*
|
||||||
|
|
@ -19,195 +50,20 @@ REQUIREMENTS *cp-requirements*
|
||||||
- uv package manager (https://docs.astral.sh/uv/)
|
- uv package manager (https://docs.astral.sh/uv/)
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
COMMANDS *cp-commands*
|
SETUP *cp-setup*
|
||||||
|
|
||||||
:CP *:CP*
|
Load cp.nvim with your package manager. For example, with lazy.nvim: >lua
|
||||||
cp.nvim uses a single :CP command with intelligent argument parsing:
|
{ 'barrettruth/cp.nvim' }
|
||||||
|
|
||||||
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_config`. Set this before using the plugin:
|
Configuration is done via `vim.g.cp`. Set this before using the plugin:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp_config = {
|
vim.g.cp = {
|
||||||
languages = {
|
languages = {
|
||||||
cpp = {
|
cpp = {
|
||||||
extension = 'cc',
|
extension = 'cc',
|
||||||
|
|
@ -243,6 +99,18 @@ Configuration is done via `vim.g.cp_config`. 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,
|
||||||
|
|
@ -274,7 +142,7 @@ the default; per-platform overrides can tweak 'extension' or 'commands'.
|
||||||
|
|
||||||
For example, to run CodeForces contests with Python by default:
|
For example, to run CodeForces contests with Python by default:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp_config = {
|
vim.g.cp = {
|
||||||
platforms = {
|
platforms = {
|
||||||
codeforces = {
|
codeforces = {
|
||||||
default_language = 'python',
|
default_language = 'python',
|
||||||
|
|
@ -285,7 +153,7 @@ For example, to run CodeForces contests with Python by default:
|
||||||
Any language is supported provided the proper configuration. For example, to
|
Any language is supported provided the proper configuration. For example, to
|
||||||
run CSES problems with Rust using the single schema:
|
run CSES problems with Rust using the single schema:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp_config = {
|
vim.g.cp = {
|
||||||
languages = {
|
languages = {
|
||||||
rust = {
|
rust = {
|
||||||
extension = 'rs',
|
extension = 'rs',
|
||||||
|
|
@ -318,7 +186,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 the contest is first opened.
|
when a new contest is opened or the active problem changes.
|
||||||
|
|
||||||
*CpPlatform*
|
*CpPlatform*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
|
|
@ -395,42 +263,339 @@ run CSES problems with Rust using the single schema:
|
||||||
|
|
||||||
*cp.Hooks*
|
*cp.Hooks*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
{before_run} (function, optional) Called before test panel opens.
|
{setup} (|cp.CpSetupHooks|, optional) One-time initialization hooks.
|
||||||
function(state: cp.State)
|
{on} (|cp.CpOnHooks|, optional) Recurring event hooks.
|
||||||
{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)
|
|
||||||
|
|
||||||
Hook functions receive the cp.nvim state object (|cp.State|). See
|
*cp.CpSetupHooks*
|
||||||
|
Fields: ~
|
||||||
|
{contest} (function, optional) Called once when a contest directory
|
||||||
|
is first created (not on subsequent visits).
|
||||||
|
function(state: cp.State)
|
||||||
|
{code} (function, optional) Called after the source buffer is
|
||||||
|
opened for the first time (guarded by cp_setup_done).
|
||||||
|
function(state: cp.State)
|
||||||
|
{io} (|cp.CpSetupIOHooks|, optional) I/O buffer hooks.
|
||||||
|
|
||||||
|
*cp.CpSetupIOHooks*
|
||||||
|
Fields: ~
|
||||||
|
{input} (function, optional) Called when the I/O input buffer is
|
||||||
|
created. function(bufnr: integer, state: cp.State)
|
||||||
|
Default: helpers.clearcol
|
||||||
|
{output} (function, optional) Called when the I/O output buffer is
|
||||||
|
created. function(bufnr: integer, state: cp.State)
|
||||||
|
Default: helpers.clearcol
|
||||||
|
|
||||||
|
*cp.CpOnHooks*
|
||||||
|
Fields: ~
|
||||||
|
{enter} (function, optional) Called on every BufEnter on the
|
||||||
|
solution buffer. Registered as a buffer-scoped autocmd and
|
||||||
|
fired immediately after setup.code.
|
||||||
|
function(state: cp.State)
|
||||||
|
{run} (function, optional) Called before the test panel opens.
|
||||||
|
function(state: cp.State)
|
||||||
|
{debug} (function, optional) Called before a debug run.
|
||||||
|
function(state: cp.State)
|
||||||
|
|
||||||
|
All hook functions receive the cp.nvim state object (|cp.State|). See
|
||||||
|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_code = function(state)
|
setup = {
|
||||||
print("Setting up " .. state.get_base_name())
|
contest = function(state)
|
||||||
print("Source file: " .. state.get_source_file())
|
local dir = vim.fn.fnamemodify(
|
||||||
end,
|
state.get_source_file(state.get_language()), ':h')
|
||||||
setup_io_input = function(bufnr, state)
|
vim.fn.system({ 'cp', '~/.clang-format', dir .. '/.clang-format' })
|
||||||
-- Custom setup for input buffer
|
end,
|
||||||
vim.api.nvim_set_option_value('number', false, { buf = bufnr })
|
code = function(state)
|
||||||
end
|
vim.opt_local.foldmethod = 'marker'
|
||||||
|
vim.diagnostic.enable(false)
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
enter = function(state) vim.opt_local.winbar = '' end,
|
||||||
|
run = function(state) require('config.lsp').format() end,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
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 login [platform]
|
||||||
|
Set or update stored credentials for a platform.
|
||||||
|
Prompts for username and password, overwriting
|
||||||
|
any previously saved credentials.
|
||||||
|
If [platform] is omitted, uses the active platform.
|
||||||
|
Examples: >
|
||||||
|
:CP login atcoder
|
||||||
|
:CP login codeforces
|
||||||
|
<
|
||||||
|
:CP logout [platform]
|
||||||
|
Remove stored credentials for a platform.
|
||||||
|
If [platform] is omitted, uses the active platform.
|
||||||
|
Examples: >
|
||||||
|
:CP logout atcoder
|
||||||
|
<
|
||||||
|
Submit Commands ~
|
||||||
|
:CP submit [--lang {language}]
|
||||||
|
Submit the current solution to the online
|
||||||
|
judge. Uses stored credentials (set via
|
||||||
|
:CP login). 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*
|
||||||
|
|
||||||
|
|
@ -508,6 +673,41 @@ 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*
|
||||||
|
|
@ -542,7 +742,9 @@ Example: Setting up and solving AtCoder contest ABC324
|
||||||
:CP
|
:CP
|
||||||
< Automatically restores abc323 contest context
|
< Automatically restores abc323 contest context
|
||||||
|
|
||||||
8. Submit solutions on AtCoder website
|
8. Submit solution: >
|
||||||
|
:CP submit
|
||||||
|
< Uses stored credentials and submits to AtCoder.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
I/O VIEW *cp-io-view*
|
I/O VIEW *cp-io-view*
|
||||||
|
|
@ -612,9 +814,9 @@ While in the I/O view buffers, use the configured keymaps to cycle through tests
|
||||||
|
|
||||||
Buffer Customization ~
|
Buffer Customization ~
|
||||||
|
|
||||||
Use the setup_io_input and setup_io_output hooks (see |cp.Hooks|) to customize
|
Use the hooks.setup.io.input and hooks.setup.io.output hooks (see |cp.Hooks|)
|
||||||
buffer appearance. By default, line numbers and columns are removed via
|
to customize buffer appearance. By default, line numbers and columns are
|
||||||
helpers.clearcol (see |cp-helpers|).
|
removed via helpers.clearcol (see |cp-helpers|).
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
VERDICT FORMATTING *cp-verdict-format*
|
VERDICT FORMATTING *cp-verdict-format*
|
||||||
|
|
@ -753,6 +955,72 @@ 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 login [platform]
|
||||||
|
Set or update credentials for a platform. Prompts for username
|
||||||
|
and password, overwriting any previously saved values.
|
||||||
|
Omit [platform] to use the currently active platform.
|
||||||
|
|
||||||
|
:CP logout [platform]
|
||||||
|
Remove stored credentials for a platform.
|
||||||
|
Omit [platform] to use the currently active platform.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
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 login). 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*
|
||||||
|
|
||||||
|
|
@ -843,6 +1111,124 @@ 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
Normal file
43
flake.lock
generated
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
139
flake.nix
Normal file
139
flake.nix
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
{
|
||||||
|
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
|
||||||
|
]);
|
||||||
|
|
||||||
|
mkDevPythonEnv =
|
||||||
|
pkgs:
|
||||||
|
pkgs.python312.withPackages (ps: [
|
||||||
|
ps.backoff
|
||||||
|
ps.beautifulsoup4
|
||||||
|
ps.curl-cffi
|
||||||
|
ps.httpx
|
||||||
|
ps.ndjson
|
||||||
|
ps.pydantic
|
||||||
|
ps.requests
|
||||||
|
ps.pytest
|
||||||
|
ps.pytest-mock
|
||||||
|
]);
|
||||||
|
|
||||||
|
mkSubmitEnv =
|
||||||
|
pkgs:
|
||||||
|
pkgs.buildFHSEnv {
|
||||||
|
name = "cp-nvim-submit";
|
||||||
|
targetPkgs =
|
||||||
|
pkgs: with pkgs; [
|
||||||
|
uv
|
||||||
|
alsa-lib
|
||||||
|
at-spi2-atk
|
||||||
|
cairo
|
||||||
|
cups
|
||||||
|
dbus
|
||||||
|
fontconfig
|
||||||
|
freetype
|
||||||
|
gdk-pixbuf
|
||||||
|
glib
|
||||||
|
gtk3
|
||||||
|
libdrm
|
||||||
|
libxkbcommon
|
||||||
|
mesa
|
||||||
|
libGL
|
||||||
|
nspr
|
||||||
|
nss
|
||||||
|
pango
|
||||||
|
libx11
|
||||||
|
libxcomposite
|
||||||
|
libxdamage
|
||||||
|
libxext
|
||||||
|
libxfixes
|
||||||
|
libxrandr
|
||||||
|
libxcb
|
||||||
|
at-spi2-core
|
||||||
|
expat
|
||||||
|
libgbm
|
||||||
|
systemdLibs
|
||||||
|
zlib
|
||||||
|
];
|
||||||
|
runScript = "${pkgs.uv}/bin/uv";
|
||||||
|
};
|
||||||
|
|
||||||
|
mkPlugin =
|
||||||
|
pkgs:
|
||||||
|
let
|
||||||
|
pythonEnv = mkPythonEnv pkgs;
|
||||||
|
submitEnv = mkSubmitEnv 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}'"
|
||||||
|
substituteInPlace lua/cp/utils.lua \
|
||||||
|
--replace-fail "local _nix_submit_cmd = nil" \
|
||||||
|
"local _nix_submit_cmd = '${submitEnv}/bin/cp-nvim-submit'"
|
||||||
|
'';
|
||||||
|
nvimSkipModule = [
|
||||||
|
"cp.pickers.telescope"
|
||||||
|
"cp.version"
|
||||||
|
];
|
||||||
|
passthru = { inherit pythonEnv submitEnv; };
|
||||||
|
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);
|
||||||
|
submitEnv = mkSubmitEnv (pkgsFor system);
|
||||||
|
});
|
||||||
|
|
||||||
|
formatter = eachSystem (system: (pkgsFor system).nixfmt-tree);
|
||||||
|
|
||||||
|
devShells = eachSystem (system: {
|
||||||
|
default = (pkgsFor system).mkShell {
|
||||||
|
packages = with (pkgsFor system); [
|
||||||
|
uv
|
||||||
|
(mkDevPythonEnv (pkgsFor system))
|
||||||
|
prettier
|
||||||
|
ruff
|
||||||
|
stylua
|
||||||
|
selene
|
||||||
|
lua-language-server
|
||||||
|
ty
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
121
lua/cp/cache.lua
121
lua/cp/cache.lua
|
|
@ -15,6 +15,7 @@
|
||||||
---@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
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
---@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[]
|
||||||
|
|
||||||
|
|
@ -38,7 +40,8 @@
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local logger = require('cp.log')
|
local CACHE_VERSION = 2
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -64,10 +67,30 @@ function M.load()
|
||||||
end
|
end
|
||||||
|
|
||||||
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 not ok then
|
||||||
|
cache_data = {}
|
||||||
|
M.save()
|
||||||
|
loaded = true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if decoded._version == 1 then
|
||||||
|
local old_creds = decoded._credentials
|
||||||
|
decoded._credentials = nil
|
||||||
|
if old_creds then
|
||||||
|
for platform, creds in pairs(old_creds) do
|
||||||
|
decoded[platform] = decoded[platform] or {}
|
||||||
|
decoded[platform]._credentials = creds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
decoded._version = CACHE_VERSION
|
||||||
|
cache_data = decoded
|
||||||
|
M.save()
|
||||||
|
elseif decoded._version == CACHE_VERSION then
|
||||||
cache_data = decoded
|
cache_data = decoded
|
||||||
else
|
else
|
||||||
logger.log('Could not decode json in cache file', vim.log.levels.ERROR)
|
cache_data = {}
|
||||||
|
M.save()
|
||||||
end
|
end
|
||||||
loaded = true
|
loaded = true
|
||||||
end
|
end
|
||||||
|
|
@ -78,6 +101,7 @@ 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)
|
||||||
|
|
@ -112,7 +136,9 @@ function M.get_cached_contest_ids(platform)
|
||||||
|
|
||||||
local contest_ids = {}
|
local contest_ids = {}
|
||||||
for contest_id, _ in pairs(cache_data[platform]) do
|
for contest_id, _ in pairs(cache_data[platform]) do
|
||||||
table.insert(contest_ids, contest_id)
|
if contest_id:sub(1, 1) ~= '_' then
|
||||||
|
table.insert(contest_ids, contest_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
table.sort(contest_ids)
|
table.sort(contest_ids)
|
||||||
return contest_ids
|
return contest_ids
|
||||||
|
|
@ -222,7 +248,8 @@ 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' },
|
||||||
|
|
@ -234,6 +261,7 @@ 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]
|
||||||
|
|
@ -244,6 +272,7 @@ 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
|
||||||
|
|
@ -265,6 +294,34 @@ 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)
|
||||||
|
|
@ -295,11 +352,13 @@ end
|
||||||
function M.get_contest_summaries(platform)
|
function M.get_contest_summaries(platform)
|
||||||
local contest_list = {}
|
local contest_list = {}
|
||||||
for contest_id, contest_data in pairs(cache_data[platform] or {}) do
|
for contest_id, contest_data in pairs(cache_data[platform] or {}) do
|
||||||
table.insert(contest_list, {
|
if contest_id:sub(1, 1) ~= '_' then
|
||||||
id = contest_id,
|
table.insert(contest_list, {
|
||||||
name = contest_data.name,
|
id = contest_id,
|
||||||
display_name = contest_data.display_name,
|
name = contest_data.name,
|
||||||
})
|
display_name = contest_data.display_name,
|
||||||
|
})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return contest_list
|
return contest_list
|
||||||
end
|
end
|
||||||
|
|
@ -312,11 +371,49 @@ 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[platform] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return cache_data[platform]._credentials
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param platform string
|
||||||
|
---@param creds table
|
||||||
|
function M.set_credentials(platform, creds)
|
||||||
|
cache_data[platform] = cache_data[platform] or {}
|
||||||
|
cache_data[platform]._credentials = creds
|
||||||
|
M.save()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param platform string
|
||||||
|
function M.clear_credentials(platform)
|
||||||
|
if cache_data[platform] then
|
||||||
|
cache_data[platform]._credentials = nil
|
||||||
|
end
|
||||||
|
M.save()
|
||||||
|
end
|
||||||
|
|
||||||
function M.clear_all()
|
function M.clear_all()
|
||||||
cache_data = {}
|
cache_data = {}
|
||||||
M.save()
|
M.save()
|
||||||
|
|
@ -338,6 +435,8 @@ function M.get_data_pretty()
|
||||||
return vim.inspect(cache_data)
|
return vim.inspect(cache_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
M._cache = cache_data
|
function M.get_raw_cache()
|
||||||
|
return cache_data
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ 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
|
||||||
|
|
@ -53,6 +55,27 @@ 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
|
||||||
|
|
@ -60,6 +83,15 @@ local function parse_command(args)
|
||||||
else
|
else
|
||||||
return { type = 'action', action = 'interact' }
|
return { type = 'action', action = 'interact' }
|
||||||
end
|
end
|
||||||
|
elseif first == 'login' or first == 'logout' then
|
||||||
|
return { type = 'action', action = first, platform = args[2] }
|
||||||
|
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
|
||||||
|
|
@ -285,6 +317,18 @@ 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()
|
||||||
|
elseif cmd.action == 'login' then
|
||||||
|
require('cp.credentials').login(cmd.platform)
|
||||||
|
elseif cmd.action == 'logout' then
|
||||||
|
require('cp.credentials').logout(cmd.platform)
|
||||||
end
|
end
|
||||||
elseif cmd.type == 'problem_jump' then
|
elseif cmd.type == 'problem_jump' then
|
||||||
local platform = state.get_platform()
|
local platform = state.get_platform()
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,15 @@
|
||||||
---@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[]
|
||||||
|
|
@ -20,6 +25,7 @@
|
||||||
---@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[]
|
||||||
|
|
@ -27,12 +33,23 @@
|
||||||
---@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 before_run? fun(state: cp.State)
|
---@field setup? CpSetupHooks
|
||||||
---@field before_debug? fun(state: cp.State)
|
---@field on? CpOnHooks
|
||||||
---@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
|
||||||
|
|
@ -61,8 +78,6 @@
|
||||||
|
|
||||||
---@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
|
||||||
|
|
@ -83,6 +98,7 @@
|
||||||
---@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
|
||||||
|
|
@ -147,13 +163,29 @@ 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 = {
|
||||||
before_run = nil,
|
setup = {
|
||||||
before_debug = nil,
|
contest = nil,
|
||||||
setup_code = nil,
|
code = nil,
|
||||||
setup_io_input = helpers.clearcol,
|
io = {
|
||||||
setup_io_output = helpers.clearcol,
|
input = helpers.clearcol,
|
||||||
|
output = helpers.clearcol,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
enter = nil,
|
||||||
|
run = nil,
|
||||||
|
debug = nil,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
debug = false,
|
debug = false,
|
||||||
scrapers = constants.PLATFORMS,
|
scrapers = constants.PLATFORMS,
|
||||||
|
|
@ -162,8 +194,6 @@ 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 = {
|
||||||
|
|
@ -173,7 +203,11 @@ M.defaults = {
|
||||||
add_test_key = 'ga',
|
add_test_key = 'ga',
|
||||||
save_and_exit_key = 'q',
|
save_and_exit_key = 'q',
|
||||||
},
|
},
|
||||||
panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50 },
|
panel = {
|
||||||
|
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' },
|
||||||
|
|
@ -215,6 +249,10 @@ 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
|
||||||
|
|
@ -253,6 +291,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -292,7 +333,15 @@ 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 cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
|
local defaults = vim.deepcopy(M.defaults)
|
||||||
|
if user_config and user_config.platforms then
|
||||||
|
for plat in pairs(defaults.platforms) do
|
||||||
|
if not user_config.platforms[plat] then
|
||||||
|
defaults.platforms[plat] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
|
||||||
|
|
||||||
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')
|
||||||
|
|
@ -302,6 +351,13 @@ 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' } },
|
||||||
|
|
@ -323,12 +379,29 @@ 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({
|
||||||
|
|
@ -355,6 +428,13 @@ 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 = {
|
||||||
|
|
@ -364,20 +444,6 @@ 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,13 +1,29 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses' }
|
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses', 'kattis', 'usaco' }
|
||||||
M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit' }
|
M.ACTIONS = {
|
||||||
|
'run',
|
||||||
|
'panel',
|
||||||
|
'next',
|
||||||
|
'prev',
|
||||||
|
'pick',
|
||||||
|
'cache',
|
||||||
|
'interact',
|
||||||
|
'edit',
|
||||||
|
'race',
|
||||||
|
'stress',
|
||||||
|
'submit',
|
||||||
|
'login',
|
||||||
|
'logout',
|
||||||
|
}
|
||||||
|
|
||||||
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'
|
||||||
|
|
|
||||||
43
lua/cp/credentials.lua
Normal file
43
lua/cp/credentials.lua
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local logger = require('cp.log')
|
||||||
|
local state = require('cp.state')
|
||||||
|
|
||||||
|
function M.login(platform)
|
||||||
|
platform = platform or state.get_platform()
|
||||||
|
if not platform then
|
||||||
|
logger.log('No platform specified. Usage: :CP login <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.logout(platform)
|
||||||
|
platform = platform or state.get_platform()
|
||||||
|
if not platform then
|
||||||
|
logger.log('No platform specified. Usage: :CP logout <platform>', vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
cache.load()
|
||||||
|
cache.clear_credentials(platform)
|
||||||
|
logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -5,33 +5,50 @@ 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.loop.os_uname()
|
local uname = vim.uv.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 vim.fn.executable('uv') == 1 then
|
if utils.is_nix_build() then
|
||||||
vim.health.ok('uv executable found')
|
local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install'
|
||||||
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
|
vim.health.ok('Nix Python environment detected (' .. source .. ')')
|
||||||
|
local py = utils.get_nix_python()
|
||||||
|
vim.health.info('Python: ' .. py)
|
||||||
|
local r = vim.system({ py, '--version' }, { text = true }):wait()
|
||||||
if r.code == 0 then
|
if r.code == 0 then
|
||||||
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
|
vim.health.info('Python version: ' .. r.stdout:gsub('\n', ''))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
|
if vim.fn.executable('uv') == 1 then
|
||||||
end
|
vim.health.ok('uv executable found')
|
||||||
|
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
|
||||||
|
if r.code == 0 then
|
||||||
|
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
|
||||||
|
end
|
||||||
|
else
|
||||||
|
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
|
||||||
|
end
|
||||||
|
|
||||||
local plugin_path = utils.get_plugin_path()
|
if vim.fn.executable('nix') == 1 then
|
||||||
local venv_dir = plugin_path .. '/.venv'
|
vim.health.info('nix available but Python environment not resolved via nix')
|
||||||
if vim.fn.isdirectory(venv_dir) == 1 then
|
end
|
||||||
vim.health.ok('Python virtual environment found at ' .. venv_dir)
|
|
||||||
else
|
local plugin_path = utils.get_plugin_path()
|
||||||
vim.health.info('Python virtual environment not set up (created on first scrape)')
|
local venv_dir = plugin_path .. '/.venv'
|
||||||
|
if vim.fn.isdirectory(venv_dir) == 1 then
|
||||||
|
vim.health.ok('Python virtual environment found at ' .. venv_dir)
|
||||||
|
else
|
||||||
|
vim.health.info('Python virtual environment not set up (created on first scrape)')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local time_cap = utils.time_capability()
|
local time_cap = utils.time_capability()
|
||||||
|
|
@ -41,7 +58,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.time_capability()
|
local timeout_cap = utils.timeout_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,17 +15,25 @@ local initialized = false
|
||||||
|
|
||||||
local function ensure_initialized()
|
local function ensure_initialized()
|
||||||
if initialized then
|
if initialized then
|
||||||
return
|
return true
|
||||||
end
|
end
|
||||||
local user_config = vim.g.cp_config or {}
|
local user_config = vim.g.cp or {}
|
||||||
local config = config_module.setup(user_config)
|
local ok, result = pcall(config_module.setup, user_config)
|
||||||
config_module.set_current_config(config)
|
if not ok then
|
||||||
|
local msg = tostring(result):gsub('^.+:%d+: ', '')
|
||||||
|
vim.notify(msg, vim.log.levels.ERROR)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
config_module.set_current_config(result)
|
||||||
initialized = true
|
initialized = true
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.handle_command(opts)
|
function M.handle_command(opts)
|
||||||
ensure_initialized()
|
if not ensure_initialized() then
|
||||||
|
return
|
||||||
|
end
|
||||||
local commands = require('cp.commands')
|
local commands = require('cp.commands')
|
||||||
commands.handle_command(opts)
|
commands.handle_command(opts)
|
||||||
end
|
end
|
||||||
|
|
@ -34,4 +42,13 @@ 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
Normal file
144
lua/cp/race.lua
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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,6 +43,7 @@ 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)
|
||||||
|
|
@ -119,6 +120,7 @@ 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,6 +19,7 @@
|
||||||
---@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[]
|
||||||
|
|
@ -56,7 +57,8 @@ 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
|
||||||
return { timeout_ms = timeout_ms, memory_mb = memory_mb }
|
local precision = cache.get_precision(platform, contest_id, problem_id)
|
||||||
|
return { timeout_ms = timeout_ms, memory_mb = memory_mb, precision = precision }
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
@ -99,6 +101,53 @@ 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 })
|
||||||
|
|
@ -143,7 +192,9 @@ 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 ok = normalize_lines(out) == normalize_lines(expected)
|
local precision = (panel_state.constraints and panel_state.constraints.precision)
|
||||||
|
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
|
||||||
|
|
@ -276,26 +327,35 @@ function M.run_all_test_cases(indices, debug, on_each, on_done)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function run_next(pos)
|
if #to_run == 0 then
|
||||||
if pos > #to_run then
|
logger.log(
|
||||||
logger.log(
|
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0),
|
||||||
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run),
|
vim.log.levels.INFO,
|
||||||
vim.log.levels.INFO,
|
true
|
||||||
true
|
)
|
||||||
)
|
on_done(panel_state.test_cases)
|
||||||
on_done(panel_state.test_cases)
|
return
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
M.run_test_case(to_run[pos], debug, function()
|
|
||||||
if on_each then
|
|
||||||
on_each(pos, #to_run)
|
|
||||||
end
|
|
||||||
run_next(pos + 1)
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
run_next(1)
|
local total = #to_run
|
||||||
|
local remaining = total
|
||||||
|
|
||||||
|
for _, idx in ipairs(to_run) do
|
||||||
|
M.run_test_case(idx, debug, function()
|
||||||
|
if on_each then
|
||||||
|
on_each(idx, total)
|
||||||
|
end
|
||||||
|
remaining = remaining - 1
|
||||||
|
if remaining == 0 then
|
||||||
|
logger.log(
|
||||||
|
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total),
|
||||||
|
vim.log.levels.INFO,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
on_done(panel_state.test_cases)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return PanelState
|
---@return PanelState
|
||||||
|
|
|
||||||
|
|
@ -5,73 +5,133 @@ 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 ok, data = pcall(vim.json.decode, result.stdout)
|
local msg = 'Failed to parse scraper output: ' .. tostring(data)
|
||||||
if not ok then
|
logger.log(msg, vim.log.levels.ERROR)
|
||||||
local msg = 'Failed to parse scraper output: ' .. tostring(data)
|
return { success = false, error = msg }
|
||||||
logger.log(msg, vim.log.levels.ERROR)
|
end
|
||||||
return { success = false, error = msg }
|
|
||||||
end
|
|
||||||
|
|
||||||
return { success = true, data = data }
|
---@param env_map table<string, string>
|
||||||
|
---@return string[]
|
||||||
|
local function spawn_env_list(env_map)
|
||||||
|
local out = {}
|
||||||
|
for key, value in pairs(env_map) do
|
||||||
|
out[#out + 1] = tostring(key) .. '=' .. tostring(value)
|
||||||
|
end
|
||||||
|
return out
|
||||||
end
|
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) }
|
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table), env_extra?: table<string, string>, stdin?: string }
|
||||||
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
|
||||||
|
|
||||||
|
if subcommand == 'submit' then
|
||||||
|
utils.setup_nix_submit_env()
|
||||||
|
end
|
||||||
|
|
||||||
local plugin_path = utils.get_plugin_path()
|
local plugin_path = utils.get_plugin_path()
|
||||||
local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand }
|
local cmd
|
||||||
|
if subcommand == 'submit' then
|
||||||
|
cmd = utils.get_python_submit_cmd(platform, plugin_path)
|
||||||
|
else
|
||||||
|
cmd = utils.get_python_cmd(platform, plugin_path)
|
||||||
|
end
|
||||||
|
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 subcommand == 'submit' and utils.is_nix_build() then
|
||||||
|
env.UV_PROJECT_ENVIRONMENT = vim.fn.stdpath('cache') .. '/cp-nvim/submit-env'
|
||||||
|
end
|
||||||
|
|
||||||
if opts and opts.ndjson then
|
if opts and opts.ndjson then
|
||||||
local uv = vim.loop
|
local uv = vim.uv
|
||||||
|
local stdin_pipe = nil
|
||||||
|
if opts.stdin then
|
||||||
|
stdin_pipe = uv.new_pipe(false)
|
||||||
|
end
|
||||||
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(
|
handle = uv.spawn(cmd[1], {
|
||||||
cmd[1],
|
args = vim.list_slice(cmd, 2),
|
||||||
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env },
|
stdio = { stdin_pipe, stdout, stderr },
|
||||||
function(code, signal)
|
env = spawn_env_list(env),
|
||||||
if buf ~= '' and opts.on_event then
|
cwd = plugin_path,
|
||||||
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
|
}, function(code, signal)
|
||||||
if ok_tail then
|
if buf ~= '' and opts.on_event then
|
||||||
opts.on_event(ev_tail)
|
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
|
||||||
end
|
if ok_tail then
|
||||||
buf = ''
|
opts.on_event(ev_tail)
|
||||||
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 stdin_pipe and not stdin_pipe:is_closing() then
|
||||||
|
stdin_pipe:close()
|
||||||
|
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
|
||||||
|
if stdin_pipe and not stdin_pipe:is_closing() then
|
||||||
|
stdin_pipe:close()
|
||||||
|
end
|
||||||
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
|
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
|
||||||
return { success = false, error = 'spawn failed' }
|
return { success = false, error = 'spawn failed' }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if stdin_pipe then
|
||||||
|
uv.write(stdin_pipe, opts.stdin, function()
|
||||||
|
uv.shutdown(stdin_pipe, function()
|
||||||
|
stdin_pipe:close()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
uv.read_start(stdout, function(_, data)
|
uv.read_start(stdout, function(_, data)
|
||||||
if data == nil then
|
if data == nil then
|
||||||
if buf ~= '' and opts.on_event then
|
if buf ~= '' and opts.on_event then
|
||||||
|
|
@ -102,7 +162,15 @@ local function run_scraper(platform, subcommand, args, opts)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local sysopts = { text = true, timeout = 30000, env = env }
|
local sysopts = {
|
||||||
|
text = true,
|
||||||
|
timeout = (subcommand == 'submit') and 120000 or 30000,
|
||||||
|
env = env,
|
||||||
|
cwd = plugin_path,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
@ -205,6 +273,7 @@ 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
|
||||||
|
|
@ -213,4 +282,45 @@ function M.scrape_all_tests(platform, contest_id, callback)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function M.submit(
|
||||||
|
platform,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
language,
|
||||||
|
source_code,
|
||||||
|
credentials,
|
||||||
|
on_status,
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
local done = false
|
||||||
|
run_scraper(platform, 'submit', { contest_id, problem_id, language }, {
|
||||||
|
ndjson = true,
|
||||||
|
stdin = source_code,
|
||||||
|
env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) },
|
||||||
|
on_event = function(ev)
|
||||||
|
if ev.credentials ~= nil then
|
||||||
|
require('cp.cache').set_credentials(platform, ev.credentials)
|
||||||
|
end
|
||||||
|
if ev.status ~= nil then
|
||||||
|
if type(on_status) == 'function' then
|
||||||
|
on_status(ev)
|
||||||
|
end
|
||||||
|
elseif ev.success ~= nil then
|
||||||
|
done = true
|
||||||
|
if type(callback) == 'function' then
|
||||||
|
callback(ev)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
on_exit = function(proc)
|
||||||
|
if not done and type(callback) == 'function' then
|
||||||
|
callback({
|
||||||
|
success = false,
|
||||||
|
error = 'submit process exited (code=' .. tostring(proc.code) .. ')',
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
123
lua/cp/setup.lua
123
lua/cp/setup.lua
|
|
@ -8,6 +8,36 @@ 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()
|
||||||
|
|
@ -100,7 +130,8 @@ 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()
|
||||||
|
|
@ -121,6 +152,7 @@ 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)
|
||||||
|
|
@ -133,7 +165,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id
|
local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id
|
||||||
|
|
||||||
cache.load()
|
cache.load()
|
||||||
|
|
||||||
|
|
@ -143,7 +175,10 @@ 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)
|
||||||
|
|
||||||
if config_module.get_config().open_url and is_new_contest and contest_data.url then
|
local is_new_problem = old_problem_id ~= pid
|
||||||
|
local should_open_url = config_module.get_config().open_url
|
||||||
|
and (is_new_contest or is_new_problem)
|
||||||
|
if should_open_url and contest_data.url then
|
||||||
vim.ui.open(contest_data.url:format(pid))
|
vim.ui.open(contest_data.url:format(pid))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -160,12 +195,7 @@ 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
|
||||||
|
|
||||||
if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
|
state.set_language(lang)
|
||||||
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,
|
||||||
|
|
@ -173,7 +203,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.loop.hrtime(),
|
token = vim.uv.hrtime(),
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
|
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
|
||||||
|
|
@ -245,7 +275,15 @@ function M.setup_problem(problem_id, language)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p')
|
local contest_dir = vim.fn.fnamemodify(source_file, ':h')
|
||||||
|
local is_new_dir = vim.fn.isdirectory(contest_dir) == 0
|
||||||
|
vim.fn.mkdir(contest_dir, 'p')
|
||||||
|
if is_new_dir then
|
||||||
|
local s = config.hooks and config.hooks.setup
|
||||||
|
if s and s.contest then
|
||||||
|
pcall(s.contest, state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -256,7 +294,6 @@ function M.setup_problem(problem_id, language)
|
||||||
state.set_provisional(nil)
|
state.set_provisional(nil)
|
||||||
else
|
else
|
||||||
vim.api.nvim_buf_set_name(prov.bufnr, source_file)
|
vim.api.nvim_buf_set_name(prov.bufnr, source_file)
|
||||||
vim.bo[prov.bufnr].swapfile = true
|
|
||||||
-- selene: allow(mixed_table)
|
-- selene: allow(mixed_table)
|
||||||
vim.cmd.write({
|
vim.cmd.write({
|
||||||
vim.fn.fnameescape(source_file),
|
vim.fn.fnameescape(source_file),
|
||||||
|
|
@ -264,14 +301,29 @@ 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 config.hooks and config.hooks.setup_code and not vim.b[prov.bufnr].cp_setup_done then
|
if not vim.b[prov.bufnr].cp_setup_done then
|
||||||
local ok = pcall(config.hooks.setup_code, state)
|
apply_template(prov.bufnr, lang, platform)
|
||||||
if ok then
|
local s = config.hooks and config.hooks.setup
|
||||||
|
if s and s.code then
|
||||||
|
local ok = pcall(s.code, state)
|
||||||
|
if ok then
|
||||||
|
vim.b[prov.bufnr].cp_setup_done = true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
helpers.clearcol(prov.bufnr)
|
||||||
vim.b[prov.bufnr].cp_setup_done = true
|
vim.b[prov.bufnr].cp_setup_done = true
|
||||||
end
|
end
|
||||||
elseif not vim.b[prov.bufnr].cp_setup_done then
|
local o = config.hooks and config.hooks.on
|
||||||
helpers.clearcol(prov.bufnr)
|
if o and o.enter then
|
||||||
vim.b[prov.bufnr].cp_setup_done = true
|
local bufnr = prov.bufnr
|
||||||
|
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'),
|
||||||
|
|
@ -290,18 +342,39 @@ function M.setup_problem(problem_id, language)
|
||||||
end
|
end
|
||||||
|
|
||||||
vim.cmd.only({ mods = { silent = true } })
|
vim.cmd.only({ mods = { silent = true } })
|
||||||
vim.cmd.e(source_file)
|
local current_file = vim.fn.expand('%:p')
|
||||||
|
if current_file ~= vim.fn.fnamemodify(source_file, ':p') then
|
||||||
|
vim.cmd.e(source_file)
|
||||||
|
end
|
||||||
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 config.hooks and config.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
|
if not vim.b[bufnr].cp_setup_done then
|
||||||
local ok = pcall(config.hooks.setup_code, state)
|
local is_new = vim.api.nvim_buf_line_count(bufnr) == 1
|
||||||
if ok then
|
and vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] == ''
|
||||||
|
if is_new then
|
||||||
|
apply_template(bufnr, lang, platform)
|
||||||
|
end
|
||||||
|
local s = config.hooks and config.hooks.setup
|
||||||
|
if s and s.code then
|
||||||
|
local ok = pcall(s.code, state)
|
||||||
|
if ok then
|
||||||
|
vim.b[bufnr].cp_setup_done = true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
helpers.clearcol(bufnr)
|
||||||
vim.b[bufnr].cp_setup_done = true
|
vim.b[bufnr].cp_setup_done = true
|
||||||
end
|
end
|
||||||
elseif not vim.b[bufnr].cp_setup_done then
|
local o = config.hooks and config.hooks.on
|
||||||
helpers.clearcol(bufnr)
|
if o and o.enter then
|
||||||
vim.b[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.expand('%:p'),
|
vim.fn.expand('%:p'),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
---@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
|
||||||
|
|
|
||||||
235
lua/cp/stress.lua
Normal file
235
lua/cp/stress.lua
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
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
|
||||||
89
lua/cp/submit.lua
Normal file
89
lua/cp/submit.lua
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local logger = require('cp.log')
|
||||||
|
local state = require('cp.state')
|
||||||
|
|
||||||
|
local STATUS_MSGS = {
|
||||||
|
installing_browser = 'Installing browser (first time setup)...',
|
||||||
|
checking_login = 'Checking login...',
|
||||||
|
logging_in = 'Logging in...',
|
||||||
|
submitting = 'Submitting...',
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
vim.cmd.redraw()
|
||||||
|
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')
|
||||||
|
|
||||||
|
vim.notify('[cp.nvim] Submitting...', vim.log.levels.INFO)
|
||||||
|
|
||||||
|
require('cp.scraper').submit(
|
||||||
|
platform,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
language,
|
||||||
|
source_code,
|
||||||
|
creds,
|
||||||
|
function(ev)
|
||||||
|
vim.schedule(function()
|
||||||
|
vim.notify('[cp.nvim] ' .. (STATUS_MSGS[ev.status] or ev.status), vim.log.levels.INFO)
|
||||||
|
end)
|
||||||
|
end,
|
||||||
|
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 = 'nofile'
|
vim.bo[input_buf].buftype = 'acwrite'
|
||||||
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 = 'nofile'
|
vim.bo[expected_buf].buftype = 'acwrite'
|
||||||
vim.bo[expected_buf].buflisted = false
|
vim.bo[expected_buf].buflisted = false
|
||||||
helpers.clearcol(expected_buf)
|
helpers.clearcol(expected_buf)
|
||||||
|
|
||||||
|
|
@ -177,6 +177,80 @@ 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()
|
||||||
|
|
@ -243,86 +317,30 @@ setup_keybindings = function(buf)
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end
|
|
||||||
|
|
||||||
local function save_all_tests()
|
vim.api.nvim_create_autocmd('BufWriteCmd', {
|
||||||
if not edit_state then
|
group = augroup,
|
||||||
return
|
buffer = buf,
|
||||||
end
|
callback = function()
|
||||||
|
save_all_tests()
|
||||||
local platform = state.get_platform()
|
vim.bo[buf].modified = false
|
||||||
local contest_id = state.get_contest_id()
|
end,
|
||||||
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' })
|
||||||
|
|
@ -411,7 +429,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 = 'nofile'
|
vim.bo[input_buf].buftype = 'acwrite'
|
||||||
vim.bo[input_buf].buflisted = false
|
vim.bo[input_buf].buflisted = false
|
||||||
helpers.clearcol(input_buf)
|
helpers.clearcol(input_buf)
|
||||||
|
|
||||||
|
|
@ -421,7 +439,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 = 'nofile'
|
vim.bo[expected_buf].buftype = 'acwrite'
|
||||||
vim.bo[expected_buf].buflisted = false
|
vim.bo[expected_buf].buflisted = false
|
||||||
helpers.clearcol(expected_buf)
|
helpers.clearcol(expected_buf)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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')
|
||||||
|
|
@ -26,6 +27,8 @@ 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
|
||||||
|
|
@ -67,11 +70,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
|
||||||
not contest_data
|
contest_data
|
||||||
or not contest_data.index_map
|
and contest_data.index_map
|
||||||
or not contest_data.problems[contest_data.index_map[problem_id]].interactive
|
and not contest_data.problems[contest_data.index_map[problem_id]].interactive
|
||||||
then
|
then
|
||||||
logger.log('This problem is interactive. Use :CP interact.', vim.log.levels.ERROR)
|
logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -121,13 +124,22 @@ 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')
|
||||||
cmdline = table.concat({
|
if utils.is_nix_build() then
|
||||||
'uv',
|
cmdline = table.concat({
|
||||||
'run',
|
vim.fn.shellescape(utils.get_nix_python()),
|
||||||
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
|
||||||
|
|
@ -229,7 +241,6 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -294,49 +305,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -435,12 +403,12 @@ function M.ensure_io_view()
|
||||||
|
|
||||||
local cfg = config_module.get_config()
|
local cfg = config_module.get_config()
|
||||||
|
|
||||||
if cfg.hooks and cfg.hooks.setup_io_output then
|
local io = cfg.hooks and cfg.hooks.setup and cfg.hooks.setup.io
|
||||||
pcall(cfg.hooks.setup_io_output, output_buf, state)
|
if io and io.output then
|
||||||
|
pcall(io.output, output_buf, state)
|
||||||
end
|
end
|
||||||
|
if io and io.input then
|
||||||
if cfg.hooks and cfg.hooks.setup_io_input then
|
pcall(io.input, input_buf, state)
|
||||||
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)
|
||||||
|
|
@ -821,6 +789,7 @@ 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)
|
||||||
|
|
@ -838,6 +807,13 @@ 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
|
||||||
|
|
@ -949,14 +925,15 @@ function M.toggle_panel(panel_opts)
|
||||||
|
|
||||||
setup_keybindings_for_buffer(test_buffers.tab_buf)
|
setup_keybindings_for_buffer(test_buffers.tab_buf)
|
||||||
|
|
||||||
if config.hooks and config.hooks.before_run then
|
local o = config.hooks and config.hooks.on
|
||||||
vim.schedule_wrap(function()
|
if o and o.run then
|
||||||
config.hooks.before_run(state)
|
vim.schedule(function()
|
||||||
|
o.run(state)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
if panel_opts and panel_opts.debug and config.hooks and config.hooks.before_debug then
|
if panel_opts and panel_opts.debug and o and o.debug then
|
||||||
vim.schedule_wrap(function()
|
vim.schedule(function()
|
||||||
config.hooks.before_debug(state)
|
o.debug(state)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
233
lua/cp/utils.lua
233
lua/cp/utils.lua
|
|
@ -2,7 +2,11 @@ local M = {}
|
||||||
|
|
||||||
local logger = require('cp.log')
|
local logger = require('cp.log')
|
||||||
|
|
||||||
local uname = vim.loop.os_uname()
|
local _nix_python = nil
|
||||||
|
local _nix_submit_cmd = nil
|
||||||
|
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
|
||||||
|
|
@ -57,7 +61,11 @@ local function find_gnu_time()
|
||||||
|
|
||||||
_time_cached = true
|
_time_cached = true
|
||||||
_time_path = nil
|
_time_path = nil
|
||||||
_time_reason = 'GNU time not found'
|
if uname and uname.sysname == 'Darwin' then
|
||||||
|
_time_reason = 'GNU time not found (install via: brew install coreutils)'
|
||||||
|
else
|
||||||
|
_time_reason = 'GNU time not found'
|
||||||
|
end
|
||||||
return _time_path, _time_reason
|
return _time_path, _time_reason
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -79,7 +87,158 @@ 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
|
||||||
|
|
||||||
|
---@param module string
|
||||||
|
---@param plugin_path string
|
||||||
|
---@return string[]
|
||||||
|
function M.get_python_submit_cmd(module, plugin_path)
|
||||||
|
if _nix_submit_cmd then
|
||||||
|
return { _nix_submit_cmd, 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
|
||||||
|
end
|
||||||
|
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
|
||||||
|
end
|
||||||
|
|
||||||
local python_env_setup = false
|
local python_env_setup = false
|
||||||
|
local _nix_submit_attempted = false
|
||||||
|
|
||||||
|
---@return boolean
|
||||||
|
local function discover_nix_submit_cmd()
|
||||||
|
local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim'
|
||||||
|
local cache_file = cache_dir .. '/nix-submit'
|
||||||
|
|
||||||
|
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_submit_cmd = cached
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local plugin_path = M.get_plugin_path()
|
||||||
|
vim.cmd.redraw()
|
||||||
|
vim.notify('Building submit environment...', vim.log.levels.INFO)
|
||||||
|
vim.cmd.redraw()
|
||||||
|
local result = vim
|
||||||
|
.system(
|
||||||
|
{ 'nix', 'build', plugin_path .. '#submitEnv', '--no-link', '--print-out-paths' },
|
||||||
|
{ text = true }
|
||||||
|
)
|
||||||
|
:wait()
|
||||||
|
|
||||||
|
if result.code ~= 0 then
|
||||||
|
logger.log('nix build #submitEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local store_path = result.stdout:gsub('%s+$', '')
|
||||||
|
local submit_cmd = store_path .. '/bin/cp-nvim-submit'
|
||||||
|
|
||||||
|
if vim.fn.executable(submit_cmd) ~= 1 then
|
||||||
|
logger.log('nix submit cmd not executable at ' .. submit_cmd, vim.log.levels.WARN)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.fn.mkdir(cache_dir, 'p')
|
||||||
|
f = io.open(cache_file, 'w')
|
||||||
|
if f then
|
||||||
|
f:write(submit_cmd)
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
_nix_submit_cmd = submit_cmd
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return boolean
|
||||||
|
function M.setup_nix_submit_env()
|
||||||
|
if _nix_submit_cmd then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if _nix_submit_attempted then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
_nix_submit_attempted = true
|
||||||
|
if vim.fn.executable('nix') == 1 then
|
||||||
|
return discover_nix_submit_cmd()
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
---@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()
|
||||||
|
|
@ -87,19 +246,20 @@ function M.setup_python_env()
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
local plugin_path = M.get_plugin_path()
|
if _nix_python then
|
||||||
local venv_dir = plugin_path .. '/.venv'
|
logger.log('Python env: nix (python=' .. _nix_python .. ')')
|
||||||
|
python_env_setup = true
|
||||||
if vim.fn.executable('uv') == 0 then
|
return true
|
||||||
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
|
||||||
|
|
||||||
if vim.fn.isdirectory(venv_dir) == 0 then
|
local on_nixos = vim.fn.filereadable('/etc/NIXOS') == 1
|
||||||
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 = ''
|
||||||
|
|
@ -108,14 +268,33 @@ 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('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
|
logger.log(
|
||||||
|
'Failed to setup Python environment: ' .. (result.stderr or ''),
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
logger.log('Python environment setup complete.')
|
if result.stderr and result.stderr ~= '' then
|
||||||
|
logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', ''))
|
||||||
|
end
|
||||||
|
|
||||||
|
python_env_setup = true
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
python_env_setup = true
|
if vim.fn.executable('nix') == 1 then
|
||||||
return true
|
logger.log('Python env: nix discovery')
|
||||||
|
if discover_nix_python() then
|
||||||
|
python_env_setup = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.',
|
||||||
|
vim.log.levels.WARN
|
||||||
|
)
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Configure the buffer with good defaults
|
--- Configure the buffer with good defaults
|
||||||
|
|
@ -162,20 +341,12 @@ 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, 'GNU time not found: ' .. (time.reason or '')
|
return false, time.reason
|
||||||
end
|
end
|
||||||
|
|
||||||
local timeout = M.timeout_capability()
|
local timeout = M.timeout_capability()
|
||||||
if not timeout.ok then
|
if not timeout.ok then
|
||||||
return false, 'GNU timeout not found: ' .. (timeout.reason or '')
|
return false, timeout.reason
|
||||||
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
|
||||||
|
|
@ -225,7 +396,11 @@ local function find_gnu_timeout()
|
||||||
|
|
||||||
_timeout_cached = true
|
_timeout_cached = true
|
||||||
_timeout_path = nil
|
_timeout_path = nil
|
||||||
_timeout_reason = 'GNU timeout not found'
|
if uname and uname.sysname == 'Darwin' then
|
||||||
|
_timeout_reason = 'GNU timeout not found (install via: brew install coreutils)'
|
||||||
|
else
|
||||||
|
_timeout_reason = 'GNU timeout not found'
|
||||||
|
end
|
||||||
return _timeout_path, _timeout_reason
|
return _timeout_path, _timeout_reason
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -240,7 +415,7 @@ function M.timeout_capability()
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.cwd_executables()
|
function M.cwd_executables()
|
||||||
local uv = vim.uv or vim.loop
|
local uv = vim.uv
|
||||||
local req = uv.fs_scandir('.')
|
local req = uv.fs_scandir('.')
|
||||||
if not req then
|
if not req then
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
0
new
0
new
|
|
@ -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] == 'interact' then
|
elseif args[2] == 'stress' or 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,6 +103,12 @@ end, {
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return filter_candidates(candidates)
|
return filter_candidates(candidates)
|
||||||
|
elseif args[2] == 'login' or args[2] == 'logout' then
|
||||||
|
return filter_candidates(platforms)
|
||||||
|
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
|
||||||
|
|
@ -112,7 +118,15 @@ end, {
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif num_args == 4 then
|
elseif num_args == 4 then
|
||||||
if args[2] == 'cache' and args[3] == 'clear' then
|
if args[2] == 'stress' then
|
||||||
|
local utils = require('cp.utils')
|
||||||
|
return filter_candidates(utils.cwd_executables())
|
||||||
|
elseif args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
cache.load()
|
||||||
|
local contests = cache.get_cached_contest_ids(args[3])
|
||||||
|
return filter_candidates(contests)
|
||||||
|
elseif args[2] == '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)
|
||||||
|
|
@ -134,7 +148,9 @@ end, {
|
||||||
return filter_candidates(candidates)
|
return filter_candidates(candidates)
|
||||||
end
|
end
|
||||||
elseif num_args == 5 then
|
elseif num_args == 5 then
|
||||||
if args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
|
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then
|
||||||
|
return filter_candidates({ '--lang' })
|
||||||
|
elseif args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
|
||||||
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])
|
||||||
|
|
@ -147,10 +163,31 @@ end, {
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif num_args == 6 then
|
elseif num_args == 6 then
|
||||||
if vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
|
if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) and args[5] == '--lang' then
|
||||||
|
return filter_candidates(get_enabled_languages(args[3]))
|
||||||
|
elseif vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
|
||||||
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' })
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,12 @@ requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backoff>=2.2.1",
|
"backoff>=2.2.1",
|
||||||
"beautifulsoup4>=4.13.5",
|
"beautifulsoup4>=4.13.5",
|
||||||
|
"scrapling[fetchers]>=0.4",
|
||||||
"curl-cffi>=0.13.0",
|
"curl-cffi>=0.13.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"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,8 +2,11 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -14,21 +17,30 @@ 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
|
from .base import BaseScraper, extract_precision
|
||||||
|
from .language_ids import get_language_id
|
||||||
from .models import (
|
from .models import (
|
||||||
CombinedTest,
|
CombinedTest,
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
TestCase,
|
TestCase,
|
||||||
TestsResult,
|
TestsResult,
|
||||||
)
|
)
|
||||||
|
from .timeouts import (
|
||||||
|
BROWSER_ELEMENT_WAIT,
|
||||||
|
BROWSER_NAV_TIMEOUT,
|
||||||
|
BROWSER_SESSION_TIMEOUT,
|
||||||
|
BROWSER_SETTLE_DELAY,
|
||||||
|
BROWSER_TURNSTILE_POLL,
|
||||||
|
HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
MIB_TO_MB = 1.048576
|
MIB_TO_MB = 1.048576
|
||||||
BASE_URL = "https://atcoder.jp"
|
BASE_URL = "https://atcoder.jp"
|
||||||
ARCHIVE_URL = f"{BASE_URL}/contests/archive"
|
ARCHIVE_URL = f"{BASE_URL}/contests/archive"
|
||||||
TIMEOUT_SECONDS = 30
|
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +83,7 @@ def _retry_after_requests(details):
|
||||||
on_backoff=_retry_after_requests,
|
on_backoff=_retry_after_requests,
|
||||||
)
|
)
|
||||||
def _fetch(url: str) -> str:
|
def _fetch(url: str) -> str:
|
||||||
r = _session.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS)
|
r = _session.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT)
|
||||||
if r.status_code in RETRY_STATUS:
|
if r.status_code in RETRY_STATUS:
|
||||||
raise requests.HTTPError(response=r)
|
raise requests.HTTPError(response=r)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
@ -94,7 +106,7 @@ def _giveup_httpx(exc: Exception) -> bool:
|
||||||
giveup=_giveup_httpx,
|
giveup=_giveup_httpx,
|
||||||
)
|
)
|
||||||
async def _get_async(client: httpx.AsyncClient, url: str) -> str:
|
async def _get_async(client: httpx.AsyncClient, url: str) -> str:
|
||||||
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS)
|
r = await client.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.text
|
return r.text
|
||||||
|
|
||||||
|
|
@ -121,6 +133,23 @@ 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")
|
||||||
|
|
@ -139,7 +168,10 @@ 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)
|
||||||
out.append(ContestSummary(id=cid, name=name, display_name=name))
|
start_time = _parse_start_time(tr)
|
||||||
|
out.append(
|
||||||
|
ContestSummary(id=cid, name=name, display_name=name, start_time=start_time)
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -169,7 +201,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]:
|
def _extract_problem_info(html: str) -> tuple[int, float, bool, float | None]:
|
||||||
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
|
||||||
|
|
@ -181,9 +213,10 @@ def _extract_problem_info(html: str) -> tuple[int, float, bool]:
|
||||||
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")
|
||||||
txt = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
|
body = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
|
||||||
interactive = "This is an interactive" in txt
|
interactive = "This is an interactive" in body
|
||||||
return timeout_ms, memory_mb, interactive
|
precision = extract_precision(body)
|
||||||
|
return timeout_ms, memory_mb, interactive, precision
|
||||||
|
|
||||||
|
|
||||||
def _extract_samples(html: str) -> list[TestCase]:
|
def _extract_samples(html: str) -> list[TestCase]:
|
||||||
|
|
@ -209,6 +242,187 @@ def _extract_samples(html: str) -> list[TestCase]:
|
||||||
return cases
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
_TURNSTILE_JS = "() => { const el = document.querySelector('[name=\"cf-turnstile-response\"]'); return el && el.value.length > 0; }"
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_turnstile(page) -> None:
|
||||||
|
if page.evaluate(_TURNSTILE_JS):
|
||||||
|
return
|
||||||
|
iframe_loc = page.locator('iframe[src*="challenges.cloudflare.com"]')
|
||||||
|
if not iframe_loc.count():
|
||||||
|
return
|
||||||
|
for _ in range(6):
|
||||||
|
try:
|
||||||
|
box = iframe_loc.first.bounding_box()
|
||||||
|
if box:
|
||||||
|
page.mouse.click(
|
||||||
|
box["x"] + box["width"] * 0.15,
|
||||||
|
box["y"] + box["height"] * 0.5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
page.wait_for_function(_TURNSTILE_JS, timeout=BROWSER_TURNSTILE_POLL)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError("Turnstile not solved after multiple attempts")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_browser() -> None:
|
||||||
|
try:
|
||||||
|
from patchright._impl._driver import compute_driver_executable # type: ignore[import-untyped,unresolved-import]
|
||||||
|
|
||||||
|
node, cli = compute_driver_executable()
|
||||||
|
browser_info = subprocess.run(
|
||||||
|
[node, cli, "install", "--dry-run", "chromium"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
for line in browser_info.stdout.splitlines():
|
||||||
|
if "Install location:" in line:
|
||||||
|
install_dir = line.split(":", 1)[1].strip()
|
||||||
|
if not os.path.isdir(install_dir):
|
||||||
|
print(json.dumps({"status": "installing_browser"}), flush=True)
|
||||||
|
subprocess.run(
|
||||||
|
[node, cli, "install", "chromium"],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_headless(
|
||||||
|
contest_id: str,
|
||||||
|
problem_id: str,
|
||||||
|
source_code: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> "SubmitResult":
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
|
||||||
|
except ImportError:
|
||||||
|
return SubmitResult(
|
||||||
|
success=False,
|
||||||
|
error="scrapling is required for AtCoder submit. Install it: uv add 'scrapling[fetchers]>=0.4'",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ensure_browser()
|
||||||
|
|
||||||
|
cookie_cache = Path.home() / ".cache" / "cp-nvim" / "atcoder-cookies.json"
|
||||||
|
cookie_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
saved_cookies: list[dict[str, Any]] = []
|
||||||
|
if cookie_cache.exists():
|
||||||
|
try:
|
||||||
|
saved_cookies = json.loads(cookie_cache.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logged_in = False
|
||||||
|
login_error: str | None = None
|
||||||
|
submit_error: str | None = None
|
||||||
|
|
||||||
|
def check_login(page):
|
||||||
|
nonlocal logged_in
|
||||||
|
logged_in = page.evaluate(
|
||||||
|
"() => Array.from(document.querySelectorAll('a')).some(a => a.textContent.trim() === 'Sign Out')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def login_action(page):
|
||||||
|
nonlocal login_error
|
||||||
|
try:
|
||||||
|
_solve_turnstile(page)
|
||||||
|
page.fill('input[name="username"]', credentials.get("username", ""))
|
||||||
|
page.fill('input[name="password"]', credentials.get("password", ""))
|
||||||
|
page.click("#submit")
|
||||||
|
page.wait_for_url(
|
||||||
|
lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
login_error = str(e)
|
||||||
|
|
||||||
|
def submit_action(page):
|
||||||
|
nonlocal submit_error
|
||||||
|
try:
|
||||||
|
_solve_turnstile(page)
|
||||||
|
page.select_option(
|
||||||
|
'select[name="data.TaskScreenName"]',
|
||||||
|
f"{contest_id}_{problem_id}",
|
||||||
|
)
|
||||||
|
page.locator(
|
||||||
|
f'select[name="data.LanguageId"] option[value="{language_id}"]'
|
||||||
|
).wait_for(state="attached", timeout=BROWSER_ELEMENT_WAIT)
|
||||||
|
page.select_option('select[name="data.LanguageId"]', language_id)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".cpp", delete=False, prefix="atcoder_"
|
||||||
|
) as tf:
|
||||||
|
tf.write(source_code)
|
||||||
|
tmp_path = tf.name
|
||||||
|
try:
|
||||||
|
page.set_input_files("#input-open-file", tmp_path)
|
||||||
|
page.wait_for_timeout(BROWSER_SETTLE_DELAY)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
page.locator('button[type="submit"]').click()
|
||||||
|
page.wait_for_url(
|
||||||
|
lambda url: "/submissions/me" in url, timeout=BROWSER_NAV_TIMEOUT
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
submit_error = str(e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
cookies=saved_cookies,
|
||||||
|
) as session:
|
||||||
|
print(json.dumps({"status": "checking_login"}), flush=True)
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/home",
|
||||||
|
page_action=check_login,
|
||||||
|
network_idle=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not logged_in:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/login",
|
||||||
|
page_action=login_action,
|
||||||
|
solve_cloudflare=True,
|
||||||
|
)
|
||||||
|
if login_error:
|
||||||
|
return SubmitResult(
|
||||||
|
success=False, error=f"Login failed: {login_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps({"status": "submitting"}), flush=True)
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/contests/{contest_id}/submit",
|
||||||
|
page_action=submit_action,
|
||||||
|
solve_cloudflare=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser_cookies = session.context.cookies()
|
||||||
|
if any(c["name"] == "REVEL_SESSION" for c in browser_cookies):
|
||||||
|
cookie_cache.write_text(json.dumps(browser_cookies))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if submit_error:
|
||||||
|
return SubmitResult(success=False, error=submit_error)
|
||||||
|
|
||||||
|
return SubmitResult(
|
||||||
|
success=True, error="", submission_id="", verdict="submitted"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return SubmitResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
def _scrape_tasks_sync(contest_id: str) -> list[dict[str, str]]:
|
def _scrape_tasks_sync(contest_id: str) -> list[dict[str, str]]:
|
||||||
html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks")
|
html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks")
|
||||||
return _parse_tasks_list(html)
|
return _parse_tasks_list(html)
|
||||||
|
|
@ -220,12 +434,13 @@ 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 = _extract_problem_info(html)
|
timeout_ms, memory_mb, interactive, precision = _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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -241,14 +456,29 @@ 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}"))
|
||||||
|
|
@ -257,6 +487,10 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -319,6 +553,7 @@ 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,
|
||||||
|
|
@ -326,6 +561,23 @@ 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:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_submit_headless,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
source_code,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def main_async() -> int:
|
async def main_async() -> int:
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
|
|
@ -382,9 +634,31 @@ 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>', or 'contests'",
|
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', 'contests', or 'submit <contest_id> <problem_id> <language>'",
|
||||||
url="",
|
url="",
|
||||||
)
|
)
|
||||||
print(result.model_dump_json())
|
print(result.model_dump_json())
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,37 @@
|
||||||
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 .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
|
from .language_ids import get_language_id
|
||||||
|
from .models import (
|
||||||
|
CombinedTest,
|
||||||
|
ContestListResult,
|
||||||
|
MetadataResult,
|
||||||
|
SubmitResult,
|
||||||
|
TestsResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PRECISION_ABS_REL_RE = re.compile(
|
||||||
|
r"(?:absolute|relative)\s+error[^.]*?10\s*[\^{]\s*\{?\s*[-\u2212]\s*(\d+)\s*\}?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PRECISION_DECIMAL_RE = re.compile(
|
||||||
|
r"round(?:ed)?\s+to\s+(\d+)\s+decimal\s+place",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_precision(text: str) -> float | None:
|
||||||
|
m = _PRECISION_ABS_REL_RE.search(text)
|
||||||
|
if m:
|
||||||
|
return 10 ** -int(m.group(1))
|
||||||
|
m = _PRECISION_DECIMAL_RE.search(text)
|
||||||
|
if m:
|
||||||
|
return 10 ** -int(m.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class BaseScraper(ABC):
|
class BaseScraper(ABC):
|
||||||
|
|
@ -19,6 +48,16 @@ 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"
|
||||||
|
|
@ -40,6 +79,9 @@ 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())
|
||||||
|
|
@ -71,6 +113,27 @@ 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,14 +6,16 @@ import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from scrapling.fetchers import Fetcher
|
from curl_cffi import requests as curl_requests
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper, extract_precision
|
||||||
|
from .timeouts import HTTP_TIMEOUT
|
||||||
from .models import (
|
from .models import (
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
TestCase,
|
TestCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,7 +27,6 @@ PROBLEM_URL = "https://www.codechef.com/problems/{problem_id}"
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
"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
|
CONNECTIONS = 8
|
||||||
MEMORY_LIMIT_RE = re.compile(
|
MEMORY_LIMIT_RE = re.compile(
|
||||||
r"Memory\s+[Ll]imit.*?([0-9.]+)\s*(MB|GB)", re.IGNORECASE | re.DOTALL
|
r"Memory\s+[Ll]imit.*?([0-9.]+)\s*(MB|GB)", re.IGNORECASE | re.DOTALL
|
||||||
|
|
@ -33,7 +34,7 @@ MEMORY_LIMIT_RE = re.compile(
|
||||||
|
|
||||||
|
|
||||||
async def fetch_json(client: httpx.AsyncClient, path: str) -> dict:
|
async def fetch_json(client: httpx.AsyncClient, path: str) -> dict:
|
||||||
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S)
|
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=HTTP_TIMEOUT)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
@ -50,8 +51,9 @@ def _extract_memory_limit(html: str) -> float:
|
||||||
|
|
||||||
|
|
||||||
def _fetch_html_sync(url: str) -> str:
|
def _fetch_html_sync(url: str) -> str:
|
||||||
response = Fetcher.get(url)
|
response = curl_requests.get(url, impersonate="chrome", timeout=HTTP_TIMEOUT)
|
||||||
return str(response.body)
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
class CodeChefScraper(BaseScraper):
|
class CodeChefScraper(BaseScraper):
|
||||||
|
|
@ -218,11 +220,13 @@ 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 ""
|
||||||
|
|
@ -240,6 +244,7 @@ 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()]
|
||||||
|
|
@ -247,6 +252,21 @@ 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,30 +2,34 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup, Tag
|
from bs4 import BeautifulSoup, Tag
|
||||||
from scrapling.fetchers import Fetcher
|
from curl_cffi import requests as curl_requests
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper, extract_precision
|
||||||
from .models import (
|
from .models import (
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
TestCase,
|
TestCase,
|
||||||
)
|
)
|
||||||
|
from .timeouts import (
|
||||||
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
|
BROWSER_ELEMENT_WAIT,
|
||||||
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
|
BROWSER_NAV_TIMEOUT,
|
||||||
|
BROWSER_SESSION_TIMEOUT,
|
||||||
|
BROWSER_SETTLE_DELAY,
|
||||||
|
HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
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"
|
||||||
TIMEOUT_SECONDS = 30
|
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +87,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 st:
|
if not isinstance(st, Tag):
|
||||||
return [], False
|
return [], False
|
||||||
|
|
||||||
input_pres: list[Tag] = [
|
input_pres: list[Tag] = [
|
||||||
|
|
@ -140,10 +144,9 @@ 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"
|
||||||
page = Fetcher.get(
|
response = curl_requests.get(url, impersonate="chrome", timeout=HTTP_TIMEOUT)
|
||||||
url,
|
response.raise_for_status()
|
||||||
)
|
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]]:
|
||||||
|
|
@ -159,6 +162,7 @@ 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(
|
||||||
|
|
@ -185,6 +189,7 @@ 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
|
||||||
|
|
@ -226,7 +231,7 @@ class CodeforcesScraper(BaseScraper):
|
||||||
|
|
||||||
async def scrape_contest_list(self) -> ContestListResult:
|
async def scrape_contest_list(self) -> ContestListResult:
|
||||||
try:
|
try:
|
||||||
r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS)
|
r = requests.get(API_CONTEST_LIST_URL, timeout=HTTP_TIMEOUT)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
if data.get("status") != "OK":
|
if data.get("status") != "OK":
|
||||||
|
|
@ -234,11 +239,20 @@ class CodeforcesScraper(BaseScraper):
|
||||||
|
|
||||||
contests: list[ContestSummary] = []
|
contests: list[ContestSummary] = []
|
||||||
for c in data["result"]:
|
for c in data["result"]:
|
||||||
if c.get("phase") != "FINISHED":
|
phase = c.get("phase")
|
||||||
|
if phase not in ("FINISHED", "BEFORE", "CODING"):
|
||||||
continue
|
continue
|
||||||
cid = str(c["id"])
|
cid = str(c["id"])
|
||||||
name = c["name"]
|
name = c["name"]
|
||||||
contests.append(ContestSummary(id=cid, name=name, display_name=name))
|
start_time = c.get("startTimeSeconds") if phase != "FINISHED" else None
|
||||||
|
contests.append(
|
||||||
|
ContestSummary(
|
||||||
|
id=cid,
|
||||||
|
name=name,
|
||||||
|
display_name=name,
|
||||||
|
start_time=start_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if not contests:
|
if not contests:
|
||||||
return self._contests_error("No contests found")
|
return self._contests_error("No contests found")
|
||||||
|
|
@ -269,11 +283,201 @@ 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 await asyncio.to_thread(
|
||||||
|
_submit_headless,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
source_code,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_gate_reload(page, wait_selector: str) -> None:
|
||||||
|
from .atcoder import _solve_turnstile
|
||||||
|
|
||||||
|
if "Verification" not in page.title():
|
||||||
|
return
|
||||||
|
_solve_turnstile(page)
|
||||||
|
page.wait_for_function(
|
||||||
|
f"() => !!document.querySelector('{wait_selector}')",
|
||||||
|
timeout=BROWSER_ELEMENT_WAIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_headless(
|
||||||
|
contest_id: str,
|
||||||
|
problem_id: str,
|
||||||
|
source_code: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
|
||||||
|
except ImportError:
|
||||||
|
return SubmitResult(
|
||||||
|
success=False,
|
||||||
|
error="scrapling is required for Codeforces submit",
|
||||||
|
)
|
||||||
|
|
||||||
|
from .atcoder import _ensure_browser, _solve_turnstile
|
||||||
|
|
||||||
|
_ensure_browser()
|
||||||
|
|
||||||
|
cookie_cache = Path.home() / ".cache" / "cp-nvim" / "codeforces-cookies.json"
|
||||||
|
cookie_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
saved_cookies: list[dict[str, Any]] = []
|
||||||
|
if cookie_cache.exists():
|
||||||
|
try:
|
||||||
|
saved_cookies = json.loads(cookie_cache.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logged_in = False
|
||||||
|
login_error: str | None = None
|
||||||
|
submit_error: str | None = None
|
||||||
|
|
||||||
|
def check_login(page):
|
||||||
|
nonlocal logged_in
|
||||||
|
logged_in = page.evaluate(
|
||||||
|
"() => Array.from(document.querySelectorAll('a'))"
|
||||||
|
".some(a => a.textContent.includes('Logout'))"
|
||||||
|
)
|
||||||
|
|
||||||
|
def login_action(page):
|
||||||
|
nonlocal login_error
|
||||||
|
try:
|
||||||
|
_wait_for_gate_reload(page, "#enterForm")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
page.fill(
|
||||||
|
'input[name="handleOrEmail"]',
|
||||||
|
credentials.get("username", ""),
|
||||||
|
)
|
||||||
|
page.fill(
|
||||||
|
'input[name="password"]',
|
||||||
|
credentials.get("password", ""),
|
||||||
|
)
|
||||||
|
page.locator('#enterForm input[type="submit"]').click()
|
||||||
|
page.wait_for_url(
|
||||||
|
lambda url: "/enter" not in url, timeout=BROWSER_NAV_TIMEOUT
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
login_error = str(e)
|
||||||
|
|
||||||
|
def submit_action(page):
|
||||||
|
nonlocal submit_error
|
||||||
|
try:
|
||||||
|
_solve_turnstile(page)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tmp_path: str | None = None
|
||||||
|
try:
|
||||||
|
page.select_option(
|
||||||
|
'select[name="submittedProblemIndex"]',
|
||||||
|
problem_id.upper(),
|
||||||
|
)
|
||||||
|
page.select_option('select[name="programTypeId"]', language_id)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".cpp", delete=False, prefix="cf_"
|
||||||
|
) as tf:
|
||||||
|
tf.write(source_code)
|
||||||
|
tmp_path = tf.name
|
||||||
|
try:
|
||||||
|
page.set_input_files('input[name="sourceFile"]', tmp_path)
|
||||||
|
page.wait_for_timeout(BROWSER_SETTLE_DELAY)
|
||||||
|
except Exception:
|
||||||
|
page.fill('textarea[name="source"]', source_code)
|
||||||
|
page.locator("form.submit-form input.submit").click(no_wait_after=True)
|
||||||
|
try:
|
||||||
|
page.wait_for_url(
|
||||||
|
lambda url: "/my" in url or "/status" in url,
|
||||||
|
timeout=BROWSER_NAV_TIMEOUT * 2,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
err_el = page.query_selector("span.error")
|
||||||
|
if err_el:
|
||||||
|
submit_error = err_el.inner_text().strip()
|
||||||
|
else:
|
||||||
|
submit_error = "Submit failed: page did not navigate"
|
||||||
|
except Exception as e:
|
||||||
|
submit_error = str(e)
|
||||||
|
finally:
|
||||||
|
if tmp_path:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
cookies=saved_cookies,
|
||||||
|
) as session:
|
||||||
|
print(json.dumps({"status": "checking_login"}), flush=True)
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/",
|
||||||
|
page_action=check_login,
|
||||||
|
network_idle=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not logged_in:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/enter",
|
||||||
|
page_action=login_action,
|
||||||
|
solve_cloudflare=True,
|
||||||
|
)
|
||||||
|
if login_error:
|
||||||
|
return SubmitResult(
|
||||||
|
success=False, error=f"Login failed: {login_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps({"status": "submitting"}), flush=True)
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/contest/{contest_id}/submit",
|
||||||
|
page_action=submit_action,
|
||||||
|
solve_cloudflare=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser_cookies = session.context.cookies()
|
||||||
|
if any(c["name"] == "JSESSIONID" for c in browser_cookies):
|
||||||
|
cookie_cache.write_text(json.dumps(browser_cookies))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if submit_error:
|
||||||
|
return SubmitResult(success=False, error=submit_error)
|
||||||
|
|
||||||
|
return SubmitResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
submission_id="",
|
||||||
|
verdict="submitted",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return SubmitResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
CodeforcesScraper().run_cli()
|
CodeforcesScraper().run_cli()
|
||||||
|
|
|
||||||
215
scrapers/cses.py
215
scrapers/cses.py
|
|
@ -1,30 +1,44 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper, extract_precision
|
||||||
|
from .timeouts import HTTP_TIMEOUT, SUBMIT_POLL_TIMEOUT
|
||||||
from .models import (
|
from .models import (
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
TestCase,
|
TestCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE_URL = "https://cses.fi"
|
BASE_URL = "https://cses.fi"
|
||||||
|
API_URL = "https://cses.fi/api"
|
||||||
|
SUBMIT_SCOPE = "courses/problemset"
|
||||||
INDEX_PATH = "/problemset"
|
INDEX_PATH = "/problemset"
|
||||||
TASK_PATH = "/problemset/task/{id}"
|
TASK_PATH = "/problemset/task/{id}"
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
"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
|
CONNECTIONS = 8
|
||||||
|
|
||||||
|
CSES_LANGUAGES: dict[str, dict[str, str]] = {
|
||||||
|
"C++17": {"name": "C++", "option": "C++17"},
|
||||||
|
"Python3": {"name": "Python", "option": "CPython3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
EXTENSIONS: dict[str, str] = {
|
||||||
|
"C++17": "cpp",
|
||||||
|
"Python3": "py",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def normalize_category_name(category_name: str) -> str:
|
def normalize_category_name(category_name: str) -> str:
|
||||||
return category_name.lower().replace(" ", "_").replace("&", "and")
|
return category_name.lower().replace(" ", "_").replace("&", "and")
|
||||||
|
|
@ -64,7 +78,7 @@ def snake_to_title(name: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
async def fetch_text(client: httpx.AsyncClient, path: str) -> str:
|
async def fetch_text(client: httpx.AsyncClient, path: str) -> str:
|
||||||
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S)
|
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=HTTP_TIMEOUT)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.text
|
return r.text
|
||||||
|
|
||||||
|
|
@ -129,17 +143,21 @@ def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _extract_problem_info(html: str) -> tuple[int, int, bool]:
|
def _extract_problem_info(html: str) -> tuple[int, int, bool, float | None]:
|
||||||
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
|
||||||
return t, m, interactive
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
@ -227,10 +245,17 @@ 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 = _extract_problem_info(html)
|
timeout_ms, memory_mb, interactive, precision = (
|
||||||
|
_extract_problem_info(html)
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
tests = []
|
tests = []
|
||||||
timeout_ms, memory_mb, interactive = 0, 0, False
|
timeout_ms, memory_mb, interactive, precision = (
|
||||||
|
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 = (
|
||||||
|
|
@ -250,6 +275,7 @@ 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]
|
||||||
|
|
@ -257,6 +283,181 @@ class CSESScraper(BaseScraper):
|
||||||
payload = await coro
|
payload = await coro
|
||||||
print(json.dumps(payload), flush=True)
|
print(json.dumps(payload), flush=True)
|
||||||
|
|
||||||
|
async def _web_login(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
) -> str | None:
|
||||||
|
login_page = await client.get(
|
||||||
|
f"{BASE_URL}/login", headers=HEADERS, timeout=HTTP_TIMEOUT
|
||||||
|
)
|
||||||
|
csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', login_page.text)
|
||||||
|
if not csrf_match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
login_resp = await client.post(
|
||||||
|
f"{BASE_URL}/login",
|
||||||
|
data={
|
||||||
|
"csrf_token": csrf_match.group(1),
|
||||||
|
"nick": username,
|
||||||
|
"pass": password,
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "Invalid username or password" in login_resp.text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_resp = await client.post(
|
||||||
|
f"{API_URL}/login", headers=HEADERS, timeout=HTTP_TIMEOUT
|
||||||
|
)
|
||||||
|
api_data = api_resp.json()
|
||||||
|
token: str = api_data["X-Auth-Token"]
|
||||||
|
auth_url: str = api_data["authentication_url"]
|
||||||
|
|
||||||
|
auth_page = await client.get(auth_url, headers=HEADERS, timeout=HTTP_TIMEOUT)
|
||||||
|
auth_csrf = re.search(r'name="csrf_token" value="([^"]+)"', auth_page.text)
|
||||||
|
form_token = re.search(r'name="token" value="([^"]+)"', auth_page.text)
|
||||||
|
if not auth_csrf or not form_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await client.post(
|
||||||
|
auth_url,
|
||||||
|
data={
|
||||||
|
"csrf_token": auth_csrf.group(1),
|
||||||
|
"token": form_token.group(1),
|
||||||
|
},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
check = await client.get(
|
||||||
|
f"{API_URL}/login",
|
||||||
|
headers={"X-Auth-Token": token, **HEADERS},
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
if check.status_code != 200:
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def _check_token(self, client: httpx.AsyncClient, token: str) -> bool:
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
f"{API_URL}/login",
|
||||||
|
headers={"X-Auth-Token": token, **HEADERS},
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
return r.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def submit(
|
||||||
|
self,
|
||||||
|
contest_id: str,
|
||||||
|
problem_id: str,
|
||||||
|
source_code: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
password = credentials.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
return self._submit_error("Missing credentials. Use :CP login cses")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
token = credentials.get("token")
|
||||||
|
|
||||||
|
if token:
|
||||||
|
print(json.dumps({"status": "checking_login"}), flush=True)
|
||||||
|
if not await self._check_token(client, token):
|
||||||
|
token = None
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
token = await self._web_login(client, username, password)
|
||||||
|
if not token:
|
||||||
|
return self._submit_error("Login failed (bad credentials?)")
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"credentials": {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps({"status": "submitting"}), flush=True)
|
||||||
|
|
||||||
|
ext = EXTENSIONS.get(language_id, "cpp")
|
||||||
|
lang = CSES_LANGUAGES.get(language_id, {})
|
||||||
|
content_b64 = base64.b64encode(source_code.encode()).decode()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"language": lang,
|
||||||
|
"filename": f"{problem_id}.{ext}",
|
||||||
|
"content": content_b64,
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"{API_URL}/{SUBMIT_SCOPE}/submissions",
|
||||||
|
json=payload,
|
||||||
|
params={"task": problem_id},
|
||||||
|
headers={
|
||||||
|
"X-Auth-Token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**HEADERS,
|
||||||
|
},
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code not in range(200, 300):
|
||||||
|
try:
|
||||||
|
err = r.json().get("message", r.text)
|
||||||
|
except Exception:
|
||||||
|
err = r.text
|
||||||
|
return self._submit_error(f"Submit request failed: {err}")
|
||||||
|
|
||||||
|
info = r.json()
|
||||||
|
submission_id = str(info.get("id", ""))
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
f"{API_URL}/{SUBMIT_SCOPE}/submissions/{submission_id}",
|
||||||
|
params={"poll": "true"},
|
||||||
|
headers={
|
||||||
|
"X-Auth-Token": token,
|
||||||
|
**HEADERS,
|
||||||
|
},
|
||||||
|
timeout=SUBMIT_POLL_TIMEOUT,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
info = r.json()
|
||||||
|
if not info.get("pending", True):
|
||||||
|
verdict = info.get("result", "unknown")
|
||||||
|
return SubmitResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
submission_id=submission_id,
|
||||||
|
verdict=verdict,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return SubmitResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
submission_id=submission_id,
|
||||||
|
verdict="submitted (poll timed out)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
CSESScraper().run_cli()
|
CSESScraper().run_cli()
|
||||||
|
|
|
||||||
289
scrapers/kattis.py
Normal file
289
scrapers/kattis.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
#!/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 .timeouts import HTTP_TIMEOUT
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
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=HTTP_TIMEOUT)
|
||||||
|
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=HTTP_TIMEOUT)
|
||||||
|
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()
|
||||||
18
scrapers/language_ids.py
Normal file
18
scrapers/language_ids.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
LANGUAGE_IDS = {
|
||||||
|
"atcoder": {
|
||||||
|
"cpp": "6017",
|
||||||
|
"python": "6082",
|
||||||
|
},
|
||||||
|
"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,6 +26,7 @@ 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")
|
||||||
|
|
||||||
|
|
@ -63,6 +64,13 @@ 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
|
||||||
|
|
|
||||||
9
scrapers/timeouts.py
Normal file
9
scrapers/timeouts.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
HTTP_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
BROWSER_SESSION_TIMEOUT = 15000
|
||||||
|
BROWSER_NAV_TIMEOUT = 10000
|
||||||
|
BROWSER_TURNSTILE_POLL = 5000
|
||||||
|
BROWSER_ELEMENT_WAIT = 10000
|
||||||
|
BROWSER_SETTLE_DELAY = 500
|
||||||
|
|
||||||
|
SUBMIT_POLL_TIMEOUT = 30.0
|
||||||
301
scrapers/usaco.py
Normal file
301
scrapers/usaco.py
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import BaseScraper
|
||||||
|
from .timeouts import HTTP_TIMEOUT
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
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=HTTP_TIMEOUT, 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()
|
||||||
13
scripts/ci.sh
Executable file
13
scripts/ci.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
nix develop --command stylua --check .
|
||||||
|
git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet
|
||||||
|
nix develop --command prettier --check .
|
||||||
|
nix fmt
|
||||||
|
git diff --exit-code -- '*.nix'
|
||||||
|
nix develop --command lua-language-server --check . --checklevel=Warning
|
||||||
|
nix develop --command ruff format --check .
|
||||||
|
nix develop --command ruff check .
|
||||||
|
nix develop --command ty check .
|
||||||
|
nix develop --command python -m pytest tests/ -v
|
||||||
113
scripts/stress.py
Executable file
113
scripts/stress.py
Executable file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/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 +1,4 @@
|
||||||
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 scrapling import fetchers
|
from curl_cffi import requests as curl_requests
|
||||||
|
|
||||||
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,12 +136,15 @@ def run_scraper_offline(fixture_text):
|
||||||
|
|
||||||
case "codeforces":
|
case "codeforces":
|
||||||
|
|
||||||
class MockCodeForcesPage:
|
class MockCurlResponse:
|
||||||
def __init__(self, html: str):
|
def __init__(self, html: str):
|
||||||
self.html_content = html
|
self.text = html
|
||||||
|
|
||||||
def _mock_stealthy_fetch(url: str, **kwargs):
|
def raise_for_status(self):
|
||||||
return MockCodeForcesPage(_router_codeforces(url=url))
|
pass
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
@ -172,7 +175,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 {
|
||||||
"Fetcher.get": _mock_stealthy_fetch,
|
"curl_requests.get": _mock_curl_get,
|
||||||
"requests.get": _mock_requests_get,
|
"requests.get": _mock_requests_get,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,21 +215,23 @@ 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 MockCodeChefPage:
|
class MockCodeChefCurlResponse:
|
||||||
def __init__(self, html: str):
|
def __init__(self, html: str):
|
||||||
self.body = html
|
self.text = html
|
||||||
self.status = 200
|
|
||||||
|
|
||||||
def _mock_stealthy_fetch(url: str, **kwargs):
|
def raise_for_status(self):
|
||||||
|
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 MockCodeChefPage(html)
|
return MockCodeChefCurlResponse(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,
|
||||||
"Fetcher.get": _mock_stealthy_fetch,
|
"curl_requests.get": _mock_curl_get,
|
||||||
}
|
}
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
|
|
@ -245,7 +250,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":
|
||||||
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
|
curl_requests.get = offline_fetches["curl_requests.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"]
|
||||||
|
|
@ -254,7 +259,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"]
|
||||||
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
|
curl_requests.get = offline_fetches["curl_requests.get"]
|
||||||
|
|
||||||
scraper_class = getattr(ns, scraper_classes[scraper_name])
|
scraper_class = getattr(ns, scraper_classes[scraper_name])
|
||||||
scraper = scraper_class()
|
scraper = scraper_class()
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,6 @@ from scrapers.models import (
|
||||||
TestsResult,
|
TestsResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
MODEL_FOR_MODE = {
|
|
||||||
"metadata": MetadataResult,
|
|
||||||
"contests": ContestListResult,
|
|
||||||
}
|
|
||||||
|
|
||||||
MATRIX = {
|
MATRIX = {
|
||||||
"cses": {
|
"cses": {
|
||||||
"metadata": ("introductory_problems",),
|
"metadata": ("introductory_problems",),
|
||||||
|
|
@ -43,17 +38,16 @@ 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 in ("metadata", "contests"):
|
if mode == "metadata":
|
||||||
Model = MODEL_FOR_MODE[mode]
|
model = MetadataResult.model_validate(objs[-1])
|
||||||
model = Model.model_validate(objs[-1])
|
|
||||||
assert model is not None
|
|
||||||
assert model.success is True
|
assert model.success is True
|
||||||
if mode == "metadata":
|
assert model.url
|
||||||
assert model.url
|
assert len(model.problems) >= 1
|
||||||
assert len(model.problems) >= 1
|
assert all(isinstance(p.id, str) and p.id for p in model.problems)
|
||||||
assert all(isinstance(p.id, str) and p.id for p in model.problems)
|
elif mode == "contests":
|
||||||
else:
|
model = ContestListResult.model_validate(objs[-1])
|
||||||
assert len(model.contests) >= 1
|
assert model.success is True
|
||||||
|
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
|
||||||
|
|
|
||||||
30
vim.toml
30
vim.toml
|
|
@ -1,30 +0,0 @@
|
||||||
[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
Normal file
26
vim.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
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