Compare commits

...
Sign in to create a new pull request.

47 commits

Author SHA1 Message Date
f01800d0e0 style(runner): add luacats to compare_outputs 2026-02-26 23:00:38 -05:00
Barrett Ruth
e4a7e7c434
Merge branch 'main' into feat/epsilon-float-comparison 2026-02-26 22:59:25 -05:00
84d12758c2 style(setup): apply stylua formatting 2026-02-26 22:57:39 -05:00
2c25ec616a feat: add per-language template file support
Problem: new solution files were always created empty, requiring users
to manually paste boilerplate or rely on editor snippets that fire
outside cp.nvim's control.

Solution: add an optional template field to the language config. When
set to a file path, its contents are written into every newly created
solution buffer before the setup_code hook runs. Existing files are
never overwritten.
2026-02-26 22:57:39 -05:00
ce5648f9cf docs: add statusline integration recipes
Problem: cp.nvim exposed no documentation showing how to integrate its
runtime state into a statusline. Users had to discover the state module
API by reading source.

Solution: add a STATUSLINE INTEGRATION section to the vimdoc with a
state API reference and recipes for vanilla statusline, lualine, and
heirline. Also anchors the *cp.State* help tag referenced in prose
elsewhere in the doc.
2026-02-26 22:56:36 -05:00
585cf2a077 feat(runner): run test cases in parallel
Problem: test cases were executed sequentially, each waiting for the
previous process to finish before starting the next. On problems with
many test cases this meant wall-clock run time scaled linearly.

Solution: fan out all test case processes simultaneously. A remaining
counter fires on_done once all callbacks have returned. on_each is
called per completion as before; callers that pass on_each ignore its
arguments so the index semantics change is non-breaking.
2026-02-26 22:55:50 -05:00
c59b53cdc4 feat: add epsilon tolerance for floating-point output comparison
Problem: output comparison used exact string equality after whitespace
normalisation, causing correct solutions to fail on problems where
floating-point answers are accepted within a tolerance (e.g. 1e-6).

Solution: add an optional ui.panel.epsilon config value. When set,
actual and expected output are compared token-by-token: numeric tokens
are compared with math.abs(a - b) <= epsilon, non-numeric tokens fall
back to exact string equality. Per-problem epsilon can also be stored
in the cache and takes precedence over the global default.
2026-02-26 22:55:05 -05:00
81f5273840 chore: convert .luarc.json to nested format and add busted library
Problem: .luarc.json used the flat dotted-key format which is not the
canonical LuaLS schema. The busted library was also missing, so LuaLS
could not resolve types in test files.

Solution: rewrite .luarc.json using nested objects and add
${3rd}/busted/library to workspace.library.
2026-02-26 22:47:05 -05:00
d274e0c117 fix(cache): replace stale M._cache field with get_raw_cache accessor
Problem: M._cache = cache_data captured the initial empty table reference
at module load time. After M.load() reassigns cache_data to the decoded
JSON, M._cache is permanently stale and returns the wrong table.

Solution: remove the field assignment and expose get_raw_cache() which
closes over cache_data and always returns the current table.
2026-02-26 22:46:06 -05:00
3cb872a65f fix: replace deprecated vim.loop with vim.uv
Problem: vim.loop is deprecated since Neovim 0.10 in favour of vim.uv.
Five call sites across scraper.lua, setup.lua, utils.lua, and health.lua
still referenced the old alias.

Solution: replace every vim.loop reference with vim.uv directly.
2026-02-26 22:45:07 -05:00
4ccab9ee1f
fix(config): add bit to ignored filetypes 2026-02-26 19:09:16 -05:00
48c08825b2
ci: add missing packages 2026-02-23 18:16:22 -05:00
4d58db8520
ci: nix config migration 2026-02-23 18:04:17 -05:00
591f70a237
build(flake): add lua-language-server to devShell
Problem: lua-language-server is not available in the dev shell, making
it impossible to run local type-checking diagnostics.

Solution: add lua-language-server to the devShell packages.
2026-02-23 17:37:47 -05:00
Harivansh Rathi
e989897c77 style(lua): format URL-open condition in setup
Co-authored-by: Codex <noreply@openai.com>
2026-02-22 22:19:51 -05:00
Harivansh Rathi
1c31abe3d6 fix(open_url): open problem URL when switching problems
Also fix contest-change detection so URL open logic triggers when either platform or contest changes. This makes :CP next/:CP prev and problem jumps open the correct page when open_url is enabled.

Co-authored-by: Codex <noreply@openai.com>
2026-02-22 22:19:51 -05:00
d3ac300ea0 fix(cache): remove unused logger import
Problem: the logger import became unused after replacing the error log
with a silent cache wipe.

Solution: drop the require.
2026-02-22 22:19:51 -05:00
db5bd791f9 fix(cache): invalidate stale cache on version mismatch
Problem: after an install or update, the on-disk cache may contain data
written by an older version of the plugin whose format no longer matches
what the current code expects.

Solution: embed a CACHE_VERSION in every saved cache file. On load, if
the stored version is missing or differs from the current one, wipe the
cache and rewrite it. Corrupt (non-decodable) cache files are handled
the same way instead of only logging an error.
2026-02-22 22:19:51 -05:00
Harivansh Rathi
9fc34cb6fd chore(scraper): add LuaCATS types for env helper
Add LuaCATS annotations to the env conversion helper and drop the table.sort call since ordering is not required by uv.spawn.

Co-authored-by: Codex <noreply@openai.com>
2026-02-22 12:06:01 -05:00
Harivansh Rathi
484a4a56d0 fix(scraper): pass uv.spawn env as KEY=VALUE list
Neovim/libuv spawn expects env as a list of KEY=VALUE strings. Passing the map from vim.fn.environ() can fail process startup with ENOENT, which breaks NDJSON test scraping and surfaces as 'Failed to start scraper process'.\n\nConvert env map to a deterministic list before uv.spawn in the NDJSON scraper path.

Co-authored-by: Codex <noreply@openai.com>
2026-02-22 12:06:01 -05:00
ff5ba39a59 docs: fix dependencies section in readme
Problem: time and timeout were listed as optional dependencies despite
being required for plugin initialization. nix was not mentioned as an
alternative to uv for the Python scraping environment.

Solution: rename section to "Dependencies", list time/timeout first,
and add nix as an alternative to uv for scraping.
2026-02-21 23:59:40 -05:00
760e7d7731 fix(ci): format 2026-02-20 17:49:34 -05:00
49e4233b3f fix: decouple python env setup from config init
Problem: setup_python_env() is called from check_required_runtime()
during config.setup(), which runs on the very first :CP command. The
uv sync and nix build calls use vim.system():wait(), blocking the
Neovim event loop. During the block the UI is frozen and
vim.schedule-based log messages never render, so the user sees an
unresponsive editor with no feedback.

Solution: remove setup_python_env() from check_required_runtime() so
config init is instant. Call it lazily from run_scraper() instead,
only when a scraper subprocess is actually needed. Use vim.notify +
vim.cmd.redraw() before blocking calls so the notification renders
immediately via a forced screen repaint, rather than being queued
behind vim.schedule.
2026-02-18 17:49:04 -05:00
622620f6d0 feat: add debug logging to python env, scraper, and runner
Problem: with debug = true, there is not enough diagnostic output to
troubleshoot environment or execution issues. The resolved python path,
scraper commands, and compile/run shell commands are not logged.

