Compare commits

..

No commits in common. "main" and "v0.7.7" have entirely different histories.
main ... v0.7.7

23 changed files with 1692 additions and 629 deletions

3
.envrc Normal file
View file

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

112
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,112 @@
name: ci
on:
workflow_call:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
lua: ${{ steps.changes.outputs.lua }}
python: ${{ steps.changes.outputs.python }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
lua:
- 'lua/**'
- 'spec/**'
- 'plugin/**'
- 'after/**'
- 'ftdetect/**'
- '*.lua'
- '.luarc.json'
- 'stylua.toml'
- 'selene.toml'
python:
- 'scripts/**'
- 'scrapers/**'
- 'tests/**'
- 'pyproject.toml'
- 'uv.lock'
lua-format:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: 2.1.0
args: --check .
lua-lint:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: NTBBloodbath/selene-action@v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --display-style quiet .
lua-typecheck:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: mrcjkb/lua-typecheck-action@v0
with:
checklevel: Warning
directories: lua
configpath: .luarc.json
python-format:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv tool install ruff
- run: ruff format --check .
python-lint:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv tool install ruff
- run: ruff check .
python-typecheck:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync --dev
- run: uvx ty check .
python-test:
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v4
- run: uv sync --dev
- run: uv run camoufox fetch
- run: uv run pytest tests/ -v

View file

@ -44,7 +44,9 @@ jobs:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
- name: Install dependencies with pytest
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,6 +14,3 @@ __pycache__
.claude/
node_modules/
.envrc
.direnv/

View file

@ -28,12 +28,11 @@ Install using your package manager of choice or via
luarocks install cp.nvim
```
## Dependencies
## Optional 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

@ -19,13 +19,188 @@ REQUIREMENTS *cp-requirements*
- uv package manager (https://docs.astral.sh/uv/)
==============================================================================
SETUP *cp-setup*
COMMANDS *cp-commands*
Load cp.nvim with your package manager. For example, with lazy.nvim: >lua
{ 'barrettruth/cp.nvim' }
:CP *:CP*
cp.nvim uses a single :CP command with intelligent argument parsing:
Setup Commands ~
:CP {platform} {contest_id} [--lang {language}]
Full setup: set platform and load contest metadata.
Scrapes test cases and creates source file.
--lang: Use specific language (default: platform default)
Examples: >
:CP codeforces 1933
:CP codeforces 1933 --lang python
<
View Commands ~
:CP run [all|n|n,m,...] [--debug]
Run tests in I/O view (see |cp-io-view|).
Lightweight split showing test verdicts.
Execution modes:
• :CP run Combined: single execution with all tests
(auto-switches to individual when multiple samples)
• :CP run all Individual: N separate executions
• :CP run n Individual: run test n only
• :CP run n,m,... Individual: run specific tests (e.g. nth and mth)
--debug: Use debug build (builds to build/<name>.dbg)
Combined mode runs all test inputs in one execution (matching
platform behavior for multi-test problems). When a problem has
multiple independent sample test cases, :CP run auto-switches to
individual mode to run each sample separately.
Examples: >
:CP run " Combined: all tests, one execution
:CP run all " Individual: all tests, N executions
:CP run 2 " Individual: test 2 only
:CP run 1,3,5 " Individual: tests 1, 3, and 5
:CP run all --debug " Individual with debug build
<
:CP panel [--debug] [n]
Open full-screen test panel (see |cp-panel|).
Aggregate table with diff modes for detailed analysis.
Optional [n] focuses on specific test.
--debug: Use debug build (with sanitizers, etc.)
Examples: >
:CP panel " All tests
:CP panel --debug 3 " Test 3, debug build
<
:CP pick [--lang {language}]
Launch configured picker for interactive
platform/contest selection.
--lang: Pre-select language for chosen contest.
Example: >
:CP pick
:CP pick --lang python
<
:CP interact [script]
Open an interactive terminal for the current problem.
If an executable interactor is provided, runs the compiled
binary against the source file (see
*cp-interact*). Otherwise, runs the source
file. Only valid for interactive problems.
Navigation Commands ~
:CP next [--lang {language}]
Navigate to next problem in current contest.
Stops at last problem (no wrapping).
--lang: Use specific language for next problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP next
:CP next --lang python
<
:CP prev [--lang {language}]
Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
--lang: Use specific language for previous problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP prev
:CP prev --lang cpp
<
:CP {problem_id} [--lang {language}]
Jump to problem {problem_id} in a contest.
Requires that a contest has already been set up.
--lang: Use specific language for this problem.
Examples: >
:CP B
:CP C --lang python
<
Edit Commands ~
:CP edit [n]
Open grid test editor showing all test cases.
Tests displayed as 2×N grid (2 rows, N columns):
• Top row: Test inputs (editable)
• Bottom row: Expected outputs (editable)
Optional [n]: Jump cursor to test n's input buffer
Changes saved to both cache and disk on exit,
taking effect immediately in :CP run and CLI.
Keybindings (configurable via |EditConfig|):
q Save all and exit editor
]t Jump to next test column
[t Jump to previous test column
gd Delete current test column
ga Add new test column at end
<c-w> Normal window navigation
Examples: >
:CP edit " Edit all tests
:CP edit 3 " Edit all, start at test 3
<
State Restoration ~
:CP Restore state from current file.
Automatically detects platform, contest, problem,
and language from cached state. Use this after
switching files to restore your CP environment.
Cache Commands ~
:CP cache clear [platform] [contest]
Clear cache data at different granularities:
• No args: Clear all cached data
• [platform]: Clear all data for a platform
• [platform] [contest]: Clear specific contest
Examples: >
:CP cache clear
:CP cache clear codeforces
:CP cache clear codeforces 1848
<
:CP cache read
View the cache in a pretty-printed lua buffer.
Exit with q.
Template Variables ~
*cp-template-vars*
Command templates support variable substitution using {variable} syntax:
• {source} Source file path (e.g. "abc324a.cpp")
• {binary} Output binary path (e.g. "build/abc324a.run" or
"build/abc324a.dbg" for debug builds)
Example template: >
build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
< Would expand to: >
g++ abc324a.cpp -o build/abc324a.run -std=c++17
<
Debug Builds ~
*cp-debug-builds*
The --debug flag uses the debug command configuration instead of build:
• Normal build: commands.build → outputs to build/<name>.run
• Debug build: commands.debug → outputs to build/<name>.dbg
Debug builds typically include sanitizers (address, undefined behavior) to
catch memory errors, buffer overflows, and other issues. Both binaries
coexist, so you can switch between normal and debug mode without
recompiling.
Example debug configuration: >
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
}
}
}
<
The plugin works automatically with no configuration required. For
customization, see |cp-config|.
==============================================================================
CONFIGURATION *cp-config*
@ -250,232 +425,12 @@ run CSES problems with Rust using the single schema:
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
}
<
==============================================================================
COMMANDS *cp-commands*
:CP *:CP*
cp.nvim uses a single :CP command with intelligent argument parsing:
Setup Commands ~
:CP {platform} {contest_id} [--lang {language}]
Full setup: set platform and load contest metadata.
Scrapes test cases and creates source file.
--lang: Use specific language (default: platform default)
Examples: >
:CP codeforces 1933
:CP codeforces 1933 --lang python
<
View Commands ~
:CP run [all|n|n,m,...] [--debug]
Run tests in I/O view (see |cp-io-view|).
Lightweight split showing test verdicts.
Execution modes:
• :CP run Combined: single execution with all tests
(auto-switches to individual when multiple samples)
• :CP run all Individual: N separate executions
• :CP run n Individual: run test n only
• :CP run n,m,... Individual: run specific tests (e.g. nth and mth)
--debug: Use debug build (builds to build/<name>.dbg)
Combined mode runs all test inputs in one execution (matching
platform behavior for multi-test problems). When a problem has
multiple independent sample test cases, :CP run auto-switches to
individual mode to run each sample separately.
Examples: >
:CP run " Combined: all tests, one execution
:CP run all " Individual: all tests, N executions
:CP run 2 " Individual: test 2 only
:CP run 1,3,5 " Individual: tests 1, 3, and 5
:CP run all --debug " Individual with debug build
<
:CP panel [--debug] [n]
Open full-screen test panel (see |cp-panel|).
Aggregate table with diff modes for detailed analysis.
Optional [n] focuses on specific test.
--debug: Use debug build (with sanitizers, etc.)
Examples: >
:CP panel " All tests
:CP panel --debug 3 " Test 3, debug build
<
:CP pick [--lang {language}]
Launch configured picker for interactive
platform/contest selection.
--lang: Pre-select language for chosen contest.
Example: >
:CP pick
:CP pick --lang python
<
:CP interact [script]
Open an interactive terminal for the current problem.
If an executable interactor is provided, runs the compiled
binary against the source file (see
*cp-interact*). Otherwise, runs the source
file. Only valid for interactive problems.
Navigation Commands ~
:CP next [--lang {language}]
Navigate to next problem in current contest.
Stops at last problem (no wrapping).
--lang: Use specific language for next problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP next
:CP next --lang python
<
:CP prev [--lang {language}]
Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
--lang: Use specific language for previous problem.
By default, preserves current file's language if
enabled for the new problem, otherwise uses platform
default.
Examples: >
:CP prev
:CP prev --lang cpp
<
:CP {problem_id} [--lang {language}]
Jump to problem {problem_id} in a contest.
Requires that a contest has already been set up.
--lang: Use specific language for this problem.
Examples: >
:CP B
:CP C --lang python
<
Edit Commands ~
:CP edit [n]
Open grid test editor showing all test cases.
Tests displayed as 2×N grid (2 rows, N columns):
• Top row: Test inputs (editable)
• Bottom row: Expected outputs (editable)
Optional [n]: Jump cursor to test n's input buffer
Changes saved to both cache and disk on exit,
taking effect immediately in :CP run and CLI.
Keybindings (configurable via |EditConfig|):
q Save all and exit editor
]t Jump to next test column
[t Jump to previous test column
gd Delete current test column
ga Add new test column at end
<c-w> Normal window navigation
Examples: >
:CP edit " Edit all tests
:CP edit 3 " Edit all, start at test 3
<
State Restoration ~
:CP Restore state from current file.
Automatically detects platform, contest, problem,
and language from cached state. Use this after
switching files to restore your CP environment.
Cache Commands ~
:CP cache clear [platform] [contest]
Clear cache data at different granularities:
• No args: Clear all cached data
• [platform]: Clear all data for a platform
• [platform] [contest]: Clear specific contest
Examples: >
:CP cache clear
:CP cache clear codeforces
:CP cache clear codeforces 1848
<
:CP cache read
View the cache in a pretty-printed lua buffer.
Exit with q.
Template Variables ~
*cp-template-vars*
Command templates support variable substitution using {variable} syntax:
• {source} Source file path (e.g. "abc324a.cpp")
• {binary} Output binary path (e.g. "build/abc324a.run" or
"build/abc324a.dbg" for debug builds)
Example template: >
build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' }
< Would expand to: >
g++ abc324a.cpp -o build/abc324a.run -std=c++17
<
Debug Builds ~
*cp-debug-builds*
The --debug flag uses the debug command configuration instead of build:
• Normal build: commands.build → outputs to build/<name>.run
• Debug build: commands.debug → outputs to build/<name>.dbg
Debug builds typically include sanitizers (address, undefined behavior) to
catch memory errors, buffer overflows, and other issues. Both binaries
coexist, so you can switch between normal and debug mode without
recompiling.
Example debug configuration: >
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
}
}
}
<
==============================================================================
MAPPINGS *cp-mappings*
cp.nvim provides <Plug> mappings for all primary actions. These dispatch
through the same code path as |:CP|.
*<Plug>(cp-run)*
<Plug>(cp-run) Run tests in I/O view. Equivalent to :CP run.
*<Plug>(cp-panel)*
<Plug>(cp-panel) Open full-screen test panel. Equivalent to :CP panel.
*<Plug>(cp-edit)*
<Plug>(cp-edit) Open the test case editor. Equivalent to :CP edit.
*<Plug>(cp-next)*
<Plug>(cp-next) Navigate to the next problem. Equivalent to :CP next.
*<Plug>(cp-prev)*
<Plug>(cp-prev) Navigate to the previous problem. Equivalent to :CP prev.
*<Plug>(cp-pick)*
<Plug>(cp-pick) Launch the contest picker. Equivalent to :CP pick.
*<Plug>(cp-interact)*
<Plug>(cp-interact) Open interactive mode. Equivalent to :CP interact.
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)')
<
==============================================================================
LANGUAGE SELECTION *cp-lang-selection*

43
flake.lock generated
View file

@ -1,43 +0,0 @@
{
"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
}

View file

@ -1,72 +0,0 @@
{
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
];
};
});
};
}

View file

@ -292,15 +292,7 @@ end
---@return cp.Config
function M.setup(user_config)
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
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 {})
local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
if not next(cfg.languages) then
error('[cp.nvim] At least one language must be configured')

View file

@ -5,8 +5,6 @@ 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
@ -18,37 +16,22 @@ local function check()
vim.health.error('Windows is not supported')
end
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 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('Python version: ' .. r.stdout:gsub('\n', ''))
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
end
else
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
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end
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
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
local time_cap = utils.time_capability()
@ -58,7 +41,7 @@ local function check()
vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
end
local timeout_cap = utils.timeout_capability()
local timeout_cap = utils.time_capability()
if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else

View file

@ -15,25 +15,17 @@ local initialized = false
local function ensure_initialized()
if initialized then
return true
return
end
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)
local config = config_module.setup(user_config)
config_module.set_current_config(config)
initialized = true
return true
end
---@return nil
function M.handle_command(opts)
if not ensure_initialized() then
return
end
ensure_initialized()
local commands = require('cp.commands')
commands.handle_command(opts)
end

View file

@ -43,7 +43,6 @@ 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)
@ -120,7 +119,6 @@ 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

@ -25,22 +25,10 @@ end
---@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 = utils.get_python_cmd(platform, plugin_path)
vim.list_extend(cmd, { subcommand })
local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand }
vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
@ -53,32 +41,31 @@ local function run_scraper(platform, subcommand, args, opts)
local buf = ''
local handle
handle = uv.spawn(cmd[1], {
args = vim.list_slice(cmd, 2),
stdio = { nil, stdout, stderr },
env = 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)
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()
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)
@ -115,7 +102,7 @@ local function run_scraper(platform, subcommand, args, opts)
return
end
local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path }
local sysopts = { text = true, timeout = 30000, env = env }
if opts and opts.sync then
local result = vim.system(cmd, sysopts):wait()
return syshandle(result)

View file

@ -121,22 +121,13 @@ function M.toggle_interactive(interactor_cmd)
end
local orchestrator =
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
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
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
else
cmdline = vim.fn.shellescape(binary)
end

View file

@ -2,9 +2,6 @@ local M = {}
local logger = require('cp.log')
local _nix_python = nil
local _nix_discovered = false
local uname = vim.loop.os_uname()
local _time_cached = false
@ -60,11 +57,7 @@ local function find_gnu_time()
_time_cached = true
_time_path = nil
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
_time_reason = 'GNU time not found'
return _time_path, _time_reason
end
@ -86,101 +79,27 @@ 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
if _nix_python then
logger.log('Python env: nix (python=' .. _nix_python .. ')')
python_env_setup = true
return true
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
end
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()
if vim.fn.isdirectory(venv_dir) == 0 then
logger.log('Setting up Python environment for scrapers...')
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
@ -189,33 +108,14 @@ 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 or ''),
vim.log.levels.ERROR
)
logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
return false
end
if result.stderr and result.stderr ~= '' then
logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', ''))
end
python_env_setup = true
return true
logger.log('Python environment setup complete.')
end
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
python_env_setup = true
return true
end
--- Configure the buffer with good defaults
@ -262,12 +162,20 @@ function M.check_required_runtime()
local time = M.time_capability()
if not time.ok then
return false, time.reason
return false, 'GNU time not found: ' .. (time.reason or '')
end
local timeout = M.timeout_capability()
if not timeout.ok then
return false, timeout.reason
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'
end
return true
@ -317,11 +225,7 @@ local function find_gnu_timeout()
_timeout_cached = true
_timeout_path = nil
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
_timeout_reason = 'GNU timeout not found'
return _timeout_path, _timeout_reason
end

0
new Normal file
View file

View file

@ -154,17 +154,3 @@ 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,6 +12,8 @@ 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 curl_cffi import requests as curl_requests
from scrapling.fetchers import Fetcher
from .base import BaseScraper
from .models import (
@ -50,9 +50,8 @@ def _extract_memory_limit(html: str) -> float:
def _fetch_html_sync(url: str) -> str:
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S)
response.raise_for_status()
return response.text
response = Fetcher.get(url)
return str(response.body)
class CodeChefScraper(BaseScraper):

View file

@ -2,12 +2,13 @@
import asyncio
import json
import logging
import re
from typing import Any
import requests
from bs4 import BeautifulSoup, Tag
from curl_cffi import requests as curl_requests
from scrapling.fetchers import Fetcher
from .base import BaseScraper
from .models import (
@ -18,6 +19,10 @@ 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
@ -78,7 +83,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 isinstance(st, Tag):
if not st:
return [], False
input_pres: list[Tag] = [
@ -135,9 +140,10 @@ def _is_interactive(block: Tag) -> bool:
def _fetch_problems_html(contest_id: str) -> str:
url = f"{BASE_URL}/contest/{contest_id}/problems"
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS)
response.raise_for_status()
return response.text
page = Fetcher.get(
url,
)
return page.html_content
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:

View file

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

View file

@ -6,6 +6,11 @@ from scrapers.models import (
TestsResult,
)
MODEL_FOR_MODE = {
"metadata": MetadataResult,
"contests": ContestListResult,
}
MATRIX = {
"cses": {
"metadata": ("introductory_problems",),
@ -38,16 +43,17 @@ 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 == "metadata":
model = MetadataResult.model_validate(objs[-1])
if mode in ("metadata", "contests"):
Model = MODEL_FOR_MODE[mode]
model = Model.model_validate(objs[-1])
assert model is not None
assert model.success is True
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
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
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