Compare commits
107 commits
chore/add-
...
fix/login-
| Author | SHA1 | Date | |
|---|---|---|---|
| 646a417cf7 | |||
| 84343d2045 | |||
|
|
8465e70772 | ||
|
|
9727dccc6f | ||
|
|
4ef00afe66 | ||
|
|
e89c57558d | ||
|
|
82640709d6 | ||
| c4be8b4f9e | |||
|
|
0b40e0f33e | ||
|
|
6a3d2fe4f8 | ||
|
|
ba5ae8df69 | ||
|
|
b6d3df03e3 | ||
|
|
543480a4fe | ||
|
|
2d50f0a52a | ||
|
|
401afb205e | ||
|
|
7bf4cf2538 | ||
|
|
b959f29bd4 | ||
|
|
6c036a7b2e | ||
|
|
7e7e135681 | ||
|
|
916c9174a6 | ||
|
|
b3014e9c86 | ||
|
|
924601ce99 | ||
|
|
2c119774df | ||
|
|
a202725cc5 | ||
|
|
127089c57f | ||
|
|
6fcb5d1bbc | ||
|
|
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 |
65 changed files with 5736 additions and 2070 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
|
||||||
|
|
||||||
|
|
|
||||||
881
doc/cp.nvim.txt
881
doc/cp.nvim.txt
|
|
@ -3,13 +3,45 @@
|
||||||
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. Open ......................................................... |cp-open|
|
||||||
|
20. ANSI Colors ................................................... |cp-ansi|
|
||||||
|
21. Highlight Groups ........................................ |cp-highlights|
|
||||||
|
22. Terminal Colors .................................... |cp-terminal-colors|
|
||||||
|
23. Highlight Customization .......................... |cp-highlight-custom|
|
||||||
|
24. Helpers .................................................... |cp-helpers|
|
||||||
|
25. Statusline Integration .................................. |cp-statusline|
|
||||||
|
26. Panel Keymaps .......................................... |cp-panel-keys|
|
||||||
|
27. File Structure ................................................ |cp-files|
|
||||||
|
28. 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 +51,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',
|
||||||
|
|
@ -235,16 +92,7 @@ Configuration is done via `vim.g.cp_config`. Set this before using the plugin:
|
||||||
cpp = { extension = 'cpp', commands = { build = { ... } } }
|
cpp = { extension = 'cpp', commands = { build = { ... } } }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
atcoder = {
|
|
||||||
enabled_languages = { 'cpp', 'python' },
|
|
||||||
default_language = 'cpp',
|
|
||||||
},
|
|
||||||
codeforces = {
|
|
||||||
enabled_languages = { 'cpp', 'python' },
|
|
||||||
default_language = 'cpp',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
open_url = true,
|
|
||||||
debug = false,
|
debug = false,
|
||||||
ui = {
|
ui = {
|
||||||
ansi = true,
|
ansi = true,
|
||||||
|
|
@ -269,23 +117,27 @@ Configuration is done via `vim.g.cp_config`. Set this before using the plugin:
|
||||||
<
|
<
|
||||||
|
|
||||||
By default, C++ (g++ with ISO C++17) and Python are preconfigured under
|
By default, C++ (g++ with ISO C++17) and Python are preconfigured under
|
||||||
'languages'. Platforms select which languages are enabled and which one is
|
'languages'. All six platforms are enabled by default. User-supplied
|
||||||
the default; per-platform overrides can tweak 'extension' or 'commands'.
|
platform entries are merged on top of the defaults — you only need to
|
||||||
|
specify what you want to change. To disable a platform entirely, set it
|
||||||
|
to `false`.
|
||||||
|
|
||||||
For example, to run CodeForces contests with Python by default:
|
For example, to run Codeforces contests with Python by default and
|
||||||
|
disable CodeChef:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp_config = {
|
vim.g.cp = {
|
||||||
platforms = {
|
platforms = {
|
||||||
codeforces = {
|
codeforces = {
|
||||||
default_language = 'python',
|
default_language = 'python',
|
||||||
},
|
},
|
||||||
|
codechef = false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
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:
|
add Rust and use it by default on CSES:
|
||||||
>lua
|
>lua
|
||||||
vim.g.cp_config = {
|
vim.g.cp = {
|
||||||
languages = {
|
languages = {
|
||||||
rust = {
|
rust = {
|
||||||
extension = 'rs',
|
extension = 'rs',
|
||||||
|
|
@ -307,8 +159,11 @@ run CSES problems with Rust using the single schema:
|
||||||
Fields: ~
|
Fields: ~
|
||||||
{languages} (table<string,|CpLanguage|>) Global language registry.
|
{languages} (table<string,|CpLanguage|>) Global language registry.
|
||||||
Each language provides an {extension} and {commands}.
|
Each language provides an {extension} and {commands}.
|
||||||
{platforms} (table<string,|CpPlatform|>) Per-platform enablement,
|
{platforms} (table<string,|CpPlatform||false>) All six platforms
|
||||||
default language, and optional overrides.
|
are enabled by default. Each entry is merged on top
|
||||||
|
of the platform defaults — omitted fields keep their
|
||||||
|
defaults and unmentioned platforms stay enabled. Set
|
||||||
|
a platform key to `false` to disable it entirely.
|
||||||
{hooks} (|cp.Hooks|) Hook functions called at various stages.
|
{hooks} (|cp.Hooks|) Hook functions called at various stages.
|
||||||
{debug} (boolean, default: false) Show info messages.
|
{debug} (boolean, default: false) Show info messages.
|
||||||
{scrapers} (string[]) Supported platform ids.
|
{scrapers} (string[]) Supported platform ids.
|
||||||
|
|
@ -317,8 +172,6 @@ run CSES problems with Rust using the single schema:
|
||||||
Should return full filename with extension.
|
Should return full filename with extension.
|
||||||
(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
|
|
||||||
when the contest is first opened.
|
|
||||||
|
|
||||||
*CpPlatform*
|
*CpPlatform*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
|
|
@ -395,42 +248,361 @@ 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
|
||||||
|
<
|
||||||
|
:CP {platform} signup
|
||||||
|
Open the platform's registration page in the
|
||||||
|
browser via |vim.ui.open|. Works even if
|
||||||
|
{platform} is not enabled in your config.
|
||||||
|
Examples: >
|
||||||
|
:CP atcoder signup
|
||||||
|
:CP codeforces signup
|
||||||
|
<
|
||||||
|
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.
|
||||||
|
|
||||||
|
:CP open [problem|contest|standings]
|
||||||
|
Open the URL for the current problem, contest,
|
||||||
|
or standings page in the browser via
|
||||||
|
|vim.ui.open|. Defaults to "problem" if no
|
||||||
|
argument is given. Warns if the URL is not
|
||||||
|
available (e.g. CSES has no standings).
|
||||||
|
Examples: >
|
||||||
|
:CP open
|
||||||
|
:CP open contest
|
||||||
|
:CP open standings
|
||||||
|
<
|
||||||
|
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-open)*
|
||||||
|
<Plug>(cp-open) Open current problem URL in browser. Equivalent to :CP open.
|
||||||
|
|
||||||
|
*<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 +680,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 +749,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 +821,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 +962,94 @@ 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.
|
||||||
|
|
||||||
|
:CP {platform} signup
|
||||||
|
Open the platform's account registration page in the browser via
|
||||||
|
|vim.ui.open|. Works even if {platform} is not enabled in your
|
||||||
|
config. {platform} is one of: atcoder, codechef, codeforces, cses,
|
||||||
|
kattis, usaco.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
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.
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
OPEN *cp-open*
|
||||||
|
|
||||||
|
Open a platform URL for the current contest in the browser.
|
||||||
|
|
||||||
|
:CP open [problem|contest|standings]
|
||||||
|
Open the URL for the active problem, contest page, or standings.
|
||||||
|
Defaults to "problem" if no argument is given. Uses |vim.ui.open|.
|
||||||
|
Warns if the URL is unavailable (e.g. CSES has no standings page).
|
||||||
|
|
||||||
|
Platform support:
|
||||||
|
AtCoder problem, contest, standings
|
||||||
|
Codeforces problem, contest, standings
|
||||||
|
CSES problem, contest (no standings)
|
||||||
|
Others Not yet implemented.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
|
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
|
||||||
|
|
||||||
|
|
@ -843,6 +1140,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
|
||||||
|
}
|
||||||
137
flake.nix
Normal file
137
flake.nix
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
{
|
||||||
|
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.httpx
|
||||||
|
ps.ndjson
|
||||||
|
ps.pydantic
|
||||||
|
ps.requests
|
||||||
|
]);
|
||||||
|
|
||||||
|
mkDevPythonEnv =
|
||||||
|
pkgs:
|
||||||
|
pkgs.python312.withPackages (ps: [
|
||||||
|
ps.backoff
|
||||||
|
ps.beautifulsoup4
|
||||||
|
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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
154
lua/cp/cache.lua
154
lua/cp/cache.lua
|
|
@ -10,11 +10,14 @@
|
||||||
---@field name string
|
---@field name string
|
||||||
---@field display_name string
|
---@field display_name string
|
||||||
---@field url string
|
---@field url string
|
||||||
|
---@field contest_url string
|
||||||
|
---@field standings_url string
|
||||||
|
|
||||||
---@class ContestSummary
|
---@class ContestSummary
|
||||||
---@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 +30,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 +42,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 +69,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 +103,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 +138,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
|
||||||
|
|
@ -122,12 +150,16 @@ end
|
||||||
---@param contest_id string
|
---@param contest_id string
|
||||||
---@param problems Problem[]
|
---@param problems Problem[]
|
||||||
---@param url string
|
---@param url string
|
||||||
function M.set_contest_data(platform, contest_id, problems, url)
|
---@param contest_url string
|
||||||
|
---@param standings_url string
|
||||||
|
function M.set_contest_data(platform, contest_id, problems, url, contest_url, standings_url)
|
||||||
vim.validate({
|
vim.validate({
|
||||||
platform = { platform, 'string' },
|
platform = { platform, 'string' },
|
||||||
contest_id = { contest_id, 'string' },
|
contest_id = { contest_id, 'string' },
|
||||||
problems = { problems, 'table' },
|
problems = { problems, 'table' },
|
||||||
url = { url, 'string' },
|
url = { url, 'string' },
|
||||||
|
contest_url = { contest_url, 'string' },
|
||||||
|
standings_url = { standings_url, 'string' },
|
||||||
})
|
})
|
||||||
|
|
||||||
cache_data[platform] = cache_data[platform] or {}
|
cache_data[platform] = cache_data[platform] or {}
|
||||||
|
|
@ -139,6 +171,8 @@ function M.set_contest_data(platform, contest_id, problems, url)
|
||||||
problems = problems,
|
problems = problems,
|
||||||
index_map = {},
|
index_map = {},
|
||||||
url = url,
|
url = url,
|
||||||
|
contest_url = contest_url,
|
||||||
|
standings_url = standings_url,
|
||||||
}
|
}
|
||||||
for i, p in ipairs(out.problems) do
|
for i, p in ipairs(out.problems) do
|
||||||
out.index_map[p.id] = i
|
out.index_map[p.id] = i
|
||||||
|
|
@ -148,6 +182,25 @@ function M.set_contest_data(platform, contest_id, problems, url)
|
||||||
M.save()
|
M.save()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param platform string?
|
||||||
|
---@param contest_id string?
|
||||||
|
---@param problem_id string?
|
||||||
|
---@return { problem: string|nil, contest: string|nil, standings: string|nil }|nil
|
||||||
|
function M.get_open_urls(platform, contest_id, problem_id)
|
||||||
|
if not platform or not contest_id then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if not cache_data[platform] or not cache_data[platform][contest_id] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cd = cache_data[platform][contest_id]
|
||||||
|
return {
|
||||||
|
problem = cd.url ~= '' and problem_id and string.format(cd.url, problem_id) or nil,
|
||||||
|
contest = cd.contest_url ~= '' and cd.contest_url or nil,
|
||||||
|
standings = cd.standings_url ~= '' and cd.standings_url or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
---@param platform string
|
---@param platform string
|
||||||
---@param contest_id string
|
---@param contest_id string
|
||||||
function M.clear_contest_data(platform, contest_id)
|
function M.clear_contest_data(platform, contest_id)
|
||||||
|
|
@ -222,7 +275,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 +288,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 +299,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 +321,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 +379,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
|
||||||
|
|
@ -310,13 +396,53 @@ function M.set_contest_summaries(platform, contests)
|
||||||
cache_data[platform] = cache_data[platform] or {}
|
cache_data[platform] = cache_data[platform] or {}
|
||||||
for _, contest in ipairs(contests) do
|
for _, contest in ipairs(contests) do
|
||||||
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 ~= vim.NIL and contest.display_name
|
||||||
|
) or contest.name
|
||||||
cache_data[platform][contest.id].name = contest.name
|
cache_data[platform][contest.id].name = contest.name
|
||||||
|
if contest.start_time and contest.start_time ~= vim.NIL 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 +464,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
|
||||||
|
|
|
||||||
|
|
@ -47,26 +47,30 @@ function M.handle_cache_command(cmd)
|
||||||
constants.PLATFORM_DISPLAY_NAMES[cmd.platform],
|
constants.PLATFORM_DISPLAY_NAMES[cmd.platform],
|
||||||
cmd.contest
|
cmd.contest
|
||||||
),
|
),
|
||||||
vim.log.levels.INFO,
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
true
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR)
|
logger.log(
|
||||||
|
("Unknown platform '%s'."):format(cmd.platform),
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
elseif cmd.platform then
|
elseif cmd.platform then
|
||||||
if vim.tbl_contains(platforms, cmd.platform) then
|
if vim.tbl_contains(platforms, cmd.platform) then
|
||||||
cache.clear_platform(cmd.platform)
|
cache.clear_platform(cmd.platform)
|
||||||
logger.log(
|
logger.log(
|
||||||
("Cache cleared for platform '%s'"):format(constants.PLATFORM_DISPLAY_NAMES[cmd.platform]),
|
("Cache cleared for platform '%s'"):format(constants.PLATFORM_DISPLAY_NAMES[cmd.platform]),
|
||||||
vim.log.levels.INFO,
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
true
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR)
|
logger.log(
|
||||||
|
("Unknown platform '%s'."):format(cmd.platform),
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
cache.clear_all()
|
cache.clear_all()
|
||||||
logger.log('Cache cleared', vim.log.levels.INFO, true)
|
logger.log('Cache cleared', { level = vim.log.levels.INFO, override = true })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@ local actions = constants.ACTIONS
|
||||||
---@field type string
|
---@field type string
|
||||||
---@field error string?
|
---@field error string?
|
||||||
---@field action? string
|
---@field action? string
|
||||||
|
---@field requires_context? boolean
|
||||||
---@field message? string
|
---@field message? string
|
||||||
---@field contest? string
|
---@field contest? string
|
||||||
---@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
|
||||||
|
|
@ -23,6 +26,20 @@ local actions = constants.ACTIONS
|
||||||
---@field language? string
|
---@field language? string
|
||||||
---@field subcommand? string
|
---@field subcommand? string
|
||||||
|
|
||||||
|
---@param str string
|
||||||
|
---@return string
|
||||||
|
local function canonicalize_cf_contest(str)
|
||||||
|
local id = str:match('/contest/(%d+)') or str:match('/problemset/problem/(%d+)')
|
||||||
|
if id then
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
local num = str:match('^(%d+)[A-Za-z]')
|
||||||
|
if num then
|
||||||
|
return num
|
||||||
|
end
|
||||||
|
return str
|
||||||
|
end
|
||||||
|
|
||||||
--- Turn raw args into normalized structure to later dispatch
|
--- Turn raw args into normalized structure to later dispatch
|
||||||
---@param args string[] The raw command-line mode args
|
---@param args string[] The raw command-line mode args
|
||||||
---@return ParsedCommand
|
---@return ParsedCommand
|
||||||
|
|
@ -53,13 +70,48 @@ 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', requires_context = false }
|
||||||
|
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',
|
||||||
|
requires_context = false,
|
||||||
|
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
|
||||||
return { type = 'action', action = 'interact', interactor_cmd = inter }
|
return {
|
||||||
|
type = 'action',
|
||||||
|
action = 'interact',
|
||||||
|
requires_context = true,
|
||||||
|
interactor_cmd = inter,
|
||||||
|
}
|
||||||
else
|
else
|
||||||
return { type = 'action', action = 'interact' }
|
return { type = 'action', action = 'interact', requires_context = true }
|
||||||
end
|
end
|
||||||
|
elseif first == 'stress' then
|
||||||
|
return {
|
||||||
|
type = 'action',
|
||||||
|
action = 'stress',
|
||||||
|
requires_context = true,
|
||||||
|
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
|
||||||
|
|
@ -75,7 +127,7 @@ local function parse_command(args)
|
||||||
end
|
end
|
||||||
test_index = idx
|
test_index = idx
|
||||||
end
|
end
|
||||||
return { type = 'action', action = 'edit', test_index = test_index }
|
return { type = 'action', action = 'edit', requires_context = true, test_index = test_index }
|
||||||
elseif first == 'run' or first == 'panel' then
|
elseif first == 'run' or first == 'panel' then
|
||||||
local debug = false
|
local debug = false
|
||||||
local test_indices = nil
|
local test_indices = nil
|
||||||
|
|
@ -188,10 +240,28 @@ local function parse_command(args)
|
||||||
return {
|
return {
|
||||||
type = 'action',
|
type = 'action',
|
||||||
action = first,
|
action = first,
|
||||||
|
requires_context = true,
|
||||||
test_indices = test_indices,
|
test_indices = test_indices,
|
||||||
debug = debug,
|
debug = debug,
|
||||||
mode = mode,
|
mode = mode,
|
||||||
}
|
}
|
||||||
|
elseif first == 'open' then
|
||||||
|
local target = args[2] or 'problem'
|
||||||
|
if not vim.tbl_contains({ 'problem', 'contest', 'standings' }, target) then
|
||||||
|
return { type = 'error', message = 'Usage: :CP open [problem|contest|standings]' }
|
||||||
|
end
|
||||||
|
return { type = 'action', action = 'open', requires_context = true, subcommand = target }
|
||||||
|
elseif first == 'pick' then
|
||||||
|
local language = nil
|
||||||
|
if #args >= 3 and args[2] == '--lang' then
|
||||||
|
language = args[3]
|
||||||
|
elseif #args >= 2 and args[2] ~= nil and args[2]:sub(1, 2) ~= '--' then
|
||||||
|
return {
|
||||||
|
type = 'error',
|
||||||
|
message = ("Unknown argument '%s' for action '%s'"):format(args[2], first),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return { type = 'action', action = 'pick', requires_context = false, language = language }
|
||||||
else
|
else
|
||||||
local language = nil
|
local language = nil
|
||||||
if #args >= 3 and args[2] == '--lang' then
|
if #args >= 3 and args[2] == '--lang' then
|
||||||
|
|
@ -202,7 +272,7 @@ local function parse_command(args)
|
||||||
message = ("Unknown argument '%s' for action '%s'"):format(args[2], first),
|
message = ("Unknown argument '%s' for action '%s'"):format(args[2], first),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
return { type = 'action', action = first, language = language }
|
return { type = 'action', action = first, requires_context = true, language = language }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -213,16 +283,27 @@ local function parse_command(args)
|
||||||
message = 'Too few arguments - specify a contest.',
|
message = 'Too few arguments - specify a contest.',
|
||||||
}
|
}
|
||||||
elseif #args == 2 then
|
elseif #args == 2 then
|
||||||
|
if args[2] == 'login' or args[2] == 'logout' or args[2] == 'signup' then
|
||||||
|
return { type = 'action', action = args[2], requires_context = false, platform = first }
|
||||||
|
end
|
||||||
|
local contest = args[2]
|
||||||
|
if first == 'codeforces' then
|
||||||
|
contest = canonicalize_cf_contest(contest)
|
||||||
|
end
|
||||||
return {
|
return {
|
||||||
type = 'contest_setup',
|
type = 'contest_setup',
|
||||||
platform = first,
|
platform = first,
|
||||||
contest = args[2],
|
contest = contest,
|
||||||
}
|
}
|
||||||
elseif #args == 4 and args[3] == '--lang' then
|
elseif #args == 4 and args[3] == '--lang' then
|
||||||
|
local contest = args[2]
|
||||||
|
if first == 'codeforces' then
|
||||||
|
contest = canonicalize_cf_contest(contest)
|
||||||
|
end
|
||||||
return {
|
return {
|
||||||
type = 'contest_setup',
|
type = 'contest_setup',
|
||||||
platform = first,
|
platform = first,
|
||||||
contest = args[2],
|
contest = contest,
|
||||||
language = args[4],
|
language = args[4],
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -249,13 +330,29 @@ local function parse_command(args)
|
||||||
return { type = 'error', message = 'Unknown command or no contest context.' }
|
return { type = 'error', message = 'Unknown command or no contest context.' }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param platform string
|
||||||
|
---@return boolean
|
||||||
|
local function check_platform_enabled(platform)
|
||||||
|
local cfg = require('cp.config').get_config()
|
||||||
|
if not cfg.platforms[platform] then
|
||||||
|
logger.log(
|
||||||
|
("Platform '%s' is not enabled. Add it to vim.g.cp.platforms to enable it."):format(
|
||||||
|
constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
),
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
--- Core logic for handling `:CP ...` commands
|
--- Core logic for handling `:CP ...` commands
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.handle_command(opts)
|
function M.handle_command(opts)
|
||||||
local cmd = parse_command(opts.fargs)
|
local cmd = parse_command(opts.fargs)
|
||||||
|
|
||||||
if cmd.type == 'error' then
|
if cmd.type == 'error' then
|
||||||
logger.log(cmd.message, vim.log.levels.ERROR)
|
logger.log(cmd.message, { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -263,6 +360,13 @@ function M.handle_command(opts)
|
||||||
local restore = require('cp.restore')
|
local restore = require('cp.restore')
|
||||||
restore.restore_from_current_file()
|
restore.restore_from_current_file()
|
||||||
elseif cmd.type == 'action' then
|
elseif cmd.type == 'action' then
|
||||||
|
if cmd.requires_context and not state.get_platform() then
|
||||||
|
local restore = require('cp.restore')
|
||||||
|
if not restore.restore_from_current_file() then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local setup = require('cp.setup')
|
local setup = require('cp.setup')
|
||||||
local ui = require('cp.ui.views')
|
local ui = require('cp.ui.views')
|
||||||
|
|
||||||
|
|
@ -285,6 +389,51 @@ 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
|
||||||
|
if not check_platform_enabled(cmd.platform) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
require('cp.race').start(cmd.platform, cmd.contest, cmd.language)
|
||||||
|
elseif cmd.action == 'race_stop' then
|
||||||
|
require('cp.race').stop()
|
||||||
|
elseif cmd.action == 'open' then
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
cache.load()
|
||||||
|
local urls =
|
||||||
|
cache.get_open_urls(state.get_platform(), state.get_contest_id(), state.get_problem_id())
|
||||||
|
local url = urls and urls[cmd.subcommand]
|
||||||
|
if not url or url == '' then
|
||||||
|
logger.log(
|
||||||
|
("No URL available for '%s'"):format(cmd.subcommand),
|
||||||
|
{ level = vim.log.levels.WARN }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.ui.open(url)
|
||||||
|
elseif cmd.action == 'login' then
|
||||||
|
if not check_platform_enabled(cmd.platform) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
require('cp.credentials').login(cmd.platform)
|
||||||
|
elseif cmd.action == 'logout' then
|
||||||
|
if not check_platform_enabled(cmd.platform) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
require('cp.credentials').logout(cmd.platform)
|
||||||
|
elseif cmd.action == 'signup' then
|
||||||
|
local url = constants.SIGNUP_URLS[cmd.platform]
|
||||||
|
if not url then
|
||||||
|
logger.log(
|
||||||
|
("No signup URL available for '%s'"):format(cmd.platform),
|
||||||
|
{ level = vim.log.levels.WARN }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.ui.open(url)
|
||||||
end
|
end
|
||||||
elseif cmd.type == 'problem_jump' then
|
elseif cmd.type == 'problem_jump' then
|
||||||
local platform = state.get_platform()
|
local platform = state.get_platform()
|
||||||
|
|
@ -292,7 +441,7 @@ function M.handle_command(opts)
|
||||||
local problem_id = cmd.problem_id
|
local problem_id = cmd.problem_id
|
||||||
|
|
||||||
if not (platform and contest_id) then
|
if not (platform and contest_id) then
|
||||||
logger.log('No contest is currently active.', vim.log.levels.ERROR)
|
logger.log('No contest is currently active.', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -307,7 +456,7 @@ function M.handle_command(opts)
|
||||||
contest_id,
|
contest_id,
|
||||||
problem_id
|
problem_id
|
||||||
),
|
),
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -318,6 +467,9 @@ function M.handle_command(opts)
|
||||||
local cache_commands = require('cp.commands.cache')
|
local cache_commands = require('cp.commands.cache')
|
||||||
cache_commands.handle_cache_command(cmd)
|
cache_commands.handle_cache_command(cmd)
|
||||||
elseif cmd.type == 'contest_setup' then
|
elseif cmd.type == 'contest_setup' then
|
||||||
|
if not check_platform_enabled(cmd.platform) then
|
||||||
|
return
|
||||||
|
end
|
||||||
local setup = require('cp.setup')
|
local setup = require('cp.setup')
|
||||||
setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language)
|
setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ function M.handle_pick_action(language)
|
||||||
if not (config.ui and config.ui.picker) then
|
if not (config.ui and config.ui.picker) then
|
||||||
logger.log(
|
logger.log(
|
||||||
'No picker configured. Set ui.picker = "{telescope,fzf-lua}" in your config.',
|
'No picker configured. Set ui.picker = "{telescope,fzf-lua}" in your config.',
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -25,13 +25,13 @@ function M.handle_pick_action(language)
|
||||||
if not ok then
|
if not ok then
|
||||||
logger.log(
|
logger.log(
|
||||||
'telescope.nvim is not available. Install telescope.nvim xor change your picker config.',
|
'telescope.nvim is not available. Install telescope.nvim xor change your picker config.',
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope')
|
local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope')
|
||||||
if not ok_cp then
|
if not ok_cp then
|
||||||
logger.log('Failed to load telescope integration.', vim.log.levels.ERROR)
|
logger.log('Failed to load telescope integration.', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -41,13 +41,13 @@ function M.handle_pick_action(language)
|
||||||
if not ok then
|
if not ok then
|
||||||
logger.log(
|
logger.log(
|
||||||
'fzf-lua is not available. Install fzf-lua or change your picker config',
|
'fzf-lua is not available. Install fzf-lua or change your picker config',
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua')
|
local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua')
|
||||||
if not ok_cp then
|
if not ok_cp then
|
||||||
logger.log('Failed to load fzf-lua integration.', vim.log.levels.ERROR)
|
logger.log('Failed to load fzf-lua integration.', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,15 +98,16 @@
|
||||||
---@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 scrapers string[]
|
---@field scrapers string[]
|
||||||
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
||||||
---@field ui CpUI
|
---@field ui CpUI
|
||||||
---@field runtime { effective: table<string, table<string, CpLanguage>> } -- computed
|
---@field runtime { effective: table<string, table<string, CpLanguage>> } -- computed
|
||||||
|
|
||||||
---@class cp.PartialConfig: cp.Config
|
---@class cp.PartialConfig: cp.Config
|
||||||
|
---@field platforms? table<string, CpPlatform|false>
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
|
@ -102,7 +118,6 @@ local utils = require('cp.utils')
|
||||||
-- defaults per the new single schema
|
-- defaults per the new single schema
|
||||||
---@type cp.Config
|
---@type cp.Config
|
||||||
M.defaults = {
|
M.defaults = {
|
||||||
open_url = false,
|
|
||||||
languages = {
|
languages = {
|
||||||
cpp = {
|
cpp = {
|
||||||
extension = 'cc',
|
extension = 'cc',
|
||||||
|
|
@ -147,13 +162,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 +193,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 +202,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 +248,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 +290,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 +332,20 @@ 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, v in pairs(user_config.platforms) do
|
||||||
|
if v == false then
|
||||||
|
defaults.platforms[plat] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
|
||||||
|
for plat, v in pairs(cfg.platforms) do
|
||||||
|
if v == false then
|
||||||
|
cfg.platforms[plat] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
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,11 +355,17 @@ 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' } },
|
||||||
debug = { cfg.debug, { 'boolean', 'nil' }, true },
|
debug = { cfg.debug, { 'boolean', 'nil' }, true },
|
||||||
open_url = { cfg.open_url, { 'boolean', 'nil' }, true },
|
|
||||||
filename = { cfg.filename, { 'function', 'nil' }, true },
|
filename = { cfg.filename, { 'function', 'nil' }, true },
|
||||||
scrapers = {
|
scrapers = {
|
||||||
cfg.scrapers,
|
cfg.scrapers,
|
||||||
|
|
@ -323,12 +382,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 +431,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 +447,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,37 @@
|
||||||
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',
|
||||||
|
'open',
|
||||||
|
}
|
||||||
|
|
||||||
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.SIGNUP_URLS = {
|
||||||
|
atcoder = 'https://atcoder.jp/register',
|
||||||
|
codechef = 'https://www.codechef.com/register',
|
||||||
|
codeforces = 'https://codeforces.com/register',
|
||||||
|
cses = 'https://cses.fi/register',
|
||||||
|
kattis = 'https://open.kattis.com/register',
|
||||||
|
usaco = 'https://usaco.org/index.php?page=createaccount',
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CPP = 'cpp'
|
M.CPP = 'cpp'
|
||||||
|
|
|
||||||
86
lua/cp/credentials.lua
Normal file
86
lua/cp/credentials.lua
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
local logger = require('cp.log')
|
||||||
|
local state = require('cp.state')
|
||||||
|
|
||||||
|
local STATUS_MESSAGES = {
|
||||||
|
checking_login = 'Checking existing session...',
|
||||||
|
logging_in = 'Logging in...',
|
||||||
|
installing_browser = 'Installing browser...',
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.login(platform)
|
||||||
|
platform = platform or state.get_platform()
|
||||||
|
if not platform then
|
||||||
|
logger.log(
|
||||||
|
'No platform specified. Usage: :CP <platform> login',
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
|
||||||
|
vim.ui.input({ prompt = display .. ' username: ' }, function(username)
|
||||||
|
if not username or username == '' then
|
||||||
|
logger.log('Cancelled', { level = vim.log.levels.WARN })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
vim.fn.inputsave()
|
||||||
|
local password = vim.fn.inputsecret(display .. ' password: ')
|
||||||
|
vim.fn.inputrestore()
|
||||||
|
if not password or password == '' then
|
||||||
|
logger.log('Cancelled', { level = vim.log.levels.WARN })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
cache.load()
|
||||||
|
local existing = cache.get_credentials(platform) or {}
|
||||||
|
local credentials = {
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
}
|
||||||
|
if existing.token then
|
||||||
|
credentials.token = existing.token
|
||||||
|
end
|
||||||
|
|
||||||
|
local scraper = require('cp.scraper')
|
||||||
|
scraper.login(platform, credentials, function(ev)
|
||||||
|
vim.schedule(function()
|
||||||
|
local msg = STATUS_MESSAGES[ev.status] or ev.status
|
||||||
|
logger.log(display .. ': ' .. msg, { level = vim.log.levels.INFO, override = true })
|
||||||
|
end)
|
||||||
|
end, function(result)
|
||||||
|
vim.schedule(function()
|
||||||
|
if result.success then
|
||||||
|
logger.log(
|
||||||
|
display .. ' login successful',
|
||||||
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
|
)
|
||||||
|
else
|
||||||
|
local err = result.error or 'unknown error'
|
||||||
|
logger.log(display .. ' login failed: ' .. err, { level = vim.log.levels.ERROR })
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.logout(platform)
|
||||||
|
platform = platform or state.get_platform()
|
||||||
|
if not platform then
|
||||||
|
logger.log(
|
||||||
|
'No platform specified. Usage: :CP <platform> logout',
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
cache.load()
|
||||||
|
cache.clear_credentials(platform)
|
||||||
|
logger.log(display .. ' credentials cleared', { level = vim.log.levels.INFO, override = 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
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ local logger = require('cp.log')
|
||||||
M.helpers = helpers
|
M.helpers = helpers
|
||||||
|
|
||||||
if vim.fn.has('nvim-0.10.0') == 0 then
|
if vim.fn.has('nvim-0.10.0') == 0 then
|
||||||
logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR)
|
logger.log('Requires nvim-0.10.0+', { level = vim.log.levels.ERROR })
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
function M.log(msg, level, override)
|
---@class LogOpts
|
||||||
|
---@field level? integer
|
||||||
|
---@field override? boolean
|
||||||
|
---@field sync? boolean
|
||||||
|
|
||||||
|
---@param msg string
|
||||||
|
---@param opts? LogOpts
|
||||||
|
function M.log(msg, opts)
|
||||||
local debug = require('cp.config').get_config().debug or false
|
local debug = require('cp.config').get_config().debug or false
|
||||||
level = level or vim.log.levels.INFO
|
opts = opts or {}
|
||||||
|
local level = opts.level or vim.log.levels.INFO
|
||||||
|
local override = opts.override or false
|
||||||
|
local sync = opts.sync or false
|
||||||
if level >= vim.log.levels.WARN or override or debug then
|
if level >= vim.log.levels.WARN or override or debug then
|
||||||
vim.schedule(function()
|
local notify = function()
|
||||||
vim.notify(('[cp.nvim]: %s'):format(msg), level)
|
vim.notify(('[cp.nvim]: %s'):format(msg), level)
|
||||||
end)
|
end
|
||||||
|
if sync then
|
||||||
|
notify()
|
||||||
|
else
|
||||||
|
vim.schedule(notify)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,10 @@ function M.get_platform_contests(platform, refresh)
|
||||||
local picker_contests = cache.get_contest_summaries(platform)
|
local picker_contests = cache.get_contest_summaries(platform)
|
||||||
|
|
||||||
if refresh or vim.tbl_isempty(picker_contests) then
|
if refresh or vim.tbl_isempty(picker_contests) then
|
||||||
|
local display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
|
||||||
logger.log(
|
logger.log(
|
||||||
('Loading %s contests...'):format(constants.PLATFORM_DISPLAY_NAMES[platform]),
|
('Fetching %s contests...'):format(display_name),
|
||||||
vim.log.levels.INFO,
|
{ level = vim.log.levels.INFO, override = true, sync = true }
|
||||||
true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
local contests = scraper.scrape_contest_list(platform)
|
local contests = scraper.scrape_contest_list(platform)
|
||||||
|
|
@ -53,12 +53,8 @@ function M.get_platform_contests(platform, refresh)
|
||||||
picker_contests = cache.get_contest_summaries(platform)
|
picker_contests = cache.get_contest_summaries(platform)
|
||||||
|
|
||||||
logger.log(
|
logger.log(
|
||||||
('Loaded %d %s contests.'):format(
|
('Fetched %d %s contests.'):format(#picker_contests, display_name),
|
||||||
#picker_contests,
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
constants.PLATFORM_DISPLAY_NAMES[platform]
|
|
||||||
),
|
|
||||||
vim.log.levels.INFO,
|
|
||||||
true
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
146
lua/cp/race.lua
Normal file
146
lua/cp/race.lua
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
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', { level = vim.log.levels.ERROR })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not contest_id or contest_id == '' then
|
||||||
|
logger.log('Contest ID required', { level = vim.log.levels.ERROR })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if race_state.timer then
|
||||||
|
logger.log('Race already active. Use :CP race stop first.', { level = 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...', { level = vim.log.levels.INFO, override = 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
|
||||||
|
),
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local remaining = start_time - os.time()
|
||||||
|
if remaining <= 0 then
|
||||||
|
logger.log(
|
||||||
|
'Contest has already started, setting up...',
|
||||||
|
{ level = vim.log.levels.INFO, override = 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)
|
||||||
|
),
|
||||||
|
{ level = vim.log.levels.INFO, override = 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!', { level = vim.log.levels.INFO, override = 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', { level = 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', { level = vim.log.levels.INFO, override = 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
|
||||||
|
|
@ -11,7 +11,7 @@ function M.restore_from_current_file()
|
||||||
local current_file = (vim.uv.fs_realpath(vim.fn.expand('%:p')) or vim.fn.expand('%:p'))
|
local current_file = (vim.uv.fs_realpath(vim.fn.expand('%:p')) or vim.fn.expand('%:p'))
|
||||||
local file_state = cache.get_file_state(current_file)
|
local file_state = cache.get_file_state(current_file)
|
||||||
if not file_state then
|
if not file_state then
|
||||||
logger.log('No cached state found for current file.', vim.log.levels.ERROR)
|
logger.log('No cached state found for current file.', { level = vim.log.levels.ERROR })
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -51,7 +52,7 @@ function M.compile(compile_cmd, substitutions, on_complete)
|
||||||
r.stdout = ansi.bytes_to_string(r.stdout or '')
|
r.stdout = ansi.bytes_to_string(r.stdout or '')
|
||||||
|
|
||||||
if r.code == 0 then
|
if r.code == 0 then
|
||||||
logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO)
|
logger.log(('Compilation successful in %.1fms.'):format(dt), { level = vim.log.levels.INFO })
|
||||||
else
|
else
|
||||||
logger.log(('Compilation failed in %.1fms.'):format(dt))
|
logger.log(('Compilation failed in %.1fms.'):format(dt))
|
||||||
end
|
end
|
||||||
|
|
@ -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
|
||||||
|
|
@ -194,7 +245,7 @@ function M.load_test_cases()
|
||||||
state.get_problem_id()
|
state.get_problem_id()
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.log(('Loaded %d test case(s)'):format(#tcs), vim.log.levels.INFO)
|
logger.log(('Loaded %d test case(s)'):format(#tcs), { level = vim.log.levels.INFO })
|
||||||
return #tcs > 0
|
return #tcs > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -208,7 +259,7 @@ function M.run_combined_test(debug, on_complete)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not combined then
|
if not combined then
|
||||||
logger.log('No combined test found', vim.log.levels.ERROR)
|
logger.log('No combined test found', { level = vim.log.levels.ERROR })
|
||||||
on_complete(nil)
|
on_complete(nil)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -276,26 +327,33 @@ 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),
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
vim.log.levels.INFO,
|
)
|
||||||
true
|
on_done(panel_state.test_cases)
|
||||||
)
|
return
|
||||||
on_done(panel_state.test_cases)
|
|
||||||
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),
|
||||||
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
|
)
|
||||||
|
on_done(panel_state.test_cases)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
---@return PanelState
|
---@return PanelState
|
||||||
|
|
|
||||||
|
|
@ -5,55 +5,141 @@ 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, { level = 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, { level = vim.log.levels.ERROR })
|
||||||
|
if opts and opts.on_exit then
|
||||||
|
opts.on_exit({ success = false, error = msg })
|
||||||
|
end
|
||||||
|
return { success = false, error = msg }
|
||||||
|
end
|
||||||
|
|
||||||
|
local needs_browser = subcommand == 'submit'
|
||||||
|
or subcommand == 'login'
|
||||||
|
or (platform == 'codeforces' and (subcommand == 'metadata' or subcommand == 'tests'))
|
||||||
|
|
||||||
|
if needs_browser 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 needs_browser 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 needs_browser 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 timer = nil
|
||||||
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 timer and not timer:is_closing() then
|
||||||
opts.on_event(ev_tail)
|
timer:stop()
|
||||||
end
|
timer:close()
|
||||||
buf = ''
|
end
|
||||||
|
if buf ~= '' and opts.on_event then
|
||||||
|
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
|
||||||
|
if ok_tail then
|
||||||
|
opts.on_event(ev_tail)
|
||||||
end
|
end
|
||||||
if opts.on_exit then
|
buf = ''
|
||||||
opts.on_exit({ success = (code == 0), code = code, signal = signal })
|
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 stdin_pipe and not stdin_pipe:is_closing() then
|
||||||
|
stdin_pipe:close()
|
||||||
|
end
|
||||||
|
logger.log('Failed to start scraper process', { level = vim.log.levels.ERROR })
|
||||||
|
return { success = false, error = 'spawn failed' }
|
||||||
|
end
|
||||||
|
|
||||||
|
if needs_browser then
|
||||||
|
timer = uv.new_timer()
|
||||||
|
timer:start(120000, 0, function()
|
||||||
|
timer:stop()
|
||||||
|
timer:close()
|
||||||
|
if stdin_pipe and not stdin_pipe:is_closing() then
|
||||||
|
stdin_pipe:close()
|
||||||
end
|
end
|
||||||
if not stdout:is_closing() then
|
if not stdout:is_closing() then
|
||||||
stdout:close()
|
stdout:close()
|
||||||
|
|
@ -62,14 +148,21 @@ local function run_scraper(platform, subcommand, args, opts)
|
||||||
stderr:close()
|
stderr:close()
|
||||||
end
|
end
|
||||||
if handle and not handle:is_closing() then
|
if handle and not handle:is_closing() then
|
||||||
|
handle:kill(15)
|
||||||
handle:close()
|
handle:close()
|
||||||
end
|
end
|
||||||
end
|
if opts.on_exit then
|
||||||
)
|
opts.on_exit({ success = false, error = 'submit timed out' })
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
if not handle then
|
if stdin_pipe then
|
||||||
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
|
uv.write(stdin_pipe, opts.stdin, function()
|
||||||
return { success = false, error = 'spawn failed' }
|
uv.shutdown(stdin_pipe, function()
|
||||||
|
stdin_pipe:close()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
uv.read_start(stdout, function(_, data)
|
uv.read_start(stdout, function(_, data)
|
||||||
|
|
@ -102,7 +195,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 = needs_browser 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)
|
||||||
|
|
@ -124,7 +225,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
|
||||||
constants.PLATFORM_DISPLAY_NAMES[platform],
|
constants.PLATFORM_DISPLAY_NAMES[platform],
|
||||||
contest_id
|
contest_id
|
||||||
),
|
),
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -135,7 +236,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback)
|
||||||
constants.PLATFORM_DISPLAY_NAMES[platform],
|
constants.PLATFORM_DISPLAY_NAMES[platform],
|
||||||
contest_id
|
contest_id
|
||||||
),
|
),
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -154,7 +255,7 @@ function M.scrape_contest_list(platform)
|
||||||
platform,
|
platform,
|
||||||
(result and result.error) or 'unknown'
|
(result and result.error) or 'unknown'
|
||||||
),
|
),
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
@ -164,9 +265,15 @@ end
|
||||||
---@param platform string
|
---@param platform string
|
||||||
---@param contest_id string
|
---@param contest_id string
|
||||||
---@param callback fun(data: table)|nil
|
---@param callback fun(data: table)|nil
|
||||||
function M.scrape_all_tests(platform, contest_id, callback)
|
---@param on_done fun()|nil
|
||||||
|
function M.scrape_all_tests(platform, contest_id, callback, on_done)
|
||||||
run_scraper(platform, 'tests', { contest_id }, {
|
run_scraper(platform, 'tests', { contest_id }, {
|
||||||
ndjson = true,
|
ndjson = true,
|
||||||
|
on_exit = function()
|
||||||
|
if type(on_done) == 'function' then
|
||||||
|
vim.schedule(on_done)
|
||||||
|
end
|
||||||
|
end,
|
||||||
on_event = function(ev)
|
on_event = function(ev)
|
||||||
if ev.done then
|
if ev.done then
|
||||||
return
|
return
|
||||||
|
|
@ -178,7 +285,7 @@ function M.scrape_all_tests(platform, contest_id, callback)
|
||||||
contest_id,
|
contest_id,
|
||||||
ev.error
|
ev.error
|
||||||
),
|
),
|
||||||
vim.log.levels.WARN
|
{ level = vim.log.levels.WARN }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -205,6 +312,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 +321,75 @@ function M.scrape_all_tests(platform, contest_id, callback)
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function M.login(platform, credentials, on_status, callback)
|
||||||
|
local done = false
|
||||||
|
run_scraper(platform, 'login', {}, {
|
||||||
|
ndjson = true,
|
||||||
|
env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) },
|
||||||
|
on_event = function(ev)
|
||||||
|
if ev.credentials ~= nil and next(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 = 'login process exited (code=' .. tostring(proc.code) .. ')',
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.submit(
|
||||||
|
platform,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
language,
|
||||||
|
source_file,
|
||||||
|
credentials,
|
||||||
|
on_status,
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
local done = false
|
||||||
|
run_scraper(platform, 'submit', { contest_id, problem_id, language, source_file }, {
|
||||||
|
ndjson = true,
|
||||||
|
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
|
||||||
|
|
|
||||||
208
lua/cp/setup.lua
208
lua/cp/setup.lua
|
|
@ -8,6 +8,39 @@ 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),
|
||||||
|
{ level = 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()
|
||||||
|
|
@ -82,11 +115,15 @@ local function start_tests(platform, contest_id, problems)
|
||||||
return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id))
|
return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id))
|
||||||
end, problems)
|
end, problems)
|
||||||
if cached_len ~= #problems then
|
if cached_len ~= #problems then
|
||||||
|
local to_fetch = #problems - cached_len
|
||||||
logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems))
|
logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems))
|
||||||
scraper.scrape_all_tests(platform, contest_id, function(ev)
|
scraper.scrape_all_tests(platform, contest_id, function(ev)
|
||||||
local cached_tests = {}
|
local cached_tests = {}
|
||||||
if not ev.interactive and vim.tbl_isempty(ev.tests) then
|
if not ev.interactive and vim.tbl_isempty(ev.tests) then
|
||||||
logger.log(("No tests found for problem '%s'."):format(ev.problem_id), vim.log.levels.WARN)
|
logger.log(
|
||||||
|
("No tests found for problem '%s'."):format(ev.problem_id),
|
||||||
|
{ level = vim.log.levels.WARN }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
for i, t in ipairs(ev.tests) do
|
for i, t in ipairs(ev.tests) do
|
||||||
cached_tests[i] = { index = i, input = t.input, expected = t.expected }
|
cached_tests[i] = { index = i, input = t.input, expected = t.expected }
|
||||||
|
|
@ -100,7 +137,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()
|
||||||
|
|
@ -111,6 +149,11 @@ local function start_tests(platform, contest_id, problems)
|
||||||
require('cp.utils').update_buffer_content(io_state.input_buf, input_lines, nil, nil)
|
require('cp.utils').update_buffer_content(io_state.input_buf, input_lines, nil, nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end, function()
|
||||||
|
logger.log(
|
||||||
|
('Loaded %d test%s.'):format(to_fetch, to_fetch == 1 and '' or 's'),
|
||||||
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -128,24 +171,39 @@ function M.setup_contest(platform, contest_id, problem_id, language)
|
||||||
if language then
|
if language then
|
||||||
local lang_result = config_module.get_language_for_platform(platform, language)
|
local lang_result = config_module.get_language_for_platform(platform, language)
|
||||||
if not lang_result.valid then
|
if not lang_result.valid then
|
||||||
logger.log(lang_result.error, vim.log.levels.ERROR)
|
logger.log(lang_result.error, { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
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
|
||||||
|
|
||||||
|
if is_new_contest then
|
||||||
|
local views = require('cp.ui.views')
|
||||||
|
views.cancel_io_view()
|
||||||
|
local active = state.get_active_panel()
|
||||||
|
if active == 'interactive' then
|
||||||
|
views.cancel_interactive()
|
||||||
|
elseif active == 'stress' then
|
||||||
|
require('cp.stress').cancel()
|
||||||
|
elseif active == 'run' then
|
||||||
|
views.disable()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
cache.load()
|
cache.load()
|
||||||
|
|
||||||
local function proceed(contest_data)
|
local function proceed(contest_data)
|
||||||
|
if is_new_contest then
|
||||||
|
local io_state = state.get_io_view_state()
|
||||||
|
if io_state and io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
||||||
|
require('cp.utils').update_buffer_content(io_state.output_buf, {}, nil, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
local problems = contest_data.problems
|
local problems = contest_data.problems
|
||||||
local pid = problem_id and problem_id or problems[1].id
|
local pid = problem_id and problem_id or problems[1].id
|
||||||
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
|
|
||||||
vim.ui.open(contest_data.url:format(pid))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local contest_data = cache.get_contest_data(platform, contest_id)
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||||
|
|
@ -160,12 +218,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,16 +226,23 @@ 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...', { level = vim.log.levels.INFO, override = true })
|
||||||
scraper.scrape_contest_metadata(
|
scraper.scrape_contest_metadata(
|
||||||
platform,
|
platform,
|
||||||
contest_id,
|
contest_id,
|
||||||
vim.schedule_wrap(function(result)
|
vim.schedule_wrap(function(result)
|
||||||
local problems = result.problems or {}
|
local problems = result.problems or {}
|
||||||
cache.set_contest_data(platform, contest_id, problems, result.url)
|
cache.set_contest_data(
|
||||||
|
platform,
|
||||||
|
contest_id,
|
||||||
|
problems,
|
||||||
|
result.url,
|
||||||
|
result.contest_url or '',
|
||||||
|
result.standings_url or ''
|
||||||
|
)
|
||||||
local prov = state.get_provisional()
|
local prov = state.get_provisional()
|
||||||
if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then
|
if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then
|
||||||
return
|
return
|
||||||
|
|
@ -212,7 +272,7 @@ end
|
||||||
function M.setup_problem(problem_id, language)
|
function M.setup_problem(problem_id, language)
|
||||||
local platform = state.get_platform()
|
local platform = state.get_platform()
|
||||||
if not platform then
|
if not platform then
|
||||||
logger.log('No platform/contest/problem configured.', vim.log.levels.ERROR)
|
logger.log('No platform/contest/problem configured.', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -233,7 +293,7 @@ function M.setup_problem(problem_id, language)
|
||||||
if language then
|
if language then
|
||||||
local lang_result = config_module.get_language_for_platform(platform, language)
|
local lang_result = config_module.get_language_for_platform(platform, language)
|
||||||
if not lang_result.valid then
|
if not lang_result.valid then
|
||||||
logger.log(lang_result.error, vim.log.levels.ERROR)
|
logger.log(lang_result.error, { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -245,7 +305,38 @@ function M.setup_problem(problem_id, language)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p')
|
if vim.fn.filereadable(source_file) == 1 then
|
||||||
|
local existing = cache.get_file_state(vim.fn.fnamemodify(source_file, ':p'))
|
||||||
|
if
|
||||||
|
existing
|
||||||
|
and (
|
||||||
|
existing.platform ~= platform
|
||||||
|
or existing.contest_id ~= (state.get_contest_id() or '')
|
||||||
|
or existing.problem_id ~= problem_id
|
||||||
|
)
|
||||||
|
then
|
||||||
|
logger.log(
|
||||||
|
('File %q already exists for %s/%s %s.'):format(
|
||||||
|
source_file,
|
||||||
|
existing.platform,
|
||||||
|
existing.contest_id,
|
||||||
|
existing.problem_id
|
||||||
|
),
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local contest_dir = vim.fn.fnamemodify(source_file, ':h')
|
||||||
|
local is_new_dir = vim.fn.isdirectory(contest_dir) == 0
|
||||||
|
vim.fn.mkdir(contest_dir, 'p')
|
||||||
|
if is_new_dir then
|
||||||
|
local s = config.hooks and config.hooks.setup
|
||||||
|
if s and s.contest then
|
||||||
|
pcall(s.contest, state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
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 +347,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 +354,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 +395,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'),
|
||||||
|
|
@ -324,7 +450,7 @@ function M.navigate_problem(direction, language)
|
||||||
local contest_id = state.get_contest_id()
|
local contest_id = state.get_contest_id()
|
||||||
local current_problem_id = state.get_problem_id()
|
local current_problem_id = state.get_problem_id()
|
||||||
if not platform or not contest_id or not current_problem_id then
|
if not platform or not contest_id or not current_problem_id then
|
||||||
logger.log('No platform configured.', vim.log.levels.ERROR)
|
logger.log('No platform configured.', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -336,7 +462,7 @@ function M.navigate_problem(direction, language)
|
||||||
constants.PLATFORM_DISPLAY_NAMES[platform],
|
constants.PLATFORM_DISPLAY_NAMES[platform],
|
||||||
contest_id
|
contest_id
|
||||||
),
|
),
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -350,9 +476,15 @@ function M.navigate_problem(direction, language)
|
||||||
|
|
||||||
logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id))
|
logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id))
|
||||||
|
|
||||||
|
local views = require('cp.ui.views')
|
||||||
|
views.cancel_io_view()
|
||||||
local active_panel = state.get_active_panel()
|
local active_panel = state.get_active_panel()
|
||||||
if active_panel == 'run' then
|
if active_panel == 'run' then
|
||||||
require('cp.ui.views').disable()
|
views.disable()
|
||||||
|
elseif active_panel == 'interactive' then
|
||||||
|
views.cancel_interactive()
|
||||||
|
elseif active_panel == 'stress' then
|
||||||
|
require('cp.stress').cancel()
|
||||||
end
|
end
|
||||||
|
|
||||||
local lang = nil
|
local lang = nil
|
||||||
|
|
@ -360,7 +492,7 @@ function M.navigate_problem(direction, language)
|
||||||
if language then
|
if language then
|
||||||
local lang_result = config_module.get_language_for_platform(platform, language)
|
local lang_result = config_module.get_language_for_platform(platform, language)
|
||||||
if not lang_result.valid then
|
if not lang_result.valid then
|
||||||
logger.log(lang_result.error, vim.log.levels.ERROR)
|
logger.log(lang_result.error, { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
lang = language
|
lang = language
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
249
lua/cp/stress.lua
Normal file
249
lua/cp/stress.lua
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
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 ''),
|
||||||
|
{ level = 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.', { level = 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}.',
|
||||||
|
{ level = 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}.',
|
||||||
|
{ level = 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.', { level = 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
|
||||||
|
|
||||||
|
function M.cancel()
|
||||||
|
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.fn.delete(state.saved_stress_session)
|
||||||
|
state.saved_stress_session = nil
|
||||||
|
end
|
||||||
|
state.set_active_panel(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
92
lua/cp/submit.lua
Normal file
92
lua/cp/submit.lua
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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', { level = 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', { level = 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.',
|
||||||
|
{ level = 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', { level = vim.log.levels.ERROR })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
source_file = vim.fn.fnamemodify(source_file, ':p')
|
||||||
|
|
||||||
|
prompt_credentials(platform, function(creds)
|
||||||
|
vim.cmd.update()
|
||||||
|
vim.notify('[cp.nvim] Submitting...', vim.log.levels.INFO)
|
||||||
|
|
||||||
|
require('cp.scraper').submit(
|
||||||
|
platform,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
language,
|
||||||
|
source_file,
|
||||||
|
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', { level = vim.log.levels.INFO, override = true })
|
||||||
|
else
|
||||||
|
local err = result and result.error or 'unknown error'
|
||||||
|
if err:match('^Login failed') then
|
||||||
|
cache.clear_credentials(platform)
|
||||||
|
end
|
||||||
|
logger.log('Submit failed: ' .. err, { level = vim.log.levels.ERROR })
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -90,7 +90,7 @@ local function delete_current_test()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if #edit_state.test_buffers == 1 then
|
if #edit_state.test_buffers == 1 then
|
||||||
logger.log('Problems must have at least one test case.', vim.log.levels.ERROR)
|
logger.log('Problems must have at least one test case.', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -237,92 +311,39 @@ setup_keybindings = function(buf)
|
||||||
end
|
end
|
||||||
|
|
||||||
if is_tracked then
|
if is_tracked then
|
||||||
logger.log('Test buffer closed unexpectedly. Exiting editor.', vim.log.levels.WARN)
|
logger.log(
|
||||||
|
'Test buffer closed unexpectedly. Exiting editor.',
|
||||||
|
{ level = vim.log.levels.WARN }
|
||||||
|
)
|
||||||
M.toggle_edit()
|
M.toggle_edit()
|
||||||
end
|
end
|
||||||
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' })
|
||||||
|
|
@ -350,7 +371,10 @@ function M.toggle_edit(test_index)
|
||||||
state.get_platform(), state.get_contest_id(), state.get_problem_id()
|
state.get_platform(), state.get_contest_id(), state.get_problem_id()
|
||||||
|
|
||||||
if not platform or not contest_id or not problem_id then
|
if not platform or not contest_id or not problem_id then
|
||||||
logger.log('No problem context. Run :CP <platform> <contest> first.', vim.log.levels.ERROR)
|
logger.log(
|
||||||
|
'No problem context. Run :CP <platform> <contest> first.',
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -358,7 +382,7 @@ function M.toggle_edit(test_index)
|
||||||
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
|
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
|
||||||
|
|
||||||
if not test_cases or #test_cases == 0 then
|
if not test_cases or #test_cases == 0 then
|
||||||
logger.log('No test cases available for editing.', vim.log.levels.ERROR)
|
logger.log('No test cases available for editing.', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -371,7 +395,7 @@ function M.toggle_edit(test_index)
|
||||||
if target_index < 1 or target_index > #test_cases then
|
if target_index < 1 or target_index > #test_cases then
|
||||||
logger.log(
|
logger.log(
|
||||||
('Test %d does not exist (only %d tests available)'):format(target_index, #test_cases),
|
('Test %d does not exist (only %d tests available)'):format(target_index, #test_cases),
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -411,7 +435,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 +445,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')
|
||||||
|
|
@ -13,7 +14,7 @@ local utils = require('cp.utils')
|
||||||
|
|
||||||
local current_diff_layout = nil
|
local current_diff_layout = nil
|
||||||
local current_mode = nil
|
local current_mode = nil
|
||||||
local io_view_running = false
|
local _run_gen = 0
|
||||||
|
|
||||||
function M.disable()
|
function M.disable()
|
||||||
local active_panel = state.get_active_panel()
|
local active_panel = state.get_active_panel()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -50,7 +53,7 @@ function M.toggle_interactive(interactor_cmd)
|
||||||
end
|
end
|
||||||
|
|
||||||
if state.get_active_panel() then
|
if state.get_active_panel() then
|
||||||
logger.log('Another panel is already active.', vim.log.levels.WARN)
|
logger.log('Another panel is already active.', { level = vim.log.levels.WARN })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -59,7 +62,7 @@ function M.toggle_interactive(interactor_cmd)
|
||||||
if not platform or not contest_id or not problem_id then
|
if not platform or not contest_id or not problem_id then
|
||||||
logger.log(
|
logger.log(
|
||||||
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -67,11 +70,14 @@ 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}.',
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -100,7 +106,7 @@ function M.toggle_interactive(interactor_cmd)
|
||||||
|
|
||||||
local binary = state.get_binary_file()
|
local binary = state.get_binary_file()
|
||||||
if not binary or binary == '' then
|
if not binary or binary == '' then
|
||||||
logger.log('No binary produced.', vim.log.levels.ERROR)
|
logger.log('No binary produced.', { level = vim.log.levels.ERROR })
|
||||||
restore_session()
|
restore_session()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -114,20 +120,29 @@ function M.toggle_interactive(interactor_cmd)
|
||||||
if vim.fn.executable(interactor) ~= 1 then
|
if vim.fn.executable(interactor) ~= 1 then
|
||||||
logger.log(
|
logger.log(
|
||||||
("Interactor '%s' is not executable."):format(interactor_cmd),
|
("Interactor '%s' is not executable."):format(interactor_cmd),
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
restore_session()
|
restore_session()
|
||||||
return
|
return
|
||||||
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 +244,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 +308,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
|
||||||
|
|
||||||
|
|
@ -386,7 +357,7 @@ function M.ensure_io_view()
|
||||||
if not platform or not contest_id or not problem_id then
|
if not platform or not contest_id or not problem_id then
|
||||||
logger.log(
|
logger.log(
|
||||||
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -415,7 +386,10 @@ function M.ensure_io_view()
|
||||||
and contest_data.index_map
|
and contest_data.index_map
|
||||||
and contest_data.problems[contest_data.index_map[problem_id]].interactive
|
and contest_data.problems[contest_data.index_map[problem_id]].interactive
|
||||||
then
|
then
|
||||||
logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR)
|
logger.log(
|
||||||
|
'This problem is not interactive. Use :CP {run,panel}.',
|
||||||
|
{ level = vim.log.levels.ERROR }
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -435,12 +409,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)
|
||||||
|
|
@ -625,13 +599,13 @@ local function render_io_view_results(io_state, test_indices, mode, combined_res
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.run_io_view(test_indices_arg, debug, mode)
|
function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
if io_view_running then
|
_run_gen = _run_gen + 1
|
||||||
logger.log('Tests already running', vim.log.levels.WARN)
|
local gen = _run_gen
|
||||||
return
|
|
||||||
end
|
|
||||||
io_view_running = true
|
|
||||||
|
|
||||||
logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), vim.log.levels.INFO, true)
|
logger.log(
|
||||||
|
('%s tests...'):format(debug and 'Debugging' or 'Running'),
|
||||||
|
{ level = vim.log.levels.INFO, override = true }
|
||||||
|
)
|
||||||
|
|
||||||
mode = mode or 'combined'
|
mode = mode or 'combined'
|
||||||
|
|
||||||
|
|
@ -640,17 +614,15 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
if not platform or not contest_id or not problem_id then
|
if not platform or not contest_id or not problem_id then
|
||||||
logger.log(
|
logger.log(
|
||||||
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
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 not contest_data or not contest_data.index_map then
|
if not contest_data or not contest_data.index_map then
|
||||||
logger.log('No test cases available.', vim.log.levels.ERROR)
|
logger.log('No test cases available.', { level = vim.log.levels.ERROR })
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -666,14 +638,12 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
if mode == 'combined' then
|
if mode == 'combined' then
|
||||||
local combined = cache.get_combined_test(platform, contest_id, problem_id)
|
local combined = cache.get_combined_test(platform, contest_id, problem_id)
|
||||||
if not combined then
|
if not combined then
|
||||||
logger.log('No combined test available', vim.log.levels.ERROR)
|
logger.log('No combined test available', { level = vim.log.levels.ERROR })
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
if not run.load_test_cases() then
|
if not run.load_test_cases() then
|
||||||
logger.log('No test cases available', vim.log.levels.ERROR)
|
logger.log('No test cases available', { level = vim.log.levels.ERROR })
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -692,9 +662,8 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
idx,
|
idx,
|
||||||
#test_state.test_cases
|
#test_state.test_cases
|
||||||
),
|
),
|
||||||
vim.log.levels.WARN
|
{ level = vim.log.levels.WARN }
|
||||||
)
|
)
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -712,7 +681,6 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
|
|
||||||
local io_state = state.get_io_view_state()
|
local io_state = state.get_io_view_state()
|
||||||
if not io_state then
|
if not io_state then
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -725,8 +693,10 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
local execute = require('cp.runner.execute')
|
local execute = require('cp.runner.execute')
|
||||||
|
|
||||||
execute.compile_problem(debug, function(compile_result)
|
execute.compile_problem(debug, function(compile_result)
|
||||||
|
if gen ~= _run_gen then
|
||||||
|
return
|
||||||
|
end
|
||||||
if not vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
if not vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -746,43 +716,62 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
|
|
||||||
local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error')
|
local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error')
|
||||||
utils.update_buffer_content(io_state.output_buf, lines, highlights, ns)
|
utils.update_buffer_content(io_state.output_buf, lines, highlights, ns)
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if mode == 'combined' then
|
if mode == 'combined' then
|
||||||
local combined = cache.get_combined_test(platform, contest_id, problem_id)
|
local combined = cache.get_combined_test(platform, contest_id, problem_id)
|
||||||
if not combined then
|
if not combined then
|
||||||
logger.log('No combined test found', vim.log.levels.ERROR)
|
logger.log('No combined test found', { level = vim.log.levels.ERROR })
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
run.load_test_cases()
|
run.load_test_cases()
|
||||||
|
|
||||||
run.run_combined_test(debug, function(result)
|
run.run_combined_test(debug, function(result)
|
||||||
|
if gen ~= _run_gen then
|
||||||
|
return
|
||||||
|
end
|
||||||
if not result then
|
if not result then
|
||||||
logger.log('Failed to run combined test', vim.log.levels.ERROR)
|
logger.log('Failed to run combined test', { level = vim.log.levels.ERROR })
|
||||||
io_view_running = false
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
if vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
||||||
render_io_view_results(io_state, test_indices, mode, result, combined.input)
|
render_io_view_results(io_state, test_indices, mode, result, combined.input)
|
||||||
end
|
end
|
||||||
io_view_running = false
|
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
run.run_all_test_cases(test_indices, debug, nil, function()
|
run.run_all_test_cases(test_indices, debug, nil, function()
|
||||||
|
if gen ~= _run_gen then
|
||||||
|
return
|
||||||
|
end
|
||||||
if vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
if vim.api.nvim_buf_is_valid(io_state.output_buf) then
|
||||||
render_io_view_results(io_state, test_indices, mode, nil, nil)
|
render_io_view_results(io_state, test_indices, mode, nil, nil)
|
||||||
end
|
end
|
||||||
io_view_running = false
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function M.cancel_io_view()
|
||||||
|
_run_gen = _run_gen + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.cancel_interactive()
|
||||||
|
if state.interactive_buf and vim.api.nvim_buf_is_valid(state.interactive_buf) then
|
||||||
|
local job = vim.b[state.interactive_buf].terminal_job_id
|
||||||
|
if job then
|
||||||
|
vim.fn.jobstop(job)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if state.saved_interactive_session then
|
||||||
|
vim.fn.delete(state.saved_interactive_session)
|
||||||
|
state.saved_interactive_session = nil
|
||||||
|
end
|
||||||
|
state.set_active_panel(nil)
|
||||||
|
end
|
||||||
|
|
||||||
---@param panel_opts? PanelOpts
|
---@param panel_opts? PanelOpts
|
||||||
function M.toggle_panel(panel_opts)
|
function M.toggle_panel(panel_opts)
|
||||||
if state.get_active_panel() == 'run' then
|
if state.get_active_panel() == 'run' then
|
||||||
|
|
@ -803,7 +792,7 @@ function M.toggle_panel(panel_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
if state.get_active_panel() then
|
if state.get_active_panel() then
|
||||||
logger.log('another panel is already active', vim.log.levels.ERROR)
|
logger.log('another panel is already active', { level = vim.log.levels.ERROR })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -812,7 +801,7 @@ function M.toggle_panel(panel_opts)
|
||||||
if not platform or not contest_id then
|
if not platform or not contest_id then
|
||||||
logger.log(
|
logger.log(
|
||||||
'No platform/contest configured. Use :CP <platform> <contest> [...] first.',
|
'No platform/contest configured. Use :CP <platform> <contest> [...] first.',
|
||||||
vim.log.levels.ERROR
|
{ level = vim.log.levels.ERROR }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -821,9 +810,13 @@ 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.',
|
||||||
|
{ level = vim.log.levels.WARN }
|
||||||
|
)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -834,10 +827,17 @@ function M.toggle_panel(panel_opts)
|
||||||
logger.log(('run panel: checking test cases for %s'):format(input_file or 'none'))
|
logger.log(('run panel: checking test cases for %s'):format(input_file or 'none'))
|
||||||
|
|
||||||
if not run.load_test_cases() then
|
if not run.load_test_cases() then
|
||||||
logger.log('no test cases found', vim.log.levels.WARN)
|
logger.log('no test cases found', { level = vim.log.levels.WARN })
|
||||||
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 +949,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
|
||||||
|
|
||||||
|
|
|
||||||
239
lua/cp/utils.lua
239
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,164 @@ 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 ''),
|
||||||
|
{ level = 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, { level = 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 ''),
|
||||||
|
{ level = 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, { level = 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 +252,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 +274,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 ''),
|
||||||
|
{ level = 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.',
|
||||||
|
{ level = vim.log.levels.WARN }
|
||||||
|
)
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Configure the buffer with good defaults
|
--- Configure the buffer with good defaults
|
||||||
|
|
@ -162,20 +347,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 +402,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 +421,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
|
|
@ -43,9 +43,13 @@ end, {
|
||||||
vim.list_extend(candidates, platforms)
|
vim.list_extend(candidates, platforms)
|
||||||
table.insert(candidates, 'cache')
|
table.insert(candidates, 'cache')
|
||||||
table.insert(candidates, 'pick')
|
table.insert(candidates, 'pick')
|
||||||
|
|
||||||
if platform and contest_id then
|
if platform and contest_id then
|
||||||
vim.list_extend(candidates, actions)
|
vim.list_extend(
|
||||||
|
candidates,
|
||||||
|
vim.tbl_filter(function(a)
|
||||||
|
return a ~= 'pick' and a ~= 'cache'
|
||||||
|
end, actions)
|
||||||
|
)
|
||||||
local cache = require('cp.cache')
|
local cache = require('cp.cache')
|
||||||
cache.load()
|
cache.load()
|
||||||
local contest_data = cache.get_contest_data(platform, contest_id)
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||||
|
|
@ -60,13 +64,14 @@ end, {
|
||||||
return filter_candidates(candidates)
|
return filter_candidates(candidates)
|
||||||
elseif num_args == 3 then
|
elseif num_args == 3 then
|
||||||
if vim.tbl_contains(platforms, args[2]) then
|
if vim.tbl_contains(platforms, args[2]) then
|
||||||
|
local candidates = { 'login', 'logout', 'signup' }
|
||||||
local cache = require('cp.cache')
|
local cache = require('cp.cache')
|
||||||
cache.load()
|
cache.load()
|
||||||
local contests = cache.get_cached_contest_ids(args[2])
|
vim.list_extend(candidates, cache.get_cached_contest_ids(args[2]))
|
||||||
return filter_candidates(contests)
|
return filter_candidates(candidates)
|
||||||
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 +108,12 @@ end, {
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return filter_candidates(candidates)
|
return filter_candidates(candidates)
|
||||||
|
elseif args[2] == 'race' then
|
||||||
|
local candidates = { 'stop' }
|
||||||
|
vim.list_extend(candidates, platforms)
|
||||||
|
return filter_candidates(candidates)
|
||||||
|
elseif args[2] == 'open' then
|
||||||
|
return filter_candidates({ 'problem', 'contest', 'standings' })
|
||||||
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 +123,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 +153,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 +168,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,11 @@ requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backoff>=2.2.1",
|
"backoff>=2.2.1",
|
||||||
"beautifulsoup4>=4.13.5",
|
"beautifulsoup4>=4.13.5",
|
||||||
"curl-cffi>=0.13.0",
|
"scrapling[fetchers]>=0.4",
|
||||||
"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,9 +2,11 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import backoff
|
import backoff
|
||||||
|
|
@ -14,21 +16,34 @@ 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 .models import (
|
from .models import (
|
||||||
CombinedTest,
|
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
|
LoginResult,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
TestCase,
|
TestCase,
|
||||||
TestsResult,
|
|
||||||
)
|
)
|
||||||
|
from .timeouts import (
|
||||||
|
BROWSER_ELEMENT_WAIT,
|
||||||
|
BROWSER_NAV_TIMEOUT,
|
||||||
|
BROWSER_SESSION_TIMEOUT,
|
||||||
|
BROWSER_SETTLE_DELAY,
|
||||||
|
BROWSER_SUBMIT_NAV_TIMEOUT,
|
||||||
|
BROWSER_TURNSTILE_POLL,
|
||||||
|
HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LANGUAGE_ID_EXTENSION = {
|
||||||
|
"6017": "cc",
|
||||||
|
"6082": "py",
|
||||||
|
}
|
||||||
|
|
||||||
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 +86,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 +109,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 +136,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 +171,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 +204,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 +216,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 +245,261 @@ 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()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _login_headless(credentials: dict[str, str]) -> LoginResult:
|
||||||
|
try:
|
||||||
|
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
|
||||||
|
except ImportError:
|
||||||
|
return LoginResult(
|
||||||
|
success=False,
|
||||||
|
error="scrapling is required for AtCoder login. 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)
|
||||||
|
|
||||||
|
logged_in = False
|
||||||
|
login_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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
) as session:
|
||||||
|
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 LoginResult(success=False, error=f"Login failed: {login_error}")
|
||||||
|
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/home", page_action=check_login, network_idle=True
|
||||||
|
)
|
||||||
|
if not logged_in:
|
||||||
|
return LoginResult(
|
||||||
|
success=False, error="Login failed (bad credentials?)"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return LoginResult(success=True, error="")
|
||||||
|
except Exception as e:
|
||||||
|
return LoginResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_headless(
|
||||||
|
contest_id: str,
|
||||||
|
problem_id: str,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
_retried: bool = False,
|
||||||
|
) -> "SubmitResult":
|
||||||
|
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 = cookie_cache.exists() and not _retried
|
||||||
|
login_error: str | None = None
|
||||||
|
submit_error: str | None = None
|
||||||
|
needs_relogin = False
|
||||||
|
|
||||||
|
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, needs_relogin
|
||||||
|
if "/login" in page.url:
|
||||||
|
needs_relogin = True
|
||||||
|
return
|
||||||
|
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)
|
||||||
|
page.set_input_files("#input-open-file", file_path)
|
||||||
|
page.wait_for_timeout(BROWSER_SETTLE_DELAY)
|
||||||
|
page.locator('button[type="submit"]').click()
|
||||||
|
page.wait_for_url(
|
||||||
|
lambda url: "/submissions/me" in url,
|
||||||
|
timeout=BROWSER_SUBMIT_NAV_TIMEOUT["atcoder"],
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
submit_error = str(e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
cookies=saved_cookies if (cookie_cache.exists() and not _retried) else [],
|
||||||
|
) as session:
|
||||||
|
if not (cookie_cache.exists() and not _retried):
|
||||||
|
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 needs_relogin and not _retried:
|
||||||
|
cookie_cache.unlink(missing_ok=True)
|
||||||
|
return _submit_headless(
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
file_path,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
|
_retried=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 +511,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 +533,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 +564,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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -279,6 +590,8 @@ class AtcoderScraper(BaseScraper):
|
||||||
contest_id=contest_id,
|
contest_id=contest_id,
|
||||||
problems=problems,
|
problems=problems,
|
||||||
url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s",
|
url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s",
|
||||||
|
contest_url=f"https://atcoder.jp/contests/{contest_id}",
|
||||||
|
standings_url=f"https://atcoder.jp/contests/{contest_id}/standings",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._metadata_error(str(e))
|
return self._metadata_error(str(e))
|
||||||
|
|
@ -319,6 +632,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,74 +640,28 @@ class AtcoderScraper(BaseScraper):
|
||||||
|
|
||||||
await asyncio.gather(*(emit(r) for r in rows))
|
await asyncio.gather(*(emit(r) for r in rows))
|
||||||
|
|
||||||
|
async def submit(
|
||||||
async def main_async() -> int:
|
self,
|
||||||
if len(sys.argv) < 2:
|
contest_id: str,
|
||||||
result = MetadataResult(
|
problem_id: str,
|
||||||
success=False,
|
file_path: str,
|
||||||
error="Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> OR atcoder.py contests",
|
language_id: str,
|
||||||
url="",
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_submit_headless,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
file_path,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
)
|
)
|
||||||
print(result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
|
|
||||||
mode: str = sys.argv[1]
|
async def login(self, credentials: dict[str, str]) -> LoginResult:
|
||||||
scraper = AtcoderScraper()
|
if not credentials.get("username") or not credentials.get("password"):
|
||||||
|
return self._login_error("Missing username or password")
|
||||||
if mode == "metadata":
|
return await asyncio.to_thread(_login_headless, credentials)
|
||||||
if len(sys.argv) != 3:
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: atcoder.py metadata <contest_id>",
|
|
||||||
url="",
|
|
||||||
)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
contest_id = sys.argv[2]
|
|
||||||
result = await scraper.scrape_contest_metadata(contest_id)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 0 if result.success else 1
|
|
||||||
|
|
||||||
if mode == "tests":
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
tests_result = TestsResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: atcoder.py tests <contest_id>",
|
|
||||||
problem_id="",
|
|
||||||
combined=CombinedTest(input="", expected=""),
|
|
||||||
tests=[],
|
|
||||||
timeout_ms=0,
|
|
||||||
memory_mb=0,
|
|
||||||
)
|
|
||||||
print(tests_result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
contest_id = sys.argv[2]
|
|
||||||
await scraper.stream_tests_for_category_async(contest_id)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if mode == "contests":
|
|
||||||
if len(sys.argv) != 2:
|
|
||||||
contest_result = ContestListResult(
|
|
||||||
success=False, error="Usage: atcoder.py contests"
|
|
||||||
)
|
|
||||||
print(contest_result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
contest_result = await scraper.scrape_contest_list()
|
|
||||||
print(contest_result.model_dump_json())
|
|
||||||
return 0 if contest_result.success else 1
|
|
||||||
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
|
|
||||||
url="",
|
|
||||||
)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
sys.exit(asyncio.run(main_async()))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
AtcoderScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,38 @@
|
||||||
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,
|
||||||
|
LoginResult,
|
||||||
|
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,9 +49,22 @@ 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,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def login(self, credentials: dict[str, str]) -> LoginResult: ...
|
||||||
|
|
||||||
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 | login"
|
||||||
|
|
||||||
def _metadata_error(self, msg: str) -> MetadataResult:
|
def _metadata_error(self, msg: str) -> MetadataResult:
|
||||||
return MetadataResult(success=False, error=msg, url="")
|
return MetadataResult(success=False, error=msg, url="")
|
||||||
|
|
@ -40,6 +83,12 @@ 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)
|
||||||
|
|
||||||
|
def _login_error(self, msg: str) -> LoginResult:
|
||||||
|
return LoginResult(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 +120,36 @@ 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) != 6:
|
||||||
|
print(
|
||||||
|
self._submit_error(
|
||||||
|
"Usage: <platform> submit <contest_id> <problem_id> <language_id> <file_path>"
|
||||||
|
).model_dump_json()
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
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], args[5], language_id, credentials
|
||||||
|
)
|
||||||
|
print(result.model_dump_json())
|
||||||
|
return 0 if result.success else 1
|
||||||
|
|
||||||
|
case "login":
|
||||||
|
creds_raw = os.environ.get("CP_CREDENTIALS", "{}")
|
||||||
|
try:
|
||||||
|
credentials = json.loads(creds_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
credentials = {}
|
||||||
|
result = await self.login(credentials)
|
||||||
|
print(result.model_dump_json())
|
||||||
|
return 0 if result.success else 1
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
print(
|
print(
|
||||||
self._metadata_error(
|
self._metadata_error(
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,21 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from scrapling.fetchers import Fetcher
|
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper
|
||||||
|
from .timeouts import BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, HTTP_TIMEOUT
|
||||||
from .models import (
|
from .models import (
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
|
LoginResult,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
TestCase,
|
TestCase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,37 +25,264 @@ BASE_URL = "https://www.codechef.com"
|
||||||
API_CONTESTS_ALL = "/api/list/contests/all"
|
API_CONTESTS_ALL = "/api/list/contests/all"
|
||||||
API_CONTEST = "/api/contests/{contest_id}"
|
API_CONTEST = "/api/contests/{contest_id}"
|
||||||
API_PROBLEM = "/api/contests/{contest_id}/problems/{problem_id}"
|
API_PROBLEM = "/api/contests/{contest_id}/problems/{problem_id}"
|
||||||
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(
|
|
||||||
r"Memory\s+[Ll]imit.*?([0-9.]+)\s*(MB|GB)", re.IGNORECASE | re.DOTALL
|
_COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "codechef-cookies.json"
|
||||||
)
|
|
||||||
|
_CC_CHECK_LOGIN_JS = """() => {
|
||||||
|
const d = document.getElementById('__NEXT_DATA__');
|
||||||
|
if (d) {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(d.textContent);
|
||||||
|
if (p?.props?.pageProps?.currentUser?.username) return true;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return !!document.querySelector('a[href="/logout"]') ||
|
||||||
|
!!document.querySelector('[class*="user-name"]');
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
async def fetch_json(client: httpx.AsyncClient, path: str) -> dict:
|
async def fetch_json(client: httpx.AsyncClient, path: str) -> dict[str, Any]:
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
def _extract_memory_limit(html: str) -> float:
|
def _login_headless_codechef(credentials: dict[str, str]) -> LoginResult:
|
||||||
m = MEMORY_LIMIT_RE.search(html)
|
try:
|
||||||
if not m:
|
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
|
||||||
return 256.0
|
except ImportError:
|
||||||
value = float(m.group(1))
|
return LoginResult(
|
||||||
unit = m.group(2).upper()
|
success=False,
|
||||||
if unit == "GB":
|
error="scrapling is required for CodeChef login",
|
||||||
return value * 1024.0
|
)
|
||||||
return value
|
|
||||||
|
from .atcoder import _ensure_browser
|
||||||
|
|
||||||
|
_ensure_browser()
|
||||||
|
|
||||||
|
_COOKIE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logged_in = False
|
||||||
|
login_error: str | None = None
|
||||||
|
|
||||||
|
def check_login(page):
|
||||||
|
nonlocal logged_in
|
||||||
|
logged_in = page.evaluate(_CC_CHECK_LOGIN_JS)
|
||||||
|
|
||||||
|
def login_action(page):
|
||||||
|
nonlocal login_error
|
||||||
|
try:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(
|
||||||
|
credentials.get("username", "")
|
||||||
|
)
|
||||||
|
page.locator('input[type="password"], input[name="password"]').first.fill(
|
||||||
|
credentials.get("password", "")
|
||||||
|
)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
page.wait_for_url(
|
||||||
|
lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
login_error = str(e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
) as session:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
session.fetch(f"{BASE_URL}/login", page_action=login_action)
|
||||||
|
if login_error:
|
||||||
|
return LoginResult(success=False, error=f"Login failed: {login_error}")
|
||||||
|
|
||||||
|
session.fetch(f"{BASE_URL}/", page_action=check_login, network_idle=True)
|
||||||
|
if not logged_in:
|
||||||
|
return LoginResult(
|
||||||
|
success=False, error="Login failed (bad credentials?)"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser_cookies = session.context.cookies()
|
||||||
|
if browser_cookies:
|
||||||
|
_COOKIE_PATH.write_text(json.dumps(browser_cookies))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return LoginResult(success=True, error="")
|
||||||
|
except Exception as e:
|
||||||
|
return LoginResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
def _fetch_html_sync(url: str) -> str:
|
def _submit_headless_codechef(
|
||||||
response = Fetcher.get(url)
|
contest_id: str,
|
||||||
return str(response.body)
|
problem_id: str,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
_retried: bool = False,
|
||||||
|
) -> SubmitResult:
|
||||||
|
source_code = Path(file_path).read_text()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
|
||||||
|
except ImportError:
|
||||||
|
return SubmitResult(
|
||||||
|
success=False,
|
||||||
|
error="scrapling is required for CodeChef submit",
|
||||||
|
)
|
||||||
|
|
||||||
|
from .atcoder import _ensure_browser
|
||||||
|
|
||||||
|
_ensure_browser()
|
||||||
|
|
||||||
|
_COOKIE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
saved_cookies: list[dict[str, Any]] = []
|
||||||
|
if _COOKIE_PATH.exists() and not _retried:
|
||||||
|
try:
|
||||||
|
saved_cookies = json.loads(_COOKIE_PATH.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logged_in = bool(saved_cookies) and not _retried
|
||||||
|
login_error: str | None = None
|
||||||
|
submit_error: str | None = None
|
||||||
|
needs_relogin = False
|
||||||
|
|
||||||
|
def check_login(page):
|
||||||
|
nonlocal logged_in
|
||||||
|
logged_in = page.evaluate(_CC_CHECK_LOGIN_JS)
|
||||||
|
|
||||||
|
def login_action(page):
|
||||||
|
nonlocal login_error
|
||||||
|
try:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(
|
||||||
|
credentials.get("username", "")
|
||||||
|
)
|
||||||
|
page.locator('input[type="password"], input[name="password"]').first.fill(
|
||||||
|
credentials.get("password", "")
|
||||||
|
)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
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, needs_relogin
|
||||||
|
if "/login" in page.url:
|
||||||
|
needs_relogin = True
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
selected = False
|
||||||
|
selects = page.locator("select")
|
||||||
|
for i in range(selects.count()):
|
||||||
|
try:
|
||||||
|
sel = selects.nth(i)
|
||||||
|
opts = sel.locator("option").all_inner_texts()
|
||||||
|
match = next(
|
||||||
|
(o for o in opts if language_id.lower() in o.lower()), None
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
sel.select_option(label=match)
|
||||||
|
selected = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
lang_trigger = page.locator(
|
||||||
|
'[class*="language"] button, [data-testid*="language"] button'
|
||||||
|
).first
|
||||||
|
lang_trigger.click()
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
page.locator(
|
||||||
|
f'[role="option"]:has-text("{language_id}"), '
|
||||||
|
f'li:has-text("{language_id}")'
|
||||||
|
).first.click()
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"""(code) => {
|
||||||
|
if (typeof monaco !== 'undefined') {
|
||||||
|
const models = monaco.editor.getModels();
|
||||||
|
if (models.length > 0) { models[0].setValue(code); return; }
|
||||||
|
}
|
||||||
|
const cm = document.querySelector('.CodeMirror');
|
||||||
|
if (cm && cm.CodeMirror) { cm.CodeMirror.setValue(code); return; }
|
||||||
|
const ta = document.querySelector('textarea');
|
||||||
|
if (ta) { ta.value = code; ta.dispatchEvent(new Event('input', {bubbles: true})); }
|
||||||
|
}""",
|
||||||
|
source_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
page.locator(
|
||||||
|
'button[type="submit"]:has-text("Submit"), button:has-text("Submit Code")'
|
||||||
|
).first.click()
|
||||||
|
page.wait_for_url(
|
||||||
|
lambda url: "/submit/" not in url or "submission" in url,
|
||||||
|
timeout=BROWSER_NAV_TIMEOUT * 2,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
submit_error = str(e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
cookies=saved_cookies if (saved_cookies and not _retried) else [],
|
||||||
|
) as session:
|
||||||
|
if not logged_in:
|
||||||
|
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}/login", page_action=login_action)
|
||||||
|
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_id}/submit/{problem_id}",
|
||||||
|
page_action=submit_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser_cookies = session.context.cookies()
|
||||||
|
if browser_cookies and logged_in:
|
||||||
|
_COOKIE_PATH.write_text(json.dumps(browser_cookies))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if needs_relogin and not _retried:
|
||||||
|
_COOKIE_PATH.unlink(missing_ok=True)
|
||||||
|
return _submit_headless_codechef(
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
file_path,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
|
_retried=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
class CodeChefScraper(BaseScraper):
|
class CodeChefScraper(BaseScraper):
|
||||||
|
|
@ -94,56 +325,31 @@ class CodeChefScraper(BaseScraper):
|
||||||
data = await fetch_json(client, API_CONTESTS_ALL)
|
data = await fetch_json(client, API_CONTESTS_ALL)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
return self._contests_error(f"Failed to fetch contests: {e}")
|
return self._contests_error(f"Failed to fetch contests: {e}")
|
||||||
all_contests = data.get("future_contests", []) + data.get(
|
contests: list[ContestSummary] = []
|
||||||
"past_contests", []
|
seen: set[str] = set()
|
||||||
|
for c in data.get("future_contests", []) + data.get("past_contests", []):
|
||||||
|
code = c.get("contest_code", "")
|
||||||
|
name = c.get("contest_name", code)
|
||||||
|
if not re.match(r"^START\d+$", code):
|
||||||
|
continue
|
||||||
|
if code in seen:
|
||||||
|
continue
|
||||||
|
seen.add(code)
|
||||||
|
start_time: int | None = None
|
||||||
|
iso = c.get("contest_start_date_iso")
|
||||||
|
if iso:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso)
|
||||||
|
start_time = int(dt.timestamp())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
contests.append(
|
||||||
|
ContestSummary(
|
||||||
|
id=code, name=name, display_name=name, start_time=start_time
|
||||||
|
)
|
||||||
)
|
)
|
||||||
max_num = 0
|
if not contests:
|
||||||
for contest in all_contests:
|
return self._contests_error("No Starters contests found")
|
||||||
contest_code = contest.get("contest_code", "")
|
|
||||||
if contest_code.startswith("START"):
|
|
||||||
match = re.match(r"START(\d+)", contest_code)
|
|
||||||
if match:
|
|
||||||
num = int(match.group(1))
|
|
||||||
max_num = max(max_num, num)
|
|
||||||
if max_num == 0:
|
|
||||||
return self._contests_error("No Starters contests found")
|
|
||||||
contests = []
|
|
||||||
sem = asyncio.Semaphore(CONNECTIONS)
|
|
||||||
|
|
||||||
async def fetch_divisions(i: int) -> list[ContestSummary]:
|
|
||||||
parent_id = f"START{i}"
|
|
||||||
async with sem:
|
|
||||||
try:
|
|
||||||
parent_data = await fetch_json(
|
|
||||||
client, API_CONTEST.format(contest_id=parent_id)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
print(f"Error fetching {parent_id}: {e}", file=sys.stderr)
|
|
||||||
return []
|
|
||||||
child_contests = parent_data.get("child_contests", {})
|
|
||||||
if not child_contests:
|
|
||||||
return []
|
|
||||||
base_name = f"Starters {i}"
|
|
||||||
divisions = []
|
|
||||||
for div_key, div_data in child_contests.items():
|
|
||||||
div_code = div_data.get("contest_code", "")
|
|
||||||
div_num = div_data.get("div", {}).get("div_number", "")
|
|
||||||
if div_code and div_num:
|
|
||||||
divisions.append(
|
|
||||||
ContestSummary(
|
|
||||||
id=div_code,
|
|
||||||
name=base_name,
|
|
||||||
display_name=f"{base_name} (Div. {div_num})",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return divisions
|
|
||||||
|
|
||||||
tasks = [fetch_divisions(i) for i in range(1, max_num + 1)]
|
|
||||||
for coro in asyncio.as_completed(tasks):
|
|
||||||
divisions = await coro
|
|
||||||
contests.extend(divisions)
|
|
||||||
return ContestListResult(success=True, error="", contests=contests)
|
return ContestListResult(success=True, error="", contests=contests)
|
||||||
|
|
||||||
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
||||||
|
|
@ -211,18 +417,15 @@ class CodeChefScraper(BaseScraper):
|
||||||
]
|
]
|
||||||
time_limit_str = problem_data.get("max_timelimit", "1")
|
time_limit_str = problem_data.get("max_timelimit", "1")
|
||||||
timeout_ms = int(float(time_limit_str) * 1000)
|
timeout_ms = int(float(time_limit_str) * 1000)
|
||||||
problem_url = PROBLEM_URL.format(problem_id=problem_code)
|
memory_mb = 256.0
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
html = await loop.run_in_executor(
|
|
||||||
None, _fetch_html_sync, problem_url
|
|
||||||
)
|
|
||||||
memory_mb = _extract_memory_limit(html)
|
|
||||||
interactive = False
|
interactive = False
|
||||||
|
precision = None
|
||||||
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 +443,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 +451,30 @@ 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,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
if not credentials.get("username") or not credentials.get("password"):
|
||||||
|
return self._submit_error("Missing credentials. Use :CP codechef login")
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_submit_headless_codechef,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
file_path,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def login(self, credentials: dict[str, str]) -> LoginResult:
|
||||||
|
if not credentials.get("username") or not credentials.get("password"):
|
||||||
|
return self._login_error("Missing username or password")
|
||||||
|
return await asyncio.to_thread(_login_headless_codechef, credentials)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
CodeChefScraper().run_cli()
|
CodeChefScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,31 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup, Tag
|
from bs4 import BeautifulSoup, Tag
|
||||||
from scrapling.fetchers import Fetcher
|
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper, extract_precision
|
||||||
from .models import (
|
from .models import (
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
|
LoginResult,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
TestCase,
|
TestCase,
|
||||||
)
|
)
|
||||||
|
from .timeouts import (
|
||||||
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
|
BROWSER_NAV_TIMEOUT,
|
||||||
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
|
BROWSER_SESSION_TIMEOUT,
|
||||||
|
BROWSER_SUBMIT_NAV_TIMEOUT,
|
||||||
|
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 +84,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] = [
|
||||||
|
|
@ -139,11 +140,30 @@ def _is_interactive(block: Tag) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def _fetch_problems_html(contest_id: str) -> str:
|
def _fetch_problems_html(contest_id: str) -> str:
|
||||||
|
try:
|
||||||
|
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("scrapling is required for Codeforces metadata")
|
||||||
|
|
||||||
|
from .atcoder import _ensure_browser
|
||||||
|
|
||||||
|
_ensure_browser()
|
||||||
|
|
||||||
url = f"{BASE_URL}/contest/{contest_id}/problems"
|
url = f"{BASE_URL}/contest/{contest_id}/problems"
|
||||||
page = Fetcher.get(
|
html = ""
|
||||||
url,
|
|
||||||
)
|
def page_action(page):
|
||||||
return page.html_content
|
nonlocal html
|
||||||
|
html = page.content()
|
||||||
|
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
) as session:
|
||||||
|
session.fetch(url, page_action=page_action, solve_cloudflare=True)
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
|
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
|
||||||
|
|
@ -159,6 +179,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 +206,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
|
||||||
|
|
@ -220,13 +242,15 @@ class CodeforcesScraper(BaseScraper):
|
||||||
contest_id=contest_id,
|
contest_id=contest_id,
|
||||||
problems=problems,
|
problems=problems,
|
||||||
url=f"https://codeforces.com/contest/{contest_id}/problem/%s",
|
url=f"https://codeforces.com/contest/{contest_id}/problem/%s",
|
||||||
|
contest_url=f"https://codeforces.com/contest/{contest_id}",
|
||||||
|
standings_url=f"https://codeforces.com/contest/{contest_id}/standings",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._metadata_error(str(e))
|
return self._metadata_error(str(e))
|
||||||
|
|
||||||
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 +258,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 +302,283 @@ 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,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_submit_headless,
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
file_path,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def login(self, credentials: dict[str, str]) -> LoginResult:
|
||||||
|
if not credentials.get("username") or not credentials.get("password"):
|
||||||
|
return self._login_error("Missing username or password")
|
||||||
|
return await asyncio.to_thread(_login_headless_cf, credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def _login_headless_cf(credentials: dict[str, str]) -> LoginResult:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import]
|
||||||
|
except ImportError:
|
||||||
|
return LoginResult(
|
||||||
|
success=False,
|
||||||
|
error="scrapling is required for Codeforces login",
|
||||||
|
)
|
||||||
|
|
||||||
|
from .atcoder import _ensure_browser
|
||||||
|
|
||||||
|
_ensure_browser()
|
||||||
|
|
||||||
|
cookie_cache = Path.home() / ".cache" / "cp-nvim" / "codeforces-cookies.json"
|
||||||
|
cookie_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logged_in = False
|
||||||
|
login_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:
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
) as session:
|
||||||
|
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 LoginResult(success=False, error=f"Login failed: {login_error}")
|
||||||
|
|
||||||
|
session.fetch(
|
||||||
|
f"{BASE_URL}/",
|
||||||
|
page_action=check_login,
|
||||||
|
network_idle=True,
|
||||||
|
)
|
||||||
|
if not logged_in:
|
||||||
|
return LoginResult(
|
||||||
|
success=False, error="Login failed (bad credentials?)"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser_cookies = session.context.cookies()
|
||||||
|
if any(c.get("name") == "X-User-Handle" for c in browser_cookies):
|
||||||
|
cookie_cache.write_text(json.dumps(browser_cookies))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return LoginResult(success=True, error="")
|
||||||
|
except Exception as e:
|
||||||
|
return LoginResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_headless(
|
||||||
|
contest_id: str,
|
||||||
|
problem_id: str,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
_retried: bool = False,
|
||||||
|
) -> SubmitResult:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
source_code = Path(file_path).read_text()
|
||||||
|
|
||||||
|
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 = cookie_cache.exists() and not _retried
|
||||||
|
login_error: str | None = None
|
||||||
|
submit_error: str | None = None
|
||||||
|
needs_relogin = False
|
||||||
|
|
||||||
|
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:
|
||||||
|
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, needs_relogin
|
||||||
|
if "/enter" in page.url or "/login" in page.url:
|
||||||
|
needs_relogin = True
|
||||||
|
return
|
||||||
|
_solve_turnstile(page)
|
||||||
|
try:
|
||||||
|
page.select_option(
|
||||||
|
'select[name="submittedProblemIndex"]',
|
||||||
|
problem_id.upper(),
|
||||||
|
)
|
||||||
|
page.select_option('select[name="programTypeId"]', language_id)
|
||||||
|
page.evaluate(
|
||||||
|
"""(code) => {
|
||||||
|
const cm = document.querySelector('.CodeMirror');
|
||||||
|
if (cm && cm.CodeMirror) {
|
||||||
|
cm.CodeMirror.setValue(code);
|
||||||
|
}
|
||||||
|
const ta = document.querySelector('textarea[name="source"]');
|
||||||
|
if (ta) ta.value = code;
|
||||||
|
}""",
|
||||||
|
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_SUBMIT_NAV_TIMEOUT["codeforces"],
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with StealthySession(
|
||||||
|
headless=True,
|
||||||
|
timeout=BROWSER_SESSION_TIMEOUT,
|
||||||
|
google_search=False,
|
||||||
|
cookies=saved_cookies if (cookie_cache.exists() and not _retried) else [],
|
||||||
|
) as session:
|
||||||
|
if not (cookie_cache.exists() and not _retried):
|
||||||
|
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=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser_cookies = session.context.cookies()
|
||||||
|
if any(c.get("name") == "X-User-Handle" for c in browser_cookies):
|
||||||
|
cookie_cache.write_text(json.dumps(browser_cookies))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if needs_relogin and not _retried:
|
||||||
|
cookie_cache.unlink(missing_ok=True)
|
||||||
|
return _submit_headless(
|
||||||
|
contest_id,
|
||||||
|
problem_id,
|
||||||
|
file_path,
|
||||||
|
language_id,
|
||||||
|
credentials,
|
||||||
|
_retried=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
|
||||||
247
scrapers/cses.py
247
scrapers/cses.py
|
|
@ -1,30 +1,45 @@
|
||||||
#!/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,
|
||||||
|
LoginResult,
|
||||||
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 +79,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 +144,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:
|
||||||
|
|
@ -199,6 +218,8 @@ class CSESScraper(BaseScraper):
|
||||||
contest_id=contest_id,
|
contest_id=contest_id,
|
||||||
problems=problems,
|
problems=problems,
|
||||||
url="https://cses.fi/problemset/task/%s",
|
url="https://cses.fi/problemset/task/%s",
|
||||||
|
contest_url="https://cses.fi/problemset",
|
||||||
|
standings_url="",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def scrape_contest_list(self) -> ContestListResult:
|
async def scrape_contest_list(self) -> ContestListResult:
|
||||||
|
|
@ -211,6 +232,28 @@ class CSESScraper(BaseScraper):
|
||||||
)
|
)
|
||||||
return ContestListResult(success=True, error="", contests=cats)
|
return ContestListResult(success=True, error="", contests=cats)
|
||||||
|
|
||||||
|
async def login(self, credentials: dict[str, str]) -> LoginResult:
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
password = credentials.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
return self._login_error("Missing username or password")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
token = await self._web_login(client, username, password)
|
||||||
|
if not token:
|
||||||
|
return self._login_error("Login failed (bad credentials?)")
|
||||||
|
|
||||||
|
return LoginResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
credentials={
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"token": token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
limits=httpx.Limits(max_connections=CONNECTIONS)
|
limits=httpx.Limits(max_connections=CONNECTIONS)
|
||||||
|
|
@ -227,10 +270,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 +300,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 +308,188 @@ 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 | None = api_data.get("X-Auth-Token")
|
||||||
|
auth_url: str | None = api_data.get("authentication_url")
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("CSES API login response missing 'X-Auth-Token'")
|
||||||
|
if not auth_url:
|
||||||
|
raise RuntimeError("CSES API login response missing '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 (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError):
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def submit(
|
||||||
|
self,
|
||||||
|
contest_id: str,
|
||||||
|
problem_id: str,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
source_code = Path(file_path).read_text()
|
||||||
|
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()
|
||||||
|
|
|
||||||
413
scrapers/kattis.py
Normal file
413
scrapers/kattis.py
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import BaseScraper, extract_precision
|
||||||
|
from .timeouts import HTTP_TIMEOUT
|
||||||
|
from .models import (
|
||||||
|
ContestListResult,
|
||||||
|
ContestSummary,
|
||||||
|
LoginResult,
|
||||||
|
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
|
||||||
|
|
||||||
|
_COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "kattis-cookies.json"
|
||||||
|
|
||||||
|
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, display_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)
|
||||||
|
precision = extract_precision(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,
|
||||||
|
"precision": precision,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_kattis_cookies(client: httpx.AsyncClient) -> None:
|
||||||
|
if not _COOKIE_PATH.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for k, v in json.loads(_COOKIE_PATH.read_text()).items():
|
||||||
|
client.cookies.set(k, v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_kattis_cookies(client: httpx.AsyncClient) -> None:
|
||||||
|
cookies = {k: v for k, v in client.cookies.items()}
|
||||||
|
if cookies:
|
||||||
|
_COOKIE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_COOKIE_PATH.write_text(json.dumps(cookies))
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_kattis_login(
|
||||||
|
client: httpx.AsyncClient, username: str, password: str
|
||||||
|
) -> bool:
|
||||||
|
client.cookies.clear()
|
||||||
|
r = await client.post(
|
||||||
|
f"{BASE_URL}/login",
|
||||||
|
data={"user": username, "password": password, "script": "true"},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
return r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
contest_url=f"{BASE_URL}/contests/{contest_id}",
|
||||||
|
standings_url=f"{BASE_URL}/contests/{contest_id}/standings",
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
contest_url=f"{BASE_URL}/problems/{contest_id}",
|
||||||
|
standings_url="",
|
||||||
|
)
|
||||||
|
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?kattis_original=on&kattis_recycled=off&user_created=off",
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
source = Path(file_path).read_bytes()
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
password = credentials.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
return self._submit_error("Missing credentials. Use :CP kattis login")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
await _load_kattis_cookies(client)
|
||||||
|
if not client.cookies:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
ok = await _do_kattis_login(client, username, password)
|
||||||
|
if not ok:
|
||||||
|
return self._submit_error("Login failed (bad credentials?)")
|
||||||
|
await _save_kattis_cookies(client)
|
||||||
|
|
||||||
|
print(json.dumps({"status": "submitting"}), flush=True)
|
||||||
|
ext = "py" if "python" in language_id.lower() else "cpp"
|
||||||
|
data: dict[str, str] = {
|
||||||
|
"submit": "true",
|
||||||
|
"script": "true",
|
||||||
|
"language": language_id,
|
||||||
|
"problem": problem_id,
|
||||||
|
"mainclass": "",
|
||||||
|
"submit_ctr": "2",
|
||||||
|
}
|
||||||
|
if contest_id != problem_id:
|
||||||
|
data["contest"] = contest_id
|
||||||
|
|
||||||
|
async def _do_submit() -> httpx.Response:
|
||||||
|
return await client.post(
|
||||||
|
f"{BASE_URL}/submit",
|
||||||
|
data=data,
|
||||||
|
files={"sub_file[]": (f"solution.{ext}", source, "text/plain")},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await _do_submit()
|
||||||
|
r.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
return self._submit_error(f"Submit request failed: {e}")
|
||||||
|
|
||||||
|
if r.text == "Request validation failed":
|
||||||
|
_COOKIE_PATH.unlink(missing_ok=True)
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
ok = await _do_kattis_login(client, username, password)
|
||||||
|
if not ok:
|
||||||
|
return self._submit_error("Login failed (bad credentials?)")
|
||||||
|
await _save_kattis_cookies(client)
|
||||||
|
try:
|
||||||
|
r = await _do_submit()
|
||||||
|
r.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
return self._submit_error(f"Submit request failed: {e}")
|
||||||
|
|
||||||
|
sid_m = re.search(r"Submission ID:\s*(\d+)", r.text, re.IGNORECASE)
|
||||||
|
if not sid_m:
|
||||||
|
return self._submit_error(
|
||||||
|
r.text.strip() or "Submit failed (no submission ID)"
|
||||||
|
)
|
||||||
|
return SubmitResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
submission_id=sid_m.group(1),
|
||||||
|
verdict="submitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def login(self, credentials: dict[str, str]) -> LoginResult:
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
password = credentials.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
return self._login_error("Missing username or password")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
ok = await _do_kattis_login(client, username, password)
|
||||||
|
if not ok:
|
||||||
|
return self._login_error("Login failed (bad credentials?)")
|
||||||
|
await _save_kattis_cookies(client)
|
||||||
|
return LoginResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
credentials={"username": username, "password": password},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
KattisScraper().run_cli()
|
||||||
30
scrapers/language_ids.py
Normal file
30
scrapers/language_ids.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
LANGUAGE_IDS = {
|
||||||
|
"atcoder": {
|
||||||
|
"cpp": "6017",
|
||||||
|
"python": "6082",
|
||||||
|
},
|
||||||
|
"codeforces": {
|
||||||
|
"cpp": "89",
|
||||||
|
"python": "70",
|
||||||
|
},
|
||||||
|
"cses": {
|
||||||
|
"cpp": "C++17",
|
||||||
|
"python": "Python3",
|
||||||
|
},
|
||||||
|
"usaco": {
|
||||||
|
"cpp": "cpp",
|
||||||
|
"python": "python",
|
||||||
|
},
|
||||||
|
"kattis": {
|
||||||
|
"cpp": "C++",
|
||||||
|
"python": "Python 3",
|
||||||
|
},
|
||||||
|
"codechef": {
|
||||||
|
"cpp": "C++ 17",
|
||||||
|
"python": "Python 3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
@ -41,6 +42,8 @@ class MetadataResult(ScrapingResult):
|
||||||
contest_id: str = ""
|
contest_id: str = ""
|
||||||
problems: list[ProblemSummary] = Field(default_factory=list)
|
problems: list[ProblemSummary] = Field(default_factory=list)
|
||||||
url: str
|
url: str
|
||||||
|
contest_url: str = ""
|
||||||
|
standings_url: str = ""
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -63,6 +66,19 @@ class TestsResult(ScrapingResult):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResult(ScrapingResult):
|
||||||
|
credentials: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
15
scrapers/timeouts.py
Normal file
15
scrapers/timeouts.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
HTTP_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
BROWSER_SESSION_TIMEOUT = 15000
|
||||||
|
BROWSER_NAV_TIMEOUT = 10000
|
||||||
|
BROWSER_SUBMIT_NAV_TIMEOUT: defaultdict[str, int] = defaultdict(
|
||||||
|
lambda: BROWSER_NAV_TIMEOUT
|
||||||
|
)
|
||||||
|
BROWSER_SUBMIT_NAV_TIMEOUT["codeforces"] = BROWSER_NAV_TIMEOUT * 2
|
||||||
|
BROWSER_TURNSTILE_POLL = 5000
|
||||||
|
BROWSER_ELEMENT_WAIT = 10000
|
||||||
|
BROWSER_SETTLE_DELAY = 500
|
||||||
|
|
||||||
|
SUBMIT_POLL_TIMEOUT = 30.0
|
||||||
528
scrapers/usaco.py
Normal file
528
scrapers/usaco.py
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import BaseScraper, extract_precision
|
||||||
|
from .timeouts import HTTP_TIMEOUT
|
||||||
|
from .models import (
|
||||||
|
ContestListResult,
|
||||||
|
ContestSummary,
|
||||||
|
LoginResult,
|
||||||
|
MetadataResult,
|
||||||
|
ProblemSummary,
|
||||||
|
SubmitResult,
|
||||||
|
TestCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_URL = "http://www.usaco.org"
|
||||||
|
_AUTH_BASE = "https://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
|
||||||
|
|
||||||
|
_COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "usaco-cookies.json"
|
||||||
|
_LOGIN_PATH = "/current/tpcm/login-session.php"
|
||||||
|
_SUBMIT_PATH = "/current/tpcm/submit-solution.php"
|
||||||
|
|
||||||
|
_LANG_KEYWORDS: dict[str, list[str]] = {
|
||||||
|
"cpp": ["c++17", "c++ 17", "g++17", "c++", "cpp"],
|
||||||
|
"python": ["python3", "python 3", "python"],
|
||||||
|
"java": ["java"],
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
div = heading_m.group(3)
|
||||||
|
if div:
|
||||||
|
key = div.lower()
|
||||||
|
current_div = key
|
||||||
|
sections.setdefault(key, [])
|
||||||
|
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()
|
||||||
|
precision = extract_precision(html)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tests": tests,
|
||||||
|
"timeout_ms": timeout_ms,
|
||||||
|
"memory_mb": memory_mb,
|
||||||
|
"interactive": interactive,
|
||||||
|
"precision": precision,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_lang_option(select_body: str, language_id: str) -> str | None:
|
||||||
|
keywords = _LANG_KEYWORDS.get(language_id.lower(), [language_id.lower()])
|
||||||
|
options = [
|
||||||
|
(m.group(1), m.group(2).strip().lower())
|
||||||
|
for m in re.finditer(
|
||||||
|
r'<option\b[^>]*\bvalue=["\']([^"\']*)["\'][^>]*>([^<]+)',
|
||||||
|
select_body,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for kw in keywords:
|
||||||
|
for val, text in options:
|
||||||
|
if kw in text:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_submit_form(
|
||||||
|
html: str, language_id: str
|
||||||
|
) -> tuple[str, dict[str, str], str | None]:
|
||||||
|
form_action = _AUTH_BASE + _SUBMIT_PATH
|
||||||
|
hidden: dict[str, str] = {}
|
||||||
|
lang_val: str | None = None
|
||||||
|
for form_m in re.finditer(
|
||||||
|
r'<form\b[^>]*action=["\']([^"\']+)["\'][^>]*>(.*?)</form>',
|
||||||
|
html,
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
):
|
||||||
|
action, body = form_m.group(1), form_m.group(2)
|
||||||
|
if "sourcefile" not in body.lower():
|
||||||
|
continue
|
||||||
|
if action.startswith("http"):
|
||||||
|
form_action = action
|
||||||
|
elif action.startswith("/"):
|
||||||
|
form_action = _AUTH_BASE + action
|
||||||
|
else:
|
||||||
|
form_action = _AUTH_BASE + "/" + action
|
||||||
|
for input_m in re.finditer(
|
||||||
|
r'<input\b[^>]*\btype=["\']hidden["\'][^>]*/?>',
|
||||||
|
body,
|
||||||
|
re.IGNORECASE,
|
||||||
|
):
|
||||||
|
tag = input_m.group(0)
|
||||||
|
name_m = re.search(r'\bname=["\']([^"\']+)["\']', tag, re.IGNORECASE)
|
||||||
|
val_m = re.search(r'\bvalue=["\']([^"\']*)["\']', tag, re.IGNORECASE)
|
||||||
|
if name_m and val_m:
|
||||||
|
hidden[name_m.group(1)] = val_m.group(1)
|
||||||
|
for sel_m in re.finditer(
|
||||||
|
r'<select\b[^>]*\bname=["\']([^"\']+)["\'][^>]*>(.*?)</select>',
|
||||||
|
body,
|
||||||
|
re.DOTALL | re.IGNORECASE,
|
||||||
|
):
|
||||||
|
name, sel_body = sel_m.group(1), sel_m.group(2)
|
||||||
|
if "lang" in name.lower():
|
||||||
|
lang_val = _pick_lang_option(sel_body, language_id)
|
||||||
|
break
|
||||||
|
break
|
||||||
|
return form_action, hidden, lang_val
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_usaco_cookies(client: httpx.AsyncClient) -> None:
|
||||||
|
if not _COOKIE_PATH.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for k, v in json.loads(_COOKIE_PATH.read_text()).items():
|
||||||
|
client.cookies.set(k, v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_usaco_cookies(client: httpx.AsyncClient) -> None:
|
||||||
|
cookies = {k: v for k, v in client.cookies.items()}
|
||||||
|
if cookies:
|
||||||
|
_COOKIE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_COOKIE_PATH.write_text(json.dumps(cookies))
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_usaco_login(client: httpx.AsyncClient, username: str) -> bool:
|
||||||
|
try:
|
||||||
|
r = await client.get(
|
||||||
|
f"{_AUTH_BASE}/index.php",
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
text = r.text.lower()
|
||||||
|
return username.lower() in text or "logout" in text
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_usaco_login(
|
||||||
|
client: httpx.AsyncClient, username: str, password: str
|
||||||
|
) -> bool:
|
||||||
|
r = await client.post(
|
||||||
|
f"{_AUTH_BASE}{_LOGIN_PATH}",
|
||||||
|
data={"uname": username, "password": password},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
try:
|
||||||
|
return r.json().get("code") == 1
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"precision": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"precision": info["precision"],
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
file_path: str,
|
||||||
|
language_id: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
) -> SubmitResult:
|
||||||
|
source = Path(file_path).read_bytes()
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
password = credentials.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
return self._submit_error("Missing credentials. Use :CP usaco login")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
await _load_usaco_cookies(client)
|
||||||
|
if not client.cookies:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
try:
|
||||||
|
ok = await _do_usaco_login(client, username, password)
|
||||||
|
except Exception as e:
|
||||||
|
return self._submit_error(f"Login failed: {e}")
|
||||||
|
if not ok:
|
||||||
|
return self._submit_error("Login failed (bad credentials?)")
|
||||||
|
await _save_usaco_cookies(client)
|
||||||
|
|
||||||
|
result = await self._do_submit(client, problem_id, language_id, source)
|
||||||
|
|
||||||
|
if result.success or result.error != "auth_failure":
|
||||||
|
return result
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
try:
|
||||||
|
ok = await _do_usaco_login(client, username, password)
|
||||||
|
except Exception as e:
|
||||||
|
return self._submit_error(f"Login failed: {e}")
|
||||||
|
if not ok:
|
||||||
|
return self._submit_error("Login failed (bad credentials?)")
|
||||||
|
await _save_usaco_cookies(client)
|
||||||
|
|
||||||
|
return await self._do_submit(client, problem_id, language_id, source)
|
||||||
|
|
||||||
|
async def _do_submit(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
problem_id: str,
|
||||||
|
language_id: str,
|
||||||
|
source: bytes,
|
||||||
|
) -> SubmitResult:
|
||||||
|
print(json.dumps({"status": "submitting"}), flush=True)
|
||||||
|
try:
|
||||||
|
page_r = await client.get(
|
||||||
|
f"{_AUTH_BASE}/index.php?page=viewproblem2&cpid={problem_id}",
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
if "login" in page_r.url.path.lower() or "Login" in page_r.text[:2000]:
|
||||||
|
return self._submit_error("auth_failure")
|
||||||
|
form_url, hidden_fields, lang_val = _parse_submit_form(
|
||||||
|
page_r.text, language_id
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
form_url = _AUTH_BASE + _SUBMIT_PATH
|
||||||
|
hidden_fields = {}
|
||||||
|
lang_val = None
|
||||||
|
|
||||||
|
data: dict[str, str] = {"cpid": problem_id, **hidden_fields}
|
||||||
|
data["language"] = lang_val if lang_val is not None else language_id
|
||||||
|
ext = "py" if "python" in language_id.lower() else "cpp"
|
||||||
|
try:
|
||||||
|
r = await client.post(
|
||||||
|
form_url,
|
||||||
|
data=data,
|
||||||
|
files={"sourcefile": (f"solution.{ext}", source, "text/plain")},
|
||||||
|
headers=HEADERS,
|
||||||
|
timeout=HTTP_TIMEOUT,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
return self._submit_error(f"Submit request failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = r.json()
|
||||||
|
if resp.get("code") == 0 and "login" in resp.get("message", "").lower():
|
||||||
|
return self._submit_error("auth_failure")
|
||||||
|
sid = str(resp.get("submission_id", resp.get("id", "")))
|
||||||
|
except Exception:
|
||||||
|
sid = ""
|
||||||
|
return SubmitResult(
|
||||||
|
success=True, error="", submission_id=sid, verdict="submitted"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def login(self, credentials: dict[str, str]) -> LoginResult:
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
password = credentials.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
return self._login_error("Missing username or password")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
|
print(json.dumps({"status": "logging_in"}), flush=True)
|
||||||
|
try:
|
||||||
|
ok = await _do_usaco_login(client, username, password)
|
||||||
|
except Exception as e:
|
||||||
|
return self._login_error(f"Login request failed: {e}")
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
return self._login_error("Login failed (bad credentials?)")
|
||||||
|
|
||||||
|
await _save_usaco_cookies(client)
|
||||||
|
return LoginResult(
|
||||||
|
success=True,
|
||||||
|
error="",
|
||||||
|
credentials={"username": username, "password": password},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from scrapling import fetchers
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
FIX = Path(__file__).resolve().parent / "fixtures"
|
FIX = Path(__file__).resolve().parent / "fixtures"
|
||||||
|
|
@ -104,6 +105,35 @@ def run_scraper_offline(fixture_text):
|
||||||
|
|
||||||
raise AssertionError(f"No fixture for Codeforces url={url!r}")
|
raise AssertionError(f"No fixture for Codeforces url={url!r}")
|
||||||
|
|
||||||
|
def _router_kattis(*, url: str) -> str:
|
||||||
|
url = url.removeprefix("https://open.kattis.com")
|
||||||
|
if "/contests?" in url:
|
||||||
|
return fixture_text("kattis/contests.html")
|
||||||
|
m = re.search(r"/contests/([^/]+)/problems", url)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return fixture_text(f"kattis/contest_{m.group(1)}_problems.html")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "<html></html>"
|
||||||
|
if "/problems/" in url and "/file/statement" not in url:
|
||||||
|
slug = url.rstrip("/").split("/")[-1]
|
||||||
|
return fixture_text(f"kattis/problem_{slug}.html")
|
||||||
|
raise AssertionError(f"No fixture for Kattis url={url!r}")
|
||||||
|
|
||||||
|
def _router_usaco(*, url: str) -> str:
|
||||||
|
if "page=contests" in url and "results" not in url:
|
||||||
|
return fixture_text("usaco/contests.html")
|
||||||
|
m = re.search(r"page=([a-z]+\d{2,4}results)", url)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return fixture_text(f"usaco/{m.group(1)}.html")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "<html></html>"
|
||||||
|
m = re.search(r"page=viewproblem2&cpid=(\d+)", url)
|
||||||
|
if m:
|
||||||
|
return fixture_text(f"usaco/problem_{m.group(1)}.html")
|
||||||
|
raise AssertionError(f"No fixture for USACO url={url!r}")
|
||||||
|
|
||||||
def _make_offline_fetches(scraper_name: str):
|
def _make_offline_fetches(scraper_name: str):
|
||||||
match scraper_name:
|
match scraper_name:
|
||||||
case "cses":
|
case "cses":
|
||||||
|
|
@ -136,12 +166,10 @@ def run_scraper_offline(fixture_text):
|
||||||
|
|
||||||
case "codeforces":
|
case "codeforces":
|
||||||
|
|
||||||
class MockCodeForcesPage:
|
def _mock_fetch_problems_html(cid: str) -> str:
|
||||||
def __init__(self, html: str):
|
return _router_codeforces(
|
||||||
self.html_content = html
|
url=f"https://codeforces.com/contest/{cid}/problems"
|
||||||
|
)
|
||||||
def _mock_stealthy_fetch(url: str, **kwargs):
|
|
||||||
return MockCodeForcesPage(_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 +200,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,
|
"_fetch_problems_html": _mock_fetch_problems_html,
|
||||||
"requests.get": _mock_requests_get,
|
"requests.get": _mock_requests_get,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,21 +240,39 @@ 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:
|
|
||||||
def __init__(self, html: str):
|
|
||||||
self.body = html
|
|
||||||
self.status = 200
|
|
||||||
|
|
||||||
def _mock_stealthy_fetch(url: str, **kwargs):
|
|
||||||
if "/problems/" in url:
|
|
||||||
problem_id = url.rstrip("/").split("/")[-1]
|
|
||||||
html = fixture_text(f"codechef/{problem_id}.html")
|
|
||||||
return MockCodeChefPage(html)
|
|
||||||
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,
|
}
|
||||||
|
|
||||||
|
case "kattis":
|
||||||
|
|
||||||
|
async def __offline_get_kattis(client, url: str, **kwargs):
|
||||||
|
if "/file/statement/samples.zip" in url:
|
||||||
|
raise httpx.HTTPError("not found")
|
||||||
|
html = _router_kattis(url=url)
|
||||||
|
return SimpleNamespace(
|
||||||
|
text=html,
|
||||||
|
content=html.encode(),
|
||||||
|
status_code=200,
|
||||||
|
raise_for_status=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"__offline_get_async": __offline_get_kattis,
|
||||||
|
}
|
||||||
|
|
||||||
|
case "usaco":
|
||||||
|
|
||||||
|
async def __offline_get_usaco(client, url: str, **kwargs):
|
||||||
|
html = _router_usaco(url=url)
|
||||||
|
return SimpleNamespace(
|
||||||
|
text=html,
|
||||||
|
status_code=200,
|
||||||
|
raise_for_status=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"__offline_get_async": __offline_get_usaco,
|
||||||
}
|
}
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
|
|
@ -237,6 +283,8 @@ def run_scraper_offline(fixture_text):
|
||||||
"atcoder": "AtcoderScraper",
|
"atcoder": "AtcoderScraper",
|
||||||
"codeforces": "CodeforcesScraper",
|
"codeforces": "CodeforcesScraper",
|
||||||
"codechef": "CodeChefScraper",
|
"codechef": "CodeChefScraper",
|
||||||
|
"kattis": "KattisScraper",
|
||||||
|
"usaco": "USACOScraper",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _run(scraper_name: str, mode: str, *args: str):
|
def _run(scraper_name: str, mode: str, *args: str):
|
||||||
|
|
@ -245,16 +293,15 @@ 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"]
|
ns._fetch_problems_html = offline_fetches["_fetch_problems_html"]
|
||||||
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"]
|
||||||
ns._get_async = offline_fetches["_get_async"]
|
ns._get_async = offline_fetches["_get_async"]
|
||||||
elif scraper_name == "cses":
|
elif scraper_name == "cses":
|
||||||
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
|
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
|
||||||
elif scraper_name == "codechef":
|
elif scraper_name in ("codechef", "kattis", "usaco"):
|
||||||
httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
|
httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
|
||||||
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
|
|
||||||
|
|
||||||
scraper_class = getattr(ns, scraper_classes[scraper_name])
|
scraper_class = getattr(ns, scraper_classes[scraper_name])
|
||||||
scraper = scraper_class()
|
scraper = scraper_class()
|
||||||
|
|
|
||||||
10
tests/fixtures/kattis/contest_open2024_problems.html
vendored
Normal file
10
tests/fixtures/kattis/contest_open2024_problems.html
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<html><body><table>
|
||||||
|
<tr>
|
||||||
|
<td>A</td>
|
||||||
|
<td><a href="/contests/open2024/problems/kth2024a">Arithmetic Sequence</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>B</td>
|
||||||
|
<td><a href="/contests/open2024/problems/kth2024b">Binary Tree</a></td>
|
||||||
|
</tr>
|
||||||
|
</table></body></html>
|
||||||
10
tests/fixtures/kattis/contests.html
vendored
Normal file
10
tests/fixtures/kattis/contests.html
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<html><body><table>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/contests/open2024">Open 2024</a></td>
|
||||||
|
<td data-timestamp="1711800000">2024-03-30</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/contests/icpc2023">ICPC 2023</a></td>
|
||||||
|
<td data-timestamp="1698768000">2023-10-31</td>
|
||||||
|
</tr>
|
||||||
|
</table></body></html>
|
||||||
11
tests/fixtures/kattis/problem_hello.html
vendored
Normal file
11
tests/fixtures/kattis/problem_hello.html
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<html>
|
||||||
|
<head><title>Hello World</title></head>
|
||||||
|
<body>
|
||||||
|
<span>CPU Time limit</span><span class="num">1 second</span>
|
||||||
|
<span>Memory limit</span><span class="num">256 MB</span>
|
||||||
|
<table class="sample">
|
||||||
|
<pre>Hello World</pre>
|
||||||
|
<pre>Hello World</pre>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
tests/fixtures/kattis/problem_kth2024a.html
vendored
Normal file
17
tests/fixtures/kattis/problem_kth2024a.html
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<html>
|
||||||
|
<head><title>Arithmetic Sequence</title></head>
|
||||||
|
<body>
|
||||||
|
<span>CPU Time limit</span><span class="num">2 seconds</span>
|
||||||
|
<span>Memory limit</span><span class="num">512 MB</span>
|
||||||
|
<table class="sample">
|
||||||
|
<pre>3
|
||||||
|
1 2 3</pre>
|
||||||
|
<pre>YES</pre>
|
||||||
|
</table>
|
||||||
|
<table class="sample">
|
||||||
|
<pre>2
|
||||||
|
1 3</pre>
|
||||||
|
<pre>NO</pre>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
tests/fixtures/kattis/problem_kth2024b.html
vendored
Normal file
12
tests/fixtures/kattis/problem_kth2024b.html
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<html>
|
||||||
|
<head><title>Binary Tree</title></head>
|
||||||
|
<body>
|
||||||
|
<span>CPU Time limit</span><span class="num">1 second</span>
|
||||||
|
<span>Memory limit</span><span class="num">256 MB</span>
|
||||||
|
<table class="sample">
|
||||||
|
<pre>5
|
||||||
|
1 2 3 4 5</pre>
|
||||||
|
<pre>3</pre>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
tests/fixtures/usaco/contests.html
vendored
Normal file
3
tests/fixtures/usaco/contests.html
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<html><body>
|
||||||
|
<a href="index.php?page=dec24results">December 2024 Results</a>
|
||||||
|
</body></html>
|
||||||
14
tests/fixtures/usaco/dec24results.html
vendored
Normal file
14
tests/fixtures/usaco/dec24results.html
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<html><body>
|
||||||
|
|
||||||
|
<h2>USACO 2024 December Contest, Gold</h2>
|
||||||
|
|
||||||
|
<b>Farmer John's Favorite Problem</b><br/>
|
||||||
|
<a href="index.php?page=viewproblem2&cpid=1469">View Problem</a>
|
||||||
|
|
||||||
|
<b>Binary Indexed Tree</b><br/>
|
||||||
|
<a href="index.php?page=viewproblem2&cpid=1470">View Problem</a>
|
||||||
|
|
||||||
|
<b>Counting Subsequences</b><br/>
|
||||||
|
<a href="index.php?page=viewproblem2&cpid=1471">View Problem</a>
|
||||||
|
|
||||||
|
</body></html>
|
||||||
10
tests/fixtures/usaco/problem_1469.html
vendored
Normal file
10
tests/fixtures/usaco/problem_1469.html
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<html><body>
|
||||||
|
<p>Time limit: 4s. Memory limit: 256 MB.</p>
|
||||||
|
<p>Given N cows, find the answer.</p>
|
||||||
|
<pre class="in">3
|
||||||
|
1 2 3</pre>
|
||||||
|
<pre class="out">6</pre>
|
||||||
|
<pre class="in">1
|
||||||
|
5</pre>
|
||||||
|
<pre class="out">5</pre>
|
||||||
|
</body></html>
|
||||||
7
tests/fixtures/usaco/problem_1470.html
vendored
Normal file
7
tests/fixtures/usaco/problem_1470.html
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<html><body>
|
||||||
|
<p>Time limit: 2s. Memory limit: 512 MB.</p>
|
||||||
|
<p>Build a binary indexed tree.</p>
|
||||||
|
<pre class="in">4
|
||||||
|
1 3 2 4</pre>
|
||||||
|
<pre class="out">10</pre>
|
||||||
|
</body></html>
|
||||||
7
tests/fixtures/usaco/problem_1471.html
vendored
Normal file
7
tests/fixtures/usaco/problem_1471.html
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<html><body>
|
||||||
|
<p>Time limit: 4s. Memory limit: 256 MB.</p>
|
||||||
|
<p>Output the answer with absolute error at most 10^{-6}.</p>
|
||||||
|
<pre class="in">2
|
||||||
|
1 2</pre>
|
||||||
|
<pre class="out">1.500000</pre>
|
||||||
|
</body></html>
|
||||||
|
|
@ -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",),
|
||||||
|
|
@ -32,6 +27,16 @@ MATRIX = {
|
||||||
"tests": ("START209D",),
|
"tests": ("START209D",),
|
||||||
"contests": tuple(),
|
"contests": tuple(),
|
||||||
},
|
},
|
||||||
|
"kattis": {
|
||||||
|
"metadata": ("hello",),
|
||||||
|
"tests": ("hello",),
|
||||||
|
"contests": tuple(),
|
||||||
|
},
|
||||||
|
"usaco": {
|
||||||
|
"metadata": ("dec24_gold",),
|
||||||
|
"tests": ("dec24_gold",),
|
||||||
|
"contests": tuple(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,17 +48,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
|
||||||
|
|
@ -91,5 +95,45 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
|
||||||
)
|
)
|
||||||
assert "multi_test" in obj, "Missing multi_test field in raw JSON"
|
assert "multi_test" in obj, "Missing multi_test field in raw JSON"
|
||||||
assert isinstance(obj["multi_test"], bool), "multi_test not boolean"
|
assert isinstance(obj["multi_test"], bool), "multi_test not boolean"
|
||||||
|
assert "precision" in obj, "Missing precision field in raw JSON"
|
||||||
|
assert obj["precision"] is None or isinstance(
|
||||||
|
obj["precision"], float
|
||||||
|
), "precision must be None or float"
|
||||||
validated_any = True
|
validated_any = True
|
||||||
assert validated_any, "No valid tests payloads validated"
|
assert validated_any, "No valid tests payloads validated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_kattis_contest_metadata(run_scraper_offline):
|
||||||
|
rc, objs = run_scraper_offline("kattis", "metadata", "open2024")
|
||||||
|
assert rc == 0
|
||||||
|
assert objs
|
||||||
|
model = MetadataResult.model_validate(objs[-1])
|
||||||
|
assert model.success is True
|
||||||
|
assert len(model.problems) == 2
|
||||||
|
assert model.contest_url != ""
|
||||||
|
assert model.standings_url != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_usaco_precision_extracted(run_scraper_offline):
|
||||||
|
rc, objs = run_scraper_offline("usaco", "tests", "dec24_gold")
|
||||||
|
assert rc == 0
|
||||||
|
precisions = [obj["precision"] for obj in objs if "problem_id" in obj]
|
||||||
|
assert any(p is not None for p in precisions), (
|
||||||
|
"Expected at least one problem with precision"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"scraper,contest_id",
|
||||||
|
[
|
||||||
|
("cses", "nonexistent_category_xyz"),
|
||||||
|
("usaco", "badformat"),
|
||||||
|
("kattis", "nonexistent_problem_xyz"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_scraper_metadata_error(run_scraper_offline, scraper, contest_id):
|
||||||
|
rc, objs = run_scraper_offline(scraper, "metadata", contest_id)
|
||||||
|
assert rc == 1
|
||||||
|
assert objs
|
||||||
|
assert objs[-1].get("success") is False
|
||||||
|
assert objs[-1].get("error")
|
||||||
|
|
|
||||||
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