Solution: add logger.log calls at key decision points: python env
resolution (nix vs uv vs discovery), uv sync stderr output, scraper
subprocess commands, and compile/run shell strings. All gated behind
the existing debug flag so they only appear when debug = true.
2026-02-18 17:40:06 -05:00
976838d981 fix: always run uv sync to recover from partial installs
Problem: setup_python_env() skips uv sync when .venv/ exists. If a
previous sync was interrupted (e.g. network timeout), the directory
exists but is broken, and every subsequent session silently uses a
corrupt environment.

Solution: remove the isdirectory guard and always run uv sync. It is
idempotent and near-instant when dependencies are already installed,
so the only cost is one subprocess call per session.
2026-02-18 17:32:12 -05:00
06f72bbe2b fix: only show user-configured platforms in picker
Problem: tbl_deep_extend merges user platforms on top of defaults, so
all four default platforms survive even when the user only configures a
subset. The picker then shows platforms the user never intended to use.

Solution: before the deep merge, prune any default platform not present
in the user's platforms table. This preserves per-platform default
filling (the user doesn't have to re-specify every field) while ensuring
only explicitly configured platforms appear.
2026-02-18 17:29:41 -05:00
6045042dfb fix: surface runtime check failures as clean notifications
Problem: when required dependencies (GNU time/timeout, Python env) are
missing, config.setup() throws a raw error() that surfaces as a Lua
traceback. On macOS without coreutils the message is also redundant
("GNU time not found: GNU time not found") and offers no install hint.

Solution: wrap config.setup() in pcall inside ensure_initialized(),
strip the Lua source-location prefix, and emit a vim.notify at ERROR
level. Add Darwin-specific install guidance to the GNU time/timeout
not-found messages. Pass capability reasons directly instead of
wrapping them in a redundant outer message.
2026-02-18 17:25:50 -05:00
c192afc5d7 fix(ci): format 2026-02-18 14:13:37 -05:00
b6f3398bbc fix(ci): formatting and typing 2026-02-18 14:13:37 -05:00
e02a29bd40 fix(ci): remove duplicate workflows 2026-02-18 14:13:37 -05:00
0f9715298e fix(ci): remove deprecated setups 2026-02-18 14:13:37 -05:00
2148d9bd07 feat(nix): add health 2026-02-18 14:13:37 -05:00
1162e7046b try to fix the setup 2026-02-18 14:13:37 -05:00
b36ffba63a feat(nix): initial flake config; 2026-02-18 14:13:37 -05:00
04d0c124cf
fix: remove flake config 2026-02-17 21:11:11 -05:00
da433068ef
remove straggler file 2026-02-17 21:10:56 -05:00
51504b0121
fix: flake config; 2026-02-17 21:10:29 -05:00
49df7e015d docs: add setup section and reorder vimdoc
Problem: the vimdoc had no setup section, and configuration was buried
after commands and mappings.

Solution: add a cp-setup section with lazy.nvim example and move both
setup and configuration above commands for better discoverability.
2026-02-17 21:09:58 -05:00
029ea125b9 feat: add <Plug> mappings for all primary actions
Problem: users who want keybindings must call vim.cmd('CP run') or
reach into internal Lua modules directly. There is no stable,
discoverable, lazy-load-friendly public API for key binding.

Solution: define 7 <Plug> mappings in plugin/cp.lua that dispatch
through the same handle_command() code path as :CP. Document them
in a new MAPPINGS section in the vimdoc with helptags and an example
config block.
2026-02-07 13:23:45 -05:00
Barrett Ruth
43193c3762
Merge pull request #239 from barrettruth/refactor/remove-cp-config-compat
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
refactor: remove vim.g.cp_config compatibility shim
2026-02-06 16:42:41 -05:00
de2bc07532 refactor: remove vim.g.cp_config compatibility shim
Problem: the deprecated vim.g.cp_config fallback was kept for
backwards compatibility after the rename to vim.g.cp in v0.7.6.

Solution: drop the shim entirely and update the setup() deprecation
target to v0.7.7.
2026-02-06 16:40:39 -05:00
Barrett Ruth
041e09ac04
Merge pull request #238 from barrettruth/fix/setup-code-hook-language
fix(setup): set language state before setup_code hook on first open
2026-02-06 16:38:41 -05:00
d23b4e59d1 fix(setup): set language state before setup_code hook on first open
Problem: when opening a contest for the first time (metadata not
cached), the setup_code hook fired before state.set_language() was
called, causing state.get_language() to return nil inside the hook.

Solution: call state.set_language(lang) before the hook in the
provisional-buffer branch of setup_contest(). The value is already
computed at that point and is identical to what setup_problem() sets
later, so the early write is idempotent.
2026-02-06 16:29:46 -05:00
Barrett Ruth
19e71ac7fa
Merge pull request #237 from barrettruth/feat/vim-g-update
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
refactor: rename `vim.g.cp_config` to `vim.g.cp`
2026-02-06 16:07:03 -05:00
a54a06f939 refactor: rename vim.g.cp_config to vim.g.cp 2026-02-06 15:16:21 -05:00
Barrett Ruth
b2c7f16890
Merge pull request #234 from barrettruth/fix/deprecation-warning
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
fix: add deprecation warning for setup()
2026-02-03 21:51:19 -05:00
276241447c fix: add deprecation warning for setup()
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-02-03 21:46:47 -05:00
31 changed files with 1036 additions and 1831 deletions

3
.envrc
View file

@ -1,3 +0,0 @@
VIRTUAL_ENV="$PWD/.venv"
PATH_add "$VIRTUAL_ENV/bin"
export VIRTUAL_ENV

View file

@ -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

View file

@ -28,6 +28,7 @@ jobs:
- '*.lua'
- '.luarc.json'
- '*.toml'
- 'vim.yaml'
python:
- 'scripts/**/.py'
- 'scrapers/**/*.py'
@ -45,11 +46,8 @@ jobs:
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 .
- uses: cachix/install-nix-action@v31
- run: nix develop --command stylua --check .
lua-lint:
name: Lua Lint Check
@ -58,11 +56,8 @@ jobs:
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Lint with Selene
uses: NTBBloodbath/selene-action@v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --display-style quiet .
- uses: cachix/install-nix-action@v31
- run: nix develop --command selene --display-style quiet .
lua-typecheck:
name: Lua Type Check
@ -127,15 +122,5 @@ jobs:
if: ${{ needs.changes.outputs.markdown == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install prettier
run: pnpm add -g prettier@3.1.0
- name: Check markdown formatting with prettier
run: prettier --check .
- uses: cachix/install-nix-action@v31
- run: nix develop --command prettier --check .

View file

@ -44,9 +44,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies with pytest
- name: Install dependencies
run: uv sync --dev
- name: Fetch camoufox data
run: uv run camoufox fetch
- name: Run Python tests
run: uv run pytest tests/ -v

3
.gitignore vendored
View file

@ -14,3 +14,6 @@ __pycache__
.claude/
node_modules/
.envrc
.direnv/

View file

@ -1,8 +1,16 @@
{
"runtime.version": "Lua 5.1",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"],
"diagnostics.globals": ["vim"],
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"],
"workspace.checkThirdParty": false,
"completion.callSnippet": "Replace"
"runtime": {
"version": "LuaJIT",
"path": ["lua/?.lua", "lua/?/init.lua"]
},
"diagnostics": {
"globals": ["vim"]
},
"workspace": {
"library": ["$VIMRUNTIME/lua", "${3rd}/luv/library", "${3rd}/busted/library"],
"checkThirdParty": false
},
"completion": {
"callSnippet": "Replace"
}
}

View file

@ -28,11 +28,12 @@ Install using your package manager of choice or via
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
[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

View file

@ -18,6 +18,243 @@ REQUIREMENTS *cp-requirements*
- Unix-like operating system
- uv package manager (https://docs.astral.sh/uv/)
==============================================================================
SETUP *cp-setup*
Load cp.nvim with your package manager. For example, with lazy.nvim: >lua
{ 'barrettruth/cp.nvim' }
<
The plugin works automatically with no configuration required. For
customization, see |cp-config|.
==============================================================================
CONFIGURATION *cp-config*
Configuration is done via `vim.g.cp`. Set this before using the plugin:
>lua
vim.g.cp = {
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{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 = {
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
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,
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',
},
}
<
By default, C++ (g++ with ISO C++17) and Python are preconfigured under
'languages'. Platforms select which languages are enabled and which one is
the default; per-platform overrides can tweak 'extension' or 'commands'.
For example, to run CodeForces contests with Python by default:
>lua
vim.g.cp = {
platforms = {
codeforces = {
default_language = 'python',
},
},
}
<
Any language is supported provided the proper configuration. For example, to
run CSES problems with Rust using the single schema:
>lua
vim.g.cp = {
languages = {
rust = {
extension = 'rs',
commands = {
build = { 'rustc', '{source}', '-o', '{binary}' },
run = { '{binary}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python', 'rust' },
default_language = 'rust',
},
},
}
<
*cp.Config*
Fields: ~
{languages} (table<string,|CpLanguage|>) Global language registry.
Each language provides an {extension} and {commands}.
{platforms} (table<string,|CpPlatform|>) Per-platform enablement,
default language, and optional overrides.
{hooks} (|cp.Hooks|) Hook functions called at various stages.
{debug} (boolean, default: false) Show info messages.
{scrapers} (string[]) Supported platform ids.
{filename} (function, optional)
function(contest, contest_id, problem_id, config, language): string
Should return full filename with extension.
(default: concatenates contest_id and problem_id, lowercased)
{ui} (|CpUI|) UI settings: panel, diff backend, picker.
{open_url} (boolean) Open the contest & problem url in the browser
when a new contest is opened or the active problem changes.
*CpPlatform*
Fields: ~
{enabled_languages} (string[]) Language ids enabled on this platform.
{default_language} (string) One of {enabled_languages}.
{overrides} (table<string,|CpPlatformOverrides|>, optional)
Per-language overrides of {extension} and/or {commands}.
*CpLanguage*
Fields: ~
{extension} (string) File extension without leading dot.
{commands} (|CpLangCommands|) Command templates.
*CpLangCommands*
Fields: ~
{build} (string[], optional) For compiled languages.
Must include {source} and {binary}.
{run} (string[], optional) Runtime command.
Compiled: must include {binary}.
Interpreted: must include {source}.
{debug} (string[], optional) Debug variant; same token rules
as {build} (compiled) or {run} (interpreted).
*CpUI*
Fields: ~
{ansi} (boolean, default: true) Enable ANSI color parsing
and highlighting in both I/O view and panel.
{run} (|RunConfig|) I/O view configuration.
{panel} (|PanelConfig|) Test panel behavior configuration.
{diff} (|DiffConfig|) Diff backend configuration.
{picker} (string|nil) 'telescope', 'fzf-lua', or nil.
*RunConfig*
Fields: ~
{width} (number, default: 0.3) Width of I/O view splits as
fraction of screen (0.0 to 1.0).
{next_test_key} (string|nil, default: '<c-n>') Keymap to navigate
to next test in I/O view. Set to nil to disable.
{prev_test_key} (string|nil, default: '<c-p>') Keymap to navigate
to previous test in I/O view. Set to nil to disable.
{format_verdict} (|VerdictFormatter|, default: nil) Custom verdict line
formatter. See |cp-verdict-format|.
*EditConfig*
Fields: ~
{next_test_key} (string|nil, default: ']t') Jump to next test.
{prev_test_key} (string|nil, default: '[t') Jump to previous test.
{delete_test_key} (string|nil, default: 'gd') Delete current test.
{add_test_key} (string|nil, default: 'ga') Add new test.
{save_and_exit_key} (string|nil, default: 'q') Save and exit editor.
All keys are nil-able. Set to nil to disable.
*cp.PanelConfig*
Fields: ~
{diff_modes} (string[], default: {'side-by-side', 'git', 'vim'})
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.
*cp.DiffConfig*
Fields: ~
{git} (|cp.DiffGitConfig|) Git diff backend configuration.
*cp.DiffGitConfig*
Fields: ~
{args} (string[]) Command-line arguments for git diff.
Default: { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' }
• --no-index: Compare files outside git repository
• --word-diff=plain: Character-level diff markers
• --word-diff-regex=.: Split on every character
• --no-prefix: Remove a/ b/ prefixes from output
*cp.Hooks*
Fields: ~
{before_run} (function, optional) Called before test panel opens.
function(state: cp.State)
{before_debug} (function, optional) Called before debug build/run.
function(state: cp.State)
{setup_code} (function, optional) Called after source file is opened.
function(state: cp.State)
{setup_io_input} (function, optional) Called when I/O input buffer created.
function(bufnr: integer, state: cp.State)
Default: helpers.clearcol (removes line numbers/columns)
{setup_io_output} (function, optional) Called when I/O output buffer created.
function(bufnr: integer, state: cp.State)
Default: helpers.clearcol (removes line numbers/columns)
Hook functions receive the cp.nvim state object (|cp.State|). See
|lua/cp/state.lua| for available methods and fields.
The I/O buffer hooks are called once when the buffers are first created
during problem setup. Use these to customize buffer appearance (e.g.,
remove line numbers, set custom options). Access helpers via:
>lua
local helpers = require('cp').helpers
<
Example usage:
>lua
hooks = {
setup_code = function(state)
print("Setting up " .. state.get_base_name())
print("Source file: " .. state.get_source_file())
end,
setup_io_input = function(bufnr, state)
vim.api.nvim_set_option_value('number', false, { buf = bufnr })
end
}
<
==============================================================================
COMMANDS *cp-commands*
@ -203,232 +440,40 @@ Debug Builds ~
<
==============================================================================
CONFIGURATION *cp-config*
MAPPINGS *cp-mappings*
Configuration is done via `vim.g.cp_config`. Set this before using the plugin:
>lua
vim.g.cp_config = {
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{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 = {
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
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,
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',
},
}
<
cp.nvim provides <Plug> mappings for all primary actions. These dispatch
through the same code path as |:CP|.
By default, C++ (g++ with ISO C++17) and Python are preconfigured under
'languages'. Platforms select which languages are enabled and which one is
the default; per-platform overrides can tweak 'extension' or 'commands'.
*<Plug>(cp-run)*
<Plug>(cp-run) Run tests in I/O view. Equivalent to :CP run.
For example, to run CodeForces contests with Python by default:
>lua
vim.g.cp_config = {
platforms = {
codeforces = {
default_language = 'python',
},
},
}
<
Any language is supported provided the proper configuration. For example, to
run CSES problems with Rust using the single schema:
>lua
vim.g.cp_config = {
languages = {
rust = {
extension = 'rs',
commands = {
build = { 'rustc', '{source}', '-o', '{binary}' },
run = { '{binary}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python', 'rust' },
default_language = 'rust',
},
},
}
<
*cp.Config*
Fields: ~
{languages} (table<string,|CpLanguage|>) Global language registry.
Each language provides an {extension} and {commands}.
{platforms} (table<string,|CpPlatform|>) Per-platform enablement,
default language, and optional overrides.
{hooks} (|cp.Hooks|) Hook functions called at various stages.
{debug} (boolean, default: false) Show info messages.
{scrapers} (string[]) Supported platform ids.
{filename} (function, optional)
function(contest, contest_id, problem_id, config, language): string
Should return full filename with extension.
(default: concatenates contest_id and problem_id, lowercased)
{ui} (|CpUI|) UI settings: panel, diff backend, picker.
{open_url} (boolean) Open the contest & problem url in the browser
when the contest is first opened.
*<Plug>(cp-panel)*
<Plug>(cp-panel) Open full-screen test panel. Equivalent to :CP panel.
*CpPlatform*
Fields: ~
{enabled_languages} (string[]) Language ids enabled on this platform.
{default_language} (string) One of {enabled_languages}.
{overrides} (table<string,|CpPlatformOverrides|>, optional)
Per-language overrides of {extension} and/or {commands}.
*<Plug>(cp-edit)*
<Plug>(cp-edit) Open the test case editor. Equivalent to :CP edit.
*CpLanguage*
Fields: ~
{extension} (string) File extension without leading dot.
{commands} (|CpLangCommands|) Command templates.
*<Plug>(cp-next)*
<Plug>(cp-next) Navigate to the next problem. Equivalent to :CP next.
*CpLangCommands*
Fields: ~
{build} (string[], optional) For compiled languages.
Must include {source} and {binary}.
{run} (string[], optional) Runtime command.
Compiled: must include {binary}.
Interpreted: must include {source}.
{debug} (string[], optional) Debug variant; same token rules
as {build} (compiled) or {run} (interpreted).
*<Plug>(cp-prev)*
<Plug>(cp-prev) Navigate to the previous problem. Equivalent to :CP prev.
*CpUI*
Fields: ~
{ansi} (boolean, default: true) Enable ANSI color parsing
and highlighting in both I/O view and panel.
{run} (|RunConfig|) I/O view configuration.
{panel} (|PanelConfig|) Test panel behavior configuration.
{diff} (|DiffConfig|) Diff backend configuration.
{picker} (string|nil) 'telescope', 'fzf-lua', or nil.
*<Plug>(cp-pick)*
<Plug>(cp-pick) Launch the contest picker. Equivalent to :CP pick.
*RunConfig*
Fields: ~
{width} (number, default: 0.3) Width of I/O view splits as
fraction of screen (0.0 to 1.0).
{next_test_key} (string|nil, default: '<c-n>') Keymap to navigate
to next test in I/O view. Set to nil to disable.
{prev_test_key} (string|nil, default: '<c-p>') Keymap to navigate
to previous test in I/O view. Set to nil to disable.
{format_verdict} (|VerdictFormatter|, default: nil) Custom verdict line
formatter. See |cp-verdict-format|.
*<Plug>(cp-interact)*
<Plug>(cp-interact) Open interactive mode. Equivalent to :CP interact.
*EditConfig*
Fields: ~
{next_test_key} (string|nil, default: ']t') Jump to next test.
{prev_test_key} (string|nil, default: '[t') Jump to previous test.
{delete_test_key} (string|nil, default: 'gd') Delete current test.
{add_test_key} (string|nil, default: 'ga') Add new test.
{save_and_exit_key} (string|nil, default: 'q') Save and exit editor.
All keys are nil-able. Set to nil to disable.
*cp.PanelConfig*
Fields: ~
{diff_modes} (string[], default: {'side-by-side', 'git', 'vim'})
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.
*cp.DiffConfig*
Fields: ~
{git} (|cp.DiffGitConfig|) Git diff backend configuration.
*cp.DiffGitConfig*
Fields: ~
{args} (string[]) Command-line arguments for git diff.
Default: { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' }
• --no-index: Compare files outside git repository
• --word-diff=plain: Character-level diff markers
• --word-diff-regex=.: Split on every character
• --no-prefix: Remove a/ b/ prefixes from output
*cp.Hooks*
Fields: ~
{before_run} (function, optional) Called before test panel opens.
function(state: cp.State)
{before_debug} (function, optional) Called before debug build/run.
function(state: cp.State)
{setup_code} (function, optional) Called after source file is opened.
function(state: cp.State)
{setup_io_input} (function, optional) Called when I/O input buffer created.
function(bufnr: integer, state: cp.State)
Default: helpers.clearcol (removes line numbers/columns)
{setup_io_output} (function, optional) Called when I/O output buffer created.
function(bufnr: integer, state: cp.State)
Default: helpers.clearcol (removes line numbers/columns)
Hook functions receive the cp.nvim state object (|cp.State|). See
|lua/cp/state.lua| for available methods and fields.
The I/O buffer hooks are called once when the buffers are first created
during problem setup. Use these to customize buffer appearance (e.g.,
remove line numbers, set custom options). Access helpers via:
>lua
local helpers = require('cp').helpers
<
Example usage:
>lua
hooks = {
setup_code = function(state)
print("Setting up " .. state.get_base_name())
print("Source file: " .. state.get_source_file())
end,
setup_io_input = function(bufnr, state)
-- Custom setup for input buffer
vim.api.nvim_set_option_value('number', false, { buf = bufnr })
end
}
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)')
<
==============================================================================
@ -843,6 +888,116 @@ Functions ~
Parameters: ~
{bufnr} (integer) Buffer handle
==============================================================================
STATUSLINE INTEGRATION *cp-statusline*
cp.nvim exposes its runtime state through a public module that can be queried
from any statusline plugin. Import it with: >lua
local state = require('cp.state')
<
All getters return nil when no problem is active, so guard every value before
use. Calling any getter outside a CP context is safe and has no side effects.
State API ~
*cp.State*
The following getters are available for statusline use:
get_platform() (string?) Platform id. e.g. "codeforces", "atcoder"
get_contest_id() (string?) Contest id. e.g. "1933", "abc324"
get_problem_id() (string?) Problem id. e.g. "A", "B"
get_language() (string?) Language id. e.g. "cpp", "python"
get_base_name() (string?) Derived filename stem. e.g. "1933a"
get_source_file() (string?) Full source filename. e.g. "1933a.cc"
get_active_panel() (string?) Non-nil when the test panel is open.
Recipe: vanilla statusline ~
Set vim.o.statusline from an autocommand so it is recalculated on every
BufEnter: >lua
local function cp_component()
local state = require('cp.state')
local platform = state.get_platform()
if not platform then
return ''
end
local parts = {
platform,
state.get_contest_id(),
state.get_problem_id(),
state.get_language(),
}
local filtered = {}
for _, v in ipairs(parts) do
if v then filtered[#filtered + 1] = v end
end
return '[' .. table.concat(filtered, ' · ') .. ']'
end
vim.api.nvim_create_autocmd({ 'BufEnter', 'User' }, {
callback = function()
vim.o.statusline = cp_component() .. ' %f %=%l:%c'
end
})
<
Recipe: lualine ~
Add a custom component to any lualine section. The cond field hides the
component entirely when no problem is active: >lua
local function cp_lualine()
local state = require('cp.state')
local parts = {
state.get_platform(),
state.get_contest_id(),
state.get_problem_id(),
state.get_language(),
}
local filtered = {}
for _, v in ipairs(parts) do
if v then filtered[#filtered + 1] = v end
end
return table.concat(filtered, ' · ')
end
require('lualine').setup({
sections = {
lualine_c = {
{
cp_lualine,
cond = function()
return require('cp.state').get_platform() ~= nil
end,
},
},
},
})
<
Recipe: heirline ~
Build a heirline component using a provider and condition: >lua
local CpComponent = {
condition = function()
return require('cp.state').get_platform() ~= nil
end,
provider = function()
local state = require('cp.state')
local parts = {
state.get_platform(),
state.get_contest_id(),
state.get_problem_id(),
state.get_language(),
}
local filtered = {}
for _, v in ipairs(parts) do
if v then filtered[#filtered + 1] = v end
end
return '[' .. table.concat(filtered, ' · ') .. ']'
end,
}
<
Include CpComponent in your heirline StatusLine spec wherever desired.
==============================================================================
PANEL KEYMAPS *cp-panel-keys*

43
flake.lock generated Normal file
View 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
}

76
flake.nix Normal file
View file

@ -0,0 +1,76 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
systems.url = "github:nix-systems/default-linux";
};
outputs =
{
self,
nixpkgs,
systems,
}:
let
eachSystem = nixpkgs.lib.genAttrs (import systems);
pkgsFor = system: nixpkgs.legacyPackages.${system};
mkPythonEnv =
pkgs:
pkgs.python312.withPackages (ps: [
ps.backoff
ps.beautifulsoup4
ps.curl-cffi
ps.httpx
ps.ndjson
ps.pydantic
ps.requests
]);
mkPlugin =
pkgs:
let
pythonEnv = mkPythonEnv pkgs;
in
pkgs.vimUtils.buildVimPlugin {
pname = "cp-nvim";
version = "0-unstable-${self.shortRev or self.dirtyShortRev or "dev"}";
src = self;
postPatch = ''
substituteInPlace lua/cp/utils.lua \
--replace-fail "local _nix_python = nil" \
"local _nix_python = '${pythonEnv.interpreter}'"
'';
nvimSkipModule = [
"cp.pickers.telescope"
"cp.version"
];
passthru = { inherit pythonEnv; };
meta.description = "Competitive programming plugin for Neovim";
};
in
{
overlays.default = final: prev: {
vimPlugins = prev.vimPlugins // {
cp-nvim = mkPlugin final;
};
};
packages = eachSystem (system: {
default = mkPlugin (pkgsFor system);
pythonEnv = mkPythonEnv (pkgsFor system);
});
devShells = eachSystem (system: {
default = (pkgsFor system).mkShell {
packages = with (pkgsFor system); [
uv
python312
prettier
stylua
selene
lua-language-server
];
};
});
};
}

View file

@ -27,6 +27,7 @@
---@field multi_test? boolean
---@field memory_mb? number
---@field timeout_ms? number
---@field epsilon? number
---@field combined_test? CombinedTest
---@field test_cases TestCase[]
@ -38,7 +39,8 @@
local M = {}
local logger = require('cp.log')
local CACHE_VERSION = 1
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
local cache_data = {}
local loaded = false
@ -65,9 +67,15 @@ function M.load()
local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n'))
if ok then
cache_data = decoded
if decoded._version ~= CACHE_VERSION then
cache_data = {}
M.save()
else
cache_data = decoded
end
else
logger.log('Could not decode json in cache file', vim.log.levels.ERROR)
cache_data = {}
M.save()
end
loaded = true
end
@ -78,6 +86,7 @@ function M.save()
vim.schedule(function()
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
cache_data._version = CACHE_VERSION
local encoded = vim.json.encode(cache_data)
local lines = vim.split(encoded, '\n')
vim.fn.writefile(lines, cache_file)
@ -265,6 +274,34 @@ function M.get_constraints(platform, contest_id, problem_id)
return problem_data.timeout_ms, problem_data.memory_mb
end
---@param platform string
---@param contest_id string
---@param problem_id? string
---@return number?
function M.get_epsilon(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.epsilon or nil
end
---@param file_path string
---@return FileState|nil
function M.get_file_state(file_path)
@ -338,6 +375,8 @@ function M.get_data_pretty()
return vim.inspect(cache_data)
end
M._cache = cache_data
function M.get_raw_cache()
return cache_data
end
return M

View file

@ -7,6 +7,7 @@
---@class CpLanguage
---@field extension string
---@field commands CpLangCommands
---@field template? string
---@class CpPlatformOverrides
---@field extension? string
@ -20,6 +21,7 @@
---@class PanelConfig
---@field diff_modes string[]
---@field max_output_lines integer
---@field epsilon number?
---@class DiffGitConfig
---@field args string[]
@ -173,7 +175,7 @@ M.defaults = {
add_test_key = 'ga',
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, epsilon = nil },
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
@ -215,6 +217,10 @@ local function validate_language(id, lang)
commands = { lang.commands, { 'table' } },
})
if lang.template ~= nil then
vim.validate({ template = { lang.template, 'string' } })
end
if not lang.commands.run then
error(('[cp.nvim] languages.%s.commands.run is required'):format(id))
end
@ -292,7 +298,15 @@ end
---@return cp.Config
function M.setup(user_config)
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
local defaults = vim.deepcopy(M.defaults)
if user_config and user_config.platforms then
for plat in pairs(defaults.platforms) do
if not user_config.platforms[plat] then
defaults.platforms[plat] = nil
end
end
end
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
if not next(cfg.languages) then
error('[cp.nvim] At least one language must be configured')
@ -355,6 +369,13 @@ function M.setup(user_config)
end,
'positive integer',
},
epsilon = {
cfg.ui.panel.epsilon,
function(v)
return v == nil or (type(v) == 'number' and v >= 0)
end,
'nil or non-negative number',
},
git = { cfg.ui.diff.git, { 'table' } },
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
width = {

View file

@ -5,33 +5,50 @@ local utils = require('cp.utils')
local function check()
vim.health.start('cp.nvim [required] ~')
utils.setup_python_env()
if vim.fn.has('nvim-0.10.0') == 1 then
vim.health.ok('Neovim 0.10.0+ detected')
else
vim.health.error('cp.nvim requires Neovim 0.10.0+')
end
local uname = vim.loop.os_uname()
local uname = vim.uv.os_uname()
if uname.sysname == 'Windows_NT' then
vim.health.error('Windows is not supported')
end
if vim.fn.executable('uv') == 1 then
vim.health.ok('uv executable found')
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
if utils.is_nix_build() then
local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install'
vim.health.ok('Nix Python environment detected (' .. source .. ')')
local py = utils.get_nix_python()
vim.health.info('Python: ' .. py)
local r = vim.system({ py, '--version' }, { text = true }):wait()
if r.code == 0 then
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
vim.health.info('Python version: ' .. r.stdout:gsub('\n', ''))
end
else
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end
if vim.fn.executable('uv') == 1 then
vim.health.ok('uv executable found')
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
if r.code == 0 then
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
end
else
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end
local plugin_path = utils.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
if vim.fn.isdirectory(venv_dir) == 1 then
vim.health.ok('Python virtual environment found at ' .. venv_dir)
else
vim.health.info('Python virtual environment not set up (created on first scrape)')
if vim.fn.executable('nix') == 1 then
vim.health.info('nix available but Python environment not resolved via nix')
end
local plugin_path = utils.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
if vim.fn.isdirectory(venv_dir) == 1 then
vim.health.ok('Python virtual environment found at ' .. venv_dir)
else
vim.health.info('Python virtual environment not set up (created on first scrape)')
end
end
local time_cap = utils.time_capability()
@ -41,7 +58,7 @@ local function check()
vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
end
local timeout_cap = utils.time_capability()
local timeout_cap = utils.timeout_capability()
if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else

View file

@ -15,17 +15,25 @@ local initialized = false
local function ensure_initialized()
if initialized then
return
return true
end
local user_config = vim.g.cp_config or {}
local config = config_module.setup(user_config)
config_module.set_current_config(config)
local user_config = vim.g.cp or {}
local ok, result = pcall(config_module.setup, user_config)
if not ok then
local msg = tostring(result):gsub('^.+:%d+: ', '')
vim.notify(msg, vim.log.levels.ERROR)
return false
end
config_module.set_current_config(result)
initialized = true
return true
end
---@return nil
function M.handle_command(opts)
ensure_initialized()
if not ensure_initialized() then
return
end
local commands = require('cp.commands')
commands.handle_command(opts)
end
@ -34,4 +42,13 @@ function M.is_initialized()
return initialized
end
---@deprecated Use `vim.g.cp` instead
function M.setup(user_config)
vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false)
if user_config then
vim.g.cp = vim.tbl_deep_extend('force', vim.g.cp or {}, user_config)
end
end
return M

View file

@ -43,6 +43,7 @@ end
function M.compile(compile_cmd, substitutions, on_complete)
local cmd = substitute_template(compile_cmd, substitutions)
local sh = table.concat(cmd, ' ') .. ' 2>&1'
logger.log('compile: ' .. sh)
local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { text = false }, function(r)
@ -119,6 +120,7 @@ function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete)
local sec = math.ceil(timeout_ms / 1000)
local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec)
local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog)
logger.log('run: ' .. sh)
local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)

View file

@ -19,6 +19,7 @@
---@class ProblemConstraints
---@field timeout_ms number
---@field memory_mb number
---@field epsilon number?
---@class PanelState
---@field test_cases RanTestCase[]
@ -56,7 +57,8 @@ local function load_constraints_from_cache(platform, contest_id, problem_id)
cache.load()
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
if timeout_ms and memory_mb then
return { timeout_ms = timeout_ms, memory_mb = memory_mb }
local epsilon = cache.get_epsilon(platform, contest_id, problem_id)
return { timeout_ms = timeout_ms, memory_mb = memory_mb, epsilon = epsilon }
end
return nil
end
@ -99,6 +101,53 @@ local function build_command(cmd, substitutions)
return execute.build_command(cmd, substitutions)
end
---@param actual string
---@param expected string
---@param epsilon number?
---@return boolean
local function compare_outputs(actual, expected, epsilon)
local norm_actual = normalize_lines(actual)
local norm_expected = normalize_lines(expected)
if epsilon == nil or epsilon == 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) > epsilon then
return false
end
else
if a_tok ~= e_tok then
return false
end
end
end
end
return true
end
---@param test_case RanTestCase
---@param debug boolean?
---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string?, tled: boolean, mled: boolean, rss_mb: number })
@ -143,7 +192,9 @@ local function run_single_test_case(test_case, debug, on_complete)
end
local expected = test_case.expected or ''
local ok = normalize_lines(out) == normalize_lines(expected)
local epsilon = (panel_state.constraints and panel_state.constraints.epsilon)
or config.ui.panel.epsilon
local ok = compare_outputs(out, expected, epsilon)
local signal = r.signal
if not signal and r.code and r.code >= 128 then
@ -276,26 +327,35 @@ function M.run_all_test_cases(indices, debug, on_each, on_done)
end
end
local function run_next(pos)
if pos > #to_run then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
return
end
M.run_test_case(to_run[pos], debug, function()
if on_each then
on_each(pos, #to_run)
end
run_next(pos + 1)
end)
if #to_run == 0 then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
return
end
run_next(1)
local total = #to_run
local remaining = total
for _, idx in ipairs(to_run) do
M.run_test_case(idx, debug, function()
if on_each then
on_each(idx, total)
end
remaining = remaining - 1
if remaining == 0 then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
end
end)
end
end
---@return PanelState

View file

@ -20,52 +20,75 @@ local function syshandle(result)
return { success = true, data = data }
end
---@param env_map table<string, string>
---@return string[]
local function spawn_env_list(env_map)
local out = {}
for key, value in pairs(env_map) do
out[#out + 1] = tostring(key) .. '=' .. tostring(value)
end
return out
end
---@param platform string
---@param subcommand string
---@param args string[]
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) }
local function run_scraper(platform, subcommand, args, opts)
if not utils.setup_python_env() then
local msg = 'no Python environment available (install uv or nix)'
logger.log(msg, vim.log.levels.ERROR)
if opts and opts.on_exit then
opts.on_exit({ success = false, error = msg })
end
return { success = false, error = msg }
end
local plugin_path = utils.get_plugin_path()
local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand }
local cmd = utils.get_python_cmd(platform, plugin_path)
vim.list_extend(cmd, { subcommand })
vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
env.CONDA_PREFIX = ''
if opts and opts.ndjson then
local uv = vim.loop
local uv = vim.uv
local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false)
local buf = ''
local handle
handle = uv.spawn(
cmd[1],
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env },
function(code, signal)
if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if ok_tail then
opts.on_event(ev_tail)
end
buf = ''
end
if opts.on_exit then
opts.on_exit({ success = (code == 0), code = code, signal = signal })
end
if not stdout:is_closing() then
stdout:close()
end
if not stderr:is_closing() then
stderr:close()
end
if handle and not handle:is_closing() then
handle:close()
handle = uv.spawn(cmd[1], {
args = vim.list_slice(cmd, 2),
stdio = { nil, stdout, stderr },
env = spawn_env_list(env),
cwd = plugin_path,
}, function(code, signal)
if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if ok_tail then
opts.on_event(ev_tail)
end
buf = ''
end
)
if opts.on_exit then
opts.on_exit({ success = (code == 0), code = code, signal = signal })
end
if not stdout:is_closing() then
stdout:close()
end
if not stderr:is_closing() then
stderr:close()
end
if handle and not handle:is_closing() then
handle:close()
end
end)
if not handle then
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
@ -102,7 +125,7 @@ local function run_scraper(platform, subcommand, args, opts)
return
end
local sysopts = { text = true, timeout = 30000, env = env }
local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path }
if opts and opts.sync then
local result = vim.system(cmd, sysopts):wait()
return syshandle(result)

View file

@ -8,6 +8,21 @@ local logger = require('cp.log')
local scraper = require('cp.scraper')
local state = require('cp.state')
local function apply_template(bufnr, lang_id, platform)
local config = config_module.get_config()
local eff = config.runtime.effective[platform] and config.runtime.effective[platform][lang_id]
if not eff or not eff.template then
return
end
local path = vim.fn.expand(eff.template)
if vim.fn.filereadable(path) ~= 1 then
logger.log(('[cp.nvim] template not readable: %s'):format(path), vim.log.levels.WARN)
return
end
local lines = vim.fn.readfile(path)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
end
---Get the language of the current file from cache
---@return string?
local function get_current_file_language()
@ -121,6 +136,7 @@ end
---@param language? string
function M.setup_contest(platform, contest_id, problem_id, language)
local old_platform, old_contest_id = state.get_platform(), state.get_contest_id()
local old_problem_id = state.get_problem_id()
state.set_platform(platform)
state.set_contest_id(contest_id)
@ -133,7 +149,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
end
end
local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id
local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id
cache.load()
@ -143,7 +159,10 @@ function M.setup_contest(platform, contest_id, problem_id, language)
M.setup_problem(pid, language)
start_tests(platform, contest_id, problems)
if config_module.get_config().open_url and is_new_contest and contest_data.url then
local is_new_problem = old_problem_id ~= pid
local should_open_url = config_module.get_config().open_url
and (is_new_contest or is_new_problem)
if should_open_url and contest_data.url then
vim.ui.open(contest_data.url:format(pid))
end
end
@ -160,6 +179,8 @@ function M.setup_contest(platform, contest_id, problem_id, language)
vim.bo[bufnr].buftype = ''
vim.bo[bufnr].swapfile = false
state.set_language(lang)
if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
local ok = pcall(cfg.hooks.setup_code, state)
if ok then
@ -173,7 +194,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
contest_id = contest_id,
language = lang,
requested_problem_id = problem_id,
token = vim.loop.hrtime(),
token = vim.uv.hrtime(),
})
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
@ -264,14 +285,17 @@ function M.setup_problem(problem_id, language)
mods = { silent = true, noautocmd = true, keepalt = true },
})
state.set_solution_win(vim.api.nvim_get_current_win())
if config.hooks and config.hooks.setup_code and not vim.b[prov.bufnr].cp_setup_done then
local ok = pcall(config.hooks.setup_code, state)
if ok then
if not vim.b[prov.bufnr].cp_setup_done then
apply_template(prov.bufnr, lang, platform)
if config.hooks and config.hooks.setup_code then
local ok = pcall(config.hooks.setup_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
end
elseif not vim.b[prov.bufnr].cp_setup_done then
helpers.clearcol(prov.bufnr)
vim.b[prov.bufnr].cp_setup_done = true
end
cache.set_file_state(
vim.fn.fnamemodify(source_file, ':p'),
@ -294,14 +318,21 @@ function M.setup_problem(problem_id, language)
local bufnr = vim.api.nvim_get_current_buf()
state.set_solution_win(vim.api.nvim_get_current_win())
require('cp.ui.views').ensure_io_view()
if config.hooks and config.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
local ok = pcall(config.hooks.setup_code, state)
if ok then
if not vim.b[bufnr].cp_setup_done then
local is_new = vim.api.nvim_buf_line_count(bufnr) == 1
and vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] == ''
if is_new then
apply_template(bufnr, lang, platform)
end
if config.hooks and config.hooks.setup_code then
local ok = pcall(config.hooks.setup_code, state)
if ok then
vim.b[bufnr].cp_setup_done = true
end
else
helpers.clearcol(bufnr)
vim.b[bufnr].cp_setup_done = true
end
elseif not vim.b[bufnr].cp_setup_done then
helpers.clearcol(bufnr)
vim.b[bufnr].cp_setup_done = true
end
cache.set_file_state(
vim.fn.expand('%:p'),

View file

@ -121,13 +121,22 @@ function M.toggle_interactive(interactor_cmd)
end
local orchestrator =
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
if utils.is_nix_build() then
cmdline = table.concat({
vim.fn.shellescape(utils.get_nix_python()),
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
else
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
end
else
cmdline = vim.fn.shellescape(binary)
end

View file

@ -2,7 +2,10 @@ local M = {}
local logger = require('cp.log')
local uname = vim.loop.os_uname()
local _nix_python = nil
local _nix_discovered = false
local uname = vim.uv.os_uname()
local _time_cached = false
local _time_path = nil
@ -57,7 +60,11 @@ local function find_gnu_time()
_time_cached = true
_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
end
@ -79,27 +86,101 @@ function M.get_plugin_path()
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
end
---@return boolean
function M.is_nix_build()
return _nix_python ~= nil
end
---@return string|nil
function M.get_nix_python()
return _nix_python
end
---@return boolean
function M.is_nix_discovered()
return _nix_discovered
end
---@param module string
---@param plugin_path string
---@return string[]
function M.get_python_cmd(module, plugin_path)
if _nix_python then
return { _nix_python, '-m', 'scrapers.' .. module }
end
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
end
local python_env_setup = false
---@return boolean
local function discover_nix_python()
local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim'
local cache_file = cache_dir .. '/nix-python'
local f = io.open(cache_file, 'r')
if f then
local cached = f:read('*l')
f:close()
if cached and vim.fn.executable(cached) == 1 then
_nix_python = cached
return true
end
end
local plugin_path = M.get_plugin_path()
vim.notify('[cp.nvim] Building Python environment with nix...', vim.log.levels.INFO)
vim.cmd.redraw()
local result = vim
.system(
{ 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' },
{ text = true }
)
:wait()
if result.code ~= 0 then
logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN)
return false
end
local store_path = result.stdout:gsub('%s+$', '')
local python_path = store_path .. '/bin/python3'
if vim.fn.executable(python_path) ~= 1 then
logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN)
return false
end
vim.fn.mkdir(cache_dir, 'p')
f = io.open(cache_file, 'w')
if f then
f:write(python_path)
f:close()
end
_nix_python = python_path
_nix_discovered = true
return true
end
---@return boolean success
function M.setup_python_env()
if python_env_setup then
return true
end
local plugin_path = M.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
if vim.fn.executable('uv') == 0 then
logger.log(
'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/',
vim.log.levels.WARN
)
return false
if _nix_python then
logger.log('Python env: nix (python=' .. _nix_python .. ')')
python_env_setup = true
return true
end
if vim.fn.isdirectory(venv_dir) == 0 then
logger.log('Setting up Python environment for scrapers...')
if 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()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
@ -108,14 +189,33 @@ function M.setup_python_env()
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
:wait()
if result.code ~= 0 then
logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
logger.log(
'Failed to setup Python environment: ' .. (result.stderr or ''),
vim.log.levels.ERROR
)
return false
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
python_env_setup = true
return true
if vim.fn.executable('nix') == 1 then
logger.log('Python env: nix discovery')
if discover_nix_python() then
python_env_setup = true
return true
end
end
logger.log(
'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.',
vim.log.levels.WARN
)
return false
end
--- Configure the buffer with good defaults
@ -162,20 +262,12 @@ function M.check_required_runtime()
local time = M.time_capability()
if not time.ok then
return false, 'GNU time not found: ' .. (time.reason or '')
return false, time.reason
end
local timeout = M.timeout_capability()
if not timeout.ok then
return false, 'GNU timeout not found: ' .. (timeout.reason or '')
end
if vim.fn.executable('uv') ~= 1 then
return false, 'uv not found (https://docs.astral.sh/uv/)'
end
if not M.setup_python_env() then
return false, 'failed to set up Python virtual environment'
return false, timeout.reason
end
return true
@ -225,7 +317,11 @@ local function find_gnu_timeout()
_timeout_cached = true
_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
end
@ -240,7 +336,7 @@ function M.timeout_capability()
end
function M.cwd_executables()
local uv = vim.uv or vim.loop
local uv = vim.uv
local req = uv.fs_scandir('.')
if not req then
return {}

0
new
View file

View file

@ -154,3 +154,17 @@ end, {
return {}
end,
})
local function cp_action(action)
return function()
require('cp').handle_command({ fargs = { action } })
end
end
vim.keymap.set('n', '<Plug>(cp-run)', cp_action('run'), { desc = 'CP run tests' })
vim.keymap.set('n', '<Plug>(cp-panel)', cp_action('panel'), { desc = 'CP open panel' })
vim.keymap.set('n', '<Plug>(cp-edit)', cp_action('edit'), { desc = 'CP edit test cases' })
vim.keymap.set('n', '<Plug>(cp-next)', cp_action('next'), { desc = 'CP next problem' })
vim.keymap.set('n', '<Plug>(cp-prev)', cp_action('prev'), { desc = 'CP previous problem' })
vim.keymap.set('n', '<Plug>(cp-pick)', cp_action('pick'), { desc = 'CP pick contest' })
vim.keymap.set('n', '<Plug>(cp-interact)', cp_action('interact'), { desc = 'CP interactive mode' })

View file

@ -12,8 +12,6 @@ dependencies = [
"ndjson>=0.3.1",
"pydantic>=2.11.10",
"requests>=2.32.5",
"scrapling[fetchers]>=0.3.5",
"types-requests>=2.32.4.20250913",
]
[dependency-groups]

View file

@ -6,7 +6,7 @@ import re
from typing import Any
import httpx
from scrapling.fetchers import Fetcher
from curl_cffi import requests as curl_requests
from .base import BaseScraper
from .models import (
@ -50,8 +50,9 @@ def _extract_memory_limit(html: str) -> float:
def _fetch_html_sync(url: str) -> str:
response = Fetcher.get(url)
return str(response.body)
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S)
response.raise_for_status()
return response.text
class CodeChefScraper(BaseScraper):

View file

@ -2,13 +2,12 @@
import asyncio
import json
import logging
import re
from typing import Any
import requests
from bs4 import BeautifulSoup, Tag
from scrapling.fetchers import Fetcher
from curl_cffi import requests as curl_requests
from .base import BaseScraper
from .models import (
@ -19,10 +18,6 @@ from .models import (
TestCase,
)
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
BASE_URL = "https://codeforces.com"
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
TIMEOUT_SECONDS = 30
@ -83,7 +78,7 @@ def _extract_title(block: Tag) -> tuple[str, str]:
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
st = block.find("div", class_="sample-test")
if not st:
if not isinstance(st, Tag):
return [], False
input_pres: list[Tag] = [
@ -140,10 +135,9 @@ def _is_interactive(block: Tag) -> bool:
def _fetch_problems_html(contest_id: str) -> str:
url = f"{BASE_URL}/contest/{contest_id}/problems"
page = Fetcher.get(
url,
)
return page.html_content
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS)
response.raise_for_status()
return response.text
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:

View file

@ -1 +1,4 @@
std = 'vim'
[lints]
bad_string_escape = 'allow'

View file

@ -10,7 +10,7 @@ from typing import Any
import httpx
import pytest
import requests
from scrapling import fetchers
from curl_cffi import requests as curl_requests
ROOT = Path(__file__).resolve().parent.parent
FIX = Path(__file__).resolve().parent / "fixtures"
@ -136,12 +136,15 @@ def run_scraper_offline(fixture_text):
case "codeforces":
class MockCodeForcesPage:
class MockCurlResponse:
def __init__(self, html: str):
self.html_content = html
self.text = html
def _mock_stealthy_fetch(url: str, **kwargs):
return MockCodeForcesPage(_router_codeforces(url=url))
def raise_for_status(self):
pass
def _mock_curl_get(url: str, **kwargs):
return MockCurlResponse(_router_codeforces(url=url))
def _mock_requests_get(url: str, **kwargs):
if "api/contest.list" in url:
@ -172,7 +175,7 @@ def run_scraper_offline(fixture_text):
raise AssertionError(f"Unexpected requests.get call: {url}")
return {
"Fetcher.get": _mock_stealthy_fetch,
"curl_requests.get": _mock_curl_get,
"requests.get": _mock_requests_get,
}
@ -212,21 +215,23 @@ def run_scraper_offline(fixture_text):
return MockResponse(data)
raise AssertionError(f"No fixture for CodeChef url={url!r}")
class MockCodeChefPage:
class MockCodeChefCurlResponse:
def __init__(self, html: str):
self.body = html
self.status = 200
self.text = html
def _mock_stealthy_fetch(url: str, **kwargs):
def raise_for_status(self):
pass
def _mock_curl_get(url: str, **kwargs):
if "/problems/" in url:
problem_id = url.rstrip("/").split("/")[-1]
html = fixture_text(f"codechef/{problem_id}.html")
return MockCodeChefPage(html)
return MockCodeChefCurlResponse(html)
raise AssertionError(f"No fixture for CodeChef url={url!r}")
return {
"__offline_get_async": __offline_get_async,
"Fetcher.get": _mock_stealthy_fetch,
"curl_requests.get": _mock_curl_get,
}
case _:
@ -245,7 +250,7 @@ def run_scraper_offline(fixture_text):
offline_fetches = _make_offline_fetches(scraper_name)
if scraper_name == "codeforces":
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
curl_requests.get = offline_fetches["curl_requests.get"]
requests.get = offline_fetches["requests.get"]
elif scraper_name == "atcoder":
ns._fetch = offline_fetches["_fetch"]
@ -254,7 +259,7 @@ def run_scraper_offline(fixture_text):
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
elif scraper_name == "codechef":
httpx.AsyncClient.get = offline_fetches["__offline_get_async"]
fetchers.Fetcher.get = offline_fetches["Fetcher.get"]
curl_requests.get = offline_fetches["curl_requests.get"]
scraper_class = getattr(ns, scraper_classes[scraper_name])
scraper = scraper_class()

View file

@ -6,11 +6,6 @@ from scrapers.models import (
TestsResult,
)
MODEL_FOR_MODE = {
"metadata": MetadataResult,
"contests": ContestListResult,
}
MATRIX = {
"cses": {
"metadata": ("introductory_problems",),
@ -43,17 +38,16 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
assert rc in (0, 1), f"Bad exit code {rc}"
assert objs, f"No JSON output for {scraper}:{mode}"
if mode in ("metadata", "contests"):
Model = MODEL_FOR_MODE[mode]
model = Model.model_validate(objs[-1])
assert model is not None
if mode == "metadata":
model = MetadataResult.model_validate(objs[-1])
assert model.success is True
if mode == "metadata":
assert model.url
assert len(model.problems) >= 1
assert all(isinstance(p.id, str) and p.id for p in model.problems)
else:
assert len(model.contests) >= 1
assert model.url
assert len(model.problems) >= 1
assert all(isinstance(p.id, str) and p.id for p in model.problems)
elif mode == "contests":
model = ContestListResult.model_validate(objs[-1])
assert model.success is True
assert len(model.contests) >= 1
else:
assert len(objs) >= 1, "No test objects returned"
validated_any = False

1269
uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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
View 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