Compare commits
54 commits
feat/async
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
| dc635d5167 | |||
|
|
81ddd1ea87 | ||
|
|
7444a99b22 | ||
| ec487aa489 | |||
|
|
c4af9bf604 | ||
|
|
a4437bc1c6 | ||
| 1a7e9517ba | |||
| 11b8365aac | |||
|
|
585ebf0daf | ||
| 08fb654d23 | |||
| 01efc7c344 | |||
| f9f993db0c | |||
| f184a7874a | |||
|
|
89e3c0e21d | ||
|
|
a9ce31a291 | ||
| c8f735617a | |||
|
|
a14f543371 | ||
|
|
56ec178cdd | ||
| 5cd6f75419 | |||
|
|
99d907aa7a | ||
| c06d819597 | |||
|
|
682b267019 | ||
|
|
8a2871ec1b | ||
| de1295d361 | |||
|
|
32f449850b | ||
| 6966e8e101 | |||
|
|
a5e094d44a | ||
|
|
5de6fb2fee | ||
| bd25f1db0b | |||
|
|
9daa4e4ec4 | ||
| 0b5c0f0c40 | |||
|
|
af559b0fa3 | ||
| d496509fce | |||
| 383b327442 | |||
| 3f677137de | |||
| 0a1cea9b43 | |||
|
|
6ba51a92c2 | ||
|
|
86f2e41983 | ||
| d89a40b21f | |||
| 3348ac3e51 | |||
| ee38da5074 | |||
| 9af359eb01 | |||
| 0b21d02f24 | |||
| 282d701327 | |||
|
|
dcadf7447d | ||
| 89c1a3c683 | |||
| 83514c453e | |||
| d5c6783124 | |||
| 5293515aca | |||
|
|
7dafb7ea43 | ||
| 0f82ae4fdb | |||
| 873ddee0d4 | |||
| fb7888b83c | |||
|
|
ae7b571b68 |
28 changed files with 630 additions and 484 deletions
78
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
name: Bug Report
|
||||||
|
description: Report a bug
|
||||||
|
title: 'bug: '
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Prerequisites
|
||||||
|
options:
|
||||||
|
- label:
|
||||||
|
I have searched [existing
|
||||||
|
issues](https://github.com/barrettruth/cp.nvim/issues)
|
||||||
|
required: true
|
||||||
|
- label: I have updated to the latest version
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: 'Neovim version'
|
||||||
|
description: 'Output of `nvim --version`'
|
||||||
|
render: text
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: 'Operating system'
|
||||||
|
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
|
description: What happened? What did you expect?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Minimal steps to trigger the bug
|
||||||
|
value: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: 'Health check'
|
||||||
|
description: 'Output of `:checkhealth cp`'
|
||||||
|
render: text
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Minimal reproduction
|
||||||
|
description: |
|
||||||
|
Save the script below as `repro.lua`, edit if needed, and run:
|
||||||
|
```
|
||||||
|
nvim -u repro.lua
|
||||||
|
```
|
||||||
|
Confirm the bug reproduces with this config before submitting.
|
||||||
|
render: lua
|
||||||
|
value: |
|
||||||
|
vim.env.LAZY_STDPATH = '.repro'
|
||||||
|
load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))()
|
||||||
|
require('lazy.nvim').setup({
|
||||||
|
spec = {
|
||||||
|
{
|
||||||
|
'barrett-ruth/cp.nvim',
|
||||||
|
opts = {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
5
.github/ISSUE_TEMPLATE/config.yaml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yaml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Questions
|
||||||
|
url: https://github.com/barrettruth/cp.nvim/discussions
|
||||||
|
about: Ask questions and discuss ideas
|
||||||
30
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
30
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
name: Feature Request
|
||||||
|
description: Suggest a feature
|
||||||
|
title: 'feat: '
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Prerequisites
|
||||||
|
options:
|
||||||
|
- label:
|
||||||
|
I have searched [existing
|
||||||
|
issues](https://github.com/barrettruth/cp.nvim/issues)
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: What problem does this solve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
112
.github/workflows/ci.yaml
vendored
Normal file
112
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
lua: ${{ steps.changes.outputs.lua }}
|
||||||
|
python: ${{ steps.changes.outputs.python }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dorny/paths-filter@v3
|
||||||
|
id: changes
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
lua:
|
||||||
|
- 'lua/**'
|
||||||
|
- 'spec/**'
|
||||||
|
- 'plugin/**'
|
||||||
|
- 'after/**'
|
||||||
|
- 'ftdetect/**'
|
||||||
|
- '*.lua'
|
||||||
|
- '.luarc.json'
|
||||||
|
- 'stylua.toml'
|
||||||
|
- 'selene.toml'
|
||||||
|
python:
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'scrapers/**'
|
||||||
|
- 'tests/**'
|
||||||
|
- 'pyproject.toml'
|
||||||
|
- 'uv.lock'
|
||||||
|
|
||||||
|
lua-format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.lua == 'true' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: JohnnyMorganz/stylua-action@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
version: 2.1.0
|
||||||
|
args: --check .
|
||||||
|
|
||||||
|
lua-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.lua == 'true' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: NTBBloodbath/selene-action@v1.0.0
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
args: --display-style quiet .
|
||||||
|
|
||||||
|
lua-typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.lua == 'true' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: mrcjkb/lua-typecheck-action@v0
|
||||||
|
with:
|
||||||
|
checklevel: Warning
|
||||||
|
directories: lua
|
||||||
|
configpath: .luarc.json
|
||||||
|
|
||||||
|
python-format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.python == 'true' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v4
|
||||||
|
- run: uv tool install ruff
|
||||||
|
- run: ruff format --check .
|
||||||
|
|
||||||
|
python-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.python == 'true' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v4
|
||||||
|
- run: uv tool install ruff
|
||||||
|
- run: ruff check .
|
||||||
|
|
||||||
|
python-typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.python == 'true' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v4
|
||||||
|
- run: uv sync --dev
|
||||||
|
- run: uvx ty check .
|
||||||
|
|
||||||
|
python-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: changes
|
||||||
|
if: ${{ needs.changes.outputs.python == 'true' }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v4
|
||||||
|
- run: uv sync --dev
|
||||||
|
- run: uv run camoufox fetch
|
||||||
|
- run: uv run pytest tests/ -v
|
||||||
17
.github/workflows/luarocks.yaml
vendored
17
.github/workflows/luarocks.yaml
vendored
|
|
@ -1,18 +1,21 @@
|
||||||
name: Release
|
name: luarocks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish-luarocks:
|
ci:
|
||||||
name: Publish to LuaRocks
|
uses: ./.github/workflows/ci.yaml
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: ci
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Publish to LuaRocks
|
|
||||||
uses: nvim-neorocks/luarocks-tag-release@v7
|
- uses: nvim-neorocks/luarocks-tag-release@v7
|
||||||
env:
|
env:
|
||||||
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}
|
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}
|
||||||
|
|
|
||||||
2
.github/workflows/quality.yaml
vendored
2
.github/workflows/quality.yaml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Code Quality
|
name: quality
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
|
||||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Tests
|
name: tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ repos:
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
name: prettier
|
name: prettier
|
||||||
files: \.(md|,toml,yaml,sh)$
|
files: \.(md|toml|ya?ml|sh)$
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148
|
||||||
- **Language agnostic**: Works with any language
|
- **Language agnostic**: Works with any language
|
||||||
- **Diff viewer**: Compare expected vs actual output with 3 diff modes
|
- **Diff viewer**: Compare expected vs actual output with 3 diff modes
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install using your package manager of choice or via
|
||||||
|
[luarocks](https://luarocks.org/modules/barrettruth/cp.nvim):
|
||||||
|
|
||||||
|
```
|
||||||
|
luarocks install cp.nvim
|
||||||
|
```
|
||||||
|
|
||||||
## Optional Dependencies
|
## Optional Dependencies
|
||||||
|
|
||||||
- [uv](https://docs.astral.sh/uv/) for problem scraping
|
- [uv](https://docs.astral.sh/uv/) for problem scraping
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ rockspec_format = '3.0'
|
||||||
package = 'cp.nvim'
|
package = 'cp.nvim'
|
||||||
version = 'scm-1'
|
version = 'scm-1'
|
||||||
|
|
||||||
source = { url = 'git://github.com/barrett-ruth/cp.nvim' }
|
source = { url = 'git://github.com/barrettruth/cp.nvim' }
|
||||||
build = { type = 'builtin' }
|
build = { type = 'builtin' }
|
||||||
|
|
||||||
test_dependencies = {
|
test_dependencies = {
|
||||||
|
|
|
||||||
149
doc/cp.nvim.txt
149
doc/cp.nvim.txt
|
|
@ -205,71 +205,66 @@ Debug Builds ~
|
||||||
==============================================================================
|
==============================================================================
|
||||||
CONFIGURATION *cp-config*
|
CONFIGURATION *cp-config*
|
||||||
|
|
||||||
Here's an example configuration with lazy.nvim:
|
Configuration is done via `vim.g.cp_config`. Set this before using the plugin:
|
||||||
>lua
|
>lua
|
||||||
{
|
vim.g.cp_config = {
|
||||||
'barrett-ruth/cp.nvim',
|
languages = {
|
||||||
cmd = 'CP',
|
cpp = {
|
||||||
build = 'uv sync',
|
extension = 'cc',
|
||||||
opts = {
|
commands = {
|
||||||
languages = {
|
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}',
|
||||||
cpp = {
|
'-fdiagnostics-color=always' },
|
||||||
extension = 'cc',
|
run = { '{binary}' },
|
||||||
commands = {
|
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
|
||||||
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}',
|
'{source}', '-o', '{binary}' },
|
||||||
'-fdiagnostics-color=always' },
|
|
||||||
run = { '{binary}' },
|
|
||||||
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
|
|
||||||
'{source}', '-o', '{binary}' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
python = {
|
|
||||||
extension = 'py',
|
|
||||||
commands = {
|
|
||||||
run = { 'python', '{source}' },
|
|
||||||
debug = { 'python', '{source}' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
platforms = {
|
python = {
|
||||||
cses = {
|
extension = 'py',
|
||||||
enabled_languages = { 'cpp', 'python' },
|
commands = {
|
||||||
default_language = 'cpp',
|
run = { 'python', '{source}' },
|
||||||
overrides = {
|
debug = { 'python', '{source}' },
|
||||||
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,
|
platforms = {
|
||||||
ui = {
|
cses = {
|
||||||
ansi = true,
|
enabled_languages = { 'cpp', 'python' },
|
||||||
run = {
|
default_language = 'cpp',
|
||||||
width = 0.3,
|
overrides = {
|
||||||
next_test_key = '<c-n>', -- or nil to disable
|
cpp = { extension = 'cpp', commands = { build = { ... } } }
|
||||||
prev_test_key = '<c-p>', -- or nil to disable
|
|
||||||
},
|
},
|
||||||
panel = {
|
|
||||||
diff_mode = 'vim',
|
|
||||||
max_output_lines = 50,
|
|
||||||
},
|
|
||||||
diff = {
|
|
||||||
git = {
|
|
||||||
args = { 'diff', '--no-index', '--word-diff=plain',
|
|
||||||
'--word-diff-regex=.', '--no-prefix' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
picker = 'telescope',
|
|
||||||
},
|
},
|
||||||
}
|
atcoder = {
|
||||||
|
enabled_languages = { 'cpp', 'python' },
|
||||||
|
default_language = 'cpp',
|
||||||
|
},
|
||||||
|
codeforces = {
|
||||||
|
enabled_languages = { 'cpp', 'python' },
|
||||||
|
default_language = 'cpp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
open_url = true,
|
||||||
|
debug = false,
|
||||||
|
ui = {
|
||||||
|
ansi = true,
|
||||||
|
run = {
|
||||||
|
width = 0.3,
|
||||||
|
next_test_key = '<c-n>', -- or nil to disable
|
||||||
|
prev_test_key = '<c-p>', -- or nil to disable
|
||||||
|
},
|
||||||
|
panel = {
|
||||||
|
diff_modes = { 'side-by-side', 'git', 'vim' },
|
||||||
|
max_output_lines = 50,
|
||||||
|
},
|
||||||
|
diff = {
|
||||||
|
git = {
|
||||||
|
args = { 'diff', '--no-index', '--word-diff=plain',
|
||||||
|
'--word-diff-regex=.', '--no-prefix' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
picker = 'telescope',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
|
||||||
|
|
@ -279,7 +274,7 @@ the default; per-platform overrides can tweak 'extension' or 'commands'.
|
||||||
|
|
||||||
For example, to run CodeForces contests with Python by default:
|
For example, to run CodeForces contests with Python by default:
|
||||||
>lua
|
>lua
|
||||||
{
|
vim.g.cp_config = {
|
||||||
platforms = {
|
platforms = {
|
||||||
codeforces = {
|
codeforces = {
|
||||||
default_language = 'python',
|
default_language = 'python',
|
||||||
|
|
@ -290,7 +285,7 @@ For example, to run CodeForces contests with Python by default:
|
||||||
Any language is supported provided the proper configuration. For example, to
|
Any language is supported provided the proper configuration. For example, to
|
||||||
run CSES problems with Rust using the single schema:
|
run CSES problems with Rust using the single schema:
|
||||||
>lua
|
>lua
|
||||||
{
|
vim.g.cp_config = {
|
||||||
languages = {
|
languages = {
|
||||||
rust = {
|
rust = {
|
||||||
extension = 'rs',
|
extension = 'rs',
|
||||||
|
|
@ -378,8 +373,10 @@ run CSES problems with Rust using the single schema:
|
||||||
|
|
||||||
*cp.PanelConfig*
|
*cp.PanelConfig*
|
||||||
Fields: ~
|
Fields: ~
|
||||||
{diff_mode} (string, default: "none") Diff backend: "none",
|
{diff_modes} (string[], default: {'side-by-side', 'git', 'vim'})
|
||||||
"vim", or "git".
|
List of diff modes to cycle through with 't' key.
|
||||||
|
First element is the default mode.
|
||||||
|
Valid modes: 'side-by-side', 'git', 'vim'.
|
||||||
{max_output_lines} (number, default: 50) Maximum lines of test output.
|
{max_output_lines} (number, default: 50) Maximum lines of test output.
|
||||||
|
|
||||||
*cp.DiffConfig*
|
*cp.DiffConfig*
|
||||||
|
|
@ -784,12 +781,15 @@ HIGHLIGHT GROUPS *cp-highlights*
|
||||||
|
|
||||||
Test Status Groups ~
|
Test Status Groups ~
|
||||||
|
|
||||||
CpTestAC Green foreground for AC status
|
All test status groups link to builtin highlight groups, automatically adapting
|
||||||
CpTestWA Red foreground for WA status
|
to your colorscheme:
|
||||||
CpTestTLE Orange foreground for TLE status
|
|
||||||
CpTestMLE Orange foreground for MLE status
|
CpTestAC Links to DiagnosticOk (AC status)
|
||||||
CpTestRTE Purple foreground for RTE status
|
CpTestWA Links to DiagnosticError (WA status)
|
||||||
CpTestNA Gray foreground for remaining state
|
CpTestTLE Links to DiagnosticWarn (TLE status)
|
||||||
|
CpTestMLE Links to DiagnosticWarn (MLE status)
|
||||||
|
CpTestRTE Links to DiagnosticHint (RTE status)
|
||||||
|
CpTestNA Links to Comment (pending/unknown status)
|
||||||
|
|
||||||
ANSI Color Groups ~
|
ANSI Color Groups ~
|
||||||
|
|
||||||
|
|
@ -848,17 +848,20 @@ PANEL KEYMAPS *cp-panel-keys*
|
||||||
|
|
||||||
<c-n> Navigate to next test case
|
<c-n> Navigate to next test case
|
||||||
<c-p> Navigate to previous test case
|
<c-p> Navigate to previous test case
|
||||||
t Cycle through diff modes: none → git → vim
|
t Cycle through configured diff modes (see |cp.PanelConfig|)
|
||||||
q Exit panel and restore layout
|
q Exit panel and restore layout
|
||||||
<c-q> Exit interactive terminal and restore layout
|
<c-q> Exit interactive terminal and restore layout
|
||||||
|
|
||||||
Diff Modes ~
|
Diff Modes ~
|
||||||
|
|
||||||
Three diff backends are available:
|
Three diff modes are available:
|
||||||
|
|
||||||
none Nothing
|
side-by-side Expected and actual output shown side-by-side (default)
|
||||||
vim Built-in vim diff (default, always available)
|
vim Built-in vim diff (always available)
|
||||||
git Character-level git word-diff (requires git, more precise)
|
git Character-level git word-diff (requires git, more precise)
|
||||||
|
|
||||||
|
Configure which modes to cycle through via |cp.PanelConfig|.diff_modes.
|
||||||
|
The first element is used as the default mode.
|
||||||
|
|
||||||
The git backend shows character-level changes with [-removed-] and {+added+}
|
The git backend shows character-level changes with [-removed-] and {+added+}
|
||||||
markers.
|
markers.
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
---@field overrides? table<string, CpPlatformOverrides>
|
---@field overrides? table<string, CpPlatformOverrides>
|
||||||
|
|
||||||
---@class PanelConfig
|
---@class PanelConfig
|
||||||
---@field diff_mode "none"|"vim"|"git"
|
---@field diff_modes string[]
|
||||||
---@field max_output_lines integer
|
---@field max_output_lines integer
|
||||||
|
|
||||||
---@class DiffGitConfig
|
---@class DiffGitConfig
|
||||||
|
|
@ -173,7 +173,7 @@ M.defaults = {
|
||||||
add_test_key = 'ga',
|
add_test_key = 'ga',
|
||||||
save_and_exit_key = 'q',
|
save_and_exit_key = 'q',
|
||||||
},
|
},
|
||||||
panel = { diff_mode = 'none', max_output_lines = 50 },
|
panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50 },
|
||||||
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' },
|
||||||
|
|
@ -305,7 +305,24 @@ function M.setup(user_config)
|
||||||
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 },
|
||||||
open_url = { cfg.open_url, { 'boolean', 'nil' }, true },
|
open_url = { cfg.open_url, { 'boolean', 'nil' }, true },
|
||||||
|
filename = { cfg.filename, { 'function', 'nil' }, true },
|
||||||
|
scrapers = {
|
||||||
|
cfg.scrapers,
|
||||||
|
function(v)
|
||||||
|
if type(v) ~= 'table' then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for _, s in ipairs(v) do
|
||||||
|
if not vim.tbl_contains(constants.PLATFORMS, s) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')),
|
||||||
|
},
|
||||||
before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
|
before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
|
||||||
before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
|
before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
|
||||||
setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
|
setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
|
||||||
|
|
@ -313,14 +330,23 @@ function M.setup(user_config)
|
||||||
setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true },
|
setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
local layouts = require('cp.ui.layouts')
|
||||||
vim.validate({
|
vim.validate({
|
||||||
ansi = { cfg.ui.ansi, 'boolean' },
|
ansi = { cfg.ui.ansi, 'boolean' },
|
||||||
diff_mode = {
|
diff_modes = {
|
||||||
cfg.ui.panel.diff_mode,
|
cfg.ui.panel.diff_modes,
|
||||||
function(v)
|
function(v)
|
||||||
return vim.tbl_contains({ 'none', 'vim', 'git' }, v)
|
if type(v) ~= 'table' then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for _, mode in ipairs(v) do
|
||||||
|
if not layouts.DIFF_MODES[mode] then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
end,
|
end,
|
||||||
"diff_mode must be 'none', 'vim', or 'git'",
|
('one of {%s}'):format(table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',')),
|
||||||
},
|
},
|
||||||
max_output_lines = {
|
max_output_lines = {
|
||||||
cfg.ui.panel.max_output_lines,
|
cfg.ui.panel.max_output_lines,
|
||||||
|
|
@ -330,6 +356,14 @@ function M.setup(user_config)
|
||||||
'positive integer',
|
'positive integer',
|
||||||
},
|
},
|
||||||
git = { cfg.ui.diff.git, { 'table' } },
|
git = { cfg.ui.diff.git, { 'table' } },
|
||||||
|
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
|
||||||
|
width = {
|
||||||
|
cfg.ui.run.width,
|
||||||
|
function(v)
|
||||||
|
return type(v) == 'number' and v > 0 and v <= 1
|
||||||
|
end,
|
||||||
|
'decimal between 0 and 1',
|
||||||
|
},
|
||||||
next_test_key = {
|
next_test_key = {
|
||||||
cfg.ui.run.next_test_key,
|
cfg.ui.run.next_test_key,
|
||||||
function(v)
|
function(v)
|
||||||
|
|
@ -383,6 +417,13 @@ function M.setup(user_config)
|
||||||
end,
|
end,
|
||||||
'nil or non-empty string',
|
'nil or non-empty string',
|
||||||
},
|
},
|
||||||
|
picker = {
|
||||||
|
cfg.ui.picker,
|
||||||
|
function(v)
|
||||||
|
return v == nil or v == 'telescope' or v == 'fzf-lua'
|
||||||
|
end,
|
||||||
|
"nil, 'telescope', or 'fzf-lua'",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
for id, lang in pairs(cfg.languages) do
|
for id, lang in pairs(cfg.languages) do
|
||||||
|
|
@ -443,7 +484,18 @@ function M.get_language_for_platform(platform_id, language_id)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local effective = cfg.runtime.effective[platform_id][language_id]
|
local platform_effective = cfg.runtime.effective[platform_id]
|
||||||
|
if not platform_effective then
|
||||||
|
return {
|
||||||
|
valid = false,
|
||||||
|
error = string.format(
|
||||||
|
'No runtime config for platform %s (plugin not initialized)',
|
||||||
|
platform_id
|
||||||
|
),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local effective = platform_effective[language_id]
|
||||||
if not effective then
|
if not effective then
|
||||||
return {
|
return {
|
||||||
valid = false,
|
valid = false,
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,25 @@ if vim.fn.has('nvim-0.10.0') == 0 then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
||||||
local user_config = {}
|
|
||||||
local config = nil
|
|
||||||
local initialized = false
|
local initialized = false
|
||||||
|
|
||||||
|
local function ensure_initialized()
|
||||||
|
if initialized then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local user_config = vim.g.cp_config or {}
|
||||||
|
local config = config_module.setup(user_config)
|
||||||
|
config_module.set_current_config(config)
|
||||||
|
initialized = true
|
||||||
|
end
|
||||||
|
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.handle_command(opts)
|
function M.handle_command(opts)
|
||||||
|
ensure_initialized()
|
||||||
local commands = require('cp.commands')
|
local commands = require('cp.commands')
|
||||||
commands.handle_command(opts)
|
commands.handle_command(opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.setup(opts)
|
|
||||||
opts = opts or {}
|
|
||||||
user_config = opts
|
|
||||||
config = config_module.setup(user_config)
|
|
||||||
config_module.set_current_config(config)
|
|
||||||
|
|
||||||
initialized = true
|
|
||||||
end
|
|
||||||
|
|
||||||
function M.is_initialized()
|
function M.is_initialized()
|
||||||
return initialized
|
return initialized
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,16 @@ function M.compile_problem(debug, on_complete)
|
||||||
local language = state.get_language() or config.platforms[platform].default_language
|
local language = state.get_language() or config.platforms[platform].default_language
|
||||||
local eff = config.runtime.effective[platform][language]
|
local eff = config.runtime.effective[platform][language]
|
||||||
|
|
||||||
|
local source_file = state.get_source_file()
|
||||||
|
if source_file then
|
||||||
|
local buf = vim.fn.bufnr(source_file)
|
||||||
|
if buf ~= -1 and vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].modified then
|
||||||
|
vim.api.nvim_buf_call(buf, function()
|
||||||
|
vim.cmd.write({ mods = { silent = true, noautocmd = true } })
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local compile_config = (debug and eff.commands.debug) or eff.commands.build
|
local compile_config = (debug and eff.commands.debug) or eff.commands.build
|
||||||
|
|
||||||
if not compile_config then
|
if not compile_config then
|
||||||
|
|
@ -184,6 +194,8 @@ function M.compile_problem(debug, on_complete)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
require('cp.utils').ensure_dirs()
|
||||||
|
|
||||||
local binary = debug and state.get_debug_file() or state.get_binary_file()
|
local binary = debug and state.get_debug_file() or state.get_binary_file()
|
||||||
local substitutions = { source = state.get_source_file(), binary = binary }
|
local substitutions = { source = state.get_source_file(), binary = binary }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -367,14 +367,12 @@ end
|
||||||
---@return table<string, table>
|
---@return table<string, table>
|
||||||
function M.get_highlight_groups()
|
function M.get_highlight_groups()
|
||||||
return {
|
return {
|
||||||
CpTestAC = { fg = '#10b981' },
|
CpTestAC = { link = 'DiagnosticOk' },
|
||||||
CpTestWA = { fg = '#ef4444' },
|
CpTestWA = { link = 'DiagnosticError' },
|
||||||
CpTestTLE = { fg = '#f59e0b' },
|
CpTestTLE = { link = 'DiagnosticWarn' },
|
||||||
CpTestMLE = { fg = '#f59e0b' },
|
CpTestMLE = { link = 'DiagnosticWarn' },
|
||||||
CpTestRTE = { fg = '#8b5cf6' },
|
CpTestRTE = { link = 'DiagnosticHint' },
|
||||||
CpTestNA = { fg = '#6b7280' },
|
CpTestNA = { link = 'Comment' },
|
||||||
CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' },
|
|
||||||
CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' },
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ function M.scrape_all_tests(platform, contest_id, callback)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
|
require('cp.utils').ensure_dirs()
|
||||||
local config = require('cp.config')
|
local config = require('cp.config')
|
||||||
local base_name = config.default_filename(contest_id, ev.problem_id)
|
local base_name = config.default_filename(contest_id, ev.problem_id)
|
||||||
for i, t in ipairs(ev.tests) do
|
for i, t in ipairs(ev.tests) do
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ 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
|
||||||
logger.log(('Fetching problem test data... (%d/%d)'):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
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ local function parse_diff_line(text)
|
||||||
line = 0,
|
line = 0,
|
||||||
col_start = highlight_start,
|
col_start = highlight_start,
|
||||||
col_end = #result_text,
|
col_end = #result_text,
|
||||||
highlight_group = 'CpDiffRemoved',
|
highlight_group = 'DiffDelete',
|
||||||
})
|
})
|
||||||
pos = removed_end + 1
|
pos = removed_end + 1
|
||||||
else
|
else
|
||||||
|
|
@ -38,7 +38,7 @@ local function parse_diff_line(text)
|
||||||
line = 0,
|
line = 0,
|
||||||
col_start = highlight_start,
|
col_start = highlight_start,
|
||||||
col_end = #result_text,
|
col_end = #result_text,
|
||||||
highlight_group = 'CpDiffAdded',
|
highlight_group = 'DiffAdd',
|
||||||
})
|
})
|
||||||
pos = added_end + 1
|
pos = added_end + 1
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ local M = {}
|
||||||
local helpers = require('cp.helpers')
|
local helpers = require('cp.helpers')
|
||||||
local utils = require('cp.utils')
|
local utils = require('cp.utils')
|
||||||
|
|
||||||
local function create_none_diff_layout(parent_win, expected_content, actual_content)
|
M.DIFF_MODES = {
|
||||||
|
['side-by-side'] = 'side-by-side',
|
||||||
|
vim = 'vim',
|
||||||
|
git = 'git',
|
||||||
|
}
|
||||||
|
|
||||||
|
local function create_side_by_side_layout(parent_win, expected_content, actual_content)
|
||||||
local expected_buf = utils.create_buffer_with_options()
|
local expected_buf = utils.create_buffer_with_options()
|
||||||
local actual_buf = utils.create_buffer_with_options()
|
local actual_buf = utils.create_buffer_with_options()
|
||||||
helpers.clearcol(expected_buf)
|
helpers.clearcol(expected_buf)
|
||||||
|
|
@ -21,8 +27,13 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont
|
||||||
|
|
||||||
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
|
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
|
||||||
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
|
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
|
||||||
vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win })
|
local label = M.DIFF_MODES['side-by-side']
|
||||||
vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win })
|
vim.api.nvim_set_option_value(
|
||||||
|
'winbar',
|
||||||
|
('expected (diff: %s)'):format(label),
|
||||||
|
{ win = expected_win }
|
||||||
|
)
|
||||||
|
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
|
||||||
|
|
||||||
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
|
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
|
||||||
local actual_lines = vim.split(actual_content, '\n', { plain = true })
|
local actual_lines = vim.split(actual_content, '\n', { plain = true })
|
||||||
|
|
@ -33,6 +44,7 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont
|
||||||
return {
|
return {
|
||||||
buffers = { expected_buf, actual_buf },
|
buffers = { expected_buf, actual_buf },
|
||||||
windows = { expected_win, actual_win },
|
windows = { expected_win, actual_win },
|
||||||
|
mode = 'side-by-side',
|
||||||
cleanup = function()
|
cleanup = function()
|
||||||
pcall(vim.api.nvim_win_close, expected_win, true)
|
pcall(vim.api.nvim_win_close, expected_win, true)
|
||||||
pcall(vim.api.nvim_win_close, actual_win, true)
|
pcall(vim.api.nvim_win_close, actual_win, true)
|
||||||
|
|
@ -60,8 +72,13 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte
|
||||||
|
|
||||||
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
|
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
|
||||||
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
|
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
|
||||||
vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win })
|
local label = M.DIFF_MODES.vim
|
||||||
vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win })
|
vim.api.nvim_set_option_value(
|
||||||
|
'winbar',
|
||||||
|
('expected (diff: %s)'):format(label),
|
||||||
|
{ win = expected_win }
|
||||||
|
)
|
||||||
|
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
|
||||||
|
|
||||||
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
|
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
|
||||||
local actual_lines = vim.split(actual_content, '\n', { plain = true })
|
local actual_lines = vim.split(actual_content, '\n', { plain = true })
|
||||||
|
|
@ -83,6 +100,7 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte
|
||||||
return {
|
return {
|
||||||
buffers = { expected_buf, actual_buf },
|
buffers = { expected_buf, actual_buf },
|
||||||
windows = { expected_win, actual_win },
|
windows = { expected_win, actual_win },
|
||||||
|
mode = 'vim',
|
||||||
cleanup = function()
|
cleanup = function()
|
||||||
pcall(vim.api.nvim_win_close, expected_win, true)
|
pcall(vim.api.nvim_win_close, expected_win, true)
|
||||||
pcall(vim.api.nvim_win_close, actual_win, true)
|
pcall(vim.api.nvim_win_close, actual_win, true)
|
||||||
|
|
@ -103,7 +121,8 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte
|
||||||
vim.api.nvim_win_set_buf(diff_win, diff_buf)
|
vim.api.nvim_win_set_buf(diff_win, diff_buf)
|
||||||
|
|
||||||
vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf })
|
vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf })
|
||||||
vim.api.nvim_set_option_value('winbar', 'Expected vs Actual', { win = diff_win })
|
local label = M.DIFF_MODES.git
|
||||||
|
vim.api.nvim_set_option_value('winbar', ('diff: %s'):format(label), { win = diff_win })
|
||||||
|
|
||||||
local diff_backend = require('cp.ui.diff')
|
local diff_backend = require('cp.ui.diff')
|
||||||
local backend = diff_backend.get_best_backend('git')
|
local backend = diff_backend.get_best_backend('git')
|
||||||
|
|
@ -121,6 +140,7 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte
|
||||||
return {
|
return {
|
||||||
buffers = { diff_buf },
|
buffers = { diff_buf },
|
||||||
windows = { diff_win },
|
windows = { diff_win },
|
||||||
|
mode = 'git',
|
||||||
cleanup = function()
|
cleanup = function()
|
||||||
pcall(vim.api.nvim_win_close, diff_win, true)
|
pcall(vim.api.nvim_win_close, diff_win, true)
|
||||||
pcall(vim.api.nvim_buf_delete, diff_buf, { force = true })
|
pcall(vim.api.nvim_buf_delete, diff_buf, { force = true })
|
||||||
|
|
@ -143,6 +163,7 @@ local function create_single_layout(parent_win, content)
|
||||||
return {
|
return {
|
||||||
buffers = { buf },
|
buffers = { buf },
|
||||||
windows = { win },
|
windows = { win },
|
||||||
|
mode = 'single',
|
||||||
cleanup = function()
|
cleanup = function()
|
||||||
pcall(vim.api.nvim_win_close, win, true)
|
pcall(vim.api.nvim_win_close, win, true)
|
||||||
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
||||||
|
|
@ -153,12 +174,14 @@ end
|
||||||
function M.create_diff_layout(mode, parent_win, expected_content, actual_content)
|
function M.create_diff_layout(mode, parent_win, expected_content, actual_content)
|
||||||
if mode == 'single' then
|
if mode == 'single' then
|
||||||
return create_single_layout(parent_win, actual_content)
|
return create_single_layout(parent_win, actual_content)
|
||||||
elseif mode == 'none' then
|
elseif mode == 'side-by-side' then
|
||||||
return create_none_diff_layout(parent_win, expected_content, actual_content)
|
return create_side_by_side_layout(parent_win, expected_content, actual_content)
|
||||||
elseif mode == 'git' then
|
elseif mode == 'git' then
|
||||||
return create_git_diff_layout(parent_win, expected_content, actual_content)
|
return create_git_diff_layout(parent_win, expected_content, actual_content)
|
||||||
else
|
elseif mode == 'vim' then
|
||||||
return create_vim_diff_layout(parent_win, expected_content, actual_content)
|
return create_vim_diff_layout(parent_win, expected_content, actual_content)
|
||||||
|
else
|
||||||
|
return create_side_by_side_layout(parent_win, expected_content, actual_content)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -191,12 +214,13 @@ function M.update_diff_panes(
|
||||||
actual_content = actual_content
|
actual_content = actual_content
|
||||||
end
|
end
|
||||||
|
|
||||||
local desired_mode = is_compilation_failure and 'single' or config.ui.panel.diff_mode
|
local default_mode = config.ui.panel.diff_modes[1]
|
||||||
|
local desired_mode = is_compilation_failure and 'single' or (current_mode or default_mode)
|
||||||
local highlight = require('cp.ui.highlight')
|
local highlight = require('cp.ui.highlight')
|
||||||
local diff_namespace = highlight.create_namespace()
|
local diff_namespace = highlight.create_namespace()
|
||||||
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')
|
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')
|
||||||
|
|
||||||
if current_diff_layout and current_mode ~= desired_mode then
|
if current_diff_layout and current_diff_layout.mode ~= desired_mode then
|
||||||
local saved_pos = vim.api.nvim_win_get_cursor(0)
|
local saved_pos = vim.api.nvim_win_get_cursor(0)
|
||||||
current_diff_layout.cleanup()
|
current_diff_layout.cleanup()
|
||||||
current_diff_layout = nil
|
current_diff_layout = nil
|
||||||
|
|
@ -251,7 +275,7 @@ function M.update_diff_panes(
|
||||||
ansi_namespace
|
ansi_namespace
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
elseif desired_mode == 'none' then
|
elseif desired_mode == 'side-by-side' then
|
||||||
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
|
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
|
||||||
local actual_lines = vim.split(actual_content, '\n', { plain = true })
|
local actual_lines = vim.split(actual_content, '\n', { plain = true })
|
||||||
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})
|
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,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
|
||||||
|
|
||||||
function M.disable()
|
function M.disable()
|
||||||
local active_panel = state.get_active_panel()
|
local active_panel = state.get_active_panel()
|
||||||
|
|
@ -390,6 +391,8 @@ function M.ensure_io_view()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
require('cp.utils').ensure_dirs()
|
||||||
|
|
||||||
local source_file = state.get_source_file()
|
local source_file = state.get_source_file()
|
||||||
if source_file then
|
if source_file then
|
||||||
local source_file_abs = vim.fn.fnamemodify(source_file, ':p')
|
local source_file_abs = vim.fn.fnamemodify(source_file, ':p')
|
||||||
|
|
@ -622,6 +625,12 @@ 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
|
||||||
|
logger.log('Tests already running', vim.log.levels.WARN)
|
||||||
|
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'), vim.log.levels.INFO, true)
|
||||||
|
|
||||||
mode = mode or 'combined'
|
mode = mode or 'combined'
|
||||||
|
|
@ -633,6 +642,7 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
'No platform/contest/problem configured. Use :CP <platform> <contest> [...] first.',
|
||||||
vim.log.levels.ERROR
|
vim.log.levels.ERROR
|
||||||
)
|
)
|
||||||
|
io_view_running = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -640,6 +650,7 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
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.', vim.log.levels.ERROR)
|
||||||
|
io_view_running = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -656,11 +667,13 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
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', 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', vim.log.levels.ERROR)
|
||||||
|
io_view_running = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -681,6 +694,7 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
),
|
),
|
||||||
vim.log.levels.WARN
|
vim.log.levels.WARN
|
||||||
)
|
)
|
||||||
|
io_view_running = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -698,6 +712,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -711,6 +726,7 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
|
|
||||||
execute.compile_problem(debug, function(compile_result)
|
execute.compile_problem(debug, function(compile_result)
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -730,6 +746,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -737,6 +754,7 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
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', vim.log.levels.ERROR)
|
||||||
|
io_view_running = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -745,18 +763,21 @@ function M.run_io_view(test_indices_arg, debug, mode)
|
||||||
run.run_combined_test(debug, function(result)
|
run.run_combined_test(debug, function(result)
|
||||||
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', 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 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)
|
||||||
|
|
@ -859,6 +880,9 @@ function M.toggle_panel(panel_opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function refresh_panel()
|
local function refresh_panel()
|
||||||
|
if state.get_active_panel() ~= 'run' then
|
||||||
|
return
|
||||||
|
end
|
||||||
if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
|
if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -884,6 +908,10 @@ function M.toggle_panel(panel_opts)
|
||||||
vim.cmd.normal({ 'zz', bang = true })
|
vim.cmd.normal({ 'zz', bang = true })
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if test_windows.tab_win and vim.api.nvim_win_is_valid(test_windows.tab_win) then
|
||||||
|
vim.api.nvim_set_current_win(test_windows.tab_win)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function navigate_test_case(delta)
|
local function navigate_test_case(delta)
|
||||||
|
|
@ -900,15 +928,15 @@ function M.toggle_panel(panel_opts)
|
||||||
M.toggle_panel()
|
M.toggle_panel()
|
||||||
end, { buffer = buf, silent = true })
|
end, { buffer = buf, silent = true })
|
||||||
vim.keymap.set('n', 't', function()
|
vim.keymap.set('n', 't', function()
|
||||||
local modes = { 'none', 'git', 'vim' }
|
local modes = config.ui.panel.diff_modes
|
||||||
local current_idx = 1
|
local current_idx = 1
|
||||||
for i, mode in ipairs(modes) do
|
for i, mode in ipairs(modes) do
|
||||||
if config.ui.panel.diff_mode == mode then
|
if current_mode == mode then
|
||||||
current_idx = i
|
current_idx = i
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
config.ui.panel.diff_mode = modes[(current_idx % #modes) + 1]
|
current_mode = modes[(current_idx % #modes) + 1]
|
||||||
refresh_panel()
|
refresh_panel()
|
||||||
end, { buffer = buf, silent = true })
|
end, { buffer = buf, silent = true })
|
||||||
vim.keymap.set('n', '<c-n>', function()
|
vim.keymap.set('n', '<c-n>', function()
|
||||||
|
|
@ -942,6 +970,9 @@ function M.toggle_panel(panel_opts)
|
||||||
|
|
||||||
local function finalize_panel()
|
local function finalize_panel()
|
||||||
vim.schedule(function()
|
vim.schedule(function()
|
||||||
|
if state.get_active_panel() ~= 'run' then
|
||||||
|
return
|
||||||
|
end
|
||||||
if config.ui.ansi then
|
if config.ui.ansi then
|
||||||
require('cp.ui.ansi').setup_highlight_groups()
|
require('cp.ui.ansi').setup_highlight_groups()
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -262,4 +262,8 @@ function M.cwd_executables()
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function M.ensure_dirs()
|
||||||
|
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
0
new
Normal file
0
new
Normal file
|
|
@ -266,43 +266,31 @@ class AtcoderScraper(BaseScraper):
|
||||||
return "atcoder"
|
return "atcoder"
|
||||||
|
|
||||||
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
||||||
async def impl(cid: str) -> MetadataResult:
|
try:
|
||||||
try:
|
rows = await asyncio.to_thread(_scrape_tasks_sync, contest_id)
|
||||||
rows = await asyncio.to_thread(_scrape_tasks_sync, cid)
|
|
||||||
except requests.HTTPError as e:
|
|
||||||
if e.response is not None and e.response.status_code == 404:
|
|
||||||
return self._create_metadata_error(
|
|
||||||
f"No problems found for contest {cid}", cid
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
problems = _to_problem_summaries(rows)
|
problems = _to_problem_summaries(rows)
|
||||||
if not problems:
|
if not problems:
|
||||||
return self._create_metadata_error(
|
return self._metadata_error(
|
||||||
f"No problems found for contest {cid}", cid
|
f"No problems found for contest {contest_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return MetadataResult(
|
return MetadataResult(
|
||||||
success=True,
|
success=True,
|
||||||
error="",
|
error="",
|
||||||
contest_id=cid,
|
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",
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
return await self._safe_execute("metadata", impl, contest_id)
|
return self._metadata_error(str(e))
|
||||||
|
|
||||||
async def scrape_contest_list(self) -> ContestListResult:
|
async def scrape_contest_list(self) -> ContestListResult:
|
||||||
async def impl() -> ContestListResult:
|
try:
|
||||||
try:
|
contests = await _fetch_all_contests_async()
|
||||||
contests = await _fetch_all_contests_async()
|
|
||||||
except Exception as e:
|
|
||||||
return self._create_contests_error(str(e))
|
|
||||||
if not contests:
|
if not contests:
|
||||||
return self._create_contests_error("No contests found")
|
return self._contests_error("No contests found")
|
||||||
return ContestListResult(success=True, error="", contests=contests)
|
return ContestListResult(success=True, error="", contests=contests)
|
||||||
|
except Exception as e:
|
||||||
return await self._safe_execute("contests", impl)
|
return self._contests_error(str(e))
|
||||||
|
|
||||||
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
||||||
rows = await asyncio.to_thread(_scrape_tasks_sync, category_id)
|
rows = await asyncio.to_thread(_scrape_tasks_sync, category_id)
|
||||||
|
|
|
||||||
101
scrapers/base.py
101
scrapers/base.py
|
|
@ -1,9 +1,8 @@
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Awaitable, Callable, ParamSpec, cast
|
|
||||||
|
|
||||||
from .models import ContestListResult, MetadataResult, TestsResult
|
from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
|
||||||
|
|
||||||
P = ParamSpec("P")
|
|
||||||
|
|
||||||
|
|
||||||
class BaseScraper(ABC):
|
class BaseScraper(ABC):
|
||||||
|
|
@ -20,57 +19,65 @@ 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: ...
|
||||||
|
|
||||||
def _create_metadata_error(
|
def _usage(self) -> str:
|
||||||
self, error_msg: str, contest_id: str = ""
|
name = self.platform_name
|
||||||
) -> MetadataResult:
|
return f"Usage: {name}.py metadata <id> | tests <id> | contests"
|
||||||
return MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error=f"{self.platform_name}: {error_msg}",
|
|
||||||
contest_id=contest_id,
|
|
||||||
problems=[],
|
|
||||||
url="",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_tests_error(
|
def _metadata_error(self, msg: str) -> MetadataResult:
|
||||||
self, error_msg: str, problem_id: str = "", url: str = ""
|
return MetadataResult(success=False, error=msg, url="")
|
||||||
) -> TestsResult:
|
|
||||||
from .models import CombinedTest
|
|
||||||
|
|
||||||
|
def _tests_error(self, msg: str) -> TestsResult:
|
||||||
return TestsResult(
|
return TestsResult(
|
||||||
success=False,
|
success=False,
|
||||||
error=f"{self.platform_name}: {error_msg}",
|
error=msg,
|
||||||
problem_id=problem_id,
|
problem_id="",
|
||||||
combined=CombinedTest(input="", expected=""),
|
combined=CombinedTest(input="", expected=""),
|
||||||
tests=[],
|
tests=[],
|
||||||
timeout_ms=0,
|
timeout_ms=0,
|
||||||
memory_mb=0,
|
memory_mb=0,
|
||||||
interactive=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_contests_error(self, error_msg: str) -> ContestListResult:
|
def _contests_error(self, msg: str) -> ContestListResult:
|
||||||
return ContestListResult(
|
return ContestListResult(success=False, error=msg)
|
||||||
success=False,
|
|
||||||
error=f"{self.platform_name}: {error_msg}",
|
|
||||||
contests=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _safe_execute(
|
async def _run_cli_async(self, args: list[str]) -> int:
|
||||||
self,
|
if len(args) < 2:
|
||||||
operation: str,
|
print(self._metadata_error(self._usage()).model_dump_json())
|
||||||
func: Callable[P, Awaitable[Any]],
|
return 1
|
||||||
*args: P.args,
|
|
||||||
**kwargs: P.kwargs,
|
mode = args[1]
|
||||||
):
|
|
||||||
try:
|
match mode:
|
||||||
return await func(*args, **kwargs)
|
case "metadata":
|
||||||
except Exception as e:
|
if len(args) != 3:
|
||||||
if operation == "metadata":
|
print(self._metadata_error(self._usage()).model_dump_json())
|
||||||
contest_id = cast(str, args[0]) if args else ""
|
return 1
|
||||||
return self._create_metadata_error(str(e), contest_id)
|
result = await self.scrape_contest_metadata(args[2])
|
||||||
elif operation == "tests":
|
print(result.model_dump_json())
|
||||||
problem_id = cast(str, args[1]) if len(args) > 1 else ""
|
return 0 if result.success else 1
|
||||||
return self._create_tests_error(str(e), problem_id)
|
|
||||||
elif operation == "contests":
|
case "tests":
|
||||||
return self._create_contests_error(str(e))
|
if len(args) != 3:
|
||||||
else:
|
print(self._tests_error(self._usage()).model_dump_json())
|
||||||
raise
|
return 1
|
||||||
|
await self.stream_tests_for_category_async(args[2])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
case "contests":
|
||||||
|
if len(args) != 2:
|
||||||
|
print(self._contests_error(self._usage()).model_dump_json())
|
||||||
|
return 1
|
||||||
|
result = await self.scrape_contest_list()
|
||||||
|
print(result.model_dump_json())
|
||||||
|
return 0 if result.success else 1
|
||||||
|
|
||||||
|
case _:
|
||||||
|
print(
|
||||||
|
self._metadata_error(
|
||||||
|
f"Unknown mode: {mode}. {self._usage()}"
|
||||||
|
).model_dump_json()
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def run_cli(self) -> None:
|
||||||
|
sys.exit(asyncio.run(self._run_cli_async(sys.argv)))
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -10,13 +10,11 @@ from scrapling.fetchers import Fetcher
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper
|
||||||
from .models import (
|
from .models import (
|
||||||
CombinedTest,
|
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
TestCase,
|
TestCase,
|
||||||
TestsResult,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE_URL = "https://www.codechef.com"
|
BASE_URL = "https://www.codechef.com"
|
||||||
|
|
@ -62,42 +60,40 @@ class CodeChefScraper(BaseScraper):
|
||||||
return "codechef"
|
return "codechef"
|
||||||
|
|
||||||
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
||||||
async with httpx.AsyncClient() as client:
|
try:
|
||||||
try:
|
async with httpx.AsyncClient() as client:
|
||||||
data = await fetch_json(
|
data = await fetch_json(
|
||||||
client, API_CONTEST.format(contest_id=contest_id)
|
client, API_CONTEST.format(contest_id=contest_id)
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError as e:
|
if not data.get("problems"):
|
||||||
return self._create_metadata_error(
|
return self._metadata_error(
|
||||||
f"Failed to fetch contest {contest_id}: {e}", contest_id
|
f"No problems found for contest {contest_id}"
|
||||||
)
|
)
|
||||||
if not data.get("problems"):
|
problems = []
|
||||||
return self._create_metadata_error(
|
for problem_code, problem_data in data["problems"].items():
|
||||||
f"No problems found for contest {contest_id}", contest_id
|
if problem_data.get("category_name") == "main":
|
||||||
)
|
problems.append(
|
||||||
problems = []
|
ProblemSummary(
|
||||||
for problem_code, problem_data in data["problems"].items():
|
id=problem_code,
|
||||||
if problem_data.get("category_name") == "main":
|
name=problem_data.get("name", problem_code),
|
||||||
problems.append(
|
)
|
||||||
ProblemSummary(
|
|
||||||
id=problem_code,
|
|
||||||
name=problem_data.get("name", problem_code),
|
|
||||||
)
|
)
|
||||||
)
|
return MetadataResult(
|
||||||
return MetadataResult(
|
success=True,
|
||||||
success=True,
|
error="",
|
||||||
error="",
|
contest_id=contest_id,
|
||||||
contest_id=contest_id,
|
problems=problems,
|
||||||
problems=problems,
|
url=f"{BASE_URL}/{contest_id}",
|
||||||
url=f"{BASE_URL}/{contest_id}",
|
)
|
||||||
)
|
except Exception as e:
|
||||||
|
return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}")
|
||||||
|
|
||||||
async def scrape_contest_list(self) -> ContestListResult:
|
async def scrape_contest_list(self) -> ContestListResult:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
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._create_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(
|
all_contests = data.get("future_contests", []) + data.get(
|
||||||
"past_contests", []
|
"past_contests", []
|
||||||
)
|
)
|
||||||
|
|
@ -110,7 +106,7 @@ class CodeChefScraper(BaseScraper):
|
||||||
num = int(match.group(1))
|
num = int(match.group(1))
|
||||||
max_num = max(max_num, num)
|
max_num = max(max_num, num)
|
||||||
if max_num == 0:
|
if max_num == 0:
|
||||||
return self._create_contests_error("No Starters contests found")
|
return self._contests_error("No Starters contests found")
|
||||||
contests = []
|
contests = []
|
||||||
sem = asyncio.Semaphore(CONNECTIONS)
|
sem = asyncio.Semaphore(CONNECTIONS)
|
||||||
|
|
||||||
|
|
@ -252,68 +248,5 @@ class CodeChefScraper(BaseScraper):
|
||||||
print(json.dumps(payload), flush=True)
|
print(json.dumps(payload), flush=True)
|
||||||
|
|
||||||
|
|
||||||
async def main_async() -> int:
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: codechef.py metadata <contest_id> OR codechef.py tests <contest_id> OR codechef.py contests",
|
|
||||||
url="",
|
|
||||||
)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
mode: str = sys.argv[1]
|
|
||||||
scraper = CodeChefScraper()
|
|
||||||
if mode == "metadata":
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: codechef.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: codechef.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: codechef.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=f"Unknown mode: {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()
|
CodeChefScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -13,13 +12,11 @@ from scrapling.fetchers import Fetcher
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper
|
||||||
from .models import (
|
from .models import (
|
||||||
CombinedTest,
|
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
TestCase,
|
TestCase,
|
||||||
TestsResult,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
|
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
|
||||||
|
|
@ -89,14 +86,14 @@ def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
|
||||||
if not st:
|
if not st:
|
||||||
return [], False
|
return [], False
|
||||||
|
|
||||||
input_pres: list[Tag] = [ # type: ignore[misc]
|
input_pres: list[Tag] = [
|
||||||
inp.find("pre") # type: ignore[misc]
|
inp.find("pre")
|
||||||
for inp in st.find_all("div", class_="input") # type: ignore[union-attr]
|
for inp in st.find_all("div", class_="input")
|
||||||
if isinstance(inp, Tag) and inp.find("pre")
|
if isinstance(inp, Tag) and inp.find("pre")
|
||||||
]
|
]
|
||||||
output_pres: list[Tag] = [
|
output_pres: list[Tag] = [
|
||||||
out.find("pre") # type: ignore[misc]
|
out.find("pre")
|
||||||
for out in st.find_all("div", class_="output") # type: ignore[union-attr]
|
for out in st.find_all("div", class_="output")
|
||||||
if isinstance(out, Tag) and out.find("pre")
|
if isinstance(out, Tag) and out.find("pre")
|
||||||
]
|
]
|
||||||
input_pres = [p for p in input_pres if isinstance(p, Tag)]
|
input_pres = [p for p in input_pres if isinstance(p, Tag)]
|
||||||
|
|
@ -209,49 +206,46 @@ class CodeforcesScraper(BaseScraper):
|
||||||
return "codeforces"
|
return "codeforces"
|
||||||
|
|
||||||
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
|
||||||
async def impl(cid: str) -> MetadataResult:
|
try:
|
||||||
problems = await asyncio.to_thread(_scrape_contest_problems_sync, cid)
|
problems = await asyncio.to_thread(
|
||||||
|
_scrape_contest_problems_sync, contest_id
|
||||||
|
)
|
||||||
if not problems:
|
if not problems:
|
||||||
return self._create_metadata_error(
|
return self._metadata_error(
|
||||||
f"No problems found for contest {cid}", cid
|
f"No problems found for contest {contest_id}"
|
||||||
)
|
)
|
||||||
return MetadataResult(
|
return MetadataResult(
|
||||||
success=True,
|
success=True,
|
||||||
error="",
|
error="",
|
||||||
contest_id=cid,
|
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",
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
return await self._safe_execute("metadata", impl, contest_id)
|
return self._metadata_error(str(e))
|
||||||
|
|
||||||
async def scrape_contest_list(self) -> ContestListResult:
|
async def scrape_contest_list(self) -> ContestListResult:
|
||||||
async def impl() -> ContestListResult:
|
try:
|
||||||
try:
|
r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS)
|
||||||
r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS)
|
r.raise_for_status()
|
||||||
r.raise_for_status()
|
data = r.json()
|
||||||
data = r.json()
|
if data.get("status") != "OK":
|
||||||
if data.get("status") != "OK":
|
return self._contests_error("Invalid API response")
|
||||||
return self._create_contests_error("Invalid API response")
|
|
||||||
|
|
||||||
contests: list[ContestSummary] = []
|
contests: list[ContestSummary] = []
|
||||||
for c in data["result"]:
|
for c in data["result"]:
|
||||||
if c.get("phase") != "FINISHED":
|
if c.get("phase") != "FINISHED":
|
||||||
continue
|
continue
|
||||||
cid = str(c["id"])
|
cid = str(c["id"])
|
||||||
name = c["name"]
|
name = c["name"]
|
||||||
contests.append(
|
contests.append(ContestSummary(id=cid, name=name, display_name=name))
|
||||||
ContestSummary(id=cid, name=name, display_name=name)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not contests:
|
if not contests:
|
||||||
return self._create_contests_error("No contests found")
|
return self._contests_error("No contests found")
|
||||||
|
|
||||||
return ContestListResult(success=True, error="", contests=contests)
|
return ContestListResult(success=True, error="", contests=contests)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._create_contests_error(str(e))
|
return self._contests_error(str(e))
|
||||||
|
|
||||||
return await self._safe_execute("contests", impl)
|
|
||||||
|
|
||||||
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
async def stream_tests_for_category_async(self, category_id: str) -> None:
|
||||||
html = await asyncio.to_thread(_fetch_problems_html, category_id)
|
html = await asyncio.to_thread(_fetch_problems_html, category_id)
|
||||||
|
|
@ -281,73 +275,5 @@ class CodeforcesScraper(BaseScraper):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def main_async() -> int:
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: codeforces.py metadata <contest_id> OR codeforces.py tests <contest_id> OR codeforces.py contests",
|
|
||||||
url="",
|
|
||||||
)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
|
|
||||||
mode: str = sys.argv[1]
|
|
||||||
scraper = CodeforcesScraper()
|
|
||||||
|
|
||||||
if mode == "metadata":
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: codeforces.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: codeforces.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: codeforces.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()
|
CodeforcesScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,17 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .base import BaseScraper
|
from .base import BaseScraper
|
||||||
from .models import (
|
from .models import (
|
||||||
CombinedTest,
|
|
||||||
ContestListResult,
|
ContestListResult,
|
||||||
ContestSummary,
|
ContestSummary,
|
||||||
MetadataResult,
|
MetadataResult,
|
||||||
ProblemSummary,
|
ProblemSummary,
|
||||||
TestCase,
|
TestCase,
|
||||||
TestsResult,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
BASE_URL = "https://cses.fi"
|
BASE_URL = "https://cses.fi"
|
||||||
|
|
@ -261,73 +258,5 @@ class CSESScraper(BaseScraper):
|
||||||
print(json.dumps(payload), flush=True)
|
print(json.dumps(payload), flush=True)
|
||||||
|
|
||||||
|
|
||||||
async def main_async() -> int:
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: cses.py metadata <category_id> OR cses.py tests <category> OR cses.py contests",
|
|
||||||
url="",
|
|
||||||
)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
|
|
||||||
mode: str = sys.argv[1]
|
|
||||||
scraper = CSESScraper()
|
|
||||||
|
|
||||||
if mode == "metadata":
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
result = MetadataResult(
|
|
||||||
success=False,
|
|
||||||
error="Usage: cses.py metadata <category_id>",
|
|
||||||
url="",
|
|
||||||
)
|
|
||||||
print(result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
category_id = sys.argv[2]
|
|
||||||
result = await scraper.scrape_contest_metadata(category_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: cses.py tests <category>",
|
|
||||||
problem_id="",
|
|
||||||
combined=CombinedTest(input="", expected=""),
|
|
||||||
tests=[],
|
|
||||||
timeout_ms=0,
|
|
||||||
memory_mb=0,
|
|
||||||
)
|
|
||||||
print(tests_result.model_dump_json())
|
|
||||||
return 1
|
|
||||||
category = sys.argv[2]
|
|
||||||
await scraper.stream_tests_for_category_async(category)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if mode == "contests":
|
|
||||||
if len(sys.argv) != 2:
|
|
||||||
contest_result = ContestListResult(
|
|
||||||
success=False, error="Usage: cses.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=f"Unknown mode: {mode}. Use 'metadata <category>', 'tests <category>', 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()
|
CSESScraper().run_cli()
|
||||||
|
|
|
||||||
|
|
@ -232,33 +232,35 @@ def run_scraper_offline(fixture_text):
|
||||||
case _:
|
case _:
|
||||||
raise AssertionError(f"Unknown scraper: {scraper_name}")
|
raise AssertionError(f"Unknown scraper: {scraper_name}")
|
||||||
|
|
||||||
|
scraper_classes = {
|
||||||
|
"cses": "CSESScraper",
|
||||||
|
"atcoder": "AtcoderScraper",
|
||||||
|
"codeforces": "CodeforcesScraper",
|
||||||
|
"codechef": "CodeChefScraper",
|
||||||
|
}
|
||||||
|
|
||||||
def _run(scraper_name: str, mode: str, *args: str):
|
def _run(scraper_name: str, mode: str, *args: str):
|
||||||
mod_path = ROOT / "scrapers" / f"{scraper_name}.py"
|
mod_path = ROOT / "scrapers" / f"{scraper_name}.py"
|
||||||
ns = _load_scraper_module(mod_path, scraper_name)
|
ns = _load_scraper_module(mod_path, scraper_name)
|
||||||
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"] # type: ignore[assignment]
|
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
|
||||||
requests.get = offline_fetches["requests.get"]
|
requests.get = offline_fetches["requests.get"]
|
||||||
elif scraper_name == "atcoder":
|
elif scraper_name == "atcoder":
|
||||||
ns._fetch = offline_fetches["_fetch"]
|
ns._fetch = offline_fetches["_fetch"]
|
||||||
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"] # type: ignore[assignment]
|
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
|
||||||
elif scraper_name == "codechef":
|
elif scraper_name == "codechef":
|
||||||
httpx.AsyncClient.get = offline_fetches["__offline_get_async"] # type: ignore[assignment]
|
httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
|
||||||
fetchers.Fetcher.get = offline_fetches["Fetcher.get"] # type: ignore[assignment]
|
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
|
||||||
|
|
||||||
main_async = getattr(ns, "main_async")
|
scraper_class = getattr(ns, scraper_classes[scraper_name])
|
||||||
assert callable(main_async), f"main_async not found in {scraper_name}"
|
scraper = scraper_class()
|
||||||
|
|
||||||
argv = [str(mod_path), mode, *args]
|
argv = [str(mod_path), mode, *args]
|
||||||
old_argv = sys.argv
|
rc, out = _capture_stdout(scraper._run_cli_async(argv))
|
||||||
sys.argv = argv
|
|
||||||
try:
|
|
||||||
rc, out = _capture_stdout(main_async())
|
|
||||||
finally:
|
|
||||||
sys.argv = old_argv
|
|
||||||
|
|
||||||
json_lines: list[Any] = []
|
json_lines: list[Any] = []
|
||||||
for line in (_line for _line in out.splitlines() if _line.strip()):
|
for line in (_line for _line in out.splitlines() if _line.strip()):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue