Compare commits

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

41 commits

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

View file

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

3
.gitignore vendored
View file

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

View file

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

View file

@ -28,11 +28,12 @@ Install using your package manager of choice or via
luarocks install cp.nvim luarocks install cp.nvim
``` ```
## Optional Dependencies ## Dependencies
- [uv](https://docs.astral.sh/uv/) for problem scraping
- GNU [time](https://www.gnu.org/software/time/) and - GNU [time](https://www.gnu.org/software/time/) and
[timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html) [timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html)
- [uv](https://docs.astral.sh/uv/) or [nix](https://nixos.org/) for problem
scraping
## Quick Start ## Quick Start

View file

@ -18,6 +18,243 @@ REQUIREMENTS *cp-requirements*
- Unix-like operating system - Unix-like operating system
- uv package manager (https://docs.astral.sh/uv/) - 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* 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: cp.nvim provides <Plug> mappings for all primary actions. These dispatch
>lua through the same code path as |:CP|.
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',
},
}
<
By default, C++ (g++ with ISO C++17) and Python are preconfigured under *<Plug>(cp-run)*
'languages'. Platforms select which languages are enabled and which one is <Plug>(cp-run) Run tests in I/O view. Equivalent to :CP run.
the default; per-platform overrides can tweak 'extension' or 'commands'.
For example, to run CodeForces contests with Python by default: *<Plug>(cp-panel)*
>lua <Plug>(cp-panel) Open full-screen test panel. Equivalent to :CP panel.
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.
*CpPlatform* *<Plug>(cp-edit)*
Fields: ~ <Plug>(cp-edit) Open the test case editor. Equivalent to :CP edit.
{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* *<Plug>(cp-next)*
Fields: ~ <Plug>(cp-next) Navigate to the next problem. Equivalent to :CP next.
{extension} (string) File extension without leading dot.
{commands} (|CpLangCommands|) Command templates.
*CpLangCommands* *<Plug>(cp-prev)*
Fields: ~ <Plug>(cp-prev) Navigate to the previous problem. Equivalent to :CP prev.
{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* *<Plug>(cp-pick)*
Fields: ~ <Plug>(cp-pick) Launch the contest picker. Equivalent to :CP pick.
{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* *<Plug>(cp-interact)*
Fields: ~ <Plug>(cp-interact) Open interactive mode. Equivalent to :CP interact.
{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* Example configuration: >lua
Fields: ~ vim.keymap.set('n', '<leader>cr', '<Plug>(cp-run)')
{next_test_key} (string|nil, default: ']t') Jump to next test. vim.keymap.set('n', '<leader>cp', '<Plug>(cp-panel)')
{prev_test_key} (string|nil, default: '[t') Jump to previous test. vim.keymap.set('n', '<leader>ce', '<Plug>(cp-edit)')
{delete_test_key} (string|nil, default: 'gd') Delete current test. vim.keymap.set('n', '<leader>cn', '<Plug>(cp-next)')
{add_test_key} (string|nil, default: 'ga') Add new test. vim.keymap.set('n', '<leader>cN', '<Plug>(cp-prev)')
{save_and_exit_key} (string|nil, default: 'q') Save and exit editor. vim.keymap.set('n', '<leader>cc', '<Plug>(cp-pick)')
All keys are nil-able. Set to nil to disable. vim.keymap.set('n', '<leader>ci', '<Plug>(cp-interact)')
*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
}
< <
============================================================================== ==============================================================================

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

@ -38,7 +38,8 @@
local M = {} local M = {}
local logger = require('cp.log') local CACHE_VERSION = 1
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
local cache_data = {} local cache_data = {}
local loaded = false local loaded = false
@ -65,9 +66,15 @@ function M.load()
local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n'))
if ok then if ok then
cache_data = decoded if decoded._version ~= CACHE_VERSION then
cache_data = {}
M.save()
else
cache_data = decoded
end
else else
logger.log('Could not decode json in cache file', vim.log.levels.ERROR) cache_data = {}
M.save()
end end
loaded = true loaded = true
end end
@ -78,6 +85,7 @@ function M.save()
vim.schedule(function() vim.schedule(function()
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
cache_data._version = CACHE_VERSION
local encoded = vim.json.encode(cache_data) local encoded = vim.json.encode(cache_data)
local lines = vim.split(encoded, '\n') local lines = vim.split(encoded, '\n')
vim.fn.writefile(lines, cache_file) vim.fn.writefile(lines, cache_file)
@ -338,6 +346,8 @@ function M.get_data_pretty()
return vim.inspect(cache_data) return vim.inspect(cache_data)
end end
M._cache = cache_data function M.get_raw_cache()
return cache_data
end
return M return M

View file

@ -292,7 +292,15 @@ end
---@return cp.Config ---@return cp.Config
function M.setup(user_config) function M.setup(user_config)
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } }) vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {}) local defaults = vim.deepcopy(M.defaults)
if user_config and user_config.platforms then
for plat in pairs(defaults.platforms) do
if not user_config.platforms[plat] then
defaults.platforms[plat] = nil
end
end
end
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
if not next(cfg.languages) then if not next(cfg.languages) then
error('[cp.nvim] At least one language must be configured') error('[cp.nvim] At least one language must be configured')

View file

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

View file

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

View file

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

View file

@ -276,26 +276,35 @@ function M.run_all_test_cases(indices, debug, on_each, on_done)
end end
end end
local function run_next(pos) if #to_run == 0 then
if pos > #to_run then logger.log(
logger.log( ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0),
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run), vim.log.levels.INFO,
vim.log.levels.INFO, true
true )
) on_done(panel_state.test_cases)
on_done(panel_state.test_cases) return
return
end
M.run_test_case(to_run[pos], debug, function()
if on_each then
on_each(pos, #to_run)
end
run_next(pos + 1)
end)
end end
run_next(1) local total = #to_run
local remaining = total
for _, idx in ipairs(to_run) do
M.run_test_case(idx, debug, function()
if on_each then
on_each(idx, total)
end
remaining = remaining - 1
if remaining == 0 then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
end
end)
end
end end
---@return PanelState ---@return PanelState

View file

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

View file

@ -121,6 +121,7 @@ end
---@param language? string ---@param language? string
function M.setup_contest(platform, contest_id, problem_id, language) function M.setup_contest(platform, contest_id, problem_id, language)
local old_platform, old_contest_id = state.get_platform(), state.get_contest_id() local old_platform, old_contest_id = state.get_platform(), state.get_contest_id()
local old_problem_id = state.get_problem_id()
state.set_platform(platform) state.set_platform(platform)
state.set_contest_id(contest_id) state.set_contest_id(contest_id)
@ -133,7 +134,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
end end
end end
local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id
cache.load() cache.load()
@ -143,7 +144,10 @@ function M.setup_contest(platform, contest_id, problem_id, language)
M.setup_problem(pid, language) M.setup_problem(pid, language)
start_tests(platform, contest_id, problems) start_tests(platform, contest_id, problems)
if config_module.get_config().open_url and is_new_contest and contest_data.url then local is_new_problem = old_problem_id ~= pid
local should_open_url = config_module.get_config().open_url
and (is_new_contest or is_new_problem)
if should_open_url and contest_data.url then
vim.ui.open(contest_data.url:format(pid)) vim.ui.open(contest_data.url:format(pid))
end end
end end
@ -160,6 +164,8 @@ function M.setup_contest(platform, contest_id, problem_id, language)
vim.bo[bufnr].buftype = '' vim.bo[bufnr].buftype = ''
vim.bo[bufnr].swapfile = false vim.bo[bufnr].swapfile = false
state.set_language(lang)
if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then 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) local ok = pcall(cfg.hooks.setup_code, state)
if ok then if ok then
@ -173,7 +179,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
contest_id = contest_id, contest_id = contest_id,
language = lang, language = lang,
requested_problem_id = problem_id, requested_problem_id = problem_id,
token = vim.loop.hrtime(), token = vim.uv.hrtime(),
}) })
logger.log('Fetching contests problems...', vim.log.levels.INFO, true) logger.log('Fetching contests problems...', vim.log.levels.INFO, true)

View file

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

View file

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

0
new
View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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