Compare commits

...

498 commits
v0.3.0 ... main

Author SHA1 Message Date
Barrett Ruth
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
Barrett Ruth
760e7d7731 fix(ci): format 2026-02-20 17:49:34 -05:00
Barrett Ruth
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
Barrett Ruth
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
Barrett Ruth
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
Barrett Ruth
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
Barrett Ruth
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
Barrett Ruth
c192afc5d7 fix(ci): format 2026-02-18 14:13:37 -05:00
Barrett Ruth
b6f3398bbc fix(ci): formatting and typing 2026-02-18 14:13:37 -05:00
Barrett Ruth
e02a29bd40 fix(ci): remove duplicate workflows 2026-02-18 14:13:37 -05:00
Barrett Ruth
0f9715298e fix(ci): remove deprecated setups 2026-02-18 14:13:37 -05:00
Barrett Ruth
2148d9bd07 feat(nix): add health 2026-02-18 14:13:37 -05:00
Barrett Ruth
1162e7046b try to fix the setup 2026-02-18 14:13:37 -05:00
Barrett Ruth
b36ffba63a feat(nix): initial flake config; 2026-02-18 14:13:37 -05:00
Barrett Ruth
04d0c124cf
fix: remove flake config 2026-02-17 21:11:11 -05:00
Barrett Ruth
da433068ef
remove straggler file 2026-02-17 21:10:56 -05:00
Barrett Ruth
51504b0121
fix: flake config; 2026-02-17 21:10:29 -05:00
Barrett Ruth
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
Barrett Ruth
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
refactor: remove vim.g.cp_config compatibility shim
2026-02-06 16:42:41 -05:00
Barrett Ruth
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
Barrett Ruth
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
refactor: rename `vim.g.cp_config` to `vim.g.cp`
2026-02-06 16:07:03 -05:00
Barrett Ruth
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
fix: add deprecation warning for setup()
2026-02-03 21:51:19 -05:00
Barrett Ruth
276241447c fix: add deprecation warning for setup() 2026-02-03 21:46:47 -05:00
Barrett Ruth
dc635d5167 chore: add issue templates 2026-02-03 21:07:01 -05:00
Barrett Ruth
81ddd1ea87
Merge pull request #231 from barrettruth/fix/config
use `vim.g` for setup
2026-02-03 16:14:19 -05:00
Barrett Ruth
7444a99b22
Merge branch 'main' into fix/config 2026-02-03 16:13:35 -05:00
Barrett Ruth
ec487aa489 feat: config update to viom.g 2026-02-03 16:12:47 -05:00
Barrett Ruth
c4af9bf604
Merge pull request #228 from barrettruth/fix/doc
via, not main
2026-02-03 01:51:38 -05:00
Barrett Ruth
a4437bc1c6
Merge branch 'main' into fix/doc 2026-02-03 01:50:46 -05:00
Barrett Ruth
1a7e9517ba force 2026-02-03 01:50:22 -05:00
Barrett Ruth
11b8365aac via, not main 2026-02-03 01:49:47 -05:00
Barrett Ruth
585ebf0daf
Merge pull request #227 from barrettruth/fix/doc
update installation method
2026-02-03 01:43:56 -05:00
Barrett Ruth
08fb654d23 format yml too in pre-commit 2026-02-03 01:43:13 -05:00
Barrett Ruth
01efc7c344 fix(ci): prettier format 2026-02-03 01:41:35 -05:00
Barrett Ruth
f9f993db0c fix: pre-commit syntax error 2026-02-03 01:39:26 -05:00
Barrett Ruth
f184a7874a feat: update docs 2026-02-03 01:38:13 -05:00
Barrett Ruth
89e3c0e21d
Merge pull request #226 from barrettruth/feat/dir-bug
misc bugfixes
2026-02-02 13:16:46 -05:00
Barrett Ruth
a9ce31a291
Merge branch 'main' into feat/dir-bug 2026-02-02 13:13:41 -05:00
Barrett Ruth
c8f735617a misc bugfixes 2026-02-02 13:13:08 -05:00
Barrett Ruth
a14f543371
Merge pull request #225 from barrettruth/fix/rockspec
fix username docs
2026-02-01 17:13:00 -05:00
Barrett Ruth
56ec178cdd
Merge branch 'main' into fix/rockspec 2026-02-01 17:12:38 -05:00
Barrett Ruth
5cd6f75419 fix username too 2026-02-01 17:11:51 -05:00
Barrett Ruth
99d907aa7a
Merge pull request #224 from barrettruth/fix/rockspec
fix rockspec url for new username
2026-02-01 17:02:22 -05:00
Barrett Ruth
c06d819597 fix(ci): fix rockspec url 2026-02-01 17:01:29 -05:00
Barrett Ruth
682b267019
Merge pull request #223 from barrettruth/fix/ci
fix ci
2026-01-27 17:20:43 -06:00
Barrett Ruth
8a2871ec1b
Merge branch 'main' into fix/ci 2026-01-27 17:20:25 -06:00
Barrett Ruth
de1295d361 fix ci 2026-01-27 18:19:49 -05:00
Barrett Ruth
32f449850b
Merge pull request #222 from barrettruth/fix/ci
feat: misc tests
2026-01-27 17:16:16 -06:00
Barrett Ruth
6966e8e101 feat: misc tests 2026-01-27 18:14:54 -05:00
Barrett Ruth
a5e094d44a
Merge pull request #221 from barrettruth/fix/ci
fix(ci): only run on tag push
2026-01-27 17:12:10 -06:00
Barrett Ruth
5de6fb2fee
Merge branch 'main' into fix/ci 2026-01-27 17:10:47 -06:00
Barrett Ruth
bd25f1db0b fix(ci): only run on tag push 2026-01-27 18:09:57 -05:00
Barrett Ruth
9daa4e4ec4
Merge pull request #220 from barrettruth/fix/ci
run luarocks build on successful ci
2026-01-27 17:06:12 -06:00
Barrett Ruth
0b5c0f0c40 fix(ci): only run luarocks build on successful ci 2026-01-27 18:04:56 -05:00
Barrett Ruth
af559b0fa3
Merge pull request #219 from barrettruth/fix/misc
improve config validation
2026-01-27 16:38:03 -06:00
Barrett Ruth
d496509fce feat(config): improve config parsing phrasing 2026-01-27 17:33:16 -05:00
Barrett Ruth
383b327442 fix(config): validate scraper names better 2026-01-27 17:32:21 -05:00
Barrett Ruth
3f677137de fix(config): one of validation 2026-01-27 17:27:15 -05:00
Barrett Ruth
0a1cea9b43 feat: debug 2026-01-27 17:25:03 -05:00
Barrett Ruth
6ba51a92c2
Merge pull request #218 from barrettruth/fix/scraper-refactor
misc tweaks
2026-01-27 16:22:08 -06:00
Barrett Ruth
86f2e41983
Merge branch 'main' into fix/scraper-refactor 2026-01-27 16:20:44 -06:00
Barrett Ruth
d89a40b21f feat: update git formatting 2026-01-27 17:18:52 -05:00
Barrett Ruth
3348ac3e51 feat: improve formatting 2026-01-27 16:48:04 -05:00
Barrett Ruth
ee38da5074 feat(layout): change formatting 2026-01-27 16:47:50 -05:00
Barrett Ruth
9af359eb01 feat(layout): cleanup mode labels 2026-01-27 16:47:42 -05:00
Barrett Ruth
0b21d02f24 fix(runner): save buffer before compile 2026-01-27 16:42:16 -05:00
Barrett Ruth
282d701327 fix: minor log msg tweak 2026-01-27 16:10:00 -05:00
Barrett Ruth
dcadf7447d
Merge pull request #215 from barrettruth/fix/scraper-refactor
refactor scrapers
2026-01-27 15:06:05 -06:00
Barrett Ruth
89c1a3c683 fix(ci): more fixes 2026-01-27 15:56:34 -05:00
Barrett Ruth
83514c453e fix(ci): remove unused import 2026-01-27 15:48:26 -05:00
Barrett Ruth
d5c6783124 feat(scrapers): refactor 2026-01-27 15:43:40 -05:00
Barrett Ruth
5293515aca feat(scrapers): refactor 2026-01-27 14:44:08 -05:00
Barrett Ruth
7dafb7ea43
Merge pull request #214 from barrettruth/feat/highlights
use default neovim group highlights
2026-01-27 13:33:01 -06:00
Barrett Ruth
0f82ae4fdb Merge branch 'main' into feat/highlights 2026-01-27 14:31:23 -05:00
Barrett Ruth
873ddee0d4 fix(doc): feature-parity 2026-01-27 14:30:22 -05:00
Barrett Ruth
fb7888b83c feat(highlight): use default highlights 2026-01-27 14:27:41 -05:00
Barrett Ruth
ae7b571b68
Merge pull request #212 from barrettruth/feat/async
make `:CP {run,panel}` asynchronous
2026-01-27 13:25:15 -06:00
Barrett Ruth
4c5c44742e feat: refactors 2026-01-27 14:23:23 -05:00
Barrett Ruth
d4c5f08b5f fix(render): change pending status text to symbol 2026-01-27 13:31:07 -05:00
Barrett Ruth
0f513370ac fix(render): fix table render in partial state 2026-01-27 13:30:32 -05:00
Barrett Ruth
8969dbccf8 fix(panel): table rendering 2026-01-27 13:18:11 -05:00
Barrett Ruth
ba26cee7f9 feat(run): make running entirely asynchronous 2026-01-27 12:55:35 -05:00
Barrett Ruth
b88e2ce746
Merge pull request #211 from barrettruth/fix/disappearing-io-view
fix `:CP {prev,next}` race condition
2026-01-27 11:28:16 -06:00
Barrett Ruth
c8c0da6d61 fix(ci): format 2026-01-27 12:27:09 -05:00
Barrett Ruth
d40d80c541 fix: race condition & logs 2026-01-27 12:22:53 -05:00
Barrett Ruth
4369fe8b0c
Merge pull request #210 from barrettruth/feat/about
motivation
2026-01-15 17:08:34 -06:00
Barrett Ruth
363a1e88e9 fix(ci): format 2026-01-15 18:08:03 -05:00
Barrett Ruth
702cce959d feat(docs): add motivatoin 2026-01-15 18:03:49 -05:00
Barrett Ruth
ebeed1887d
Merge pull request #209 from barrettruth/barrettruth-patch-1
Update README.md
2026-01-10 11:04:56 -06:00
Barrett Ruth
48bafffcde
Update README.md 2026-01-10 11:04:44 -06:00
Barrett Ruth
b85113b805
Merge pull request #208 from barrett-ruth/feat/buf-cleanup
buffer cleanup mgmt
2025-12-31 13:08:19 -06:00
Barrett Ruth
fa45d912b8 close out other bufs on source buf close 2025-12-31 13:06:25 -06:00
Barrett Ruth
d613d3d24a
Merge pull request #206 from barrett-ruth/fix/logs
fix debug msg
2025-12-18 14:43:24 -06:00
Barrett Ruth
445059a498 fix(runner): proper debug msg 2025-12-18 14:43:03 -06:00
Barrett Ruth
e0596aefff
Merge pull request #205 from barrett-ruth/fix/logging
add necessary logging
2025-12-14 16:35:51 -06:00
Barrett Ruth
3a0c0de599 another log statement 2025-12-14 16:30:10 -06:00
Barrett Ruth
10b3dcd846 fix: add debug log 2025-12-14 16:23:14 -06:00
Barrett Ruth
edb341ae51
Merge pull request #202 from barrett-ruth/fix/notice
use `scrapling.Fetcher.get`, not `scrapling.StealthyFetcher.fetch`
2025-12-08 19:48:15 -06:00
Barrett Ruth
dfd8275421 fix: use a diff scraper for now 2025-12-08 19:46:14 -06:00
Barrett Ruth
680a22f303
Merge pull request #201 from barrett-ruth/fix/notice
update docs for that scrapling DOES work
2025-12-08 19:42:31 -06:00
Barrett Ruth
eb3f93587f fix(docs): scrapling DOES work 2025-12-08 19:21:30 -06:00
Barrett Ruth
9926965677
Merge pull request #200 from barrett-ruth/fix/miscl
update uv pks
2025-12-08 19:16:37 -06:00
Barrett Ruth
c7f573a93b install deps 2025-12-08 00:44:44 -06:00
Barrett Ruth
ac51b2c799 fix(scraper): done 2025-12-08 00:20:48 -06:00
Barrett Ruth
ecd76795ce feat: add envrc 2025-12-07 16:19:42 -06:00
Barrett Ruth
3c3e6172fc fix(ci): rename parameter for type-checking 2025-12-07 16:14:00 -06:00
Barrett Ruth
f805251762 some misc fixes 2025-12-07 16:09:17 -06:00
Barrett Ruth
6647e4120e fix: remove debug script 2025-12-07 15:40:10 -06:00
Barrett Ruth
06f8627331 fix: update pkgs 2025-12-07 15:38:56 -06:00
Barrett Ruth
5b43b64401
Merge pull request #199 from barrett-ruth/feat/misc-fixups
improve error message
2025-12-04 18:21:07 -05:00
Barrett Ruth
99109f5e91 fix: cleanup picker message 2025-12-04 18:12:10 -05:00
Barrett Ruth
944d37dc75 fix(git): ignore node_modules 2025-12-04 18:10:22 -05:00
Barrett Ruth
f91fbb2ca0
Merge pull request #196 from barrett-ruth/fix/uv
fix: fix uv conflict
2025-11-28 23:46:34 -05:00
Barrett Ruth
bbe04589b8 fix: fix uv conflict 2025-11-28 23:45:17 -05:00
Barrett Ruth
6aca33e371
Merge pull request #195 from barrett-ruth/fix/ci
easier uv install in ci
2025-11-28 01:38:28 -05:00
Barrett Ruth
675917796d fix(ci): easier uv install 2025-11-28 01:37:08 -05:00
Barrett Ruth
e12b39bda1
Merge pull request #194 from barrett-ruth/fid
x
2025-11-28 01:31:01 -05:00
Barrett Ruth
c9769e04b8 x 2025-11-28 01:30:48 -05:00
Barrett Ruth
864e6ceeae
Merge pull request #193 from barrett-ruth/fid
run pre-commit prettier on allf files
2025-11-28 00:29:35 -05:00
Barrett Ruth
9cc2b52111 c;eanup 2025-11-28 00:28:21 -05:00
Barrett Ruth
dcf8150cb2
Merge pull request #192 from barrett-ruth/feat/io/cp-test-case
open io view after cp test validation
2025-11-06 01:48:07 -05:00
Barrett Ruth
71863fde7f fix(io): validate view later 2025-11-06 01:46:10 -05:00
Barrett Ruth
5bcee87892
Merge pull request #191 from barrett-ruth/feat/io/view-togggle
misc io view fixups
2025-11-06 01:40:41 -05:00
Barrett Ruth
00987bb0ff feat(io): cleanup view 2025-11-06 01:31:50 -05:00
Barrett Ruth
d121784de5
Merge pull request #190 from barrett-ruth/feat/io/view-togggle
io view toggle + scraper fixes
2025-11-06 00:20:05 -05:00
Barrett Ruth
07e4372a4a cleanup 2025-11-06 00:18:09 -05:00
Barrett Ruth
0e778a128e Merge main into feat/io/view-togggle
Resolved conflicts:
- scrapers/atcoder.py: kept defensive if tests else '' checks
- scrapers/codechef.py: kept defensive if tests else '' checks
- tests/test_scrapers.py: kept comprehensive validation from main
- lua/cp/ui/views.lua: removed misplaced navigation code from loop
2025-11-05 23:01:04 -05:00
Barrett Ruth
d0f1dbf132 cleanup 2025-11-05 19:23:30 -05:00
Barrett Ruth
5995ded7d5
Merge pull request #189 from barrett-ruth/feat/multi-test-case
Multi-Test Case View
2025-11-05 19:23:09 -05:00
Barrett Ruth
e7ba6b4bb4 fix(test): update scrapers 2025-11-05 18:43:01 -05:00
Barrett Ruth
7d8d00c5ad fix(ui): correct output buf 2025-11-05 13:10:17 -05:00
Barrett Ruth
13d931ed19 feat: update 2025-11-05 12:47:38 -05:00
Barrett Ruth
96c01bf796 cleanup 2025-11-04 23:47:06 -05:00
Barrett Ruth
127de3d6a5 fix 2025-11-04 23:39:43 -05:00
Barrett Ruth
6a1534124d fix(ci): formatting 2025-11-04 22:16:49 -05:00
Barrett Ruth
8237dc4c16 fix(ci) upgrade python format 2025-11-04 22:16:08 -05:00
Barrett Ruth
cea90dbda5 preliminary updates 2025-11-04 22:10:42 -05:00
Barrett Ruth
1b0d5e4d77 feat: fix typign 2025-11-04 22:08:07 -05:00
Barrett Ruth
bd557ab069 feat(doc): fix 2025-11-04 21:57:47 -05:00
Barrett Ruth
e1c8c4beaf feat(cli): :CP run with numbered test cases 2025-11-04 21:45:45 -05:00
Barrett Ruth
71efb24cda fix 2025-11-04 21:32:51 -05:00
Barrett Ruth
aab211902e feat: multi-test case view 2025-11-04 21:32:40 -05:00
Barrett Ruth
6477fdc20c
Merge pull request #186 from barrett-ruth/feat/io/multi-test-case
Multi-Test Case View
2025-11-04 08:35:11 -05:00
Barrett Ruth
9238118fbe fix(ci): formatting 2025-11-04 08:33:56 -05:00
Barrett Ruth
6a61780928 fix(ci): typing 2025-11-04 08:19:14 -05:00
Barrett Ruth
fef73887e4 feat(io): multi-test case view 2025-11-04 08:15:08 -05:00
Barrett Ruth
3654748632 fix(scrapers): fix multi-test case codeforces running 2025-11-02 22:42:05 -05:00
Barrett Ruth
73c91e2b28
Merge pull request #185 from barrett-ruth/cleanup
cleanup
2025-10-31 23:27:23 -04:00
Barrett Ruth
91f85d066d cleanup 2025-10-31 23:24:35 -04:00
Barrett Ruth
71a6aac826
Merge pull request #184 from barrett-ruth/fix/codeforces-problem-url
fix(codeforces): correct problem url
2025-10-31 21:47:36 -04:00
Barrett Ruth
7bfa839c84 fix(codeforces): correct problem url 2025-10-31 21:47:15 -04:00
Barrett Ruth
6a2f58430d
Merge pull request #183 from barrett-ruth/feat/codechef
format codechef
2025-10-25 02:03:22 -04:00
Barrett Ruth
161c4cc113 fix(ci): format fixutres 2025-10-25 02:01:48 -04:00
Barrett Ruth
9b1f97dfec
Merge pull request #182 from barrett-ruth/barrett-ruth-patch-1
Update README.md
2025-10-25 02:01:17 -04:00
Barrett Ruth
701d70a7ae
Update README.md 2025-10-25 01:59:27 -04:00
Barrett Ruth
8fd4ce9651
Merge pull request #179 from barrett-ruth/feat/codechef
add codechef platform
2025-10-25 01:43:32 -04:00
Barrett Ruth
e89c2e1cf5 feat(codechef): finalize codechef impl 2025-10-25 01:41:55 -04:00
Barrett Ruth
f78e43bdd4 fix paths 2025-10-25 00:42:03 -04:00
Barrett Ruth
2ab03e624c fix rest of routes 2025-10-25 00:37:30 -04:00
Barrett Ruth
fa3de99222 fix(test): relocate fixtures 2025-10-25 00:37:19 -04:00
Barrett Ruth
4fe623c806 fix(test): refactor fixtures 2025-10-25 00:34:56 -04:00
Barrett Ruth
8ba2a598fe fix(tests): refactor fixture directory 2025-10-25 00:34:32 -04:00
Barrett Ruth
2fda5a74ca feat: codechef 2025-10-25 00:26:33 -04:00
Barrett Ruth
401494aab0
Merge pull request #178 from barrett-ruth/feat/ui/remove-extra-line
close all buffers on edit in ui mode
2025-10-24 21:43:32 -04:00
Barrett Ruth
9b90e3a452 feat(ui): close all buffers on edit 2025-10-24 21:40:13 -04:00
Barrett Ruth
5de81e55a9
Merge pull request #177 from barrett-ruth/feat/ui/remove-extra-line
remove extra line from test cases
2025-10-24 21:36:33 -04:00
Barrett Ruth
8345d147cf fix(ui): remove extra line from test cases 2025-10-24 21:31:03 -04:00
Barrett Ruth
1d89fa0bdd
Merge pull request #176 from barrett-ruth/feat/ui/test-case-editing
test case management
2025-10-24 17:10:54 -04:00
Barrett Ruth
b978c3ed13 fix(docs): site readme 2025-10-24 17:07:49 -04:00
Barrett Ruth
4eb9c9a21f feat(docs): update readme by mentioning test case mgmt 2025-10-24 17:06:30 -04:00
Barrett Ruth
b736fd0131 feat(ui): test editor 2025-10-24 17:02:43 -04:00
Barrett Ruth
181fff42de feat(ui): documentation for :CP edit abilities 2025-10-24 16:35:00 -04:00
Barrett Ruth
3fdb74a3d8 fix: cleanup script 2025-10-24 16:17:56 -04:00
Barrett Ruth
a45657c583
Merge pull request #174 from barrett-ruth/feat/edit
test case editor
2025-10-24 16:15:04 -04:00
Barrett Ruth
11b6056d8c fix 2025-10-24 16:13:52 -04:00
Barrett Ruth
de45fd3393 fix: modernize use of vim.cmd 2025-10-24 15:16:22 -04:00
Barrett Ruth
8ffa3cb0d2 fix: modernize use of typing 2025-10-24 15:10:58 -04:00
Barrett Ruth
a842886933 feat(ui): auto-hide source buffer on close 2025-10-24 14:47:12 -04:00
Barrett Ruth
4b1b75fd6e fix(config): padding spacing 2025-10-24 14:44:33 -04:00
Barrett Ruth
c857b66998
Merge pull request #173 from barrett-ruth/feat/lang
fix language-based problem navigation
2025-10-24 14:27:18 -04:00
Barrett Ruth
3daf582b7a feat(cache): update cache 2025-10-24 14:26:51 -04:00
Barrett Ruth
b3168ff3f0 feat: center the curso 2025-10-24 14:03:00 -04:00
Barrett Ruth
6ff0320531 cleanup 2025-10-24 13:48:56 -04:00
Barrett Ruth
9bf3438466 fix: defer to previous problem language 2025-10-24 11:49:48 -04:00
Barrett Ruth
0790fa7d6f
Merge pull request #172 from barrett-ruth/barrett-ruth-patch-1
Update README.md
2025-10-24 11:15:58 -04:00
Barrett Ruth
3822348642
Update README.md 2025-10-24 11:15:46 -04:00
Barrett Ruth
0418ef4613
Merge pull request #166 from barrett-ruth/feat/lang
language options with `--lang`
2025-10-24 01:45:55 -04:00
Barrett Ruth
9d848eba22 feat: improve autocomplete 2025-10-24 01:44:06 -04:00
Barrett Ruth
f52244c534 better errors 2025-10-24 01:32:48 -04:00
Barrett Ruth
c48cf384a4 feat: error on invalid language 2025-10-24 01:21:54 -04:00
Barrett Ruth
48d4c6f113 feat: language 2025-10-24 01:21:16 -04:00
Barrett Ruth
bd30fb626c feat: start lang refactor 2025-10-24 01:11:19 -04:00
Barrett Ruth
36e75ad71b
Merge pull request #165 from barrett-ruth/feat/config/format
improve config flexibility
2025-10-24 00:39:48 -04:00
Barrett Ruth
ce12ab0e1a minimize docs 2025-10-24 00:37:27 -04:00
Barrett Ruth
f715075dbe fix types 2025-10-24 00:34:42 -04:00
Barrett Ruth
249e84eb5a feat: customization 2025-10-24 00:26:14 -04:00
Barrett Ruth
f9a1f79aef
Merge pull request #164 from barrett-ruth/feat/debug
`--debug` flag
2025-10-23 23:59:09 -04:00
Barrett Ruth
6f9452c7e1 renme 2025-10-23 23:57:23 -04:00
Barrett Ruth
7e2e712b56 fix: rename file 2025-10-23 23:55:27 -04:00
Barrett Ruth
64b8b03cca fix var name 2025-10-23 23:49:50 -04:00
Barrett Ruth
82021e3d97 fix ci 2025-10-23 23:48:32 -04:00
Barrett Ruth
9ffc285e16 feat: document bdingins 2025-10-23 23:45:05 -04:00
Barrett Ruth
6a6cf2c594 feat: bindings and --debug flag 2025-10-23 23:36:09 -04:00
Barrett Ruth
743c29e634
Merge pull request #163 from barrett-ruth/feat/ui/alignment
ui alignment
2025-10-23 23:21:02 -04:00
Barrett Ruth
038fcd36f8 feat(ui): fix alignment 2025-10-23 23:14:53 -04:00
Barrett Ruth
92ffa41ed0 feat: fix test rendering 2025-10-23 23:02:48 -04:00
Barrett Ruth
1becd25cc0
Merge pull request #161 from barrett-ruth/feat/ui/io-view
io view
2025-10-23 22:35:11 -04:00
Barrett Ruth
b735af6a25 fix: ignore fixtures 2025-10-23 22:31:23 -04:00
Barrett Ruth
b77c81d63e fix: ignore file 2025-10-23 22:30:16 -04:00
Barrett Ruth
a9ac06de83 fix(ci): regex 2025-10-23 22:24:39 -04:00
Barrett Ruth
ce975d0f1e fix(ci): regex 2025-10-23 22:24:20 -04:00
Barrett Ruth
57b4a3ff15 fix: rename 2025-10-23 22:17:49 -04:00
Barrett Ruth
00fe1abcf1 fix(ci): format 2025-10-23 22:12:23 -04:00
Barrett Ruth
d4b88be44b fix formatting 2025-10-23 22:11:02 -04:00
Barrett Ruth
f45926c094 fix(docs): update for new features 2025-10-23 20:40:59 -04:00
Barrett Ruth
60e5aabd99 fix types 2025-10-23 20:15:16 -04:00
Barrett Ruth
99544905df fix current mode 2025-10-23 20:04:36 -04:00
Barrett Ruth
59f5066327 fix input display 2025-10-23 20:03:17 -04:00
Barrett Ruth
dc6f2fd5b6 fix: cleanup logs 2025-10-23 18:29:20 -04:00
Barrett Ruth
c312ccbb4d fix: highlighting 2025-10-23 18:16:36 -04:00
Barrett Ruth
9ac2d148d2 fix(helpers): window-local options 2025-10-23 12:14:17 -04:00
Barrett Ruth
13933fc7fd feat: clearcol 2025-10-23 12:10:14 -04:00
Barrett Ruth
114187164e improve some refactors 2025-10-23 11:16:13 -04:00
Barrett Ruth
c9d7d51732 fix: dont inline requires 2025-10-23 10:53:23 -04:00
Barrett Ruth
92b6ce31f9 fix(ui): open panel on problem setup 2025-10-23 10:52:13 -04:00
Barrett Ruth
ad17855532 feat(ui): io view 2025-10-23 10:27:40 -04:00
Barrett Ruth
52a4286b70
Merge pull request #160 from barrett-ruth/feat/window-state
add solution window to state
2025-10-23 10:10:42 -04:00
Barrett Ruth
347be72774 feat: add solution to window state 2025-10-23 10:07:22 -04:00
Barrett Ruth
f0edb103ce
Merge pull request #159 from barrett-ruth/fix/panel-rename
fix: rename run panel to panel
2025-10-23 10:04:57 -04:00
Barrett Ruth
018d801121 fix: rename run panel to panel 2025-10-23 09:54:55 -04:00
Barrett Ruth
c29ec1c6b0
Merge pull request #158 from barrett-ruth/feat/buf-difficulties
fix: open problem-specific url
2025-10-15 17:02:51 +02:00
Barrett Ruth
352f98f26f fix: open problem-specific url 2025-10-15 11:00:31 -04:00
Barrett Ruth
0c87ce2ac4
Merge pull request #157 from barrett-ruth/feat/buf-difficulties
fix: don't always open new window
2025-10-13 02:34:11 +02:00
Barrett Ruth
7f9f60af5b fix: don't always open new window 2025-10-12 20:31:11 -04:00
Barrett Ruth
c9f6ef8278
Merge pull request #156 from barrett-ruth/feat/buf-difficulties
fix buffer naming conflicts
2025-10-12 22:40:40 +02:00
Barrett Ruth
14b8bded1d fix: buffer name 2025-10-12 16:39:06 -04:00
Barrett Ruth
0a9e83d8f9
Merge pull request #155 from barrett-ruth/feat/open-url
Open URL In Browser
2025-10-12 22:24:43 +02:00
Barrett Ruth
32a46b4e98 feat: tests upgrade 2025-10-12 16:23:06 -04:00
Barrett Ruth
600a578a17 docs: update with open_url option 2025-10-12 16:20:14 -04:00
Barrett Ruth
c0e175d84b feat(config): open url option 2025-10-12 16:19:02 -04:00
Barrett Ruth
2bc56195fd fix(restore): state from :CP 2025-10-12 14:14:56 -04:00
Barrett Ruth
75f9f5ca4f
Merge pull request #153 from barrett-ruth/feat/instant-edit
instantly open problems with provisional state
2025-10-06 05:59:25 +02:00
Barrett Ruth
e36a40a9ac fix(ci): typing 2025-10-05 23:57:01 -04:00
Barrett Ruth
6ae9488761 fix: typing 2025-10-05 23:55:23 -04:00
Barrett Ruth
fa26344cd0
Merge pull request #152 from barrett-ruth/fix/ci
ci fixes
2025-10-06 05:19:34 +02:00
Barrett Ruth
617c1741cc fix(ci): typing 2025-10-05 23:18:09 -04:00
Barrett Ruth
c9ebdcdda5 fix: pre-commit 2025-10-05 23:12:38 -04:00
Barrett Ruth
b30c036478 fix(ci): typing 2025-10-05 23:08:54 -04:00
Barrett Ruth
4fac6c8019 feat(tests): fixtures 2025-10-05 23:06:38 -04:00
Barrett Ruth
c143600c5b fix(tests): cleaunop 2025-10-05 22:13:48 -04:00
Barrett Ruth
2426e1cbd4 fix: scrapers 2025-10-05 22:10:26 -04:00
Barrett Ruth
c509102b37 feat(tests): basic tests 2025-10-05 21:58:43 -04:00
Barrett Ruth
a7eb731730 fea(ci): improve prettier config 2025-10-05 21:06:57 -04:00
Barrett Ruth
d4df57bd05 fix(scrapers): cses interactive problems 2025-10-05 20:55:43 -04:00
Barrett Ruth
78c4cc779e
Merge pull request #150 from barrett-ruth/fix/help
fix cmd help file
2025-10-06 00:45:53 +02:00
Barrett Ruth
2b8bd503ad fix cmd help file 2025-10-05 18:45:38 -04:00
Barrett Ruth
a154d85a0d
Merge pull request #149 from barrett-ruth/feat/test-sample
small fixes
2025-10-05 22:24:32 +02:00
Barrett Ruth
735bb04c95 fix test 2025-10-05 16:22:41 -04:00
Barrett Ruth
6d4299ec68
Merge pull request #148 from barrett-ruth/feat/interactor
interactive problems
2025-10-05 22:09:40 +02:00
Barrett Ruth
5d479b26ce fix(panel): remove superfluous log 2025-10-05 16:07:38 -04:00
Barrett Ruth
a0b5264761 fix: improve error handling 2025-10-05 16:06:08 -04:00
Barrett Ruth
9134a0742b feat: update docs 2025-10-05 16:00:20 -04:00
Barrett Ruth
edbf118c25 feat(interact): only expect test cases on non-interactive problems 2025-10-05 15:46:29 -04:00
Barrett Ruth
41a8d1a75b feat: interactive mode 2025-10-05 15:36:28 -04:00
Barrett Ruth
f00691ae40
Merge pull request #147 from barrett-ruth/feat/softer-scripts
Warn on no test cases - don't fail
2025-10-05 20:06:20 +02:00
Barrett Ruth
cedcd82367 fix: write interaction into cache 2025-10-05 13:50:14 -04:00
Barrett Ruth
fd550bc654 feat(setup): warn no tests found 2025-10-05 13:45:26 -04:00
Barrett Ruth
25fde26943 feat(scrapers): cses soft too 2025-10-05 13:42:06 -04:00
Barrett Ruth
ee88450b3b feat(scrapers): make scrapers softer 2025-10-05 13:40:56 -04:00
Barrett Ruth
1945999099 update docs 2025-10-05 13:16:14 -04:00
Barrett Ruth
5e9c00014d
Merge pull request #146 from barrett-ruth/feat/cli-enhancements
fixups
2025-10-05 19:01:35 +02:00
Barrett Ruth
44bfc7317d feat(cli): :CP <problem_id> 2025-10-05 12:59:50 -04:00
Barrett Ruth
91864b2992 fix(ci): type check 2025-10-05 12:40:23 -04:00
Barrett Ruth
fa2660455a default ansi colors 2025-10-05 12:38:49 -04:00
Barrett Ruth
f5a72a3a8f doc cleanups 2025-10-05 12:32:43 -04:00
Barrett Ruth
b68ecbbe96 rename and simplify things 2025-10-05 11:59:24 -04:00
Barrett Ruth
45d21be879
Merge pull request #145 from barrett-ruth/feat/cli-enhancements
Misc CLI/Config Enhancements
2025-10-05 04:48:07 +02:00
Barrett Ruth
794426402a fix(ansi): 256 dynamic colors 2025-10-04 20:18:21 -04:00
Barrett Ruth
d2bde9bad8 fix(config): better file org 2025-10-04 19:54:53 -04:00
Barrett Ruth
a76d228e3f feat(doc): update for new config 2025-10-04 19:04:49 -04:00
Barrett Ruth
2c0e808c8c update dcos 2025-10-04 17:48:35 -04:00
Barrett Ruth
c627a40c0a fix: rename fields 2025-10-04 17:46:32 -04:00
Barrett Ruth
aae98a5796 disable scraper disabling 2025-10-04 17:45:49 -04:00
Barrett Ruth
0a320945a0 fix(config): platforms, not contests 2025-10-04 16:29:35 -04:00
Barrett Ruth
ef8ee26edf remove per-problem language config 2025-10-04 16:26:01 -04:00
Barrett Ruth
17b5e0a52b make cache resilient 2025-10-04 16:15:26 -04:00
Barrett Ruth
3fbbfa9423 normalize scraper behavior 2025-10-04 16:13:04 -04:00
Barrett Ruth
91c37e35e5
Merge pull request #144 from barrett-ruth/fix/navigation
Fix Navigation On Panel Run
2025-10-04 21:20:08 +02:00
Barrett Ruth
2ac0a4996d fix: enable :CP next/prev 2025-10-04 15:16:54 -04:00
Barrett Ruth
018f61af92
Merge pull request #141 from barrett-ruth/feat/caching
misc cachign fixes
2025-10-04 21:02:09 +02:00
Barrett Ruth
b9a2c7a4ff fix(scrapers): fix 2025-10-04 15:00:37 -04:00
Barrett Ruth
18dbcd43d2 fix(cache): contest override 2025-10-04 12:48:57 -04:00
Barrett Ruth
a725925434 fix(pickers): only log on fetch 2025-10-04 12:30:50 -04:00
Barrett Ruth
8f466f135a
Merge pull request #140 from barrett-ruth/feat/async-scrapers
Asynchronous Scrapers
2025-10-04 05:34:26 +02:00
Barrett Ruth
bb0ee24476 filler 2025-10-03 23:32:41 -04:00
Barrett Ruth
88315ed6e6 fix(ci): pre declare on lint 2025-10-03 23:30:56 -04:00
Barrett Ruth
f929c8e826 feat(scrapers/atcoder): atcoder scraper 2025-10-03 23:26:09 -04:00
Barrett Ruth
179b333505 update pyproject 2025-10-03 22:38:24 -04:00
Barrett Ruth
b8c79401da fix(scrapers/codeforces): suppress scrapling logs 2025-10-03 21:14:28 -04:00
Barrett Ruth
f48acb4672 fix(scrapers/codeforces): scrape time 2025-10-03 21:06:20 -04:00
Barrett Ruth
33cc2ca36b fix(scrapers/cses): rename scraper 2025-10-03 19:29:10 -04:00
Barrett Ruth
4498c4a7fa fix scrapers 2025-10-03 19:19:02 -04:00
Barrett Ruth
34ef7bafd6 fix print order 2025-10-03 14:41:32 -04:00
Barrett Ruth
1520939d4b some refactors 2025-10-03 14:34:49 -04:00
Barrett Ruth
fea9835436 fix(utils): cleanup timeout reason 2025-10-03 13:20:42 -04:00
Barrett Ruth
7be37ad96e
Merge pull request #138 from barrett-ruth/feat/timeout
Fix Timeout with GNU Timeout
2025-10-03 15:35:25 +02:00
Barrett Ruth
5991670ef2 fix health and hl groups 2025-10-03 09:34:25 -04:00
Barrett Ruth
d35ed0450b fix table 2025-10-03 09:27:52 -04:00
Barrett Ruth
c9ba8281b0 fix(runner): proper timeout 2025-10-03 09:16:38 -04:00
Barrett Ruth
312a74d785
Merge pull request #137 from barrett-ruth/fix/print
Remove Extra Print
2025-10-03 14:36:45 +02:00
Barrett Ruth
3b2752685b remove print 2025-10-03 08:36:10 -04:00
Barrett Ruth
b338ee6ca1
Merge pull request #136 from barrett-ruth/fix/typing
fix ascii table docs
2025-10-03 06:05:07 +02:00
Barrett Ruth
82444412aa fix table 2025-10-03 00:04:20 -04:00
Barrett Ruth
6e01714fe1
Merge pull request #133 from barrett-ruth/fix/typing
mle support
2025-10-03 05:50:56 +02:00
Barrett Ruth
91dbc4560c fix(ci): unused var 2025-10-02 23:49:45 -04:00
Barrett Ruth
778ce7b8e2 right align 2025-10-02 23:48:47 -04:00
Barrett Ruth
357d1601b4 add rss to table 2025-10-02 23:46:54 -04:00
Barrett Ruth
bfcf2242ee fix(runner): cleanup cache logic 2025-10-02 23:31:26 -04:00
Barrett Ruth
d480975652 fix tle verdict 2025-10-02 23:28:51 -04:00
Barrett Ruth
cddd61f061 config hard fail 2025-10-02 23:20:51 -04:00
Barrett Ruth
69ffc2d9dd cleanup 2025-10-02 23:07:10 -04:00
Barrett Ruth
0061161a90 feat(runner): mle 2025-10-02 23:03:58 -04:00
Barrett Ruth
5fdb522095 feat(health): better organization 2025-10-02 22:43:38 -04:00
Barrett Ruth
d9537e72ba many fixes 2025-10-02 22:35:30 -04:00
Barrett Ruth
1a4573a4e4 remove useless stderr fields 2025-10-02 20:45:27 -04:00
Barrett Ruth
3c0f8d7deb
Merge pull request #132 from barrett-ruth/fix/typing
Fix/typing
2025-10-02 20:30:21 +02:00
Barrett Ruth
de6969e982 fix(panel): proper indexing 2025-10-02 14:29:08 -04:00
Barrett Ruth
4f10377255 fix types 2025-10-02 14:24:45 -04:00
Barrett Ruth
db98153b11 fix(ansi): annotate highlights too 2025-10-02 14:20:26 -04:00
Barrett Ruth
1974addbd2 fix(lua): bunch of typing 2025-10-02 14:18:26 -04:00
Barrett Ruth
057b0890c2 fix: remove unused function 2025-10-02 13:56:38 -04:00
Barrett Ruth
35ccd6e217
Merge pull request #121 from barrett-ruth/refactor/picker-fixes
Refactor/picker fixes
2025-10-02 16:40:18 +02:00
Barrett Ruth
2809689494 one test 2025-10-02 10:39:19 -04:00
Barrett Ruth
27c265141e no more tests (for now 2025-10-02 10:34:14 -04:00
Barrett Ruth
00a1d57005 fix missing key 2025-10-02 10:26:15 -04:00
Barrett Ruth
57be0c0044 remove keys 2025-10-02 10:23:01 -04:00
Barrett Ruth
91e6fbe455 fix caching 2025-10-02 10:18:29 -04:00
Barrett Ruth
6b8a1e2087 more docs 2025-10-01 21:36:53 -04:00
Barrett Ruth
7eb314b02c fix caching 2025-10-01 20:21:11 -04:00
Barrett Ruth
e6c09a4897 fix some cachign 2025-10-01 17:08:36 -04:00
Barrett Ruth
a925686a17 fix(log): improve logging 2025-10-01 16:41:24 -04:00
Barrett Ruth
62af1965f8 fix a lot of logic 2025-10-01 15:15:04 -04:00
Barrett Ruth
b406c0ce4e fix: synchronous problem fetch 2025-10-01 12:25:07 -04:00
Barrett Ruth
1b0b5e5039
Merge pull request #120 from barrett-ruth/fix/docs
better scraper config
2025-10-01 04:39:08 +02:00
Barrett Ruth
91ce43e529 fix(test): fix mock 2025-09-30 22:37:59 -04:00
Barrett Ruth
67c23c4d69 better scraper config 2025-09-30 22:33:36 -04:00
Barrett Ruth
c1b15c2991
Merge pull request #119 from barrett-ruth/feat/doc
fix
2025-10-01 04:25:26 +02:00
Barrett Ruth
2bdb06ddef fix 2025-09-30 22:24:58 -04:00
Barrett Ruth
551da072e1
Merge pull request #118 from barrett-ruth/fix/doc
fix docs
2025-10-01 04:18:18 +02:00
Barrett Ruth
b52b679d39 fix docs 2025-09-30 22:17:52 -04:00
Barrett Ruth
5c04fd9270
Merge pull request #117 from barrett-ruth/fix/docs
fix(doc): remove duplicate tag
2025-10-01 04:14:45 +02:00
Barrett Ruth
79339ff945 fix(doc): remove duplicate tag 2025-09-30 22:14:19 -04:00
Barrett Ruth
e14bc99964
Merge pull request #116 from barrett-ruth/fix/docs
Fix Docs
2025-10-01 04:12:00 +02:00
Barrett Ruth
fe3a472428 fix(docs): rename doc file to avoid builtin name conflict 2025-09-30 22:11:09 -04:00
Barrett Ruth
7332d7e871
Merge pull request #115 from barrett-ruth/fix/docs
docs
2025-10-01 04:09:49 +02:00
Barrett Ruth
6cd3f9179f fix: docs 2025-09-30 22:09:17 -04:00
Barrett Ruth
64d4d59d06
Merge pull request #114 from barrett-ruth/feat/scrapling
Scraping & Picker Fixes
2025-10-01 04:02:27 +02:00
Barrett Ruth
3427bf9bbb fix(scrapers): make atcoder scraper resilient 2025-09-30 21:59:25 -04:00
Barrett Ruth
aa1dd43e70 fix(scrapers): remove unused field 2025-09-30 21:29:42 -04:00
Barrett Ruth
7761c7c759 fix(cache): file state 2025-09-30 21:21:13 -04:00
Barrett Ruth
fe90c0b95d fix test 2025-09-30 21:15:15 -04:00
Barrett Ruth
02fe97956f fix test 2025-09-30 21:12:48 -04:00
Barrett Ruth
ea098e6c9c fix(test): params ought not to be validated 2025-09-30 21:07:40 -04:00
Barrett Ruth
f94ae157c7 fix(test): actually test picking in picker specs 2025-09-30 20:58:42 -04:00
Barrett Ruth
a54e6398cf fix(picker): rename 2025-09-30 20:57:14 -04:00
Barrett Ruth
46cd509747 fix docs and superfluous vim.validate calls 2025-09-30 20:55:29 -04:00
Barrett Ruth
b5b2c770fc fix(ci): remove unused import 2025-09-30 20:45:39 -04:00
Barrett Ruth
99d6569809 fix(scrapers): update codeforce scraper with pytest 2025-09-30 20:44:37 -04:00
Barrett Ruth
9704b11e7c fix(pickers): declare M as table 2025-09-30 20:33:40 -04:00
Barrett Ruth
7a6690f360 try to fix ci 2025-09-30 20:33:01 -04:00
Barrett Ruth
ec9bc8cb64 fix(test): remove useless picker tests 2025-09-30 20:29:51 -04:00
Barrett Ruth
5588eae526 fix(picker): rename picker function names 2025-09-30 20:27:31 -04:00
Barrett Ruth
a7cd41712d fix(picker): print fetching data early 2025-09-30 20:19:19 -04:00
Barrett Ruth
e3309e8f3c fix(pickers): expose fns properly 2025-09-30 20:18:57 -04:00
Barrett Ruth
49ba922ff7 fix(scraper): use scrapling 2025-09-30 20:16:59 -04:00
Barrett Ruth
5d7719ec4a
Merge pull request #112 from barrett-ruth/feat/interact
default contest config
2025-10-01 01:57:28 +02:00
Barrett Ruth
abe078b73d fix(ci): temporarily add cloudscraper 2025-09-30 19:53:49 -04:00
Barrett Ruth
22d0f72878 fix(ci): remove unused vars 2025-09-30 19:52:50 -04:00
Barrett Ruth
dc4326524c fix(health): simplify health check 2025-09-30 19:49:10 -04:00
Barrett Ruth
02019dbdef fix(readme): remove disclaimer from readme 2025-09-30 19:41:15 -04:00
Barrett Ruth
df7896709f cloudscraper -> scrapy 2025-09-30 18:32:18 -04:00
Barrett Ruth
b7114042d7
Merge pull request #113 from barrett-ruth/barrett-ruth-patch-1
Update README.md
2025-09-27 16:17:45 +02:00
Barrett Ruth
2d5ff2bd93 fix(test): error detection 2025-09-27 10:16:55 -04:00
Barrett Ruth
f65f9baa73
Update README.md 2025-09-27 09:15:05 -05:00
Barrett Ruth
42d2ae4aaa test debug 2025-09-27 10:14:31 -04:00
Barrett Ruth
9d30e214e0
Update README.md 2025-09-27 09:08:04 -05:00
Barrett Ruth
ae2f8b94cf feat: interactive problem finer-tuning 2025-09-27 10:05:58 -04:00
Barrett Ruth
e5aca06955 feat(doc): document default setup 2025-09-26 09:53:32 -04:00
Barrett Ruth
f0fbb15765 fix: default contest config 2025-09-26 09:28:23 -04:00
Barrett Ruth
b41ed5be13 feat: provide default contest config 2025-09-26 09:15:43 -04:00
Barrett Ruth
83645b48be
Merge pull request #111 from barrett-ruth/feat/interact
Feat/interact
2025-09-26 15:07:59 +02:00
Barrett Ruth
bf191d7f67 fix(test): toggle interactive panel 2025-09-26 09:06:04 -04:00
Barrett Ruth
0c4d09a0a9 fix(test): mock 2025-09-26 09:03:51 -04:00
Barrett Ruth
433a468ee6 fix: only one panel at a time 2025-09-26 09:03:16 -04:00
Barrett Ruth
316b6628db indeed it toggles 2025-09-26 08:36:00 -04:00
Barrett Ruth
2e478f2742 fix(interact): kill the job 2025-09-26 08:32:00 -04:00
Barrett Ruth
7efd6404b6 feat: interactive terminal 2025-09-26 08:28:19 -04:00
Barrett Ruth
543a2a7c06
Merge pull request #110 from barrett-ruth/lol
fix(hook): run hooks truly befoire
2025-09-25 04:35:57 +02:00
Barrett Ruth
6b4dd32683 fix(hook): run hooks truly befoire 2025-09-24 22:17:59 -04:00
Barrett Ruth
bcb555ec7e
Merge pull request #109 from barrett-ruth/lol
lol
2025-09-25 03:36:52 +02:00
Barrett Ruth
7711788d3d cleanup 2025-09-24 21:35:57 -04:00
Barrett Ruth
52c50cde79 lol 2025-09-24 21:23:06 -04:00
Barrett Ruth
092b4de05f
Merge pull request #108 from barrett-ruth/fix/cses-titles
Don't Hard Code CSES Title Names
2025-09-25 03:00:20 +02:00
Barrett Ruth
383c59a2ea
Merge pull request #106 from barrett-ruth/feat/picker-contests
Feat/picker contests
2025-09-25 02:58:50 +02:00
Barrett Ruth
a48f4d049b snake to title case 2025-09-24 20:58:16 -04:00
Barrett Ruth
170021af8e no more ttl 2025-09-24 20:46:43 -04:00
Barrett Ruth
71b827fe95 fix: set test cases first 2025-09-24 20:34:35 -04:00
Barrett Ruth
bcbcc4365f remove ttl 2025-09-24 20:16:33 -04:00
Barrett Ruth
81206aa050
Merge pull request #105 from barrett-ruth/feat/picker-contests
fix: only display configured platforms in pickers
2025-09-25 02:11:41 +02:00
Barrett Ruth
7c337d6b33 fix 2025-09-24 20:09:36 -04:00
Barrett Ruth
a24ac2314c remove picker spec 2025-09-24 20:08:23 -04:00
Barrett Ruth
b70f38626e cleanup 2025-09-24 20:04:29 -04:00
Barrett Ruth
d862df9104 fix: only display configured platforms in pickers 2025-09-24 19:47:00 -04:00
Barrett Ruth
177c172205
Merge pull request #104 from barrett-ruth/feat/cleanup-async
better async
2025-09-25 01:29:01 +02:00
Barrett Ruth
646b0047dc fix lint 2025-09-24 18:48:15 -04:00
Barrett Ruth
62c4d1e89e fix(state): use state right 2025-09-24 18:44:58 -04:00
Barrett Ruth
975e829f78 fix: remove version 2025-09-24 18:28:41 -04:00
Barrett Ruth
9e84d57b8a feat: context, not config 2025-09-24 18:21:34 -04:00
Barrett Ruth
cbd5569f95
Merge pull request #102 from barrett-ruth/feat/async
Asynchronous Scraping
2025-09-24 06:51:05 +02:00
Barrett Ruth
a0171ee81e xi 2025-09-24 00:50:04 -04:00
Barrett Ruth
0e4c46c31a fix(test): mock logger 2025-09-24 00:48:17 -04:00
Barrett Ruth
4429b5fe67 fix 2025-09-24 00:47:44 -04:00
Barrett Ruth
699207e713 lint 2025-09-24 00:44:08 -04:00
Barrett Ruth
7ac91a3c4d fix async 2025-09-24 00:41:10 -04:00
Barrett Ruth
540364926d feat: improve logging 2025-09-23 16:14:21 -04:00
Barrett Ruth
2d3432335c fix 2025-09-23 15:37:18 -04:00
Barrett Ruth
ca652c04ff fix(ci): unused var 2025-09-23 15:32:56 -04:00
Barrett Ruth
a2b3de51d7 fix: better tests 2025-09-23 15:32:04 -04:00
Barrett Ruth
30c1c0f2cf fix(test): unused vars 2025-09-23 15:09:13 -04:00
Barrett Ruth
a08ad8e2ee fix(test): use new st8 mgmt 2025-09-23 15:05:51 -04:00
Barrett Ruth
75994c07a5 fix(ci): tests 2025-09-23 15:02:33 -04:00
Barrett Ruth
1769ea079a fix 2025-09-23 14:49:02 -04:00
Barrett Ruth
4b9d63e4b8 fix(test): async impl 2025-09-23 14:48:01 -04:00
Barrett Ruth
2707df28ce fix(test): fix mocks 2025-09-23 12:36:15 -04:00
Barrett Ruth
79e1f1096b lint 2025-09-23 12:29:12 -04:00
Barrett Ruth
f3666a30be fix(ci): lint 2025-09-23 12:28:53 -04:00
Barrett Ruth
1f517309f2 fix(test): remove async tests 2025-09-23 12:27:23 -04:00
Barrett Ruth
8df8c16a72 fix(ci): selene lint 2025-09-23 12:25:53 -04:00
Barrett Ruth
62eab3df2d feat(picker): one step closer to fully async 2025-09-23 12:16:57 -04:00
Barrett Ruth
8a9bc7434f fix: remove comments 2025-09-23 10:22:02 -04:00
Barrett Ruth
f9cf5b1614 possibly working 2025-09-23 10:17:22 -04:00
Barrett Ruth
545793df39 remove ai comments 2025-09-23 09:43:21 -04:00
Barrett Ruth
e171017ab0 fixup 2025-09-23 09:42:45 -04:00
Barrett Ruth
5dd4d9109a try fix 2025-09-22 23:25:02 -04:00
Barrett Ruth
5f555a0285 fix 2025-09-22 23:22:07 -04:00
Barrett Ruth
de14552a3e fix(test): mock 2025-09-22 23:16:25 -04:00
Barrett Ruth
4b70a21210 fix(test): more mocks 2025-09-22 23:14:54 -04:00
Barrett Ruth
a84b1697bf fix(test): mock 2025-09-22 23:11:15 -04:00
Barrett Ruth
76cb1e456e fix(ci): unused vars 2025-09-22 23:05:52 -04:00
Barrett Ruth
7ad64677a5 fix(test): selene unused vars 2025-09-22 23:04:17 -04:00
Barrett Ruth
1f384b0ba0 fix(ci): selene unused vars 2025-09-22 23:02:54 -04:00
Barrett Ruth
a32fd396d3 feat: async scraper 2025-09-22 22:59:57 -04:00
Barrett Ruth
5707a28d58
Merge pull request #101 from barrett-ruth/refactor/scraper-reorganize
scraper qol
2025-09-23 04:49:25 +02:00
Barrett Ruth
53562eb6a8 fix(scrapers): reorg codeforces scraper 2025-09-22 22:48:24 -04:00
Barrett Ruth
0a8dc50c76 fix(test): systeamtically gather scrapers 2025-09-22 22:46:36 -04:00
Barrett Ruth
89440e5d14 feat(scrapers): simplify structure 2025-09-22 22:44:08 -04:00
Barrett Ruth
358b22077f
Merge pull request #100 from barrett-ruth/refactor/code-reorganize
refactor: massive file restructure
2025-09-23 04:17:12 +02:00
Barrett Ruth
3b768cc6c4 fix(ci): fix ruff lint 2025-09-22 22:10:49 -04:00
Barrett Ruth
db391da52c feat(scrapers): total refactor 2025-09-22 22:00:20 -04:00
Barrett Ruth
eb3f7762de fix(ci): typing 2025-09-22 20:46:27 -04:00
Barrett Ruth
87f9439607 fix(test): typing 2025-09-22 20:38:08 -04:00
Barrett Ruth
101062cb48 fix(test): clear modules properly 2025-09-22 20:24:56 -04:00
Barrett Ruth
80c7697340 fix(test): typing 2025-09-22 20:21:20 -04:00
Barrett Ruth
23310eed53 fix(test): include hl in namespace 2025-09-22 20:17:20 -04:00
Barrett Ruth
847f04d1e8 fix(test): fix 2025-09-22 20:15:09 -04:00
Barrett Ruth
1b5e713945 fix(test): more tests 2025-09-22 20:13:30 -04:00
Barrett Ruth
36806d6f5a feat: more tests 2025-09-22 19:29:42 -04:00
Barrett Ruth
3bf94cf979 feat(test): real integration tests 2025-09-22 19:25:29 -04:00
Barrett Ruth
9b443459e2 fix(runner): use state methods 2025-09-22 19:22:51 -04:00
Barrett Ruth
138f5bb2a2 this is not why 2025-09-22 19:20:35 -04:00
Barrett Ruth
7ec59109c3 fix(ci): lint 2025-09-22 19:15:12 -04:00
Barrett Ruth
ebf4856a3e fix: panel 2025-09-22 19:13:12 -04:00
Barrett Ruth
a2a3c8f365 fix: edge cases 2025-09-22 19:11:55 -04:00
Barrett Ruth
9c2be9c6b0 feat: some more updates 2025-09-22 19:11:11 -04:00
Barrett Ruth
5a6902633f refactor: massive file restructure 2025-09-22 19:00:36 -04:00
Barrett Ruth
b1ba0007e0
Merge pull request #99 from barrett-ruth/refactor/code-cleanup
Code Cleanup
2025-09-23 00:52:43 +02:00
Barrett Ruth
b7ef866a14 fix: type errors 2025-09-22 18:51:07 -04:00
Barrett Ruth
a69d9f3756 fix: type errors 2025-09-22 18:51:00 -04:00
Barrett Ruth
ba81df2266 fix(cache): expiry 2025-09-22 16:50:14 -04:00
Barrett Ruth
510393a788 fix(logger): remove config 2025-09-22 16:48:50 -04:00
Barrett Ruth
beda8a3a03 fix(logger): remove config 2025-09-22 16:48:46 -04:00
Barrett Ruth
d7f5112841 fix(test): syntax 2025-09-22 16:47:28 -04:00
Barrett Ruth
464ce8906c fix(test): require the state 2025-09-22 16:40:14 -04:00
Barrett Ruth
7352189339 feat: refactor to state 2025-09-22 16:33:03 -04:00
Barrett Ruth
039fad1614 fix(cache): cache contest data indefinitely 2025-09-22 16:32:52 -04:00
Barrett Ruth
5015a8636a
Merge pull request #97 from barrett-ruth/feat/diff-none
fix(doc): default diff to none
2025-09-22 15:47:42 +02:00
Barrett Ruth
f810958fdb fix(doc): default diff to none 2025-09-22 09:42:55 -04:00
Barrett Ruth
73ee75642c
Merge pull request #87 from barrett-ruth/feat/diff-none
Boring Diff Mode
2025-09-21 23:23:28 +02:00
Barrett Ruth
355cb5df82 fix(diff): make git the second diff choice, not vim 2025-09-21 17:21:46 -04:00
Barrett Ruth
0851339e63 fix(diff): default to boring view 2025-09-21 17:19:34 -04:00
Barrett Ruth
ff20efca71 feat(diff): third, regular diff mode 2025-09-21 17:18:22 -04:00
Barrett Ruth
7d51fc2931
Merge pull request #86 from barrett-ruth/final-fixes
Final Tweaks before release
2025-09-21 22:10:52 +02:00
Barrett Ruth
dc2b96a3c0 feat(doc): modern showcase 2025-09-21 16:01:13 -04:00
Barrett Ruth
f6b82b85f6 feat(doc): new name 2025-09-21 15:49:51 -04:00
106 changed files with 46096 additions and 9574 deletions

13
.busted
View file

@ -1,13 +0,0 @@
return {
_all = {
coverage = false,
lpath = 'lua/?.lua;lua/?/init.lua',
lua = 'nlua',
},
default = {
verbose = true,
},
tests = {
verbose = true,
},
}

78
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View file

@ -0,0 +1,78 @@
name: Bug Report
description: Report a bug
title: 'bug: '
labels: [bug]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/cp.nvim/issues)
required: true
- label: I have updated to the latest version
required: true
- type: textarea
attributes:
label: 'Neovim version'
description: 'Output of `nvim --version`'
render: text
validations:
required: true
- type: input
attributes:
label: 'Operating system'
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
validations:
required: true
- type: textarea
attributes:
label: Description
description: What happened? What did you expect?
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug
value: |
1.
2.
3.
validations:
required: true
- type: textarea
attributes:
label: 'Health check'
description: 'Output of `:checkhealth cp`'
render: text
- type: textarea
attributes:
label: Minimal reproduction
description: |
Save the script below as `repro.lua`, edit if needed, and run:
```
nvim -u repro.lua
```
Confirm the bug reproduces with this config before submitting.
render: lua
value: |
vim.env.LAZY_STDPATH = '.repro'
load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))()
require('lazy.nvim').setup({
spec = {
{
'barrett-ruth/cp.nvim',
opts = {},
},
},
})
validations:
required: true

5
.github/ISSUE_TEMPLATE/config.yaml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/barrettruth/cp.nvim/discussions
about: Ask questions and discuss ideas

View file

@ -0,0 +1,30 @@
name: Feature Request
description: Suggest a feature
title: 'feat: '
labels: [enhancement]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/cp.nvim/issues)
required: true
- type: textarea
attributes:
label: Problem
description: What problem does this solve?
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered

21
.github/workflows/luarocks.yaml vendored Normal file
View file

@ -0,0 +1,21 @@
name: luarocks
on:
push:
tags:
- 'v*'
jobs:
ci:
uses: ./.github/workflows/ci.yaml
publish:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nvim-neorocks/luarocks-tag-release@v7
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}

View file

@ -1,18 +0,0 @@
name: Release
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
publish-luarocks:
name: Publish to LuaRocks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to LuaRocks
uses: nvim-neorocks/luarocks-tag-release@v7
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}

View file

@ -1,4 +1,4 @@
name: Code Quality
name: quality
on:
pull_request:
@ -29,8 +29,9 @@ jobs:
- '.luarc.json'
- '*.toml'
python:
- 'scrapers/**'
- 'tests/scrapers/**'
- 'scripts/**/.py'
- 'scrapers/**/*.py'
- 'tests/**/*.py'
- 'pyproject.toml'
- 'uv.lock'
markdown:
@ -89,7 +90,7 @@ jobs:
- name: Install ruff
run: uv tool install ruff
- name: Check Python formatting with ruff
run: ruff format --check scrapers/ tests/scrapers/
run: ruff format --check .
python-lint:
name: Python Lint Check
@ -103,7 +104,7 @@ jobs:
- name: Install ruff
run: uv tool install ruff
- name: Lint Python files with ruff
run: ruff check scrapers/ tests/scrapers/
run: ruff check .
python-typecheck:
name: Python Type Check
@ -114,10 +115,10 @@ jobs:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies with mypy
- name: Install dependencies with uv
run: uv sync --dev
- name: Type check Python files with mypy
run: uv run mypy scrapers/ tests/scrapers/
- name: Type check Python files with ty
run: uvx ty check .
markdown-format:
name: Markdown Format Check
@ -137,4 +138,4 @@ jobs:
- name: Install prettier
run: pnpm add -g prettier@3.1.0
- name: Check markdown formatting with prettier
run: prettier --check "*.md" "docs/**/*.md" || true
run: prettier --check .

View file

@ -1,4 +1,4 @@
name: Tests
name: tests
on:
pull_request:
@ -29,26 +29,12 @@ jobs:
- 'stylua.toml'
- 'selene.toml'
python:
- 'scripts/**'
- 'scrapers/**'
- 'tests/scrapers/**'
- 'tests/**'
- 'pyproject.toml'
- 'uv.lock'
lua-test:
name: Lua Tests (${{ matrix.neovim_version }})
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
strategy:
matrix:
neovim_version: ['stable', 'nightly']
steps:
- uses: actions/checkout@v4
- name: Run Lua tests
uses: nvim-neorocks/nvim-busted-action@v1
with:
nvim_version: ${{ matrix.neovim_version }}
python-test:
name: Python Tests
runs-on: ubuntu-latest
@ -58,7 +44,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies with pytest
- name: Install dependencies
run: uv sync --dev
- name: Run Python tests
run: uv run pytest tests/scrapers/ -v
run: uv run pytest tests/ -v

15
.gitignore vendored
View file

@ -1,8 +1,19 @@
.venv/
.venv
venv
doc/tags
*.log
build
io
debug
venv/
create
.*cache*
CLAUDE.md
__pycache__
.claude/
node_modules/
.envrc
.direnv/

View file

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

View file

@ -1,35 +1,36 @@
minimum_pre_commit_version: "3.5.0"
minimum_pre_commit_version: '3.5.0'
repos:
- repo: https://github.com/JohnnyMorganz/StyLua
rev: v2.1.0
rev: v2.3.1
hooks:
- id: stylua-github
name: stylua (Lua formatter)
args: ["."]
files: ^(lua/|spec/|plugin/|after/|ftdetect/|.*\.lua$)
additional_dependencies: []
files: \.lua$
pass_filenames: true
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
rev: v0.14.3
hooks:
- id: ruff-format
name: ruff (format)
files: ^(scrapers/|tests/scrapers/|.*\.py$)
files: \.py$
- id: ruff
name: ruff (lint)
args: ["--fix", "--select=I"]
files: ^(scrapers/|tests/scrapers/|.*\.py$)
- repo: local
hooks:
- id: mypy
name: mypy (type check)
entry: uv run mypy
language: system
args: ["scrapers/", "tests/scrapers/"]
files: ^(scrapers/|tests/scrapers/|.*\.py$)
pass_filenames: false
name: ruff (lint imports)
args: ['--fix', '--select=I']
files: \.py$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
rev: v4.0.0-alpha.8
hooks:
- id: prettier
name: prettier (format markdown)
files: \.(md)$
name: prettier
files: \.(md|toml|ya?ml|sh)$
- repo: local
hooks:
- id: ty-type-check
name: ty (Python type checker)
language: system
entry: uv run ty check
types: [python]

8
.prettierignore Normal file
View file

@ -0,0 +1,8 @@
.pytest_cache/
node_modules/
.venv/
build/
dist/
*.pyc
__pycache__/
tests/fixtures/

17
.prettierrc Normal file
View file

@ -0,0 +1,17 @@
{
"proseWrap": "always",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"semi": false,
"singleQuote": true,
"overrides": [
{
"files": ["**/*.md"],
"options": {
"parser": "markdown"
}
}
]
}

View file

@ -1,26 +1,39 @@
# cp.nvim
**Fast, minimal competitive programming environment for Neovim**
**The definitive competitive programming environment for Neovim**
Scrape problems, run tests, and debug solutions across multiple platforms with zero configuration.
Scrape problems, run tests, and debug solutions across multiple platforms with
zero configuration.
> **Disclaimer**: cp.nvim webs crapes data from competitive programming platforms - use at your own risk.
https://github.com/user-attachments/assets/cb142535-fba0-4280-8f11-66ad1ca50ca9
https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148
## Features
- **Multi-platform support**: AtCoder, Codeforces, CSES with consistent interface
- **Multi-platform support**: AtCoder, CodeChef, Codeforces, and CSES
- **Automatic problem setup**: Scrape test cases and metadata in seconds
- **Rich test output**: ANSI color support for compiler errors and program output
- **Language agnostic**: Works with any compiled language
- **Template integration**: Contest-specific snippets via LuaSnip
- **Diff viewer**: Compare expected vs actual output with precision
- **Dual view modes**: Lightweight I/O view for quick feedback, full panel for
detailed analysis
- **Test case management**: Quickly view, edit, add, & remove test cases
- **Rich test output**: 256 color ANSI support for compiler errors and program
output
- **Language agnostic**: Works with any language
- **Diff viewer**: Compare expected vs actual output with 3 diff modes
## Optional Dependencies
## Installation
- [uv](https://docs.astral.sh/uv/) for problem scraping
- [LuaSnip](https://github.com/L3MON4D3/LuaSnip) for templates
Install using your package manager of choice or via
[luarocks](https://luarocks.org/modules/barrettruth/cp.nvim):
```
luarocks install cp.nvim
```
## Dependencies
- 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
@ -28,27 +41,35 @@ cp.nvim follows a simple principle: **solve locally, submit remotely**.
### Basic Usage
1. **Find a contest or problem** on the judge website
2. **Set up locally** with `:CP <platform> <contest> [<problem>]`
1. Find a contest or problem
2. Set up contests locally
```
:CP codeforces 1848
```
3. **Code and test** with instant feedback and rich diffs
3. Code and test
```
:CP run
```
4. **Navigate between problems**
4. Navigate between problems
```
:CP next
:CP prev
:CP e1
```
5. **Submit** on the original website
5. Debug and edit test cases
```
:CP edit
:CP panel --debug
```
5. Submit on the original website
## Documentation
@ -56,7 +77,22 @@ cp.nvim follows a simple principle: **solve locally, submit remotely**.
:help cp.nvim
```
See [my config](https://github.com/barrett-ruth/dots/blob/main/nvim/lua/plugins/cp.lua) for a relatively advanced setup.
See
[my config](https://github.com/barrettruth/dots/blob/main/.config/nvim/lua/plugins/cp.lua)
for the setup in the video shown above.
## Motivation
I could not find a neovim-centric, efficient, dependency-free, flexible, and
easily customizable competitive programming workflow that "just works"--so I
made it myself. I conferenced with top competitive programmers at Carnegie
Mellon Univerity and the University of Virginia and covered their (and my) pain
points:
- Scraping: contests are automatically loaded asynchronously
- Test Case Management: test case editor (`:CP edit`)
- UI: both `run` and `panel` layouts cover common formats
- Extensibility: snippet plugins, compilation, etc. are left to the programmer
## Similar Projects

View file

@ -4,3 +4,4 @@ vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.foldcolumn = '0'

View file

@ -1,6 +0,0 @@
vim.opt_local.number = false
vim.opt_local.relativenumber = false
vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true
vim.opt_local.linebreak = true

View file

@ -1,7 +0,0 @@
vim.opt_local.number = false
vim.opt_local.relativenumber = false
vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.modifiable = true

View file

@ -1,7 +0,0 @@
vim.opt_local.number = false
vim.opt_local.relativenumber = false
vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.foldcolumn = '0'

View file

@ -1,17 +0,0 @@
if exists("b:current_syntax")
finish
endif
syntax match cpOutputCode /^\[code\]:/
syntax match cpOutputTime /^\[time\]:/
syntax match cpOutputDebug /^\[debug\]:/
syntax match cpOutputOkTrue /^\[ok\]:\ze true$/
syntax match cpOutputOkFalse /^\[ok\]:\ze false$/
highlight default link cpOutputCode DiagnosticInfo
highlight default link cpOutputTime Comment
highlight default link cpOutputDebug Comment
highlight default link cpOutputOkTrue DiffAdd
highlight default link cpOutputOkFalse DiffDelete
let b:current_syntax = "cp"

View file

@ -2,7 +2,7 @@ rockspec_format = '3.0'
package = 'cp.nvim'
version = 'scm-1'
source = { url = 'git://github.com/barrett-ruth/cp.nvim' }
source = { url = 'git://github.com/barrettruth/cp.nvim' }
build = { type = 'builtin' }
test_dependencies = {

937
doc/cp.nvim.txt Normal file
View file

@ -0,0 +1,937 @@
*cp.nvim.txt* Competitive programming plugin for Neovim
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: Same terms as Vim itself (see |license|)
==============================================================================
INTRODUCTION *cp.nvim*
cp.nvim is a competitive programming plugin that automates problem setup,
compilation, and testing workflow for online judges.
Supported platforms (for now!): AtCoder, Codeforces, CSES
==============================================================================
REQUIREMENTS *cp-requirements*
- Neovim 0.10.0+
- Unix-like operating system
- uv package manager (https://docs.astral.sh/uv/)
==============================================================================
SETUP *cp-setup*
Load cp.nvim with your package manager. For example, with lazy.nvim: >lua
{ 'barrettruth/cp.nvim' }
<
The plugin works automatically with no configuration required. For
customization, see |cp-config|.
==============================================================================
CONFIGURATION *cp-config*
Configuration is done via `vim.g.cp`. Set this before using the plugin:
>lua
vim.g.cp = {
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}',
'-fdiagnostics-color=always' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
},
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
cpp = { extension = 'cpp', commands = { build = { ... } } }
},
},
atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
open_url = true,
debug = false,
ui = {
ansi = true,
run = {
width = 0.3,
next_test_key = '<c-n>', -- or nil to disable
prev_test_key = '<c-p>', -- or nil to disable
},
panel = {
diff_modes = { 'side-by-side', 'git', 'vim' },
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' },
},
},
picker = 'telescope',
},
}
<
By default, C++ (g++ with ISO C++17) and Python are preconfigured under
'languages'. Platforms select which languages are enabled and which one is
the default; per-platform overrides can tweak 'extension' or 'commands'.
For example, to run CodeForces contests with Python by default:
>lua
vim.g.cp = {
platforms = {
codeforces = {
default_language = 'python',
},
},
}
<
Any language is supported provided the proper configuration. For example, to
run CSES problems with Rust using the single schema:
>lua
vim.g.cp = {
languages = {
rust = {
extension = 'rs',
commands = {
build = { 'rustc', '{source}', '-o', '{binary}' },
run = { '{binary}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python', 'rust' },
default_language = 'rust',
},
},
}
<
*cp.Config*
Fields: ~
{languages} (table<string,|CpLanguage|>) Global language registry.
Each language provides an {extension} and {commands}.
{platforms} (table<string,|CpPlatform|>) Per-platform enablement,
default language, and optional overrides.
{hooks} (|cp.Hooks|) Hook functions called at various stages.
{debug} (boolean, default: false) Show info messages.
{scrapers} (string[]) Supported platform ids.
{filename} (function, optional)
function(contest, contest_id, problem_id, config, language): string
Should return full filename with extension.
(default: concatenates contest_id and problem_id, lowercased)
{ui} (|CpUI|) UI settings: panel, diff backend, picker.
{open_url} (boolean) Open the contest & problem url in the browser
when the contest is first opened.
*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*
: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*
cp.nvim supports multiple languages per problem. Each platform enables specific
languages and has a default. You can override the language for any setup or
navigation command using the --lang flag.
Language Selection Behavior ~
When setting up or navigating to a problem:
1. Explicit --lang flag takes highest priority
2. If no --lang flag, tries to preserve current file's language
(only if that language is enabled for the new problem)
3. Falls back to platform's default language
Multiple Solution Files ~
Different languages create different solution files. For example:
1848a.cc (C++ solution)
1848a.py (Python solution)
Both files can exist simultaneously with their own state. Switching between
languages means switching between different files.
Examples ~
>
:CP codeforces 1848 " Use platform default (likely C++)
:CP codeforces 1848 --lang python " Use Python explicitly
" In 1848a.cc (C++ file):
:CP next " Next problem tries to use C++
:CP next --lang python " Next problem uses Python
" In 1848a.py (Python file):
:CP next " Next problem tries to use Python
:CP next --lang cpp " Next problem switches to C++
<
Language Validation ~
If you request a language that isn't enabled for a platform, cp.nvim will show
a helpful error message listing available languages for that platform.
==============================================================================
WORKFLOW *cp-workflow*
For the sake of consistency and simplicity, cp.nvim extracts contest/problem
identifiers from URLs. This means that, for example, CodeForces/AtCoder
contests are configured by their round id rather than round number. See below.
==============================================================================
PLATFORM-SPECIFIC USAGE *cp-platforms*
AtCoder ~
*cp-atcoder*
URL format:
https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_{problem_id}
Usage examples: >
:CP atcoder abc324 " Set up atcoder.jp/contests/abc324
:CP atcoder abc324 --lang python " Set up with Python instead of default
Codeforces ~
*cp-codeforces*
URL format: https://codeforces.com/contest/{contest_id}/problem/{problem_id}
Usage examples: >
:CP codeforces 1934 " Set up codeforces.com/contest/1934
:CP codeforces 1934 --lang cpp " Set up with C++
CSES ~
*cp-cses*
URL format: https://cses.fi/problemset/task/{problem_id}
Usage examples: >
:CP cses dynamic_programming " Set up all problems in dp category
==============================================================================
COMPLETE WORKFLOW EXAMPLE *cp-example*
Example: Setting up and solving AtCoder contest ABC324
1. Browse to https://atcoder.jp/contests/abc324
2. Set up entire contest (bulk setup): >
:CP atcoder abc324
< This scrapes all test case data, downloads all test cases,
and opens the first problem.
3. Code your solution, then test: >
:CP run
< View test verdicts in I/O splits. For detailed analysis: >
:CP panel
< Navigate tests with <c-n>/<c-p>, exit with q
4. Move to next problem: >
:CP next
< This automatically sets up the next problem (likely problem B)
5. Continue solving problems with :CP next/:CP prev navigation
6. Try a different language for a problem: >
:CP C --lang python
< Opens problem C with Python instead of C++
7. Switch to another file (e.g. previous contest): >
:e ~/contests/abc323/a.cpp
:CP
< Automatically restores abc323 contest context
8. Submit solutions on AtCoder website
==============================================================================
I/O VIEW *cp-io-view*
The I/O view provides lightweight test feedback in persistent side splits.
Test outputs are concatenated with verdict summaries at the bottom.
The |cp-panel| offers more fine-grained analysis with diff modes.
Execution Modes ~
The I/O view supports two execution modes:
Combined Mode (:CP run with single sample)
• Single execution with all test inputs concatenated
• Matches platform behavior (e.g. Codeforces multi-test format)
• Shows one verdict for the entire execution
• Input split: All test inputs concatenated
• Output split: Single program output + verdict
• Used when problem has one sample containing multiple test cases
Individual Mode (:CP run all / :CP run n / :CP run n,m,...)
• Separate execution for each test case
• Per-test verdicts for debugging
• Input split: Selected test inputs concatenated
• Output split: All test outputs concatenated + per-test verdicts
• Auto-selected when problem has multiple independent samples
Layout ~
The I/O view appears as 30% width splits on the right side: >
┌──────────────────────────┬─────────────────────────────────────────────┐
│ │ Output (Top Split) │
│ │ 5 510 │
│ │ │
│ │ 7 714 │
│ Solution Code │ │
│ │ Test 1: WA | 212.07/2000 ms | 1/512 MB |...│
│ │ Test 2: WA | 81.94/2000 ms | 1/512 MB |...│
│ ├─────────────────────────────────────────────┤
│ │ Input (Bottom Split) │
│ │ 1 2 3 │
│ │ │
│ │ 4 5 6 │
└──────────────────────────┴─────────────────────────────────────────────┘
<
The output split shows:
1. Program output (raw, preserving all formatting)
2. Space-aligned verdict summary with:
- Test number and status (AC/WA/TLE/MLE/RTE with color highlighting)
- Runtime: actual/limit in milliseconds
- Memory: actual/limit in megabytes
- Exit code (with signal name for crashes)
Usage ~
:CP run Combined mode: all tests in one execution
:CP run all Individual mode: all tests separately
:CP run 3 Individual mode: test 3 only
:CP run 1,3,5 Individual mode: specific tests (1, 3, and 5)
Navigation ~
While in the I/O view buffers, use the configured keymaps to cycle through tests:
<c-n> Next test (default, see |RunConfig|.next_test_key)
<c-p> Previous test (default, see |RunConfig|.prev_test_key)
Buffer Customization ~
Use the setup_io_input and setup_io_output hooks (see |cp.Hooks|) to customize
buffer appearance. By default, line numbers and columns are removed via
helpers.clearcol (see |cp-helpers|).
==============================================================================
VERDICT FORMATTING *cp-verdict-format*
Customize how verdict summaries appear in the I/O view using format_verdict.
Configuration ~
Set ui.run.format_verdict to a function that formats verdict data: >lua
format_verdict = function(data)
return { line = "...", highlights = {...} }
end
<
Format Function ~
*VerdictFormatter*
Input: |VerdictFormatData| table with test results
Output: |VerdictFormatResult| table with formatted line and optional highlights
*VerdictFormatData*
{index} (integer) Test case number
{status} (table) { text: string, highlight_group: string }
{time_ms} (number) Execution time in milliseconds
{time_limit_ms} (number) Time limit in milliseconds
{memory_mb} (number) Peak memory usage in megabytes
{memory_limit_mb} (number) Memory limit in megabytes
{exit_code} (integer) Process exit code
{signal} (string|nil) Signal name for crashes (e.g. "SIGSEGV")
{time_actual_width} (integer|nil) Dynamic width for time value alignment
{time_limit_width} (integer|nil) Dynamic width for time limit alignment
{mem_actual_width} (integer|nil) Dynamic width for memory value alignment
{mem_limit_width} (integer|nil) Dynamic width for memory limit alignment
*VerdictFormatResult*
{line} (string) The formatted verdict line
{highlights} (table[], optional) Highlight regions:
{col_start} (integer) Start column (0-indexed)
{col_end} (integer) End column (exclusive)
{group} (string) Highlight group name
Examples ~
Minimal format: >lua
format_verdict = function(data)
return {
line = string.format("#%d %s", data.index, data.status.text)
}
end
<
See |cp-helpers| for alignment functions: pad_right, pad_left, center.
==============================================================================
PICKER INTEGRATION *cp-picker*
When picker integration is enabled in configuration, cp.nvim provides interactive
platform and contest selection using telescope.nvim or fzf-lua.
:CP pick *:CP-pick*
Launch configured picker for interactive problem selection.
Control Flow: Select Platform → Contest → Code!
Requires picker = 'telescope' or picker = 'fzf-lua' in configuration.
Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed.
PICKER KEYMAPS *cp-picker-keys*
<c-r> Force refresh/update contest list.
Useful when contest lists are outdated or incomplete
==============================================================================
PANEL *cp-panel*
The panel provides full-screen test analysis with diff modes for detailed
debugging. Problem time/memory limit constraints are in columns Time/Mem
respectively. Used time/memory are in columns Runtime/RSS respectively.
Access with :CP panel or :CP panel --debug (uses debug build configuration).
Interface ~
The panel uses the following table layout: >
┌─────┬────────┬──────────────┬───────────┬──────────┬──────────┬─────────────┐
│ # │ Status │ Runtime (ms) │ Time (ms) │ RSS (MB) │ Mem (MB) │ Exit Code │
├─────┼────────┼──────────────┼───────────┼──────────┼──────────┼─────────────┤
│ 1 │ AC │ 12.0 │ 2000 │ 123 │ 256 │ 0 │
│ >2 │ WA │ 45.70 │ 2000 │ 100 │ 256 │ 1 │
├─────┴────────┴──────────────┴───────────┴──────────┴──────────┴─────────────┤
│ Input: │
│ 5 3 │
├─────┬────────┬──────────────┬───────────┬──────────┬──────────┬─────────────┤
│ 3 │ TLE │ 9.0 │ 2000 │ 256 │ 256 │ 136 (SIGBUS)│
│ 4 │ RTE │ 0.0 │ 2000 │ 256 │ 256 │139 (SIGUSR2)│
└─────┴────────┴──────────────┴───────────┴──────────┴──────────┴─────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Expected vs Actual │
│ 423 │
│ 100 │
│ hello world │
└─────────────────────────────────────────────────────────────────────────────┘
Status Indicators ~
Test cases use competitive programming terminology with color highlighting:
AC Accepted (passed)
WA Wrong Answer (output mismatch)
TLE Time Limit Exceeded (timeout)
MLE Memory Limit Exceeded Error (heuristic)
RTE Runtime Error (other non-zero exit code)
NA Any other state
<
==============================================================================
INTERACTIVE MODE *cp-interact*
Run interactive problems manually or with an orchestrator. :CP interact is
available for interactive problems. Test cases are ignored in interactive mode
(no run panel, no diffs).
When using :CP interact {interactor}, the interactor must be executable
(chmod +x). Completion after :CP interact suggests executables in CWD.
1) Terminal-only ~
:CP interact
Execute the current program and open an interactive terminal running
it directly. Use this for manual testing.
2) Orchestrated ~
:CP interact {interactor}
Execute the current program and open an interactive terminal that runs
your interactor script against it.
{interactor} is an executable file relative to the CWD.
Example:
:CP interact my-executable-interactor.py
Keymaps ~
<c-q> Close the terminal and restore the previous layout.
==============================================================================
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
cp.nvim provides comprehensive ANSI color support and highlighting for
compiler output, program stderr, and diff visualization.
If you cannot see color highlighting in your config, it is likely due to an
erroneous config. Most tools (GCC, Python, Clang, Rustc) color stdout based on
whether stdout is connected to a terminal. One can usually get aorund this by
leveraging flags to force colored output. For example, to force colors with GCC,
alter your config as follows:
>lua
{
commands = {
build = {
'g++',
'-fdiagnostics-color=always',
...
}
}
}
<
==============================================================================
HIGHLIGHT GROUPS *cp-highlights*
Test Status Groups ~
All test status groups link to builtin highlight groups, automatically adapting
to your colorscheme:
CpTestAC Links to DiagnosticOk (AC status)
CpTestWA Links to DiagnosticError (WA status)
CpTestTLE Links to DiagnosticWarn (TLE status)
CpTestMLE Links to DiagnosticWarn (MLE status)
CpTestRTE Links to DiagnosticHint (RTE status)
CpTestNA Links to Comment (pending/unknown status)
ANSI Color Groups ~
cp.nvim preserves ANSI colors from compiler output and program stderr using
a sophisticated parsing system. Colors are automatically mapped to your
terminal colorscheme via vim.g.terminal_color_* variables.
Diff Highlighting ~
The git diff backend uses Neovim's built-in highlight groups that automatically
adapt to your colorscheme:
DiffAdd Highlights added text in git diffs
DiffDelete Highlights removed text in git diffs
==============================================================================
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
ANSI colors automatically use the terminal's color palette through Neovim's
vim.g.terminal_color_* variables.
==============================================================================
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
Customize highlight groups after your colorscheme loads:
>lua
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
vim.api.nvim_set_hl(0, 'CpTestAC', { link = 'String' })
end
})
==============================================================================
HELPERS *cp-helpers*
The helpers module provides utility functions for buffer customization.
Access via:
>lua
local helpers = require('cp').helpers
<
Functions ~
helpers.clearcol({bufnr}) *helpers.clearcol*
Remove line numbers, columns, and signs from buffer.
Sets:
• number = false
• relativenumber = false
• signcolumn = 'no'
• statuscolumn = ''
Parameters: ~
{bufnr} (integer) Buffer handle
==============================================================================
PANEL KEYMAPS *cp-panel-keys*
<c-n> Navigate to next test case
<c-p> Navigate to previous test case
t Cycle through configured diff modes (see |cp.PanelConfig|)
q Exit panel and restore layout
<c-q> Exit interactive terminal and restore layout
Diff Modes ~
Three diff modes are available:
side-by-side Expected and actual output shown side-by-side (default)
vim Built-in vim diff (always available)
git Character-level git word-diff (requires git, more precise)
Configure which modes to cycle through via |cp.PanelConfig|.diff_modes.
The first element is used as the default mode.
The git backend shows character-level changes with [-removed-] and {+added+}
markers.
Execution Details ~
Test cases are executed individually using the same compilation and
execution pipeline, but with isolated input/output for
precise failure analysis.
==============================================================================
FILE STRUCTURE *cp-files*
cp.nvim creates the following file structure upon problem setup: >
{problem_id}.{ext} " Source file
build/
{problem_id}.run " Compiled binary
io/
{problem_id}.n.cpin " nth test input
{problem_id}.n.cpout " nth test expected output
<
==============================================================================
HEALTH CHECK *cp-health*
Run |:checkhealth| cp to verify your setup.
vim:tw=78:ts=8:ft=help:norl:

View file

@ -1,608 +0,0 @@
*cp.txt* Competitive programming plugin for Neovim *cp.txt*
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: Same terms as Vim itself (see |license|)
==============================================================================
INTRODUCTION *cp* *cp.nvim*
cp.nvim is a competitive programming plugin that automates problem setup,
compilation, and testing workflow for online judges.
Supported platforms (for now!): AtCoder, Codeforces, CSES
==============================================================================
REQUIREMENTS *cp-requirements*
- Neovim 0.10.0+
- Unix-like operating system
Optional:
- uv package manager (https://docs.astral.sh/uv/)
- LuaSnip for template expansion (https://github.com/L3MON4D3/LuaSnip)
==============================================================================
COMMANDS *cp-commands*
:CP *:CP*
cp.nvim uses a single :CP command with intelligent argument parsing:
State Restoration ~
:CP Restore contest context from current file.
Automatically detects platform, contest, problem,
and language from cached state. Use this after
switching files to restore your CP environment.
Requires previous setup with full :CP command.
Setup Commands ~
:CP {platform} {contest_id} {problem_id} [--lang={language}]
Full setup: set platform, load contest metadata,
and set up specific problem. Scrapes test cases
and creates source file.
Example: >
:CP codeforces 1933 a
:CP codeforces 1933 a --lang=python
<
:CP {platform} {contest_id}
Contest setup: set platform, load contest metadata,
and scrape ALL problems in the contest. This creates
source files for every problem and caches all test
cases for efficient bulk setup. Opens the first
problem after completion.
Example: >
:CP atcoder abc324
:CP codeforces 1951
<
:CP {platform} Platform setup: set platform only.
Example: >
:CP cses
<
:CP {problem_id} [--lang={language}]
Problem switch: switch to different problem
within current contest context.
Example: >
:CP b
:CP b --lang=python
<
Action Commands ~
:CP run [--debug] Toggle run panel for individual test case
debugging. Shows per-test results with redesigned
layout for efficient comparison.
Use --debug flag to compile with debug flags.
Requires contest setup first.
:CP pick Launch configured picker for interactive
platform/contest/problem selection.
Navigation Commands ~
:CP next Navigate to next problem in current contest.
Stops at last problem (no wrapping).
Navigation Commands ~
:CP prev Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
Command Flags ~
*cp-flags*
Flags can be used with setup and action commands:
--lang={language} Specify language for the problem.
--lang {language} Alternative syntax for language specification.
Supported languages: cpp, python
Example: >
:CP atcoder abc324 a --lang=python
:CP b --lang cpp
<
--debug Enable debug compilation with additional flags.
Uses the `debug` command template instead of
`compile`. Typically includes debug symbols and
sanitizers for memory error detection.
Example: >
:CP run --debug
<
Note: Debug compilation may be slower but provides
better error reporting for runtime issues.
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")
• {version} Language version when specified in config
• {contest} Contest identifier (e.g. "abc324", "1933")
• {problem} Problem identifier (e.g. "a", "b")
Example template: >
compile = { 'g++', '{source}', '-o', '{binary}', '-std=c++{version}' }
< Would expand to: >
g++ abc324a.cpp -o build/abc324a.run -std=c++17
<
==============================================================================
CONFIGURATION *cp-config*
cp.nvim works out of the box. No setup required.
Here's an example configuration with lazy.nvim: >lua
{
'barrett-ruth/cp.nvim',
cmd = 'CP',
opts = {
contests = {
default = {
cpp = {
compile = { 'g++', '{source}', '-o', '{binary}',
'-std=c++17', '-fdiagnostic-colors=always' },
test = { '{binary}' },
debug = { 'g++', '{source}', '-o', '{binary}',
'-std=c++17', '-g',
'-fdiagnostic-colors=always'
'-fsanitize=address,undefined' },
},
python = {
test = { 'python3', '{source}' },
},
},
},
snippets = {},
debug = false,
scrapers = { 'atcoder', 'codeforces', 'cses' },
run_panel = {
ansi = true,
diff_mode = 'vim',
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
toggle_diff_key = 't',
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' },
},
},
picker = 'telescope', -- 'telescope', 'fzf-lua', or nil (disabled)
}
}
<
*cp.Config*
Fields: ~
{contests} (table<string,ContestConfig>) Contest configurations.
{hooks} (|cp.Hooks|) Hook functions called at various stages.
{snippets} (table[]) LuaSnip snippet definitions.
{debug} (boolean, default: false) Show info messages
during operation.
{scrapers} (table<string>) List of enabled scrapers.
Default: all scrapers enabled
{run_panel} (|RunPanelConfig|) Test panel behavior configuration.
{diff} (|DiffConfig|) Diff backend configuration.
{picker} (string, optional) Picker integration: "telescope",
"fzf-lua", or nil to disable. When enabled, provides
:CP pick for interactive platform/contest/problem selection.
{filename} (function, optional) Custom filename generation.
function(contest, contest_id, problem_id, config, language)
Should return full filename with extension.
(default: concatenates contest_id and problem_id, lowercased)
*cp.ContestConfig*
Fields: ~
{cpp} (|LanguageConfig|) C++ language configuration.
{python} (|LanguageConfig|) Python language configuration.
{default_language} (string, default: "cpp") Default language when
--lang not specified.
*cp.LanguageConfig*
Fields: ~
{compile} (string[], optional) Compile command template with
{version}, {source}, {binary} placeholders.
{test} (string[]) Test execution command template.
{debug} (string[], optional) Debug compile command template.
{version} (number, optional) Language version (e.g. 20, 23 for C++).
{extension} (string) File extension (e.g. "cc", "py").
{executable} (string, optional) Executable name for interpreted languages.
*cp.RunPanelConfig*
Fields: ~
{ansi} (boolean, default: true) Enable ANSI color parsing and
highlighting. When true, compiler output and test results
display with colored syntax highlighting. When false,
ANSI escape codes are stripped for plain text display.
Requires vim.g.terminal_color_* to be configured for
proper color display.
{diff_mode} (string, default: "vim") Diff backend: "vim" or "git".
Git provides character-level precision, vim uses
built-in diff.
{next_test_key} (string, default: "<c-n>") Key to navigate to next test case.
{prev_test_key} (string, default: "<c-p>") Key to navigate to previous test case.
{toggle_diff_key} (string, default: "t") Key to toggle diff mode.
{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(ctx: ProblemContext)
{before_debug} (function, optional) Called before debug compilation.
function(ctx: ProblemContext)
{setup_code} (function, optional) Called after source file is opened.
Good for configuring buffer settings.
function(ctx: ProblemContext)
*ProblemContext*
Context object passed to hook functions containing problem information.
Fields: ~
{contest} (string) Platform name (e.g. "atcoder", "codeforces")
{contest_id} (string) Contest ID (e.g. "abc123", "1933")
{problem_id} (string, optional) Problem ID (e.g. "a", "b") - nil for CSES
{source_file} (string) Source filename (e.g. "abc123a.cpp")
{binary_file} (string) Binary output path (e.g. "build/abc123a.run")
{input_file} (string) Test input path (e.g. "io/abc123a.cpin")
{output_file} (string) Program output path (e.g. "io/abc123a.cpout")
{expected_file} (string) Expected output path (e.g. "io/abc123a.expected")
{problem_name} (string) Display name (e.g. "abc123a")
Example usage in hook: >lua
hooks = {
setup_code = function(ctx)
print("Setting up " .. ctx.problem_name)
print("Source file: " .. ctx.source_file)
end
}
<
==============================================================================
WORKFLOW *cp-workflow*
For the sake of consistency and simplicity, cp.nvim extracts contest/problem
identifiers from URLs. This means that, for example, CodeForces/AtCoder
contests are configured by their round id rather than round number. See below.
==============================================================================
PLATFORM-SPECIFIC USAGE *cp-platforms*
AtCoder ~
*cp-atcoder*
URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a
Usage examples: >
:CP atcoder abc324 a " Full setup: problem A from contest ABC324
:CP atcoder abc324 " Contest setup: load contest metadata only
:CP b " Switch to problem B (if contest loaded)
:CP next " Navigate to next problem in contest
<
Note: AtCoder template includes optimizations
for multi-test case problems commonly found
in contests.
Codeforces ~
*cp-codeforces*
URL format: https://codeforces.com/contest/1234/problem/A
Usage examples: >
:CP codeforces 1934 a " Full setup: problem A from contest 1934
:CP codeforces 1934 " Contest setup: load contest metadata only
:CP c " Switch to problem C (if contest loaded)
:CP prev " Navigate to previous problem in contest
<
Note: Problem IDs are automatically converted
to lowercase for consistency.
CSES ~
*cp-cses*
URL format: https://cses.fi/problemset/task/1068
Usage examples: >
:CP cses dynamic_programming 1633 " Set up problem 1633 from DP category
:CP cses dynamic_programming " Set up ALL problems from DP category
<
Note: Category name is always required. For bulk
setup, omit the problem ID to scrape all problems
in the category.
==============================================================================
COMPLETE WORKFLOW EXAMPLE *cp-example*
Example: Setting up and solving AtCoder contest ABC324
1. Browse to https://atcoder.jp/contests/abc324
2. Set up entire contest (bulk setup): >
:CP atcoder abc324
< This scrapes ALL problems (A, B, C, D, ...), creates source files
for each, downloads all test cases, and opens problem A.
3. Alternative: Set up single problem: >
:CP atcoder abc324 a
< This creates only a.cc and scrapes its test cases
4. Code your solution, then test: >
:CP run
< Navigate with j/k, run specific tests with <enter>
Exit test panel with q or :CP run when done
5. If needed, debug with sanitizers: >
:CP run --debug
<
6. Move to next problem: >
:CP next
< This automatically sets up problem B
7. Continue solving problems with :CP next/:CP prev navigation
8. Switch to another file (e.g. previous contest): >
:e ~/contests/abc323/a.cpp
:CP
< Automatically restores abc323 contest context
9. Submit solutions on AtCoder website
==============================================================================
PICKER INTEGRATION *cp-picker*
When picker integration is enabled in configuration, cp.nvim provides interactive
platform, contest, and problem selection using telescope.nvim or fzf-lua.
:CP pick *:CP-pick*
Launch configured picker for interactive problem selection.
Control Flow: Select Platform → Contest → Problem → Code!
Requires picker = 'telescope' or picker = 'fzf-lua' in configuration.
Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed.
Picker Controls ~
*cp-picker-controls*
The picker interface provides several keyboard shortcuts for enhanced control:
<c-r> Force refresh contest list, bypassing cache
Useful when contest lists are outdated or incomplete
Shows loading indicator during refresh operation
Standard picker controls (telescope.nvim/fzf-lua):
<cr> Select current item and proceed to next step
<c-c> / <esc> Cancel picker and return to editor
<c-n> / <down> Navigate to next item
<c-p> / <up> Navigate to previous item
/ Start filtering/searching items
Notes ~
• Contest lists are fetched dynamically using scrapers with a TTL of 1 day
• Use <c-r> to force refresh
• Large contest lists may take time to load
==============================================================================
RUN PANEL *cp-run*
The run panel provides individual test case debugging with a streamlined
layout optimized for modern screens. Shows test status with competitive
programming terminology and efficient space usage.
Activation ~
*:CP-run*
:CP run [--debug] Toggle run panel on/off. When activated,
replaces current layout with test interface.
Automatically compiles and runs all tests.
Use --debug flag to compile with debug symbols
and sanitizers. Toggle again to restore original
layout.
Interface ~
The run panel uses the following table layout: >
┌─────┬────────┬──────────────┬───────────┬──────────┬─────────────┐
│ # │ Status │ Runtime (ms) │ Time (ms) │ Mem (MB) │ Exit Code │
├─────┼────────┼──────────────┼───────────┼──────────┼─────────────┤
│ 1 │ AC │ 12.0 │ 2000 │ 256 │ 0 │
│> 2 │ WA │ 45.70 │ 2000 │ 256 │ 1 │
├─────┴────────┴──────────────┴───────────┴──────────┴─────────────┤
│Input: │
│5 3 │
├─────┬────────┬──────────────┬───────────┬──────────┬─────────────┤
│ 3 │ AC │ 9.0 │ 2000 │ 256 │ 0 │
│ 4 │ RTE │ 0.0 │ 2000 │ 256 │139 (SIGUSR2)│
└─────┴────────┴──────────────┴───────────┴──────────┴─────────────┘
┌──────────────────────────────────────────────────────────────────┐
│Expected vs Actual │
│423 │
│100 │
│hello world │
└──────────────────────────────────────────────────────────────────┘
Status Indicators ~
Test cases use competitive programming terminology with color highlighting:
AC Accepted (passed) - Green
WA Wrong Answer (output mismatch) - Red
TLE Time Limit Exceeded (timeout) - Orange
RTE Runtime Error (non-zero exit) - Purple
<
==============================================================================
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
cp.nvim provides comprehensive ANSI color support and highlighting for
compiler output, program stderr, and diff visualization.
==============================================================================
HIGHLIGHT GROUPS *cp-highlights*
Test Status Groups ~
Test cases use competitive programming terminology with color highlighting:
CpTestAC Green foreground for AC status
CpTestWA Red foreground for WA status
CpTestTLE Orange foreground for TLE status
CpTestRTE Purple foreground for RTE status
CpTestPending Gray foreground for pending tests
ANSI Color Groups ~
cp.nvim preserves ANSI colors from compiler output and program stderr using
a sophisticated parsing system. Colors are automatically mapped to your
terminal colorscheme via vim.g.terminal_color_* variables.
Basic formatting groups:
CpAnsiBold Bold text formatting
CpAnsiItalic Italic text formatting
CpAnsiBoldItalic Combined bold and italic formatting
Standard terminal colors (each supports Bold, Italic, BoldItalic variants):
CpAnsiRed Standard red (terminal_color_1)
CpAnsiGreen Standard green (terminal_color_2)
CpAnsiYellow Standard yellow (terminal_color_3)
CpAnsiBlue Standard blue (terminal_color_4)
CpAnsiMagenta Standard magenta (terminal_color_5)
CpAnsiCyan Standard cyan (terminal_color_6)
CpAnsiWhite Standard white (terminal_color_7)
CpAnsiBlack Standard black (terminal_color_0)
Bright color variants:
CpAnsiBrightRed Bright red (terminal_color_9)
CpAnsiBrightGreen Bright green (terminal_color_10)
CpAnsiBrightYellow Bright yellow (terminal_color_11)
CpAnsiBrightBlue Bright blue (terminal_color_12)
CpAnsiBrightMagenta Bright magenta (terminal_color_13)
CpAnsiBrightCyan Bright cyan (terminal_color_14)
CpAnsiBrightWhite Bright white (terminal_color_15)
CpAnsiBrightBlack Bright black (terminal_color_8)
Example combinations:
CpAnsiBoldRed Bold red combination
CpAnsiItalicGreen Italic green combination
CpAnsiBoldItalicYellow Bold italic yellow combination
Diff Highlighting ~
Diff visualization uses Neovim's built-in highlight groups that automatically
adapt to your colorscheme:
DiffAdd Highlights added text in git diffs
DiffDelete Highlights removed text in git diffs
These groups are automatically used by the git diff backend for character-level
difference visualization with optimal colorscheme integration.
==============================================================================
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
ANSI colors automatically use your terminal's color palette through Neovim's
vim.g.terminal_color_* variables. This ensures compiler colors match your
colorscheme without manual configuration.
If your colorscheme doesn't set terminal colors, cp.nvim will warn you and
ANSI colors won't display properly - set them like so: >vim
let g:terminal_color_1 = '#ff6b6b'
...
==============================================================================
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
You can customize any highlight group by linking to existing groups or
defining custom colors: >lua
-- Customize the color of "TLE" text in run panel:
vim.api.nvim_set_hl(0, 'CpTestTLE', { fg = '#ffa500', bold = true })
-- ... or the ANSI colors used to display stderr
vim.api.nvim_set_hl(0, 'CpAnsiRed', {
fg = vim.g.terminal_color_1 or '#ef4444'
})
<
Place customizations in your init.lua or after the colorscheme loads to
prevent them from being overridden: >lua
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
-- Your cp.nvim highlight customizations here
vim.api.nvim_set_hl(0, 'CpTestAC', { link = 'String' })
end
})
<
==============================================================================
RUN PANEL KEYMAPS *cp-test-keys*
<c-n> Navigate to next test case (configurable via
run_panel.next_test_key)
<c-p> Navigate to previous test case (configurable via
run_panel.prev_test_key)
t Toggle diff mode between vim and git (configurable
via run_panel.toggle_diff_key)
q Exit test panel and restore layout
Diff Modes ~
Two diff backends are available:
vim Built-in vim diff (default, always available)
git Character-level git word-diff (requires git, more precise)
The git backend shows character-level changes with [-removed-] and {+added+}
markers for precise difference analysis.
Execution Details ~
Test cases are executed individually using the same compilation and
execution pipeline, but with isolated input/output for
precise failure analysis. All tests are automatically run when the
panel opens.
==============================================================================
FILE STRUCTURE *cp-files*
cp.nvim creates the following file structure upon problem setup: >
{problem_id}.{ext} " Source file (e.g. a.cc, b.py)
build/
{problem_id}.run " Compiled binary
io/
{problem_id}.n.cpin " nth test input
{problem_id}.n.cpout " nth program output
{problem_id}.expected " Expected output
<
==============================================================================
SNIPPETS *cp-snippets*
cp.nvim integrates with LuaSnip for automatic template expansion. Built-in
snippets include basic C++ and Python templates for each contest type.
Snippet trigger names must match the following format exactly: >
cp.nvim/{platform}.{language}
<
Where {platform} is the contest platform (atcoder, codeforces, cses) and
{language} is the programming language (cpp, python).
Examples: >
cp.nvim/atcoder.cpp
cp.nvim/codeforces.python
cp.nvim/cses.cpp
<
Custom snippets can be added via the `snippets` configuration field.
==============================================================================
HEALTH CHECK *cp-health*
Run |:checkhealth| cp to verify your setup.
vim:tw=78:ts=8:ft=help:norl:

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
}

72
flake.nix Normal file
View file

@ -0,0 +1,72 @@
{
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

@ -1,6 +0,0 @@
vim.filetype.add({
extension = {
cpin = 'cpin',
cpout = 'cpout',
},
})

View file

@ -4,88 +4,54 @@
---@field problem_id? string
---@field language? string
---@class CacheData
---@field [string] table<string, ContestData>
---@field file_states? table<string, FileState>
---@field contest_lists? table<string, ContestListData>
---@class ContestListData
---@field contests table[]
---@field cached_at number
---@field expires_at number
---@class ContestData
---@field problems Problem[]
---@field scraped_at string
---@field expires_at? number
---@field test_cases? CachedTestCase[]
---@field test_cases_cached_at? number
---@field timeout_ms? number
---@field memory_mb? number
---@field index_map table<string, number>
---@field name string
---@field display_name string
---@field url string
---@class ContestSummary
---@field display_name string
---@field name string
---@field id string
---@class CombinedTest
---@field input string
---@field expected string
---@class Problem
---@field id string
---@field name? string
---@field interactive? boolean
---@field multi_test? boolean
---@field memory_mb? number
---@field timeout_ms? number
---@field combined_test? CombinedTest
---@field test_cases TestCase[]
---@class CachedTestCase
---@class TestCase
---@field index? number
---@field input string
---@field expected? string
---@field input? string
---@field output? string
local M = {}
local logger = require('cp.log')
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
local cache_data = {}
local loaded = false
local CONTEST_LIST_TTL = {
cses = 7 * 24 * 60 * 60, -- 1 week
codeforces = 24 * 60 * 60, -- 1 day
atcoder = 24 * 60 * 60, -- 1 day
}
---@param platform string
---@return number?
local function get_expiry_date(platform)
vim.validate({
platform = { platform, 'string' },
})
if platform == 'cses' then
return os.time() + (30 * 24 * 60 * 60)
end
return nil
end
---@param contest_data ContestData
---@param platform string
---@return boolean
local function is_cache_valid(contest_data, platform)
vim.validate({
contest_data = { contest_data, 'table' },
platform = { platform, 'string' },
})
if platform ~= 'cses' then
return true
end
local expires_at = contest_data.expires_at
if not expires_at then
return false
end
return os.time() < expires_at
end
--- Load the cache from disk if not done already
---@return nil
function M.load()
if loaded then
return
end
if vim.fn.filereadable(cache_file) == 0 then
cache_data = {}
vim.fn.writefile({}, cache_file)
loaded = true
return
end
@ -101,62 +67,84 @@ function M.load()
if ok then
cache_data = decoded
else
cache_data = {}
logger.log('Could not decode json in cache file', vim.log.levels.ERROR)
end
loaded = true
end
--- Save the cache to disk, overwriting existing contents
---@return nil
function M.save()
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
local encoded = vim.json.encode(cache_data)
vim.fn.writefile(vim.split(encoded, '\n'), cache_file)
vim.schedule(function()
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
local encoded = vim.json.encode(cache_data)
local lines = vim.split(encoded, '\n')
vim.fn.writefile(lines, cache_file)
end)
end
---@param platform string
---@param contest_id string
---@return ContestData?
---@return ContestData
function M.get_contest_data(platform, contest_id)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
})
cache_data[platform] = cache_data[platform] or {}
cache_data[platform][contest_id] = cache_data[platform][contest_id] or {}
return cache_data[platform][contest_id]
end
---Get all cached contest IDs for a platform
---@param platform string
---@return string[]
function M.get_cached_contest_ids(platform)
vim.validate({
platform = { platform, 'string' },
})
if not cache_data[platform] then
return nil
return {}
end
local contest_data = cache_data[platform][contest_id]
if not contest_data then
return nil
local contest_ids = {}
for contest_id, _ in pairs(cache_data[platform]) do
table.insert(contest_ids, contest_id)
end
if not is_cache_valid(contest_data, platform) then
return nil
end
return contest_data
table.sort(contest_ids)
return contest_ids
end
---@param platform string
---@param contest_id string
---@param problems Problem[]
function M.set_contest_data(platform, contest_id, problems)
---@param url string
function M.set_contest_data(platform, contest_id, problems, url)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
problems = { problems, 'table' },
url = { url, 'string' },
})
if not cache_data[platform] then
cache_data[platform] = {}
cache_data[platform] = cache_data[platform] or {}
local prev = cache_data[platform][contest_id] or {}
local out = {
name = prev.name,
display_name = prev.display_name,
problems = problems,
index_map = {},
url = url,
}
for i, p in ipairs(out.problems) do
out.index_map[p.id] = i
end
cache_data[platform][contest_id] = {
problems = problems,
scraped_at = os.date('%Y-%m-%d'),
expires_at = get_expiry_date(platform),
}
cache_data[platform][contest_id] = out
M.save()
end
@ -177,7 +165,7 @@ end
---@param platform string
---@param contest_id string
---@param problem_id? string
---@return CachedTestCase[]?
---@return TestCase[]
function M.get_test_cases(platform, contest_id, problem_id)
vim.validate({
platform = { platform, 'string' },
@ -185,45 +173,78 @@ function M.get_test_cases(platform, contest_id, problem_id)
problem_id = { problem_id, { 'string', 'nil' }, true },
})
local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id
if not cache_data[platform] or not cache_data[platform][problem_key] then
return nil
if
not cache_data[platform]
or not cache_data[platform][contest_id]
or not cache_data[platform][contest_id].problems
or not cache_data[platform][contest_id].index_map
then
return {}
end
return cache_data[platform][problem_key].test_cases
local index = cache_data[platform][contest_id].index_map[problem_id]
return cache_data[platform][contest_id].problems[index].test_cases or {}
end
---@param platform string
---@param contest_id string
---@param problem_id? string
---@param test_cases CachedTestCase[]
---@param timeout_ms? number
---@param memory_mb? number
function M.set_test_cases(platform, contest_id, problem_id, test_cases, timeout_ms, memory_mb)
---@return CombinedTest?
function M.get_combined_test(platform, contest_id, problem_id)
if
not cache_data[platform]
or not cache_data[platform][contest_id]
or not cache_data[platform][contest_id].problems
or not cache_data[platform][contest_id].index_map
then
return nil
end
local index = cache_data[platform][contest_id].index_map[problem_id]
return cache_data[platform][contest_id].problems[index].combined_test
end
---@param platform string
---@param contest_id string
---@param problem_id string
---@param combined_test? CombinedTest
---@param test_cases TestCase[]
---@param timeout_ms number
---@param memory_mb number
---@param interactive boolean
---@param multi_test boolean
function M.set_test_cases(
platform,
contest_id,
problem_id,
combined_test,
test_cases,
timeout_ms,
memory_mb,
interactive,
multi_test
)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
combined_test = { combined_test, { 'table', 'nil' }, true },
test_cases = { test_cases, 'table' },
timeout_ms = { timeout_ms, { 'number', 'nil' }, true },
memory_mb = { memory_mb, { 'number', 'nil' }, true },
interactive = { interactive, { 'boolean', 'nil' }, true },
multi_test = { multi_test, { 'boolean', 'nil' }, true },
})
local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id
if not cache_data[platform] then
cache_data[platform] = {}
end
if not cache_data[platform][problem_key] then
cache_data[platform][problem_key] = {}
end
local index = cache_data[platform][contest_id].index_map[problem_id]
cache_data[platform][contest_id].problems[index].combined_test = combined_test
cache_data[platform][contest_id].problems[index].test_cases = test_cases
cache_data[platform][contest_id].problems[index].timeout_ms = timeout_ms
cache_data[platform][contest_id].problems[index].memory_mb = memory_mb
cache_data[platform][contest_id].problems[index].interactive = interactive
cache_data[platform][contest_id].problems[index].multi_test = multi_test
cache_data[platform][problem_key].test_cases = test_cases
cache_data[platform][problem_key].test_cases_cached_at = os.time()
if timeout_ms then
cache_data[platform][problem_key].timeout_ms = timeout_ms
end
if memory_mb then
cache_data[platform][problem_key].memory_mb = memory_mb
end
M.save()
end
@ -238,110 +259,64 @@ function M.get_constraints(platform, contest_id, problem_id)
problem_id = { problem_id, { 'string', 'nil' }, true },
})
local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id
if not cache_data[platform] or not cache_data[platform][problem_key] then
return nil, nil
end
local index = cache_data[platform][contest_id].index_map[problem_id]
local problem_data = cache_data[platform][problem_key]
local problem_data = cache_data[platform][contest_id].problems[index]
return problem_data.timeout_ms, problem_data.memory_mb
end
---@param file_path string
---@return FileState?
---@return FileState|nil
function M.get_file_state(file_path)
vim.validate({
file_path = { file_path, 'string' },
})
if not cache_data.file_states then
return nil
end
M.load()
cache_data.file_states = cache_data.file_states or {}
return cache_data.file_states[file_path]
end
---@param file_path string
---@param path string
---@param platform string
---@param contest_id string
---@param problem_id? string
---@param language? string
function M.set_file_state(file_path, platform, contest_id, problem_id, language)
vim.validate({
file_path = { file_path, 'string' },
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
language = { language, { 'string', 'nil' }, true },
})
if not cache_data.file_states then
cache_data.file_states = {}
end
cache_data.file_states[file_path] = {
---@param problem_id string
---@param language string|nil
function M.set_file_state(path, platform, contest_id, problem_id, language)
M.load()
cache_data.file_states = cache_data.file_states or {}
cache_data.file_states[path] = {
platform = platform,
contest_id = contest_id,
problem_id = problem_id,
language = language,
}
M.save()
end
---@param platform string
---@return table[]?
function M.get_contest_list(platform)
vim.validate({
platform = { platform, 'string' },
})
if not cache_data.contest_lists or not cache_data.contest_lists[platform] then
return nil
---@return ContestSummary[]
function M.get_contest_summaries(platform)
local contest_list = {}
for contest_id, contest_data in pairs(cache_data[platform] or {}) do
table.insert(contest_list, {
id = contest_id,
name = contest_data.name,
display_name = contest_data.display_name,
})
end
local contest_list_data = cache_data.contest_lists[platform]
if os.time() >= contest_list_data.expires_at then
return nil
end
return contest_list_data.contests
return contest_list
end
---@param platform string
---@param contests table[]
function M.set_contest_list(platform, contests)
vim.validate({
platform = { platform, 'string' },
contests = { contests, 'table' },
})
if not cache_data.contest_lists then
cache_data.contest_lists = {}
---@param contests ContestSummary[]
function M.set_contest_summaries(platform, contests)
cache_data[platform] = cache_data[platform] or {}
for _, contest in ipairs(contests) do
cache_data[platform][contest.id] = cache_data[platform][contest.id] or {}
cache_data[platform][contest.id].display_name = contest.display_name
cache_data[platform][contest.id].name = contest.name
end
local ttl = CONTEST_LIST_TTL[platform] or (24 * 60 * 60) -- Default 1 day
cache_data.contest_lists[platform] = {
contests = contests,
cached_at = os.time(),
expires_at = os.time() + ttl,
}
M.save()
end
---@param platform string
function M.clear_contest_list(platform)
vim.validate({
platform = { platform, 'string' },
})
if cache_data.contest_lists and cache_data.contest_lists[platform] then
cache_data.contest_lists[platform] = nil
M.save()
end
end
function M.clear_all()
cache_data = {}
M.save()
@ -349,17 +324,20 @@ end
---@param platform string
function M.clear_platform(platform)
vim.validate({
platform = { platform, 'string' },
})
if cache_data[platform] then
cache_data[platform] = nil
end
if cache_data.contest_lists and cache_data.contest_lists[platform] then
cache_data.contest_lists[platform] = nil
end
M.save()
end
---@return string
function M.get_data_pretty()
M.load()
return vim.inspect(cache_data)
end
M._cache = cache_data
return M

74
lua/cp/commands/cache.lua Normal file
View file

@ -0,0 +1,74 @@
local M = {}
local cache = require('cp.cache')
local constants = require('cp.constants')
local logger = require('cp.log')
local platforms = constants.PLATFORMS
--- Dispatch any `:CP cache ...` command
---@param cmd table
---@return nil
function M.handle_cache_command(cmd)
if cmd.subcommand == 'read' then
local data = cache.get_data_pretty()
local name = 'cp.nvim://cache.lua'
local existing = vim.fn.bufnr(name)
local buf
if existing ~= -1 then
buf = existing
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(data, '\n'))
else
buf = vim.api.nvim_create_buf(true, true)
vim.api.nvim_buf_set_name(buf, name)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(data, '\n'))
vim.bo[buf].filetype = 'lua'
vim.bo[buf].buftype = 'nofile'
vim.bo[buf].bufhidden = 'wipe'
vim.bo[buf].swapfile = false
vim.api.nvim_buf_set_keymap(
buf,
'n',
'q',
'<cmd>bd!<cr>',
{ nowait = true, noremap = true, silent = true }
)
end
vim.api.nvim_set_current_buf(buf)
elseif cmd.subcommand == 'clear' then
cache.load()
if cmd.platform and cmd.contest then
if vim.tbl_contains(platforms, cmd.platform) then
cache.clear_contest_data(cmd.platform, cmd.contest)
logger.log(
("Cache cleared for %s contest '%s'"):format(
constants.PLATFORM_DISPLAY_NAMES[cmd.platform],
cmd.contest
),
vim.log.levels.INFO,
true
)
else
logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR)
end
elseif cmd.platform then
if vim.tbl_contains(platforms, cmd.platform) then
cache.clear_platform(cmd.platform)
logger.log(
("Cache cleared for platform '%s'"):format(constants.PLATFORM_DISPLAY_NAMES[cmd.platform]),
vim.log.levels.INFO,
true
)
else
logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR)
end
else
cache.clear_all()
logger.log('Cache cleared', vim.log.levels.INFO, true)
end
end
end
return M

327
lua/cp/commands/init.lua Normal file
View file

@ -0,0 +1,327 @@
local M = {}
local constants = require('cp.constants')
local logger = require('cp.log')
local state = require('cp.state')
local platforms = constants.PLATFORMS
local actions = constants.ACTIONS
---@class ParsedCommand
---@field type string
---@field error string?
---@field action? string
---@field message? string
---@field contest? string
---@field platform? string
---@field problem_id? string
---@field interactor_cmd? string
---@field test_index? integer
---@field test_indices? integer[]
---@field mode? string
---@field debug? boolean
---@field language? string
---@field subcommand? string
--- Turn raw args into normalized structure to later dispatch
---@param args string[] The raw command-line mode args
---@return ParsedCommand
local function parse_command(args)
if vim.tbl_isempty(args) then
return {
type = 'restore_from_file',
}
end
local first = args[1]
if vim.tbl_contains(actions, first) then
if first == 'cache' then
local subcommand = args[2]
if not subcommand then
return { type = 'error', message = 'cache command requires subcommand' }
end
if vim.tbl_contains({ 'clear', 'read' }, subcommand) then
local platform = args[3]
local contest = args[4]
return {
type = 'cache',
subcommand = subcommand,
platform = platform,
contest = contest,
}
else
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
end
elseif first == 'interact' then
local inter = args[2]
if inter and inter ~= '' then
return { type = 'action', action = 'interact', interactor_cmd = inter }
else
return { type = 'action', action = 'interact' }
end
elseif first == 'edit' then
local test_index = nil
if #args >= 2 then
local idx = tonumber(args[2])
if not idx then
return {
type = 'error',
message = ("Invalid argument '%s': expected test number"):format(args[2]),
}
end
if idx < 1 or idx ~= math.floor(idx) then
return { type = 'error', message = ("'%s' is not a valid test index"):format(idx) }
end
test_index = idx
end
return { type = 'action', action = 'edit', test_index = test_index }
elseif first == 'run' or first == 'panel' then
local debug = false
local test_indices = nil
local mode = 'combined'
if #args == 2 then
if args[2] == '--debug' then
debug = true
elseif args[2] == 'all' then
mode = 'individual'
else
if args[2]:find(',') then
local indices = {}
for num in args[2]:gmatch('[^,]+') do
local idx = tonumber(num)
if not idx or idx < 1 or idx ~= math.floor(idx) then
return {
type = 'error',
message = ("Invalid test index '%s' in list"):format(num),
}
end
table.insert(indices, idx)
end
if #indices == 0 then
return { type = 'error', message = 'No valid test indices provided' }
end
test_indices = indices
mode = 'individual'
else
local idx = tonumber(args[2])
if not idx then
return {
type = 'error',
message = ("Invalid argument '%s': expected test number(s), 'all', or --debug"):format(
args[2]
),
}
end
if idx < 1 or idx ~= math.floor(idx) then
return { type = 'error', message = ("'%s' is not a valid test index"):format(idx) }
end
test_indices = { idx }
mode = 'individual'
end
end
elseif #args == 3 then
if args[2] == 'all' then
mode = 'individual'
if args[3] ~= '--debug' then
return {
type = 'error',
message = ("Invalid argument '%s': expected --debug"):format(args[3]),
}
end
debug = true
elseif args[2]:find(',') then
local indices = {}
for num in args[2]:gmatch('[^,]+') do
local idx = tonumber(num)
if not idx or idx < 1 or idx ~= math.floor(idx) then
return {
type = 'error',
message = ("Invalid test index '%s' in list"):format(num),
}
end
table.insert(indices, idx)
end
if #indices == 0 then
return { type = 'error', message = 'No valid test indices provided' }
end
if args[3] ~= '--debug' then
return {
type = 'error',
message = ("Invalid argument '%s': expected --debug"):format(args[3]),
}
end
test_indices = indices
mode = 'individual'
debug = true
else
local idx = tonumber(args[2])
if not idx then
return {
type = 'error',
message = ("Invalid argument '%s': expected test number"):format(args[2]),
}
end
if idx < 1 or idx ~= math.floor(idx) then
return { type = 'error', message = ("'%s' is not a valid test index"):format(idx) }
end
if args[3] ~= '--debug' then
return {
type = 'error',
message = ("Invalid argument '%s': expected --debug"):format(args[3]),
}
end
test_indices = { idx }
mode = 'individual'
debug = true
end
elseif #args > 3 then
return {
type = 'error',
message = 'Too many arguments. Usage: :CP '
.. first
.. ' [all|test_num[,test_num...]] [--debug]',
}
end
return {
type = 'action',
action = first,
test_indices = test_indices,
debug = debug,
mode = mode,
}
else
local language = nil
if #args >= 3 and args[2] == '--lang' then
language = args[3]
elseif #args >= 2 and args[2] ~= nil and args[2]:sub(1, 2) ~= '--' then
return {
type = 'error',
message = ("Unknown argument '%s' for action '%s'"):format(args[2], first),
}
end
return { type = 'action', action = first, language = language }
end
end
if vim.tbl_contains(platforms, first) then
if #args == 1 then
return {
type = 'error',
message = 'Too few arguments - specify a contest.',
}
elseif #args == 2 then
return {
type = 'contest_setup',
platform = first,
contest = args[2],
}
elseif #args == 4 and args[3] == '--lang' then
return {
type = 'contest_setup',
platform = first,
contest = args[2],
language = args[4],
}
else
return {
type = 'error',
message = 'Invalid arguments. Usage: :CP <platform> <contest> [--lang <language>]',
}
end
end
if #args == 1 then
return {
type = 'problem_jump',
problem_id = first,
}
elseif #args == 3 and args[2] == '--lang' then
return {
type = 'problem_jump',
problem_id = first,
language = args[3],
}
end
return { type = 'error', message = 'Unknown command or no contest context.' }
end
--- Core logic for handling `:CP ...` commands
---@return nil
function M.handle_command(opts)
local cmd = parse_command(opts.fargs)
if cmd.type == 'error' then
logger.log(cmd.message, vim.log.levels.ERROR)
return
end
if cmd.type == 'restore_from_file' then
local restore = require('cp.restore')
restore.restore_from_current_file()
elseif cmd.type == 'action' then
local setup = require('cp.setup')
local ui = require('cp.ui.views')
if cmd.action == 'interact' then
ui.toggle_interactive(cmd.interactor_cmd)
elseif cmd.action == 'run' then
ui.run_io_view(cmd.test_indices, cmd.debug, cmd.mode)
elseif cmd.action == 'panel' then
ui.toggle_panel({
debug = cmd.debug,
test_index = cmd.test_indices and cmd.test_indices[1] or nil,
})
elseif cmd.action == 'next' then
setup.navigate_problem(1, cmd.language)
elseif cmd.action == 'prev' then
setup.navigate_problem(-1, cmd.language)
elseif cmd.action == 'pick' then
local picker = require('cp.commands.picker')
picker.handle_pick_action(cmd.language)
elseif cmd.action == 'edit' then
local edit = require('cp.ui.edit')
edit.toggle_edit(cmd.test_index)
end
elseif cmd.type == 'problem_jump' then
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = cmd.problem_id
if not (platform and contest_id) then
logger.log('No contest is currently active.', vim.log.levels.ERROR)
return
end
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if not (contest_data and contest_data.index_map and contest_data.index_map[problem_id]) then
logger.log(
("%s contest '%s' has no problem '%s'."):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id,
problem_id
),
vim.log.levels.ERROR
)
return
end
local setup = require('cp.setup')
setup.setup_contest(platform, contest_id, problem_id, cmd.language)
elseif cmd.type == 'cache' then
local cache_commands = require('cp.commands.cache')
cache_commands.handle_cache_command(cmd)
elseif cmd.type == 'contest_setup' then
local setup = require('cp.setup')
setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language)
return
end
end
return M

View file

@ -0,0 +1,60 @@
local M = {}
local config_module = require('cp.config')
local logger = require('cp.log')
--- Dispatch `:CP pick` to appropriate picker
---@param language? string
---@return nil
function M.handle_pick_action(language)
local config = config_module.get_config()
if not (config.ui and config.ui.picker) then
logger.log(
'No picker configured. Set ui.picker = "{telescope,fzf-lua}" in your config.',
vim.log.levels.ERROR
)
return
end
local picker
local picker_name = config.ui.picker
if picker_name == 'telescope' then
local ok = pcall(require, 'telescope')
if not ok then
logger.log(
'telescope.nvim is not available. Install telescope.nvim xor change your picker config.',
vim.log.levels.ERROR
)
return
end
local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope')
if not ok_cp then
logger.log('Failed to load telescope integration.', vim.log.levels.ERROR)
return
end
picker = telescope_picker
elseif picker_name == 'fzf-lua' then
local ok, _ = pcall(require, 'fzf-lua')
if not ok then
logger.log(
'fzf-lua is not available. Install fzf-lua or change your picker config',
vim.log.levels.ERROR
)
return
end
local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua')
if not ok_cp then
logger.log('Failed to load fzf-lua integration.', vim.log.levels.ERROR)
return
end
picker = fzf_picker
end
picker.pick(language)
end
return M

View file

@ -1,295 +1,532 @@
---@class LanguageConfig
---@field compile? string[] Compile command template
---@field test string[] Test execution command template
---@field debug? string[] Debug command template
---@field executable? string Executable name
---@field version? number Language version
---@field extension? string File extension
-- lua/cp/config.lua
---@class CpLangCommands
---@field build? string[]
---@field run? string[]
---@field debug? string[]
---@class PartialLanguageConfig
---@field compile? string[] Compile command template
---@field test? string[] Test execution command template
---@field debug? string[] Debug command template
---@field executable? string Executable name
---@field version? number Language version
---@field extension? string File extension
---@class CpLanguage
---@field extension string
---@field commands CpLangCommands
---@class ContestConfig
---@field cpp LanguageConfig
---@field python LanguageConfig
---@field default_language? string
---@class CpPlatformOverrides
---@field extension? string
---@field commands? CpLangCommands
---@class PartialContestConfig
---@field cpp? PartialLanguageConfig
---@field python? PartialLanguageConfig
---@field default_language? string
---@class CpPlatform
---@field enabled_languages string[]
---@field default_language string
---@field overrides? table<string, CpPlatformOverrides>
---@class Hooks
---@field before_run? fun(ctx: ProblemContext)
---@field before_debug? fun(ctx: ProblemContext)
---@field setup_code? fun(ctx: ProblemContext)
---@class RunPanelConfig
---@field ansi boolean Enable ANSI color parsing and highlighting
---@field diff_mode "vim"|"git" Diff backend to use
---@field next_test_key string Key to navigate to next test case
---@field prev_test_key string Key to navigate to previous test case
---@field toggle_diff_key string Key to toggle diff mode
---@field max_output_lines number Maximum lines of test output to display
---@class PanelConfig
---@field diff_modes string[]
---@field max_output_lines integer
---@class DiffGitConfig
---@field args string[] Git diff arguments
---@field args string[]
---@class DiffConfig
---@field git DiffGitConfig
---@class Hooks
---@field before_run? fun(state: cp.State)
---@field before_debug? fun(state: cp.State)
---@field setup_code? fun(state: cp.State)
---@field setup_io_input? fun(bufnr: integer, state: cp.State)
---@field setup_io_output? fun(bufnr: integer, state: cp.State)
---@class VerdictFormatData
---@field index integer
---@field status { text: string, highlight_group: string }
---@field time_ms number
---@field time_limit_ms number
---@field memory_mb number
---@field memory_limit_mb number
---@field exit_code integer
---@field signal string|nil
---@field time_actual_width? integer
---@field time_limit_width? integer
---@field mem_actual_width? integer
---@field mem_limit_width? integer
---@class VerdictHighlight
---@field col_start integer
---@field col_end integer
---@field group string
---@class VerdictFormatResult
---@field line string
---@field highlights? VerdictHighlight[]
---@alias VerdictFormatter fun(data: VerdictFormatData): VerdictFormatResult
---@class RunConfig
---@field width number
---@field next_test_key string|nil
---@field prev_test_key string|nil
---@field format_verdict VerdictFormatter
---@class EditConfig
---@field next_test_key string|nil
---@field prev_test_key string|nil
---@field delete_test_key string|nil
---@field add_test_key string|nil
---@field save_and_exit_key string|nil
---@class CpUI
---@field ansi boolean
---@field run RunConfig
---@field edit EditConfig
---@field panel PanelConfig
---@field diff DiffConfig
---@field picker string|nil
---@class cp.Config
---@field contests table<string, ContestConfig>
---@field snippets table[]
---@field languages table<string, CpLanguage>
---@field platforms table<string, CpPlatform>
---@field hooks Hooks
---@field debug boolean
---@field scrapers table<string, boolean>
---@field open_url boolean
---@field scrapers string[]
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
---@field run_panel RunPanelConfig
---@field diff DiffConfig
---@field picker "telescope"|"fzf-lua"|nil
---@field ui CpUI
---@field runtime { effective: table<string, table<string, CpLanguage>> } -- computed
---@class cp.UserConfig
---@field contests? table<string, PartialContestConfig>
---@field snippets? table[]
---@field hooks? Hooks
---@field debug? boolean
---@field scrapers? table<string, boolean>
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
---@field run_panel? RunPanelConfig
---@field diff? DiffConfig
---@field picker? "telescope"|"fzf-lua"|nil
---@class cp.PartialConfig: cp.Config
local M = {}
local constants = require('cp.constants')
local constants = require('cp.constants')
local helpers = require('cp.helpers')
local utils = require('cp.utils')
-- defaults per the new single schema
---@type cp.Config
M.defaults = {
contests = {
default = {
cpp = {
compile = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' },
test = { '{binary}' },
open_url = false,
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}',
'-std=c++17',
'-g',
'-fsanitize=address,undefined',
},
},
python = {
test = { 'python3', '{source}' },
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
},
},
snippets = {},
platforms = {
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
-- example override, safe to keep empty initially
},
},
atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codechef = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
hooks = {
before_run = nil,
before_debug = nil,
setup_code = nil,
setup_io_input = helpers.clearcol,
setup_io_output = helpers.clearcol,
},
debug = false,
scrapers = constants.PLATFORMS,
filename = nil,
run_panel = {
ui = {
ansi = true,
diff_mode = 'git',
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
toggle_diff_key = 't',
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
run = {
width = 0.3,
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
format_verdict = helpers.default_verdict_formatter,
},
edit = {
next_test_key = ']t',
prev_test_key = '[t',
delete_test_key = 'gd',
add_test_key = 'ga',
save_and_exit_key = 'q',
},
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 = nil,
},
picker = nil,
runtime = { effective = {} },
}
---@param user_config cp.UserConfig|nil
local function is_string_list(t)
if type(t) ~= 'table' then
return false
end
for _, v in ipairs(t) do
if type(v) ~= 'string' then
return false
end
end
return true
end
local function has_tokens(cmd, required)
if type(cmd) ~= 'table' then
return false
end
local s = table.concat(cmd, ' ')
for _, tok in ipairs(required) do
if not s:find(vim.pesc(tok), 1, true) then
return false
end
end
return true
end
local function validate_language(id, lang)
vim.validate({
extension = { lang.extension, 'string' },
commands = { lang.commands, { 'table' } },
})
if not lang.commands.run then
error(('[cp.nvim] languages.%s.commands.run is required'):format(id))
end
if lang.commands.build ~= nil then
vim.validate({ build = { lang.commands.build, { 'table' } } })
if not has_tokens(lang.commands.build, { '{source}', '{binary}' }) then
error(('[cp.nvim] languages.%s.commands.build must include {source} and {binary}'):format(id))
end
for _, k in ipairs({ 'run', 'debug' }) do
if lang.commands[k] then
if not has_tokens(lang.commands[k], { '{binary}' }) then
error(('[cp.nvim] languages.%s.commands.%s must include {binary}'):format(id, k))
end
end
end
else
for _, k in ipairs({ 'run', 'debug' }) do
if lang.commands[k] then
if not has_tokens(lang.commands[k], { '{source}' }) then
error(('[cp.nvim] languages.%s.commands.%s must include {source}'):format(id, k))
end
end
end
end
end
local function merge_lang(base, ov)
if not ov then
return base
end
local out = vim.deepcopy(base)
if ov.extension then
out.extension = ov.extension
end
if ov.commands then
out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {})
end
return out
end
---@param cfg cp.Config
local function build_runtime(cfg)
cfg.runtime = cfg.runtime or { effective = {} }
for plat, p in pairs(cfg.platforms) do
vim.validate({
enabled_languages = { p.enabled_languages, is_string_list, 'string[]' },
default_language = { p.default_language, 'string' },
})
for _, lid in ipairs(p.enabled_languages) do
if not cfg.languages[lid] then
error(("[cp.nvim] platform %s references unknown language '%s'"):format(plat, lid))
end
end
if not vim.tbl_contains(p.enabled_languages, p.default_language) then
error(
("[cp.nvim] platform %s default_language '%s' not in enabled_languages"):format(
plat,
p.default_language
)
)
end
cfg.runtime.effective[plat] = {}
for _, lid in ipairs(p.enabled_languages) do
local base = cfg.languages[lid]
validate_language(lid, base)
local eff = merge_lang(base, p.overrides and p.overrides[lid] or nil)
validate_language(lid, eff)
cfg.runtime.effective[plat][lid] = eff
end
end
end
---@param user_config cp.PartialConfig|nil
---@return cp.Config
function M.setup(user_config)
vim.validate({
user_config = { user_config, { 'table', 'nil' }, true },
})
if user_config then
vim.validate({
contests = { user_config.contests, { 'table', 'nil' }, true },
snippets = { user_config.snippets, { 'table', 'nil' }, true },
hooks = { user_config.hooks, { 'table', 'nil' }, true },
debug = { user_config.debug, { 'boolean', 'nil' }, true },
scrapers = { user_config.scrapers, { 'table', 'nil' }, true },
filename = { user_config.filename, { 'function', 'nil' }, true },
run_panel = { user_config.run_panel, { 'table', 'nil' }, true },
diff = { user_config.diff, { 'table', 'nil' }, true },
picker = { user_config.picker, { 'string', 'nil' }, true },
})
if user_config.contests then
for contest_name, contest_config in pairs(user_config.contests) do
vim.validate({
[contest_name] = {
contest_config,
function(config)
if type(config) ~= 'table' then
return false
end
return true
end,
'contest configuration',
},
})
end
end
if user_config.scrapers then
for _, platform_name in ipairs(user_config.scrapers) do
if type(platform_name) ~= 'string' then
error(('Invalid scraper value type. Expected string, got %s'):format(type(platform_name)))
end
if not vim.tbl_contains(constants.PLATFORMS, platform_name) then
error(
("Invalid platform '%s' in scrapers config. Valid platforms: %s"):format(
platform_name,
table.concat(constants.PLATFORMS, ', ')
)
)
end
end
end
if user_config.picker then
if not vim.tbl_contains({ 'telescope', 'fzf-lua' }, user_config.picker) then
error(("Invalid picker '%s'. Must be 'telescope' or 'fzf-lua'"):format(user_config.picker))
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 config = vim.tbl_deep_extend('force', M.defaults, user_config or {})
if not next(cfg.languages) then
error('[cp.nvim] At least one language must be configured')
end
if not next(cfg.platforms) then
error('[cp.nvim] At least one platform must be configured')
end
vim.validate({
before_run = {
config.hooks.before_run,
{ 'function', 'nil' },
true,
},
before_debug = {
config.hooks.before_debug,
{ 'function', 'nil' },
true,
},
setup_code = {
config.hooks.setup_code,
{ 'function', 'nil' },
true,
hooks = { cfg.hooks, { 'table' } },
ui = { cfg.ui, { 'table' } },
debug = { cfg.debug, { 'boolean', 'nil' }, true },
open_url = { cfg.open_url, { 'boolean', 'nil' }, true },
filename = { cfg.filename, { 'function', 'nil' }, true },
scrapers = {
cfg.scrapers,
function(v)
if type(v) ~= 'table' then
return false
end
for _, s in ipairs(v) do
if not vim.tbl_contains(constants.PLATFORMS, s) then
return false
end
end
return true
end,
('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')),
},
before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true },
before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true },
setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true },
setup_io_input = { cfg.hooks.setup_io_input, { 'function', 'nil' }, true },
setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true },
})
local layouts = require('cp.ui.layouts')
vim.validate({
ansi = {
config.run_panel.ansi,
'boolean',
'ansi color parsing must be enabled xor disabled',
},
diff_mode = {
config.run_panel.diff_mode,
function(value)
return vim.tbl_contains({ 'vim', 'git' }, value)
ansi = { cfg.ui.ansi, 'boolean' },
diff_modes = {
cfg.ui.panel.diff_modes,
function(v)
if type(v) ~= 'table' then
return false
end
for _, mode in ipairs(v) do
if not layouts.DIFF_MODES[mode] then
return false
end
end
return true
end,
"diff_mode must be 'vim' or 'git'",
},
next_test_key = {
config.run_panel.next_test_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'next_test_key must be a non-empty string',
},
prev_test_key = {
config.run_panel.prev_test_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'prev_test_key must be a non-empty string',
},
toggle_diff_key = {
config.run_panel.toggle_diff_key,
function(value)
return type(value) == 'string' and value ~= ''
end,
'toggle_diff_key must be a non-empty string',
('one of {%s}'):format(table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',')),
},
max_output_lines = {
config.run_panel.max_output_lines,
function(value)
return type(value) == 'number' and value > 0 and value == math.floor(value)
cfg.ui.panel.max_output_lines,
function(v)
return type(v) == 'number' and v > 0 and v == math.floor(v)
end,
'max_output_lines must be a positive integer',
'positive integer',
},
git = { cfg.ui.diff.git, { 'table' } },
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
width = {
cfg.ui.run.width,
function(v)
return type(v) == 'number' and v > 0 and v <= 1
end,
'decimal between 0 and 1',
},
next_test_key = {
cfg.ui.run.next_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
prev_test_key = {
cfg.ui.run.prev_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
format_verdict = {
cfg.ui.run.format_verdict,
'function',
},
edit_next_test_key = {
cfg.ui.edit.next_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
edit_prev_test_key = {
cfg.ui.edit.prev_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
delete_test_key = {
cfg.ui.edit.delete_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
add_test_key = {
cfg.ui.edit.add_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
save_and_exit_key = {
cfg.ui.edit.save_and_exit_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
picker = {
cfg.ui.picker,
function(v)
return v == nil or v == 'telescope' or v == 'fzf-lua'
end,
"nil, 'telescope', or 'fzf-lua'",
},
})
vim.validate({
git = { config.diff.git, { 'table', 'nil' }, true },
})
for _, contest_config in pairs(config.contests) do
for lang_name, lang_config in pairs(contest_config) do
if type(lang_config) == 'table' and not lang_config.extension then
if lang_name == 'cpp' then
lang_config.extension = 'cpp'
elseif lang_name == 'python' then
lang_config.extension = 'py'
end
end
end
if not contest_config.default_language then
local available_langs = {}
for lang_name, lang_config in pairs(contest_config) do
if type(lang_config) == 'table' and lang_name ~= 'default_language' then
table.insert(available_langs, lang_name)
end
end
if #available_langs == 0 then
error('No language configurations found')
end
table.sort(available_langs)
contest_config.default_language = available_langs[1]
end
for id, lang in pairs(cfg.languages) do
validate_language(id, lang)
end
return config
build_runtime(cfg)
local ok, err = utils.check_required_runtime()
if not ok then
error('[cp.nvim] ' .. err)
end
return cfg
end
local current_config = nil
function M.set_current_config(config)
current_config = config
end
function M.get_config()
return current_config or M.defaults
end
---Validate and get effective language config for a platform
---@param platform_id string
---@param language_id string
---@return { valid: boolean, effective?: CpLanguage, extension?: string, error?: string }
function M.get_language_for_platform(platform_id, language_id)
local cfg = M.get_config()
if not cfg.platforms[platform_id] then
return { valid = false, error = string.format("Unknown platform '%s'", platform_id) }
end
local platform = cfg.platforms[platform_id]
if not cfg.languages[language_id] then
local available = table.concat(platform.enabled_languages, ', ')
return {
valid = false,
error = string.format("Unknown language '%s'. Available: [%s]", language_id, available),
}
end
if not vim.tbl_contains(platform.enabled_languages, language_id) then
local available = table.concat(platform.enabled_languages, ', ')
return {
valid = false,
error = string.format(
"Language '%s' not enabled for %s. Available: [%s]",
language_id,
platform_id,
available
),
}
end
local platform_effective = cfg.runtime.effective[platform_id]
if not platform_effective then
return {
valid = false,
error = string.format(
'No runtime config for platform %s (plugin not initialized)',
platform_id
),
}
end
local effective = platform_effective[language_id]
if not effective then
return {
valid = false,
error = string.format('No effective config for %s/%s', platform_id, language_id),
}
end
return {
valid = true,
effective = effective,
extension = effective.extension,
}
end
---@param contest_id string
---@param problem_id? string
---@return string
local function default_filename(contest_id, problem_id)
vim.validate({
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
})
if problem_id then
return (contest_id .. problem_id):lower()
else
return contest_id:lower()
end
return contest_id:lower()
end
M.default_filename = default_filename
return M

View file

@ -1,10 +1,11 @@
local M = {}
M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' }
M.ACTIONS = { 'run', 'next', 'prev', 'pick', 'cache' }
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses' }
M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit' }
M.PLATFORM_DISPLAY_NAMES = {
atcoder = 'AtCoder',
codechef = 'CodeChef',
codeforces = 'CodeForces',
cses = 'CSES',
}
@ -24,6 +25,12 @@ M.canonical_filetypes = {
[M.PYTHON] = 'python',
}
---@type table<string, string>
M.canonical_filetype_to_extension = {
[M.CPP] = 'cc',
[M.PYTHON] = 'py',
}
---@type table<number, string>
M.signal_codes = {
[128] = 'SIGILL',

View file

@ -2,94 +2,76 @@ local M = {}
local utils = require('cp.utils')
local function check_nvim_version()
local function check()
vim.health.start('cp.nvim [required] ~')
utils.setup_python_env()
if vim.fn.has('nvim-0.10.0') == 1 then
vim.health.ok('Neovim 0.10.0+ detected')
else
vim.health.error('cp.nvim requires Neovim 0.10.0+')
end
end
local function check_uv()
if vim.fn.executable('uv') == 1 then
vim.health.ok('uv executable found')
local uname = vim.loop.os_uname()
if uname.sysname == 'Windows_NT' then
vim.health.error('Windows is not supported')
end
local result = vim.system({ 'uv', '--version' }, { text = true }):wait()
if result.code == 0 then
vim.health.info('uv version: ' .. result.stdout:gsub('\n', ''))
if utils.is_nix_build() then
local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install'
vim.health.ok('Nix Python environment detected (' .. source .. ')')
local py = utils.get_nix_python()
vim.health.info('Python: ' .. py)
local r = vim.system({ py, '--version' }, { text = true }):wait()
if r.code == 0 then
vim.health.info('Python version: ' .. r.stdout:gsub('\n', ''))
end
else
vim.health.warn('uv not found - install from https://docs.astral.sh/uv/ for problem scraping')
end
end
local function check_python_env()
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.warn('Python virtual environment not set up - run :CP command to initialize')
end
end
local function check_scrapers()
local plugin_path = utils.get_plugin_path()
local scrapers = { 'atcoder.py', 'codeforces.py', 'cses.py' }
for _, scraper in ipairs(scrapers) do
local scraper_path = plugin_path .. '/scrapers/' .. scraper
if vim.fn.filereadable(scraper_path) == 1 then
vim.health.ok('Scraper found: ' .. scraper)
else
vim.health.error('Missing scraper: ' .. scraper)
end
end
end
local function check_luasnip()
local has_luasnip, luasnip = pcall(require, 'luasnip')
if has_luasnip then
vim.health.ok('LuaSnip integration available')
local snippet_count = #luasnip.get_snippets('all')
vim.health.info('LuaSnip snippets loaded: ' .. snippet_count)
else
vim.health.info('LuaSnip not available - template expansion will be limited')
end
end
local function check_config()
vim.health.ok('Plugin ready')
local cp = require('cp')
local context = cp.get_current_context()
if context.platform then
local info = context.platform
if context.contest_id then
info = info .. ' ' .. context.contest_id
if context.problem_id then
info = info .. ' ' .. context.problem_id
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.info('Current context: ' .. info)
if vim.fn.executable('nix') == 1 then
vim.health.info('nix available but Python environment not resolved via nix')
end
local plugin_path = utils.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
if vim.fn.isdirectory(venv_dir) == 1 then
vim.health.ok('Python virtual environment found at ' .. venv_dir)
else
vim.health.info('Python virtual environment not set up (created on first scrape)')
end
end
local time_cap = utils.time_capability()
if time_cap.ok then
vim.health.ok('GNU time found: ' .. time_cap.path)
else
vim.health.info('No contest context set')
vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
end
local timeout_cap = utils.timeout_capability()
if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else
vim.health.error('GNU timeout not found: ' .. (timeout_cap.reason or ''))
end
end
function M.check()
local version = require('cp.version')
vim.health.start('cp.nvim health check')
vim.health.start('cp.nvim health check ~')
vim.health.info('Version: ' .. version.version)
check_nvim_version()
check_uv()
check_python_env()
check_scrapers()
check_luasnip()
check_config()
check()
end
return M

101
lua/cp/helpers.lua Normal file
View file

@ -0,0 +1,101 @@
local M = {}
---@param bufnr integer
function M.clearcol(bufnr)
for _, win in ipairs(vim.fn.win_findbuf(bufnr)) do
vim.wo[win].signcolumn = 'no'
vim.wo[win].statuscolumn = ''
vim.wo[win].number = false
vim.wo[win].relativenumber = false
end
end
---Pad text on the right (left-align text within width)
---@param text string
---@param width integer
---@return string
function M.pad_right(text, width)
local pad = width - #text
if pad <= 0 then
return text
end
return text .. string.rep(' ', pad)
end
---Pad text on the left (right-align text within width)
---@param text string
---@param width integer
---@return string
function M.pad_left(text, width)
local pad = width - #text
if pad <= 0 then
return text
end
return string.rep(' ', pad) .. text
end
---Center text within width
---@param text string
---@param width integer
---@return string
function M.center(text, width)
local pad = width - #text
if pad <= 0 then
return text
end
local left = math.ceil(pad / 2)
return string.rep(' ', left) .. text .. string.rep(' ', pad - left)
end
---Default verdict formatter for I/O view
---@param data VerdictFormatData
---@return VerdictFormatResult
function M.default_verdict_formatter(data)
local time_actual = string.format('%.2f', data.time_ms)
local time_limit = tostring(data.time_limit_ms)
local mem_actual = string.format('%.0f', data.memory_mb)
local mem_limit = string.format('%.0f', data.memory_limit_mb)
local exit_str = data.signal and string.format('%d (%s)', data.exit_code, data.signal)
or tostring(data.exit_code)
local time_actual_w = data.time_actual_width or 6
local time_limit_w = data.time_limit_width or 4
local mem_actual_w = data.mem_actual_width or 3
local mem_limit_w = data.mem_limit_width or 3
local test_num_part = 'Test ' .. data.index .. ':'
local status_part = M.pad_right(data.status.text, 3)
local time_part = M.pad_left(time_actual, time_actual_w)
.. '/'
.. M.pad_left(time_limit, time_limit_w)
.. ' ms'
local mem_part = M.pad_left(mem_actual, mem_actual_w)
.. '/'
.. M.pad_left(mem_limit, mem_limit_w)
.. ' MB'
local exit_part = 'exit: ' .. exit_str
local line = test_num_part
.. ' '
.. status_part
.. ' | '
.. time_part
.. ' | '
.. mem_part
.. ' | '
.. exit_part
local highlights = {}
local status_pos = line:find(data.status.text, 1, true)
if status_pos then
table.insert(highlights, {
col_start = status_pos - 1,
col_end = status_pos - 1 + #data.status.text,
group = data.status.highlight_group,
})
end
return { line = line, highlights = highlights }
end
return M

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,12 @@
local M = {}
local config = nil
function M.set_config(user_config)
config = user_config
end
function M.log(msg, level, override)
local debug = require('cp.config').get_config().debug or false
level = level or vim.log.levels.INFO
if not config or config.debug or level >= vim.log.levels.WARN or override then
vim.notify(('[cp.nvim]: %s'):format(msg), level)
if level >= vim.log.levels.WARN or override or debug then
vim.schedule(function()
vim.notify(('[cp.nvim]: %s'):format(msg), level)
end)
end
end

View file

@ -1,57 +1,16 @@
local picker_utils = require('cp.pickers')
local function problem_picker(platform, contest_id)
local M = {}
local function contest_picker(platform, refresh, language)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
local fzf = require('fzf-lua')
local problems = picker_utils.get_problems_for_contest(platform, contest_id)
local contests = picker_utils.get_platform_contests(platform, refresh)
if #problems == 0 then
if vim.tbl_isempty(contests) then
vim.notify(
('No problems found for contest: %s %s'):format(platform_display_name, contest_id),
vim.log.levels.WARN
)
return
end
local entries = vim.tbl_map(function(problem)
return problem.display_name
end, problems)
return fzf.fzf_exec(entries, {
prompt = ('Select Problem (%s %s)> '):format(platform_display_name, contest_id),
actions = {
['default'] = function(selected)
if not selected or #selected == 0 then
return
end
local selected_name = selected[1]
local problem = nil
for _, p in ipairs(problems) do
if p.display_name == selected_name then
problem = p
break
end
end
if problem then
picker_utils.setup_problem(platform, contest_id, problem.id)
end
end,
},
})
end
local function contest_picker(platform)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
local fzf = require('fzf-lua')
local contests = picker_utils.get_contests_for_platform(platform)
if #contests == 0 then
vim.notify(
('No contests found for platform: %s'):format(platform_display_name),
("No contests found for platform '%s'"):format(platform_display_name),
vim.log.levels.WARN
)
return
@ -68,7 +27,7 @@ local function contest_picker(platform)
},
actions = {
['default'] = function(selected)
if not selected or #selected == 0 then
if vim.tbl_isempty(selected) then
return
end
@ -82,19 +41,23 @@ local function contest_picker(platform)
end
if contest then
problem_picker(platform, contest.id)
local cp = require('cp')
local fargs = { platform, contest.id }
if language then
table.insert(fargs, '--lang')
table.insert(fargs, language)
end
cp.handle_command({ fargs = fargs })
end
end,
['ctrl-r'] = function()
local cache = require('cp.cache')
cache.clear_contest_list(platform)
contest_picker(platform)
contest_picker(platform, true, language)
end,
},
})
end
local function platform_picker()
function M.pick(language)
local fzf = require('fzf-lua')
local platforms = picker_utils.get_platforms()
local entries = vim.tbl_map(function(platform)
@ -105,7 +68,7 @@ local function platform_picker()
prompt = 'Select Platform> ',
actions = {
['default'] = function(selected)
if not selected or #selected == 0 then
if vim.tbl_isempty(selected) then
return
end
@ -119,13 +82,11 @@ local function platform_picker()
end
if platform then
contest_picker(platform.id)
contest_picker(platform.id, false, language)
end
end,
},
})
end
return {
platform_picker = platform_picker,
}
return M

View file

@ -1,9 +1,9 @@
local M = {}
local cache = require('cp.cache')
local constants = require('cp.constants')
local logger = require('cp.log')
local scrape = require('cp.scrape')
local utils = require('cp.utils')
local scraper = require('cp.scraper')
---@class cp.PlatformItem
---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses")
@ -19,155 +19,50 @@ local utils = require('cp.utils')
---@field name string Problem name (e.g. "Two Permutations", "Painting Walls")
---@field display_name string Formatted display name for picker
---Get list of available competitive programming platforms
---@return cp.PlatformItem[]
local function get_platforms()
local constants = require('cp.constants')
return vim.tbl_map(function(platform)
return {
id = platform,
display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
}
end, constants.PLATFORMS)
end
---Get list of contests for a specific platform
---@param platform string Platform identifier (e.g. "codeforces", "atcoder")
---@return cp.ContestItem[]
local function get_contests_for_platform(platform)
local contests = {}
cache.load()
local cached_contests = cache.get_contest_list(platform)
if cached_contests then
return cached_contests
end
if not utils.setup_python_env() then
return contests
end
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
logger.log(
('Scraping %s for contests, this may take a few seconds...'):format(platform_display_name),
vim.log.levels.INFO,
true
)
local plugin_path = utils.get_plugin_path()
local cmd = {
'uv',
'run',
'--directory',
plugin_path,
'-m',
'scrapers.' .. platform,
'contests',
}
local result = vim
.system(cmd, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
:wait()
if result.code ~= 0 then
logger.log(
('Failed to get contests for %s: %s'):format(platform, result.stderr or 'unknown error'),
vim.log.levels.ERROR
)
return contests
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok or not data.success then
logger.log(('Failed to parse contest data for %s'):format(platform), vim.log.levels.ERROR)
return contests
end
for _, contest in ipairs(data.contests or {}) do
table.insert(contests, {
id = contest.id,
name = contest.name,
display_name = contest.display_name,
})
end
cache.set_contest_list(platform, contests)
return contests
end
---Get list of problems for a specific contest
---@param platform string Platform identifier
---@param contest_id string Contest identifier
---@return cp.ProblemItem[]
local function get_problems_for_contest(platform, contest_id)
local problems = {}
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if contest_data and contest_data.problems then
for _, problem in ipairs(contest_data.problems) do
table.insert(problems, {
id = problem.id,
name = problem.name,
display_name = problem.name,
function M.get_platforms()
local config = require('cp.config').get_config()
local result = {}
for _, platform in ipairs(constants.PLATFORMS) do
if config.platforms[platform] then
table.insert(result, {
id = platform,
display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
})
end
return problems
end
return result
end
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
logger.log(
('Scraping %s %s for problems, this may take a few seconds...'):format(
platform_display_name,
contest_id
),
vim.log.levels.INFO,
true
)
---@param platform string
---@param refresh? boolean
---@return cp.ContestItem[]
function M.get_platform_contests(platform, refresh)
cache.load()
local picker_contests = cache.get_contest_summaries(platform)
local metadata_result = scrape.scrape_contest_metadata(platform, contest_id)
if not metadata_result.success then
if refresh or vim.tbl_isempty(picker_contests) then
logger.log(
('Failed to get problems for %s %s: %s'):format(
platform,
contest_id,
metadata_result.error or 'unknown error'
),
vim.log.levels.ERROR
('Loading %s contests...'):format(constants.PLATFORM_DISPLAY_NAMES[platform]),
vim.log.levels.INFO,
true
)
local contests = scraper.scrape_contest_list(platform)
cache.set_contest_summaries(platform, contests)
picker_contests = cache.get_contest_summaries(platform)
logger.log(
('Loaded %d %s contests.'):format(
#picker_contests,
constants.PLATFORM_DISPLAY_NAMES[platform]
),
vim.log.levels.INFO,
true
)
return problems
end
for _, problem in ipairs(metadata_result.problems or {}) do
table.insert(problems, {
id = problem.id,
name = problem.name,
display_name = problem.name,
})
end
return problems
return picker_contests
end
---Set up a specific problem by calling the main CP handler
---@param platform string Platform identifier
---@param contest_id string Contest identifier
---@param problem_id string Problem identifier
local function setup_problem(platform, contest_id, problem_id)
vim.schedule(function()
local cp = require('cp')
cp.handle_command({ fargs = { platform, contest_id, problem_id } })
end)
end
M.get_platforms = get_platforms
M.get_contests_for_platform = get_contests_for_platform
M.get_problems_for_contest = get_problems_for_contest
M.setup_problem = setup_problem
return M

View file

@ -6,54 +6,14 @@ local actions = require('telescope.actions')
local picker_utils = require('cp.pickers')
local function problem_picker(opts, platform, contest_id)
local M = {}
local function contest_picker(opts, platform, refresh, language)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
local problems = picker_utils.get_problems_for_contest(platform, contest_id)
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
local contests = picker_utils.get_platform_contests(platform, refresh)
if #problems == 0 then
vim.notify(
('No problems found for contest: %s %s'):format(platform_display_name, contest_id),
vim.log.levels.WARN
)
return
end
pickers
.new(opts, {
prompt_title = ('Select Problem (%s %s)'):format(platform_display_name, contest_id),
finder = finders.new_table({
results = problems,
entry_maker = function(entry)
return {
value = entry,
display = entry.display_name,
ordinal = entry.display_name,
}
end,
}),
sorter = conf.generic_sorter(opts),
attach_mappings = function(prompt_bufnr)
actions.select_default:replace(function()
local selection = action_state.get_selected_entry()
actions.close(prompt_bufnr)
if selection then
picker_utils.setup_problem(platform, contest_id, selection.value.id)
end
end)
return true
end,
})
:find()
end
local function contest_picker(opts, platform)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
local contests = picker_utils.get_contests_for_platform(platform)
if #contests == 0 then
if vim.tbl_isempty(contests) then
vim.notify(
('No contests found for platform: %s'):format(platform_display_name),
vim.log.levels.WARN
@ -64,7 +24,7 @@ local function contest_picker(opts, platform)
pickers
.new(opts, {
prompt_title = ('Select Contest (%s)'):format(platform_display_name),
results_title = '<C-r> refresh',
results_title = '<c-r> refresh',
finder = finders.new_table({
results = contests,
entry_maker = function(entry)
@ -82,15 +42,19 @@ local function contest_picker(opts, platform)
actions.close(prompt_bufnr)
if selection then
problem_picker(opts, platform, selection.value.id)
local cp = require('cp')
local fargs = { platform, selection.value.id }
if language then
table.insert(fargs, '--lang')
table.insert(fargs, language)
end
cp.handle_command({ fargs = fargs })
end
end)
map('i', '<C-r>', function()
local cache = require('cp.cache')
cache.clear_contest_list(platform)
map('i', '<c-r>', function()
actions.close(prompt_bufnr)
contest_picker(opts, platform)
contest_picker(opts, platform, true, language)
end)
return true
@ -99,9 +63,8 @@ local function contest_picker(opts, platform)
:find()
end
local function platform_picker(opts)
opts = opts or {}
function M.pick(language)
local opts = {}
local platforms = picker_utils.get_platforms()
pickers
@ -124,7 +87,7 @@ local function platform_picker(opts)
actions.close(prompt_bufnr)
if selection then
contest_picker(opts, selection.value.id)
contest_picker(opts, selection.value.id, false, language)
end
end)
return true
@ -133,6 +96,4 @@ local function platform_picker(opts)
:find()
end
return {
platform_picker = platform_picker,
}
return M

View file

@ -1,68 +0,0 @@
---@class ProblemContext
---@field contest string Contest name (e.g. "atcoder", "codeforces")
---@field contest_id string Contest ID (e.g. "abc123", "1933")
---@field problem_id? string Problem ID for AtCoder/Codeforces (e.g. "a", "b")
---@field source_file string Source filename (e.g. "abc123a.cpp")
---@field binary_file string Binary output path (e.g. "build/abc123a.run")
---@field input_file string Input test file path (e.g. "io/abc123a.in")
---@field output_file string Output file path (e.g. "io/abc123a.out")
---@field expected_file string Expected output path (e.g. "io/abc123a.expected")
---@field problem_name string Canonical problem identifier (e.g. "abc123a")
local M = {}
---@param contest string
---@param contest_id string
---@param problem_id? string
---@param config cp.Config
---@param language? string
---@return ProblemContext
function M.create_context(contest, contest_id, problem_id, config, language)
vim.validate({
contest = { contest, 'string' },
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
config = { config, 'table' },
language = { language, { 'string', 'nil' }, true },
})
local contest_config = config.contests[contest]
if not contest_config then
error(("No contest config found for '%s'"):format(contest))
end
local target_language = language or contest_config.default_language
local language_config = contest_config[target_language]
if not language_config then
error(("No language config found for '%s' in contest '%s'"):format(target_language, contest))
end
if not language_config.extension then
error(
("No extension configured for language '%s' in contest '%s'"):format(target_language, contest)
)
end
local base_name
if config.filename then
base_name = config.filename(contest, contest_id, problem_id, config, language)
else
local default_filename = require('cp.config').default_filename
base_name = default_filename(contest_id, problem_id)
end
local source_file = base_name .. '.' .. language_config.extension
return {
contest = contest,
contest_id = contest_id,
problem_id = problem_id,
source_file = source_file,
binary_file = ('build/%s.run'):format(base_name),
input_file = ('io/%s.cpin'):format(base_name),
output_file = ('io/%s.cpout'):format(base_name),
expected_file = ('io/%s.expected'):format(base_name),
problem_name = base_name,
}
end
return M

30
lua/cp/restore.lua Normal file
View file

@ -0,0 +1,30 @@
local M = {}
local cache = require('cp.cache')
local logger = require('cp.log')
local state = require('cp.state')
---@return boolean
function M.restore_from_current_file()
cache.load()
local current_file = (vim.uv.fs_realpath(vim.fn.expand('%:p')) or vim.fn.expand('%:p'))
local file_state = cache.get_file_state(current_file)
if not file_state then
logger.log('No cached state found for current file.', vim.log.levels.ERROR)
return false
end
local setup = require('cp.setup')
state.set_problem_id(file_state.problem_id)
setup.setup_contest(
file_state.platform,
file_state.contest_id,
file_state.problem_id,
file_state.language
)
return true
end
return M

View file

@ -1,310 +1,213 @@
---@class ExecuteResult
---@field stdout string
---@field stderr string
---@field code integer
---@field time_ms number
---@field timed_out boolean
---@field tled boolean
---@field mled boolean
---@field peak_mb number
---@field signal string|nil
---@class SubstitutableCommand
---@field source string substituted via '{source}'
---@field binary string substitued via '{binary}'
local M = {}
local logger = require('cp.log')
local constants = require('cp.constants')
local filetype_to_language = constants.filetype_to_language
---@param source_file string
---@param contest_config table
---@return string
local function get_language_from_file(source_file, contest_config)
vim.validate({
source_file = { source_file, 'string' },
contest_config = { contest_config, 'table' },
})
local extension = vim.fn.fnamemodify(source_file, ':e')
local language = filetype_to_language[extension] or contest_config.default_language
return language
end
local logger = require('cp.log')
local utils = require('cp.utils')
---@param cmd_template string[]
---@param substitutions table<string, string>
---@return string[]
---@param substitutions SubstitutableCommand
---@return string[] string normalized with substitutions
local function substitute_template(cmd_template, substitutions)
vim.validate({
cmd_template = { cmd_template, 'table' },
substitutions = { substitutions, 'table' },
})
local result = {}
local out = {}
for _, arg in ipairs(cmd_template) do
local substituted = arg
for key, value in pairs(substitutions) do
substituted = substituted:gsub('{' .. key .. '}', value)
if arg == '{source}' and substitutions.source then
table.insert(out, substitutions.source)
elseif arg == '{binary}' and substitutions.binary then
table.insert(out, substitutions.binary)
else
table.insert(out, arg)
end
table.insert(result, substituted)
end
return result
return out
end
---@param cmd_template string[]
---@param executable? string
---@param substitutions table<string, string>
---@return string[]
local function build_command(cmd_template, executable, substitutions)
vim.validate({
cmd_template = { cmd_template, 'table' },
executable = { executable, { 'string', 'nil' }, true },
substitutions = { substitutions, 'table' },
})
local cmd = substitute_template(cmd_template, substitutions)
if executable then
table.insert(cmd, 1, executable)
end
return cmd
function M.build_command(cmd_template, substitutions)
return substitute_template(cmd_template, substitutions)
end
---@param language_config table
---@param substitutions table<string, string>
---@return {code: integer, stdout: string, stderr: string}
function M.compile_generic(language_config, substitutions)
vim.validate({
language_config = { language_config, 'table' },
substitutions = { substitutions, 'table' },
})
---@param compile_cmd string[]
---@param substitutions SubstitutableCommand
---@param on_complete fun(r: {code: integer, stdout: string})
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)
if not language_config.compile then
logger.log('no compilation step required')
return { code = 0, stderr = '' }
end
local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { text = false }, function(r)
local dt = (vim.uv.hrtime() - t0) / 1e6
local ansi = require('cp.ui.ansi')
r.stdout = ansi.bytes_to_string(r.stdout or '')
local compile_cmd = substitute_template(language_config.compile, substitutions)
local redirected_cmd = vim.deepcopy(compile_cmd)
if #redirected_cmd > 0 then
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
end
local start_time = vim.uv.hrtime()
local result = vim
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, { text = false })
:wait()
local compile_time = (vim.uv.hrtime() - start_time) / 1000000
local ansi = require('cp.ui.ansi')
result.stdout = ansi.bytes_to_string(result.stdout or '')
result.stderr = ansi.bytes_to_string(result.stderr or '')
if result.code == 0 then
logger.log(('compilation successful (%.1fms)'):format(compile_time), vim.log.levels.INFO)
else
logger.log(('compilation failed (%.1fms)'):format(compile_time))
end
return result
end
---@param cmd string[]
---@param input_data string
---@param timeout_ms number
---@return ExecuteResult
local function execute_command(cmd, input_data, timeout_ms)
vim.validate({
cmd = { cmd, 'table' },
input_data = { input_data, 'string' },
timeout_ms = { timeout_ms, 'number' },
})
local redirected_cmd = vim.deepcopy(cmd)
if #redirected_cmd > 0 then
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
end
local start_time = vim.uv.hrtime()
local result = vim
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, {
stdin = input_data,
timeout = timeout_ms,
text = true,
})
:wait()
local end_time = vim.uv.hrtime()
local execution_time = (end_time - start_time) / 1000000
local actual_code = result.code or 0
if result.code == 124 then
logger.log(('execution timed out after %.1fms'):format(execution_time), vim.log.levels.WARN)
elseif actual_code ~= 0 then
logger.log(
('execution failed (exit code %d, %.1fms)'):format(actual_code, execution_time),
vim.log.levels.WARN
)
else
logger.log(('execution successful (%.1fms)'):format(execution_time))
end
return {
stdout = result.stdout or '',
stderr = result.stderr or '',
code = actual_code,
time_ms = execution_time,
timed_out = result.code == 124,
}
end
---@param exec_result ExecuteResult
---@param expected_file string
---@param is_debug boolean
---@return string
local function format_output(exec_result, expected_file, is_debug)
vim.validate({
exec_result = { exec_result, 'table' },
expected_file = { expected_file, 'string' },
is_debug = { is_debug, 'boolean' },
})
local output_lines = { exec_result.stdout }
local metadata_lines = {}
if exec_result.timed_out then
table.insert(metadata_lines, '[code]: 124 (TIMEOUT)')
elseif exec_result.code >= 128 then
local signal_name = constants.signal_codes[exec_result.code] or 'SIGNAL'
table.insert(metadata_lines, ('[code]: %d (%s)'):format(exec_result.code, signal_name))
else
table.insert(metadata_lines, ('[code]: %d'):format(exec_result.code))
end
table.insert(metadata_lines, ('[time]: %.2f ms'):format(exec_result.time_ms))
table.insert(metadata_lines, ('[debug]: %s'):format(is_debug and 'true' or 'false'))
if vim.fn.filereadable(expected_file) == 1 and exec_result.code == 0 then
local expected_content = vim.fn.readfile(expected_file)
local actual_lines = vim.split(exec_result.stdout, '\n')
while #actual_lines > 0 and actual_lines[#actual_lines] == '' do
table.remove(actual_lines)
if r.code == 0 then
logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO)
else
logger.log(('Compilation failed in %.1fms.'):format(dt))
end
local ok = #actual_lines == #expected_content
if ok then
for i, line in ipairs(actual_lines) do
if line ~= expected_content[i] then
ok = false
break
end
vim.schedule(function()
on_complete(r)
end)
end)
end
local function parse_and_strip_time_v(output)
local s = output or ''
local last_i, from = nil, 1
while true do
local i = string.find(s, 'Command being timed:', from, true)
if not i then
break
end
last_i, from = i, i + 1
end
if not last_i then
return s, 0
end
local tab_before_marker = s:find('\t[^\t]*Command being timed:', 1)
local k
if tab_before_marker then
k = tab_before_marker - 1
else
k = last_i - 1
while k >= 1 do
local ch = s:sub(k, k)
if ch == '\n' then
break
end
k = k - 1
end
table.insert(metadata_lines, ('[ok]: %s'):format(ok and 'true' or 'false'))
end
return table.concat(output_lines, '') .. '\n' .. table.concat(metadata_lines, '\n')
local head = s:sub(1, k)
local tail = s:sub(last_i)
local peak_kb = 0.0
for line in tail:gmatch('[^\n]+') do
local kb = line:match('Maximum resident set size %(kbytes%):%s*(%d+)')
if kb then
peak_kb = tonumber(kb) or 0
end
end
local peak_mb = peak_kb / 1024.0
return head, peak_mb
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@param is_debug? boolean
---@return {success: boolean, output: string?}
function M.compile_problem(ctx, contest_config, is_debug)
vim.validate({
ctx = { ctx, 'table' },
contest_config = { contest_config, 'table' },
})
---@param on_complete fun(result: ExecuteResult)
function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete)
local time_bin = utils.time_path()
local timeout_bin = utils.timeout_path()
local language = get_language_from_file(ctx.source_file, contest_config)
local language_config = contest_config[language]
if not language_config then
logger.log('No configuration for language: ' .. language, vim.log.levels.ERROR)
return { success = false, output = 'No configuration for language: ' .. language }
end
local substitutions = {
source = ctx.source_file,
binary = ctx.binary_file,
version = tostring(language_config.version),
local prog = table.concat(cmd, ' ')
local pre = {
('ulimit -v %d'):format(memory_mb * 1024),
}
local prefix = table.concat(pre, '; ') .. '; '
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 compile_cmd = (is_debug and language_config.debug) and language_config.debug
or language_config.compile
if compile_cmd then
language_config.compile = compile_cmd
local compile_result = M.compile_generic(language_config, substitutions)
if compile_result.code ~= 0 then
return { success = false, output = compile_result.stdout or 'unknown error' }
local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)
local dt = (vim.uv.hrtime() - t0) / 1e6
local code = r.code or 0
local raw = r.stdout or ''
local cleaned, peak_mb = parse_and_strip_time_v(raw)
local tled = code == 124
local signal = nil
if code >= 128 then
signal = constants.signal_codes[code]
end
logger.log(
('compilation successful (%s)'):format(is_debug and 'debug mode' or 'test mode'),
vim.log.levels.INFO
)
end
return { success = true, output = nil }
local lower = (cleaned or ''):lower()
local oom_hint = lower:find('std::bad_alloc', 1, true)
or lower:find('cannot allocate memory', 1, true)
or lower:find('out of memory', 1, true)
or lower:find('oom', 1, true)
or lower:find('enomem', 1, true)
local near_cap = peak_mb >= (0.90 * memory_mb)
local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint ~= nil and not tled)
if tled then
logger.log(('Execution timed out in %.1fms.'):format(dt))
elseif mled then
logger.log(('Execution memory limit exceeded in %.1fms.'):format(dt))
elseif code ~= 0 then
logger.log(('Execution failed in %.1fms (exit code %d).'):format(dt, code))
else
logger.log(('Execution successful in %.1fms.'):format(dt))
end
vim.schedule(function()
on_complete({
stdout = cleaned,
code = code,
time_ms = dt,
tled = tled,
mled = mled,
peak_mb = peak_mb,
signal = signal,
})
end)
end)
end
function M.run_problem(ctx, contest_config, is_debug)
vim.validate({
ctx = { ctx, 'table' },
contest_config = { contest_config, 'table' },
is_debug = { is_debug, 'boolean' },
})
---@param debug boolean?
---@param on_complete fun(result: {success: boolean, output: string?})
function M.compile_problem(debug, on_complete)
local state = require('cp.state')
local config = require('cp.config').get_config()
local platform = state.get_platform()
local language = state.get_language() or config.platforms[platform].default_language
local eff = config.runtime.effective[platform][language]
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
local source_file = state.get_source_file()
if source_file then
local buf = vim.fn.bufnr(source_file)
if buf ~= -1 and vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].modified then
vim.api.nvim_buf_call(buf, function()
vim.cmd.write({ mods = { silent = true, noautocmd = true } })
end)
end
end
local language = get_language_from_file(ctx.source_file, contest_config)
local language_config = contest_config[language]
local compile_config = (debug and eff.commands.debug) or eff.commands.build
if not language_config then
vim.fn.writefile({ 'Error: No configuration for language: ' .. language }, ctx.output_file)
if not compile_config then
on_complete({ success = true, output = nil })
return
end
local substitutions = {
source = ctx.source_file,
binary = ctx.binary_file,
version = tostring(language_config.version),
}
require('cp.utils').ensure_dirs()
local compile_cmd = is_debug and language_config.debug or language_config.compile
if compile_cmd then
local compile_result = M.compile_generic(language_config, substitutions)
if compile_result.code ~= 0 then
vim.fn.writefile({ compile_result.stderr }, ctx.output_file)
return
local binary = debug and state.get_debug_file() or state.get_binary_file()
local substitutions = { source = state.get_source_file(), binary = binary }
M.compile(compile_config, substitutions, function(r)
if r.code ~= 0 then
on_complete({ success = false, output = r.stdout or 'unknown error' })
else
on_complete({ success = true, output = nil })
end
end
local input_data = ''
if vim.fn.filereadable(ctx.input_file) == 1 then
input_data = table.concat(vim.fn.readfile(ctx.input_file), '\n') .. '\n'
end
local cache = require('cp.cache')
cache.load()
local timeout_ms, _ = cache.get_constraints(ctx.contest, ctx.contest_id, ctx.problem_id)
timeout_ms = timeout_ms or 2000
local run_cmd = build_command(language_config.test, language_config.executable, substitutions)
local exec_result = execute_command(run_cmd, input_data, timeout_ms)
local formatted_output = format_output(exec_result, ctx.expected_file, is_debug)
local output_buf = vim.fn.bufnr(ctx.output_file)
if output_buf ~= -1 then
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = output_buf })
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = output_buf })
vim.api.nvim_set_option_value('readonly', false, { buf = output_buf })
vim.api.nvim_set_option_value('modifiable', true, { buf = output_buf })
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.split(formatted_output, '\n'))
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = output_buf })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = output_buf })
vim.api.nvim_buf_call(output_buf, function()
vim.cmd.write()
end)
else
vim.fn.writefile(vim.split(formatted_output, '\n'), ctx.output_file)
end
end)
end
return M

View file

@ -1,10 +1,10 @@
---@class TestCase
---@class RanTestCase
---@field index number
---@field input string
---@field expected string
---@field status "pending"|"pass"|"fail"|"running"|"timeout"
---@field status "pending"|"pass"|"fail"|"running"|"tle"|"mle"
---@field actual string?
---@field actual_highlights table[]?
---@field actual_highlights? Highlight[]
---@field time_ms number?
---@field error string?
---@field stderr string?
@ -12,14 +12,16 @@
---@field code number?
---@field ok boolean?
---@field signal string?
---@field timed_out boolean?
---@field tled boolean?
---@field mled boolean?
---@field rss_mb number
---@class ProblemConstraints
---@field timeout_ms number
---@field memory_mb number
---@class RunPanelState
---@field test_cases TestCase[]
---@class PanelState
---@field test_cases RanTestCase[]
---@field current_index number
---@field buffer number?
---@field namespace number?
@ -28,11 +30,15 @@
---@field constraints ProblemConstraints?
local M = {}
local cache = require('cp.cache')
local config = require('cp.config').get_config()
local constants = require('cp.constants')
local execute = require('cp.runner.execute')
local logger = require('cp.log')
local state = require('cp.state')
---@type RunPanelState
local run_panel_state = {
---@type PanelState
local panel_state = {
test_cases = {},
current_index = 1,
buffer = nil,
@ -42,354 +48,290 @@ local run_panel_state = {
constraints = nil,
}
---@param index number
---@param input string
---@param expected string
---@return TestCase
local function create_test_case(index, input, expected)
return {
index = index,
input = input,
expected = expected,
status = 'pending',
actual = nil,
time_ms = nil,
error = nil,
selected = true,
}
end
---@param platform string
---@param contest_id string
---@param problem_id string?
---@return TestCase[]
local function parse_test_cases_from_cache(platform, contest_id, problem_id)
local cache = require('cp.cache')
cache.load()
local cached_test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if not cached_test_cases or #cached_test_cases == 0 then
return {}
end
local test_cases = {}
for i, test_case in ipairs(cached_test_cases) do
local index = test_case.index or i
local expected = test_case.expected or test_case.output or ''
table.insert(test_cases, create_test_case(index, test_case.input, expected))
end
return test_cases
end
---@param input_file string
---@param expected_file string
---@return TestCase[]
local function parse_test_cases_from_files(input_file, expected_file)
if vim.fn.filereadable(input_file) == 0 or vim.fn.filereadable(expected_file) == 0 then
return {}
end
local base_name = vim.fn.fnamemodify(input_file, ':r')
local test_cases = {}
local i = 1
while true do
local individual_input_file = base_name .. '.' .. i .. '.cpin'
local individual_expected_file = base_name .. '.' .. i .. '.cpout'
if
vim.fn.filereadable(individual_input_file) == 1
and vim.fn.filereadable(individual_expected_file) == 1
then
local input_content = table.concat(vim.fn.readfile(individual_input_file), '\n')
local expected_content = table.concat(vim.fn.readfile(individual_expected_file), '\n')
table.insert(test_cases, create_test_case(i, input_content, expected_content))
i = i + 1
else
break
end
end
if #test_cases == 0 then
local input_content = table.concat(vim.fn.readfile(input_file), '\n')
local expected_content = table.concat(vim.fn.readfile(expected_file), '\n')
return { create_test_case(1, input_content, expected_content) }
end
return test_cases
end
---@param platform string
---@param contest_id string
---@param problem_id string?
---@return ProblemConstraints?
---@param problem_id string|nil
---@return ProblemConstraints|nil
local function load_constraints_from_cache(platform, contest_id, problem_id)
local cache = require('cp.cache')
cache.load()
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
if timeout_ms and memory_mb then
return {
timeout_ms = timeout_ms,
memory_mb = memory_mb,
}
return { timeout_ms = timeout_ms, memory_mb = memory_mb }
end
return nil
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@param test_case TestCase
---@return table
local function run_single_test_case(ctx, contest_config, cp_config, test_case)
local language = vim.fn.fnamemodify(ctx.source_file, ':e')
local language_name = constants.filetype_to_language[language] or contest_config.default_language
local language_config = contest_config[language_name]
--- Normalize raw problem output to a "canonical" version
--- Usually, most contests ignore leading/trailing whitespace and empty lines
---@param lines string
local function normalize_lines(lines)
local normalized = {}
for _, line in
ipairs(vim.tbl_values(vim.split(((lines or ''):gsub('\r', '')), '\n', { plain = true })))
do
local trimmed_line = vim.trim(line)
if trimmed_line ~= '' then
table.insert(normalized, trimmed_line)
end
end
return table.concat(normalized, '\n')
end
if not language_config then
return {
status = 'fail',
actual = '',
error = 'No language configuration',
time_ms = 0,
---@param test_cases TestCase[]
---@return RanTestCase[]
local function create_sentinal_panel_data(test_cases)
local out = {}
for i, tc in ipairs(test_cases) do
out[i] = {
index = tc.index or i,
input = tc.input or '',
expected = tc.expected or '',
status = 'pending',
selected = false,
}
end
return out
end
local function substitute_template(cmd_template, substitutions)
local result = {}
for _, arg in ipairs(cmd_template) do
local substituted = arg
for key, value in pairs(substitutions) do
substituted = substituted:gsub('{' .. key .. '}', value)
end
table.insert(result, substituted)
end
return result
end
---@param cmd string[]
---@return string[]
local function build_command(cmd, substitutions)
return execute.build_command(cmd, substitutions)
end
local function build_command(cmd_template, executable, substitutions)
local cmd = substitute_template(cmd_template, substitutions)
if executable then
table.insert(cmd, 1, executable)
end
return cmd
end
---@param test_case RanTestCase
---@param debug boolean?
---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string?, tled: boolean, mled: boolean, rss_mb: number })
local function run_single_test_case(test_case, debug, on_complete)
local source_file = state.get_source_file()
local substitutions = {
source = ctx.source_file,
binary = ctx.binary_file,
version = tostring(language_config.version or ''),
}
local binary_file = debug and state.get_debug_file() or state.get_binary_file()
local substitutions = { source = source_file, binary = binary_file }
if language_config.compile and vim.fn.filereadable(ctx.binary_file) == 0 then
logger.log('binary not found, compiling first...')
local compile_cmd = substitute_template(language_config.compile, substitutions)
local redirected_cmd = vim.deepcopy(compile_cmd)
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
local compile_result = vim
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, { text = false })
:wait()
local platform_config = config.platforms[state.get_platform() or '']
local language = state.get_language() or platform_config.default_language
local eff = config.runtime.effective[state.get_platform() or ''][language]
local run_template = eff and eff.commands and eff.commands.run or {}
local cmd = build_command(run_template, substitutions)
local stdin_content = (test_case.input or '') .. '\n'
local timeout_ms = (panel_state.constraints and panel_state.constraints.timeout_ms) or 0
local memory_mb = panel_state.constraints and panel_state.constraints.memory_mb or 0
execute.run(cmd, stdin_content, timeout_ms, memory_mb, function(r)
local ansi = require('cp.ui.ansi')
compile_result.stdout = ansi.bytes_to_string(compile_result.stdout or '')
compile_result.stderr = ansi.bytes_to_string(compile_result.stderr or '')
if compile_result.code ~= 0 then
return {
status = 'fail',
actual = '',
error = 'Compilation failed: ' .. (compile_result.stdout or 'Unknown error'),
stderr = compile_result.stdout or '',
time_ms = 0,
code = compile_result.code,
ok = false,
signal = nil,
timed_out = false,
}
local out = r.stdout or ''
local highlights = {}
if out ~= '' then
if config.ui.ansi then
local parsed = ansi.parse_ansi_text(out)
out = table.concat(parsed.lines, '\n')
highlights = parsed.highlights
else
out = out:gsub('\027%[[%d;]*[a-zA-Z]', '')
end
end
end
local run_cmd = build_command(language_config.test, language_config.executable, substitutions)
local max_lines = config.ui.panel.max_output_lines
local lines = vim.split(out, '\n')
if #lines > max_lines then
local trimmed = {}
for i = 1, max_lines do
table.insert(trimmed, lines[i])
end
table.insert(trimmed, string.format('... (output trimmed after %d lines)', max_lines))
out = table.concat(trimmed, '\n')
end
local stdin_content = test_case.input .. '\n'
local expected = test_case.expected or ''
local ok = normalize_lines(out) == normalize_lines(expected)
local start_time = vim.uv.hrtime()
local timeout_ms = run_panel_state.constraints and run_panel_state.constraints.timeout_ms or 2000
local signal = r.signal
if not signal and r.code and r.code >= 128 then
signal = constants.signal_codes[r.code]
end
if not run_panel_state.constraints then
logger.log('no problem constraints available, using default 2000ms timeout')
end
local redirected_run_cmd = vim.deepcopy(run_cmd)
redirected_run_cmd[#redirected_run_cmd] = redirected_run_cmd[#redirected_run_cmd] .. ' 2>&1'
local result = vim
.system({ 'sh', '-c', table.concat(redirected_run_cmd, ' ') }, {
stdin = stdin_content,
timeout = timeout_ms,
text = false,
})
:wait()
local execution_time = (vim.uv.hrtime() - start_time) / 1000000
local ansi = require('cp.ui.ansi')
local stdout_str = ansi.bytes_to_string(result.stdout or '')
local actual_output = stdout_str:gsub('\n$', '')
local actual_highlights = {}
if actual_output ~= '' then
if cp_config.run_panel.ansi then
local parsed = ansi.parse_ansi_text(actual_output)
actual_output = table.concat(parsed.lines, '\n')
actual_highlights = parsed.highlights
local status
if r.tled then
status = 'tle'
elseif r.mled then
status = 'mle'
elseif ok then
status = 'pass'
else
actual_output = actual_output:gsub('\027%[[%d;]*[a-zA-Z]', '')
status = 'fail'
end
on_complete({
status = status,
actual = out,
actual_highlights = highlights,
error = (r.code ~= 0 and not ok) and out or '',
stderr = '',
time_ms = r.time_ms,
code = r.code,
ok = ok,
signal = signal,
tled = r.tled or false,
mled = r.mled or false,
rss_mb = r.peak_mb or 0,
})
end)
end
---@return boolean
function M.load_test_cases()
local tcs = cache.get_test_cases(
state.get_platform() or '',
state.get_contest_id() or '',
state.get_problem_id()
)
panel_state.test_cases = create_sentinal_panel_data(tcs)
panel_state.current_index = 1
panel_state.constraints = load_constraints_from_cache(
state.get_platform() or '',
state.get_contest_id() or '',
state.get_problem_id()
)
logger.log(('Loaded %d test case(s)'):format(#tcs), vim.log.levels.INFO)
return #tcs > 0
end
---@param debug boolean?
---@param on_complete fun(result: RanTestCase?)
function M.run_combined_test(debug, on_complete)
local combined = cache.get_combined_test(
state.get_platform() or '',
state.get_contest_id() or '',
state.get_problem_id()
)
if not combined then
logger.log('No combined test found', vim.log.levels.ERROR)
on_complete(nil)
return
end
local max_lines = cp_config.run_panel.max_output_lines
local output_lines = vim.split(actual_output, '\n')
if #output_lines > max_lines then
local trimmed_lines = {}
for i = 1, max_lines do
table.insert(trimmed_lines, output_lines[i])
end
table.insert(trimmed_lines, string.format('... (output trimmed after %d lines)', max_lines))
actual_output = table.concat(trimmed_lines, '\n')
end
local expected_output = test_case.expected:gsub('\n$', '')
local ok = actual_output == expected_output
local status
local timed_out = result.code == 143 or result.code == 124
if timed_out then
status = 'timeout'
elseif result.code == 0 and ok then
status = 'pass'
else
status = 'fail'
end
local signal = nil
if result.code >= 128 then
signal = constants.signal_codes[result.code]
end
return {
status = status,
actual = actual_output,
actual_highlights = actual_highlights,
error = result.code ~= 0 and actual_output or nil,
stderr = '',
time_ms = execution_time,
code = result.code,
ok = ok,
signal = signal,
timed_out = timed_out,
local ran_test = {
index = 1,
input = combined.input,
expected = combined.expected,
status = 'running',
actual = nil,
time_ms = nil,
code = nil,
ok = nil,
signal = nil,
tled = false,
mled = false,
rss_mb = 0,
selected = true,
}
run_single_test_case(ran_test, debug, function(result)
on_complete(result)
end)
end
---@param ctx ProblemContext
---@param state table
---@return boolean
function M.load_test_cases(ctx, state)
local test_cases = parse_test_cases_from_cache(state.platform, state.contest_id, state.problem_id)
if #test_cases == 0 then
test_cases = parse_test_cases_from_files(ctx.input_file, ctx.expected_file)
end
run_panel_state.test_cases = test_cases
run_panel_state.current_index = 1
run_panel_state.constraints =
load_constraints_from_cache(state.platform, state.contest_id, state.problem_id)
local constraint_info = run_panel_state.constraints
and string.format(
' with %dms/%dMB limits',
run_panel_state.constraints.timeout_ms,
run_panel_state.constraints.memory_mb
)
or ''
logger.log(('loaded %d test case(s)%s'):format(#test_cases, constraint_info), vim.log.levels.INFO)
return #test_cases > 0
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@param index number
---@return boolean
function M.run_test_case(ctx, contest_config, cp_config, index)
local test_case = run_panel_state.test_cases[index]
if not test_case then
return false
---@param debug boolean?
---@param on_complete fun(success: boolean)
function M.run_test_case(index, debug, on_complete)
local tc = panel_state.test_cases[index]
if not tc then
on_complete(false)
return
end
test_case.status = 'running'
tc.status = 'running'
run_single_test_case(tc, debug, function(r)
tc.status = r.status
tc.actual = r.actual
tc.actual_highlights = r.actual_highlights
tc.error = r.error
tc.stderr = r.stderr
tc.time_ms = r.time_ms
tc.code = r.code
tc.ok = r.ok
tc.signal = r.signal
tc.tled = r.tled
tc.mled = r.mled
tc.rss_mb = r.rss_mb
local result = run_single_test_case(ctx, contest_config, cp_config, test_case)
test_case.status = result.status
test_case.actual = result.actual
test_case.actual_highlights = result.actual_highlights
test_case.error = result.error
test_case.stderr = result.stderr
test_case.time_ms = result.time_ms
test_case.code = result.code
test_case.ok = result.ok
test_case.signal = result.signal
test_case.timed_out = result.timed_out
return true
on_complete(true)
end)
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@return TestCase[]
function M.run_all_test_cases(ctx, contest_config, cp_config)
local results = {}
for i, _ in ipairs(run_panel_state.test_cases) do
M.run_test_case(ctx, contest_config, cp_config, i)
table.insert(results, run_panel_state.test_cases[i])
---@param indices? integer[]
---@param debug boolean?
---@param on_each? fun(index: integer, total: integer)
---@param on_done fun(results: RanTestCase[])
function M.run_all_test_cases(indices, debug, on_each, on_done)
local to_run = indices
if not to_run then
to_run = {}
for i = 1, #panel_state.test_cases do
to_run[i] = i
end
end
return results
local function run_next(pos)
if pos > #to_run then
logger.log(
('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run),
vim.log.levels.INFO,
true
)
on_done(panel_state.test_cases)
return
end
M.run_test_case(to_run[pos], debug, function()
if on_each then
on_each(pos, #to_run)
end
run_next(pos + 1)
end)
end
run_next(1)
end
---@return RunPanelState
function M.get_run_panel_state()
return run_panel_state
---@return PanelState
function M.get_panel_state()
return panel_state
end
function M.handle_compilation_failure(compilation_output)
---@param output string|nil
---@return nil
function M.handle_compilation_failure(output)
local ansi = require('cp.ui.ansi')
local config = require('cp.config').setup()
local clean_text
local highlights = {}
local txt
local hl = {}
if config.run_panel.ansi then
local parsed = ansi.parse_ansi_text(compilation_output or '')
clean_text = table.concat(parsed.lines, '\n')
highlights = parsed.highlights
if config.ui.ansi then
local p = ansi.parse_ansi_text(output or '')
txt = table.concat(p.lines, '\n')
hl = p.highlights
else
clean_text = (compilation_output or ''):gsub('\027%[[%d;]*[a-zA-Z]', '')
txt = (output or ''):gsub('\027%[[%d;]*[a-zA-Z]', '')
end
for _, test_case in ipairs(run_panel_state.test_cases) do
test_case.status = 'fail'
test_case.actual = clean_text
test_case.actual_highlights = highlights
test_case.error = 'Compilation failed'
test_case.stderr = ''
test_case.time_ms = 0
test_case.code = 1
test_case.ok = false
test_case.signal = nil
test_case.timed_out = false
for _, tc in ipairs(panel_state.test_cases) do
tc.status = 'fail'
tc.actual = txt
tc.actual_highlights = hl
tc.error = 'Compilation failed'
tc.stderr = ''
tc.time_ms = 0
tc.code = 1
tc.ok = false
tc.signal = ''
tc.tled = false
tc.mled = false
tc.rss_mb = 0
end
end

View file

@ -4,6 +4,10 @@
local M = {}
local function strwidth(s)
return vim.api.nvim_strwidth(s)
end
local exit_code_names = {
[128] = 'SIGHUP',
[129] = 'SIGINT',
@ -23,26 +27,30 @@ local exit_code_names = {
[143] = 'SIGCHLD',
}
---@param test_case TestCase
---@param ran_test_case RanTestCase
---@return StatusInfo
function M.get_status_info(test_case)
if test_case.status == 'pass' then
return { text = 'AC', highlight_group = 'CpTestAC' }
elseif test_case.status == 'fail' then
if test_case.timed_out then
return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif test_case.code and test_case.code >= 128 then
return { text = 'RTE', highlight_group = 'CpTestRTE' }
else
return { text = 'WA', highlight_group = 'CpTestWA' }
end
elseif test_case.status == 'timeout' then
return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif test_case.status == 'running' then
return { text = '...', highlight_group = 'CpTestPending' }
else
return { text = '', highlight_group = 'CpTestPending' }
function M.get_status_info(ran_test_case)
if ran_test_case.status == 'pending' then
return { text = '...', highlight_group = 'CpTestNA' }
elseif ran_test_case.status == 'running' then
return { text = 'RUN', highlight_group = 'CpTestNA' }
end
if ran_test_case.ok then
return { text = 'AC', highlight_group = 'CpTestAC' }
end
if ran_test_case.tled then
return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif ran_test_case.mled then
return { text = 'MLE', highlight_group = 'CpTestMLE' }
elseif ran_test_case.code and ran_test_case.code >= 128 then
return { text = 'RTE', highlight_group = 'CpTestRTE' }
elseif ran_test_case.code == 0 and not ran_test_case.ok then
return { text = 'WA', highlight_group = 'CpTestWA' }
end
return { text = 'N/A', highlight_group = 'CpTestNA' }
end
local function format_exit_code(code)
@ -54,73 +62,60 @@ local function format_exit_code(code)
end
local function compute_cols(test_state)
local w = { num = 5, status = 8, time = 6, timeout = 8, memory = 8, exit = 11 }
local w = { num = 5, status = 8, time = 6, timeout = 8, rss = 8, memory = 8, exit = 11 }
local timeout_str = ''
local memory_str = ''
local timeout_str = ''
local memory_str = ''
if test_state.constraints then
timeout_str = tostring(test_state.constraints.timeout_ms)
memory_str = string.format('%.0f', test_state.constraints.memory_mb)
else
timeout_str = ''
memory_str = ''
end
for i, tc in ipairs(test_state.test_cases) do
local prefix = (i == test_state.current_index) and '>' or ' '
w.num = math.max(w.num, #(' ' .. prefix .. i .. ' '))
w.status = math.max(w.status, #(' ' .. M.get_status_info(tc).text .. ' '))
w.num = math.max(w.num, strwidth(' ' .. prefix .. i .. ' '))
w.status = math.max(w.status, strwidth(' ' .. M.get_status_info(tc).text .. ' '))
local time_str = tc.time_ms and string.format('%.2f', tc.time_ms) or ''
w.time = math.max(w.time, #(' ' .. time_str .. ' '))
w.timeout = math.max(w.timeout, #(' ' .. timeout_str .. ' '))
w.memory = math.max(w.memory, #(' ' .. memory_str .. ' '))
w.exit = math.max(w.exit, #(' ' .. format_exit_code(tc.code) .. ' '))
w.time = math.max(w.time, strwidth(' ' .. time_str .. ' '))
w.timeout = math.max(w.timeout, strwidth(' ' .. timeout_str .. ' '))
local rss_str = (tc.rss_mb and string.format('%.0f', tc.rss_mb)) or ''
w.rss = math.max(w.rss, strwidth(' ' .. rss_str .. ' '))
w.memory = math.max(w.memory, strwidth(' ' .. memory_str .. ' '))
w.exit = math.max(w.exit, strwidth(' ' .. format_exit_code(tc.code) .. ' '))
end
w.num = math.max(w.num, #' # ')
w.status = math.max(w.status, #' Status ')
w.time = math.max(w.time, #' Runtime (ms) ')
w.timeout = math.max(w.timeout, #' Time (ms) ')
w.memory = math.max(w.memory, #' Mem (MB) ')
w.exit = math.max(w.exit, #' Exit Code ')
w.num = math.max(w.num, strwidth(' # '))
w.status = math.max(w.status, strwidth(' Status '))
w.time = math.max(w.time, strwidth(' Runtime (ms) '))
w.timeout = math.max(w.timeout, strwidth(' Time (ms) '))
w.rss = math.max(w.rss, strwidth(' RSS (MB) '))
w.memory = math.max(w.memory, strwidth(' Mem (MB) '))
w.exit = math.max(w.exit, strwidth(' Exit Code '))
local sum = w.num + w.status + w.time + w.timeout + w.memory + w.exit
local inner = sum + 5
local sum = w.num + w.status + w.time + w.timeout + w.rss + w.memory + w.exit
local inner = sum + 6
local total = inner + 2
return { w = w, sum = sum, inner = inner, total = total }
end
local function center(text, width)
local pad = width - #text
local pad = width - strwidth(text)
if pad <= 0 then
return text
end
local left = math.floor(pad / 2)
local left = math.ceil(pad / 2)
return string.rep(' ', left) .. text .. string.rep(' ', pad - left)
end
local function right_align(text, width)
local content = (' %s '):format(text)
local pad = width - #content
if pad <= 0 then
return content
end
return string.rep(' ', pad) .. content
end
local function format_num_column(prefix, idx, width)
local num_str = tostring(idx)
local content
if #num_str == 1 then
content = ' ' .. prefix .. ' ' .. num_str .. ' '
else
content = ' ' .. prefix .. num_str .. ' '
end
local total_pad = width - #content
local content = (#num_str == 1) and (' ' .. prefix .. ' ' .. num_str .. ' ')
or (' ' .. prefix .. num_str .. ' ')
local total_pad = width - strwidth(content)
if total_pad <= 0 then
return content
end
local left_pad = math.floor(total_pad / 2)
local left_pad = math.ceil(total_pad / 2)
local right_pad = total_pad - left_pad
return string.rep(' ', left_pad) .. content .. string.rep(' ', right_pad)
end
@ -136,6 +131,8 @@ local function top_border(c)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
@ -153,6 +150,8 @@ local function row_sep(c)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
@ -170,6 +169,8 @@ local function bottom_border(c)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
@ -187,6 +188,8 @@ local function flat_fence_above(c)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
@ -204,6 +207,8 @@ local function flat_fence_below(c)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
@ -225,6 +230,8 @@ local function header_line(c)
.. ''
.. center('Time (ms)', w.timeout)
.. ''
.. center('RSS (MB)', w.rss)
.. ''
.. center('Mem (MB)', w.memory)
.. ''
.. center('Exit Code', w.exit)
@ -238,33 +245,34 @@ local function data_row(c, idx, tc, is_current, test_state)
local time = tc.time_ms and string.format('%.2f', tc.time_ms) or ''
local exit = format_exit_code(tc.code)
local timeout = ''
local memory = ''
local timeout = ''
local memory = ''
if test_state.constraints then
timeout = tostring(test_state.constraints.timeout_ms)
memory = string.format('%.0f', test_state.constraints.memory_mb)
else
timeout = ''
memory = ''
end
local rss = (tc.rss_mb and string.format('%.0f', tc.rss_mb)) or ''
local line = ''
.. format_num_column(prefix, idx, w.num)
.. ''
.. right_align(status.text, w.status)
.. center(status.text, w.status)
.. ''
.. right_align(time, w.time)
.. center(time, w.time)
.. ''
.. right_align(timeout, w.timeout)
.. center(timeout, w.timeout)
.. ''
.. right_align(memory, w.memory)
.. center(rss, w.rss)
.. ''
.. right_align(exit, w.exit)
.. center(memory, w.memory)
.. ''
.. center(exit, w.exit)
.. ''
local hi
if status.text ~= '' then
local status_pos = line:find(status.text)
local status_pos = line:find(status.text, 1, true)
if status_pos then
hi = {
col_start = status_pos - 1,
@ -277,11 +285,14 @@ local function data_row(c, idx, tc, is_current, test_state)
return line, hi
end
---@param test_state RunPanelState
---@return string[], table[] lines and highlight positions
---@param test_state PanelState
---@return string[] lines
---@return Highlight[] highlights
---@return integer current_test_line
function M.render_test_list(test_state)
local lines, highlights = {}, {}
local c = compute_cols(test_state)
local current_test_line = nil
table.insert(lines, top_border(c))
table.insert(lines, header_line(c))
@ -291,6 +302,11 @@ function M.render_test_list(test_state)
local is_current = (i == test_state.current_index)
local row, hi = data_row(c, i, tc, is_current, test_state)
table.insert(lines, row)
if is_current then
current_test_line = #lines
end
if hi then
hi.line = #lines - 1
table.insert(highlights, hi)
@ -308,10 +324,10 @@ function M.render_test_list(test_state)
for _, input_line in ipairs(vim.split(tc.input, '\n', { plain = true, trimempty = false })) do
local s = input_line or ''
if #s > c.inner then
if strwidth(s) > c.inner then
s = string.sub(s, 1, c.inner)
end
local pad = c.inner - #s
local pad = c.inner - strwidth(s)
table.insert(lines, '' .. s .. string.rep(' ', pad) .. '')
end
@ -329,21 +345,21 @@ function M.render_test_list(test_state)
end
end
return lines, highlights
return lines, highlights, current_test_line or 1
end
---@param test_case TestCase?
---@param ran_test_case RanTestCase?
---@return string
function M.render_status_bar(test_case)
if not test_case then
function M.render_status_bar(ran_test_case)
if not ran_test_case then
return ''
end
local parts = {}
if test_case.time_ms then
table.insert(parts, string.format('%.2fms', test_case.time_ms))
if ran_test_case.time_ms then
table.insert(parts, string.format('%.2fms', ran_test_case.time_ms))
end
if test_case.code then
table.insert(parts, string.format('Exit: %d', test_case.code))
if ran_test_case.code then
table.insert(parts, string.format('Exit: %d', ran_test_case.code))
end
return table.concat(parts, '')
end
@ -351,13 +367,12 @@ end
---@return table<string, table>
function M.get_highlight_groups()
return {
CpTestAC = { fg = '#10b981' },
CpTestWA = { fg = '#ef4444' },
CpTestTLE = { fg = '#f59e0b' },
CpTestRTE = { fg = '#8b5cf6' },
CpTestPending = { fg = '#6b7280' },
CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' },
CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' },
CpTestAC = { link = 'DiagnosticOk' },
CpTestWA = { link = 'DiagnosticError' },
CpTestTLE = { link = 'DiagnosticWarn' },
CpTestMLE = { link = 'DiagnosticWarn' },
CpTestRTE = { link = 'DiagnosticHint' },
CpTestNA = { link = 'Comment' },
}
end

View file

@ -1,359 +0,0 @@
---@class ScraperTestCase
---@field input string
---@field expected string
---@class ScraperResult
---@field success boolean
---@field problem_id string
---@field url? string
---@field tests? ScraperTestCase[]
---@field timeout_ms? number
---@field memory_mb? number
---@field error? string
local M = {}
local cache = require('cp.cache')
local problem = require('cp.problem')
local utils = require('cp.utils')
local function check_internet_connectivity()
local result = vim.system({ 'ping', '-c', '5', '-W', '3', '8.8.8.8' }, { text = true }):wait()
return result.code == 0
end
---@param platform string
---@param contest_id string
---@return {success: boolean, problems?: table[], error?: string}
function M.scrape_contest_metadata(platform, contest_id)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
})
cache.load()
local cached_data = cache.get_contest_data(platform, contest_id)
if cached_data then
return {
success = true,
problems = cached_data.problems,
}
end
if not check_internet_connectivity() then
return {
success = false,
error = 'No internet connection available',
}
end
if not utils.setup_python_env() then
return {
success = false,
error = 'Python environment setup failed',
}
end
local plugin_path = utils.get_plugin_path()
local args = {
'uv',
'run',
'--directory',
plugin_path,
'-m',
'scrapers.' .. platform,
'metadata',
contest_id,
}
local result = vim
.system(args, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
:wait()
if result.code ~= 0 then
return {
success = false,
error = 'Failed to run metadata scraper: ' .. (result.stderr or 'Unknown error'),
}
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
return {
success = false,
error = 'Failed to parse metadata scraper output: ' .. tostring(data),
}
end
if not data.success then
return data
end
local problems_list = data.problems or {}
cache.set_contest_data(platform, contest_id, problems_list)
return {
success = true,
problems = problems_list,
}
end
---@param ctx ProblemContext
---@return {success: boolean, problem_id: string, test_count?: number, test_cases?: ScraperTestCase[], timeout_ms?: number, memory_mb?: number, url?: string, error?: string}
function M.scrape_problem(ctx)
vim.validate({
ctx = { ctx, 'table' },
})
vim.fn.mkdir('io', 'p')
if vim.fn.filereadable(ctx.input_file) == 1 and vim.fn.filereadable(ctx.expected_file) == 1 then
local base_name = vim.fn.fnamemodify(ctx.input_file, ':r')
local test_cases = {}
local i = 1
while true do
local input_file = base_name .. '.' .. i .. '.cpin'
local expected_file = base_name .. '.' .. i .. '.cpout'
if vim.fn.filereadable(input_file) == 1 and vim.fn.filereadable(expected_file) == 1 then
local input_content = table.concat(vim.fn.readfile(input_file), '\n')
local expected_content = table.concat(vim.fn.readfile(expected_file), '\n')
table.insert(test_cases, {
index = i,
input = input_content,
output = expected_content,
})
i = i + 1
else
break
end
end
return {
success = true,
problem_id = ctx.problem_name,
test_count = #test_cases,
test_cases = test_cases,
}
end
if not check_internet_connectivity() then
return {
success = false,
problem_id = ctx.problem_name,
error = 'No internet connection available',
}
end
if not utils.setup_python_env() then
return {
success = false,
problem_id = ctx.problem_name,
error = 'Python environment setup failed',
}
end
local plugin_path = utils.get_plugin_path()
local args = {
'uv',
'run',
'--directory',
plugin_path,
'-m',
'scrapers.' .. ctx.contest,
'tests',
ctx.contest_id,
ctx.problem_id,
}
local result = vim
.system(args, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
:wait()
if result.code ~= 0 then
return {
success = false,
problem_id = ctx.problem_name,
error = 'Failed to run tests scraper: ' .. (result.stderr or 'Unknown error'),
}
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
return {
success = false,
problem_id = ctx.problem_name,
error = 'Failed to parse tests scraper output: ' .. tostring(data),
}
end
if not data.success then
return data
end
if data.tests and #data.tests > 0 then
local base_name = vim.fn.fnamemodify(ctx.input_file, ':r')
for i, test_case in ipairs(data.tests) do
local input_file = base_name .. '.' .. i .. '.cpin'
local expected_file = base_name .. '.' .. i .. '.cpout'
local input_content = test_case.input:gsub('\r', '')
local expected_content = test_case.expected:gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n', true), input_file)
vim.fn.writefile(vim.split(expected_content, '\n', true), expected_file)
end
local cached_test_cases = {}
for i, test_case in ipairs(data.tests) do
table.insert(cached_test_cases, {
index = i,
input = test_case.input,
expected = test_case.expected,
})
end
cache.set_test_cases(
ctx.contest,
ctx.contest_id,
ctx.problem_id,
cached_test_cases,
data.timeout_ms,
data.memory_mb
)
end
return {
success = true,
problem_id = ctx.problem_name,
test_count = data.tests and #data.tests or 0,
test_cases = data.tests,
timeout_ms = data.timeout_ms,
memory_mb = data.memory_mb,
url = data.url,
}
end
---@param platform string
---@param contest_id string
---@param problems table[]
---@param config table
---@return table[]
function M.scrape_problems_parallel(platform, contest_id, problems, config)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
problems = { problems, 'table' },
config = { config, 'table' },
})
if not check_internet_connectivity() then
return {}
end
if not utils.setup_python_env() then
return {}
end
local plugin_path = utils.get_plugin_path()
local jobs = {}
for _, prob in ipairs(problems) do
local args = {
'uv',
'run',
'--directory',
plugin_path,
'-m',
'scrapers.' .. platform,
'tests',
contest_id,
prob.id,
}
local job = vim.system(args, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
jobs[prob.id] = {
job = job,
problem = prob,
}
end
local results = {}
for problem_id, job_data in pairs(jobs) do
local result = job_data.job:wait()
local scrape_result = {
success = false,
problem_id = problem_id,
error = 'Unknown error',
}
if result.code == 0 then
local ok, data = pcall(vim.json.decode, result.stdout)
if ok and data.success then
scrape_result = data
if data.tests and #data.tests > 0 then
local ctx = problem.create_context(platform, contest_id, problem_id, config)
local base_name = vim.fn.fnamemodify(ctx.input_file, ':r')
for i, test_case in ipairs(data.tests) do
local input_file = base_name .. '.' .. i .. '.cpin'
local expected_file = base_name .. '.' .. i .. '.cpout'
local input_content = test_case.input:gsub('\r', '')
local expected_content = test_case.expected:gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n', true), input_file)
vim.fn.writefile(vim.split(expected_content, '\n', true), expected_file)
end
local cached_test_cases = {}
for i, test_case in ipairs(data.tests) do
table.insert(cached_test_cases, {
index = i,
input = test_case.input,
expected = test_case.expected,
})
end
cache.set_test_cases(
platform,
contest_id,
problem_id,
cached_test_cases,
data.timeout_ms,
data.memory_mb
)
end
else
scrape_result.error = ok and data.error or 'Failed to parse scraper output'
end
else
scrape_result.error = 'Scraper execution failed: ' .. (result.stderr or 'Unknown error')
end
results[problem_id] = scrape_result
end
return results
end
return M

229
lua/cp/scraper.lua Normal file
View file

@ -0,0 +1,229 @@
local M = {}
local constants = require('cp.constants')
local logger = require('cp.log')
local utils = require('cp.utils')
local function syshandle(result)
if result.code ~= 0 then
local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error')
return { success = false, error = msg }
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
local msg = 'Failed to parse scraper output: ' .. tostring(data)
logger.log(msg, vim.log.levels.ERROR)
return { success = false, error = msg }
end
return { success = true, data = data }
end
---@param platform string
---@param subcommand string
---@param args string[]
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) }
local function run_scraper(platform, subcommand, args, opts)
if not utils.setup_python_env() then
local msg = 'no Python environment available (install uv or nix)'
logger.log(msg, vim.log.levels.ERROR)
if opts and opts.on_exit then
opts.on_exit({ success = false, error = msg })
end
return { success = false, error = msg }
end
local plugin_path = utils.get_plugin_path()
local cmd = utils.get_python_cmd(platform, plugin_path)
vim.list_extend(cmd, { subcommand })
vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
env.CONDA_PREFIX = ''
if opts and opts.ndjson then
local uv = vim.loop
local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false)
local buf = ''
local handle
handle = uv.spawn(cmd[1], {
args = vim.list_slice(cmd, 2),
stdio = { nil, stdout, stderr },
env = env,
cwd = plugin_path,
}, function(code, signal)
if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if ok_tail then
opts.on_event(ev_tail)
end
buf = ''
end
if opts.on_exit then
opts.on_exit({ success = (code == 0), code = code, signal = signal })
end
if not stdout:is_closing() then
stdout:close()
end
if not stderr:is_closing() then
stderr:close()
end
if handle and not handle:is_closing() then
handle:close()
end
end)
if not handle then
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
return { success = false, error = 'spawn failed' }
end
uv.read_start(stdout, function(_, data)
if data == nil then
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
return
end
buf = buf .. data
while true do
local s, e = buf:find('\n', 1, true)
if not s then
break
end
local line = buf:sub(1, s - 1)
buf = buf:sub(e + 1)
local ok, ev = pcall(vim.json.decode, line)
if ok and opts.on_event then
opts.on_event(ev)
end
end
end)
uv.read_start(stderr, function(_, _) end)
return
end
local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path }
if opts and opts.sync then
local result = vim.system(cmd, sysopts):wait()
return syshandle(result)
else
vim.system(cmd, sysopts, function(result)
if opts and opts.on_exit then
return opts.on_exit(syshandle(result))
end
end)
end
end
function M.scrape_contest_metadata(platform, contest_id, callback)
run_scraper(platform, 'metadata', { contest_id }, {
on_exit = function(result)
if not result or not result.success then
logger.log(
("Failed to scrape metadata for %s contest '%s'."):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id
),
vim.log.levels.ERROR
)
return
end
local data = result.data or {}
if not data.problems or #data.problems == 0 then
logger.log(
("No problems returned for %s contest '%s'."):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id
),
vim.log.levels.ERROR
)
return
end
if type(callback) == 'function' then
callback(data)
end
end,
})
end
function M.scrape_contest_list(platform)
local result = run_scraper(platform, 'contests', {}, { sync = true })
if not result or not result.success or not (result.data and result.data.contests) then
logger.log(
('Could not scrape contests list for platform %s: %s'):format(
platform,
(result and result.error) or 'unknown'
),
vim.log.levels.ERROR
)
return {}
end
return result.data.contests
end
---@param platform string
---@param contest_id string
---@param callback fun(data: table)|nil
function M.scrape_all_tests(platform, contest_id, callback)
run_scraper(platform, 'tests', { contest_id }, {
ndjson = true,
on_event = function(ev)
if ev.done then
return
end
if ev.error and ev.problem_id then
logger.log(
("Failed to load tests for problem '%s' in contest '%s': %s"):format(
ev.problem_id,
contest_id,
ev.error
),
vim.log.levels.WARN
)
return
end
if not ev.problem_id or not ev.tests then
return
end
vim.schedule(function()
require('cp.utils').ensure_dirs()
local config = require('cp.config')
local base_name = config.default_filename(contest_id, ev.problem_id)
for i, t in ipairs(ev.tests) do
local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin'
local expected_file = 'io/' .. base_name .. '.' .. i .. '.cpout'
local input_content = t.input:gsub('\r', '')
local expected_content = t.expected:gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n'), input_file)
vim.fn.writefile(vim.split(expected_content, '\n'), expected_file)
end
if type(callback) == 'function' then
callback({
combined = ev.combined,
tests = ev.tests,
timeout_ms = ev.timeout_ms or 0,
memory_mb = ev.memory_mb or 0,
interactive = ev.interactive or false,
multi_test = ev.multi_test or false,
problem_id = ev.problem_id,
})
end
end)
end,
})
end
return M

394
lua/cp/setup.lua Normal file
View file

@ -0,0 +1,394 @@
local M = {}
local cache = require('cp.cache')
local config_module = require('cp.config')
local constants = require('cp.constants')
local helpers = require('cp.helpers')
local logger = require('cp.log')
local scraper = require('cp.scraper')
local state = require('cp.state')
---Get the language of the current file from cache
---@return string?
local function get_current_file_language()
local current_file = vim.fn.expand('%:p')
if current_file == '' then
return nil
end
cache.load()
local file_state = cache.get_file_state(current_file)
return file_state and file_state.language or nil
end
---Check if a problem file exists for any enabled language
---@param platform string
---@param contest_id string
---@param problem_id string
---@return string?
local function get_existing_problem_language(platform, contest_id, problem_id)
local config = config_module.get_config()
local platform_config = config.platforms[platform]
if not platform_config then
return nil
end
for _, lang_id in ipairs(platform_config.enabled_languages) do
local effective = config.runtime.effective[platform][lang_id]
if effective and effective.extension then
local basename = config.filename
and config.filename(platform, contest_id, problem_id, config, lang_id)
or config_module.default_filename(contest_id, problem_id)
local filepath = basename .. '.' .. effective.extension
if vim.fn.filereadable(filepath) == 1 then
return lang_id
end
end
end
return nil
end
---@class TestCaseLite
---@field input string
---@field expected string
---@class ScrapeEvent
---@field problem_id string
---@field tests TestCaseLite[]|nil
---@field timeout_ms integer|nil
---@field memory_mb integer|nil
---@field interactive boolean|nil
---@field error string|nil
---@field done boolean|nil
---@field succeeded integer|nil
---@field failed integer|nil
---@param cd table|nil
---@return boolean
local function is_metadata_ready(cd)
return cd
and type(cd.problems) == 'table'
and #cd.problems > 0
and type(cd.index_map) == 'table'
and next(cd.index_map) ~= nil
or false
end
---@param platform string
---@param contest_id string
---@param problems table
local function start_tests(platform, contest_id, problems)
local cached_len = #vim.tbl_filter(function(p)
return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id))
end, problems)
if cached_len ~= #problems then
logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems))
scraper.scrape_all_tests(platform, contest_id, function(ev)
local cached_tests = {}
if not ev.interactive and vim.tbl_isempty(ev.tests) then
logger.log(("No tests found for problem '%s'."):format(ev.problem_id), vim.log.levels.WARN)
end
for i, t in ipairs(ev.tests) do
cached_tests[i] = { index = i, input = t.input, expected = t.expected }
end
cache.set_test_cases(
platform,
contest_id,
ev.problem_id,
ev.combined,
cached_tests,
ev.timeout_ms or 0,
ev.memory_mb or 0,
ev.interactive,
ev.multi_test
)
local io_state = state.get_io_view_state()
if io_state then
local combined_test = cache.get_combined_test(platform, contest_id, state.get_problem_id())
if combined_test then
local input_lines = vim.split(combined_test.input, '\n')
require('cp.utils').update_buffer_content(io_state.input_buf, input_lines, nil, nil)
end
end
end)
end
end
---@param platform string
---@param contest_id string
---@param problem_id? string
---@param language? string
function M.setup_contest(platform, contest_id, problem_id, language)
local old_platform, old_contest_id = state.get_platform(), state.get_contest_id()
state.set_platform(platform)
state.set_contest_id(contest_id)
if language then
local lang_result = config_module.get_language_for_platform(platform, language)
if not lang_result.valid then
logger.log(lang_result.error, vim.log.levels.ERROR)
return
end
end
local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id
cache.load()
local function proceed(contest_data)
local problems = contest_data.problems
local pid = problem_id and problem_id or problems[1].id
M.setup_problem(pid, language)
start_tests(platform, contest_id, problems)
if config_module.get_config().open_url and is_new_contest and contest_data.url then
vim.ui.open(contest_data.url:format(pid))
end
end
local contest_data = cache.get_contest_data(platform, contest_id)
if not is_metadata_ready(contest_data) then
local cfg = config_module.get_config()
local lang = language or (cfg.platforms[platform] and cfg.platforms[platform].default_language)
vim.cmd.only({ mods = { silent = true } })
local bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_win_set_buf(0, bufnr)
vim.bo[bufnr].filetype = lang or ''
vim.bo[bufnr].buftype = ''
vim.bo[bufnr].swapfile = false
state.set_language(lang)
if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
local ok = pcall(cfg.hooks.setup_code, state)
if ok then
vim.b[bufnr].cp_setup_done = true
end
end
state.set_provisional({
bufnr = bufnr,
platform = platform,
contest_id = contest_id,
language = lang,
requested_problem_id = problem_id,
token = vim.loop.hrtime(),
})
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
scraper.scrape_contest_metadata(
platform,
contest_id,
vim.schedule_wrap(function(result)
local problems = result.problems or {}
cache.set_contest_data(platform, contest_id, problems, result.url)
local prov = state.get_provisional()
if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then
return
end
local cd = cache.get_contest_data(platform, contest_id)
if not is_metadata_ready(cd) then
return
end
local pid = prov.requested_problem_id
if not pid or not cd.index_map or not cd.index_map[pid] then
pid = cd.problems[1] and cd.problems[1].id or nil
end
if not pid then
return
end
proceed(cd)
end)
)
return
end
proceed(contest_data)
end
---@param problem_id string
---@param language? string
function M.setup_problem(problem_id, language)
local platform = state.get_platform()
if not platform then
logger.log('No platform/contest/problem configured.', vim.log.levels.ERROR)
return
end
local old_problem_id = state.get_problem_id()
state.set_problem_id(problem_id)
if old_problem_id ~= problem_id then
local io_state = state.get_io_view_state()
if io_state and io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) then
local utils = require('cp.utils')
utils.update_buffer_content(io_state.output_buf, {}, nil, nil)
end
end
local config = config_module.get_config()
local lang = language
or (config.platforms[platform] and config.platforms[platform].default_language)
if language then
local lang_result = config_module.get_language_for_platform(platform, language)
if not lang_result.valid then
logger.log(lang_result.error, vim.log.levels.ERROR)
return
end
end
state.set_language(lang)
local source_file = state.get_source_file(lang)
if not source_file then
return
end
vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p')
local prov = state.get_provisional()
if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then
if vim.api.nvim_buf_is_valid(prov.bufnr) then
local existing_bufnr = vim.fn.bufnr(source_file)
if existing_bufnr ~= -1 then
vim.api.nvim_buf_delete(prov.bufnr, { force = true })
state.set_provisional(nil)
else
vim.api.nvim_buf_set_name(prov.bufnr, source_file)
vim.bo[prov.bufnr].swapfile = true
-- selene: allow(mixed_table)
vim.cmd.write({
vim.fn.fnameescape(source_file),
bang = true,
mods = { silent = true, noautocmd = true, keepalt = true },
})
state.set_solution_win(vim.api.nvim_get_current_win())
if config.hooks and config.hooks.setup_code and not vim.b[prov.bufnr].cp_setup_done then
local ok = pcall(config.hooks.setup_code, state)
if ok then
vim.b[prov.bufnr].cp_setup_done = true
end
elseif not vim.b[prov.bufnr].cp_setup_done then
helpers.clearcol(prov.bufnr)
vim.b[prov.bufnr].cp_setup_done = true
end
cache.set_file_state(
vim.fn.fnamemodify(source_file, ':p'),
platform,
state.get_contest_id() or '',
state.get_problem_id() or '',
lang
)
require('cp.ui.views').ensure_io_view()
state.set_provisional(nil)
return
end
else
state.set_provisional(nil)
end
end
vim.cmd.only({ mods = { silent = true } })
vim.cmd.e(source_file)
local bufnr = vim.api.nvim_get_current_buf()
state.set_solution_win(vim.api.nvim_get_current_win())
require('cp.ui.views').ensure_io_view()
if config.hooks and config.hooks.setup_code and not vim.b[bufnr].cp_setup_done then
local ok = pcall(config.hooks.setup_code, state)
if ok then
vim.b[bufnr].cp_setup_done = true
end
elseif not vim.b[bufnr].cp_setup_done then
helpers.clearcol(bufnr)
vim.b[bufnr].cp_setup_done = true
end
cache.set_file_state(
vim.fn.expand('%:p'),
platform,
state.get_contest_id() or '',
state.get_problem_id() or '',
lang
)
end
---@param direction integer
---@param language? string
function M.navigate_problem(direction, language)
if direction == 0 then
return
end
direction = direction > 0 and 1 or -1
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local current_problem_id = state.get_problem_id()
if not platform or not contest_id or not current_problem_id then
logger.log('No platform configured.', vim.log.levels.ERROR)
return
end
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if not is_metadata_ready(contest_data) then
logger.log(
('No data available for %s contest %s.'):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id
),
vim.log.levels.ERROR
)
return
end
local problems = contest_data.problems
local index = contest_data.index_map[current_problem_id]
local new_index = index + direction
if new_index < 1 or new_index > #problems then
return
end
logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id))
local active_panel = state.get_active_panel()
if active_panel == 'run' then
require('cp.ui.views').disable()
end
local lang = nil
if language then
local lang_result = config_module.get_language_for_platform(platform, language)
if not lang_result.valid then
logger.log(lang_result.error, vim.log.levels.ERROR)
return
end
lang = language
else
local existing_lang =
get_existing_problem_language(platform, contest_id, problems[new_index].id)
if existing_lang then
lang = existing_lang
else
lang = get_current_file_language()
if lang then
local lang_result = config_module.get_language_for_platform(platform, lang)
if not lang_result.valid then
lang = nil
end
end
end
end
local io_state = state.get_io_view_state()
if io_state and io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) then
local utils = require('cp.utils')
utils.update_buffer_content(io_state.output_buf, {}, nil, nil)
end
M.setup_contest(platform, contest_id, problems[new_index].id, lang)
end
return M

View file

@ -1,134 +0,0 @@
local M = {}
local logger = require('cp.log')
function M.setup(config)
local ok, ls = pcall(require, 'luasnip')
if not ok then
logger.log('LuaSnip not available - snippets disabled', vim.log.levels.INFO)
return
end
local s, i, fmt = ls.snippet, ls.insert_node, require('luasnip.extras.fmt').fmt
local constants = require('cp.constants')
local filetype_to_language = constants.filetype_to_language
local language_to_filetype = {}
for ext, lang in pairs(filetype_to_language) do
if not language_to_filetype[lang] then
language_to_filetype[lang] = ext
end
end
local template_definitions = {
cpp = {
codeforces = [[#include <bits/stdc++.h>
using namespace std;
void solve() {{
{}
}}
int main() {{
std::cin.tie(nullptr)->sync_with_stdio(false);
int tc = 1;
std::cin >> tc;
for (int t = 0; t < tc; ++t) {{
solve();
}}
return 0;
}}]],
atcoder = [[#include <bits/stdc++.h>
using namespace std;
void solve() {{
{}
}}
int main() {{
std::cin.tie(nullptr)->sync_with_stdio(false);
#ifdef LOCAL
int tc;
std::cin >> tc;
for (int t = 0; t < tc; ++t) {{
solve();
}}
#else
solve();
#endif
return 0;
}}]],
cses = [[#include <bits/stdc++.h>
using namespace std;
int main() {{
std::cin.tie(nullptr)->sync_with_stdio(false);
{}
return 0;
}}]],
},
python = {
codeforces = [[def solve():
{}
if __name__ == "__main__":
tc = int(input())
for _ in range(tc):
solve()]],
atcoder = [[def solve():
{}
if __name__ == "__main__":
solve()]],
cses = [[def solve():
{}
if __name__ == "__main__":
solve()]],
},
}
local user_overrides = {}
for _, snippet in ipairs(config.snippets or {}) do
user_overrides[snippet.trigger:lower()] = snippet
end
for language, template_set in pairs(template_definitions) do
local snippets = {}
local filetype = constants.canonical_filetypes[language]
for contest, template in pairs(template_set) do
local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest:lower(), language)
if not user_overrides[prefixed_trigger:lower()] then
table.insert(snippets, s(prefixed_trigger, fmt(template, { i(1) })))
end
end
for trigger, snippet in pairs(user_overrides) do
local prefix_match = trigger:lower():match('^cp%.nvim/[^.]+%.(.+)$')
if prefix_match == language then
table.insert(snippets, snippet)
end
end
ls.add_snippets(filetype, snippets)
end
end
return M

222
lua/cp/state.lua Normal file
View file

@ -0,0 +1,222 @@
---@class cp.ProvisionalState
---@field bufnr integer
---@field platform string
---@field contest_id string
---@field language string
---@field requested_problem_id string?
---@field token integer
---@class cp.IoViewState
---@field output_buf integer
---@field input_buf integer
---@field current_test_index integer?
---@field source_buf integer?
---@class cp.State
---@field get_platform fun(): string?
---@field set_platform fun(platform: string)
---@field get_contest_id fun(): string?
---@field set_contest_id fun(contest_id: string)
---@field get_problem_id fun(): string?
---@field set_problem_id fun(problem_id: string)
---@field get_language fun(): string?
---@field set_language fun(language: string)
---@field get_active_panel fun(): string?
---@field set_active_panel fun(panel: string?)
---@field get_base_name fun(): string?
---@field get_source_file fun(language?: string): string?
---@field get_binary_file fun(): string?
---@field get_input_file fun(): string?
---@field get_output_file fun(): string?
---@field get_expected_file fun(): string?
---@field get_provisional fun(): cp.ProvisionalState?
---@field set_provisional fun(p: cp.ProvisionalState?)
---@field get_saved_session fun(): string?
---@field set_saved_session fun(path: string?)
---@field get_io_view_state fun(): cp.IoViewState?
---@field set_io_view_state fun(s: cp.IoViewState?)
local M = {}
---@type table<string, any>
local state = {
platform = nil,
contest_id = nil,
problem_id = nil,
language = nil,
test_cases = nil,
saved_session = nil,
active_panel = nil,
provisional = nil,
solution_win = nil,
io_view_state = nil,
}
---@return string?
function M.get_platform()
return state.platform
end
---@param platform string
function M.set_platform(platform)
state.platform = platform
end
---@return string?
function M.get_contest_id()
return state.contest_id
end
---@param contest_id string
function M.set_contest_id(contest_id)
state.contest_id = contest_id
end
---@return string?
function M.get_problem_id()
return state.problem_id
end
---@param problem_id string
function M.set_problem_id(problem_id)
state.problem_id = problem_id
end
---@return string?
function M.get_language()
return state.language
end
---@param language string
function M.set_language(language)
state.language = language
end
---@return string?
function M.get_base_name()
local platform, contest_id, problem_id = M.get_platform(), M.get_contest_id(), M.get_problem_id()
if not platform or not contest_id or not problem_id then
return nil
end
local config_module = require('cp.config')
local config = config_module.get_config()
if config.filename then
return config.filename(platform, contest_id, problem_id, config)
else
return config_module.default_filename(contest_id, problem_id)
end
end
---@param language? string
---@return string?
function M.get_source_file(language)
local base_name = M.get_base_name()
if not base_name or not M.get_platform() then
return nil
end
local config = require('cp.config').get_config()
local plat = M.get_platform()
local platform_cfg = config.platforms[plat]
if not platform_cfg then
return nil
end
local target_language = language or state.language or platform_cfg.default_language
local eff = config.runtime.effective[plat] and config.runtime.effective[plat][target_language]
or nil
if not eff or not eff.extension then
return nil
end
return base_name .. '.' .. eff.extension
end
---@return string?
function M.get_binary_file()
local base_name = M.get_base_name()
return base_name and ('build/%s.run'):format(base_name) or nil
end
---@return string?
function M.get_debug_file()
local base_name = M.get_base_name()
return base_name and ('build/%s.dbg'):format(base_name) or nil
end
---@return string?
function M.get_input_file()
local base_name = M.get_base_name()
return base_name and ('io/%s.cpin'):format(base_name) or nil
end
---@return string?
function M.get_output_file()
local base_name = M.get_base_name()
return base_name and ('io/%s.cpout'):format(base_name) or nil
end
---@return string?
function M.get_expected_file()
local base_name = M.get_base_name()
return base_name and ('io/%s.expected'):format(base_name) or nil
end
---@return string?
function M.get_active_panel()
return state.active_panel
end
---@param panel string?
function M.set_active_panel(panel)
state.active_panel = panel
end
---@return cp.ProvisionalState?
function M.get_provisional()
return state.provisional
end
---@param p cp.ProvisionalState?
function M.set_provisional(p)
state.provisional = p
end
---@return integer?
function M.get_solution_win()
if state.solution_win and vim.api.nvim_win_is_valid(state.solution_win) then
return state.solution_win
end
return vim.api.nvim_get_current_win()
end
---@param win integer?
function M.set_solution_win(win)
state.solution_win = win
end
---@return cp.IoViewState?
function M.get_io_view_state()
return state.io_view_state
end
---@param s cp.IoViewState?
function M.set_io_view_state(s)
state.io_view_state = s
end
---@return string?
function M.get_saved_session()
return state.saved_session
end
---@param path string?
function M.set_saved_session(path)
state.saved_session = path
end
M._state = state
return M

View file

@ -1,25 +1,118 @@
---@class AnsiParseResult
---@field lines string[]
---@field highlights table[]
---@field highlights Highlight[]
---@class Highlight
---@field line number
---@field col_start number
---@field col_end number
---@field highlight_group string
local M = {}
local logger = require('cp.log')
local dyn_hl_cache = {}
---@param raw_output string|table
---@return string
function M.bytes_to_string(raw_output)
if type(raw_output) == 'string' then
return raw_output
local ANSI_TERMINAL_COLOR_CODE_FALLBACK = {
[0] = '#000000',
[1] = '#800000',
[2] = '#008000',
[3] = '#808000',
[4] = '#000080',
[5] = '#800080',
[6] = '#008080',
[7] = '#c0c0c0',
[8] = '#808080',
[9] = '#ff0000',
[10] = '#00ff00',
[11] = '#ffff00',
[12] = '#0000ff',
[13] = '#ff00ff',
[14] = '#00ffff',
[15] = '#ffffff',
}
local function xterm_to_hex(n)
if n >= 0 and n <= 15 then
local key = 'terminal_color_' .. n
return vim.g[key] or ANSI_TERMINAL_COLOR_CODE_FALLBACK[n]
end
return table.concat(vim.tbl_map(string.char, raw_output))
if n >= 16 and n <= 231 then
local c = n - 16
local r = math.floor(c / 36) % 6
local g = math.floor(c / 6) % 6
local b = c % 6
local function level(x)
return x == 0 and 0 or 55 + 40 * x
end
return ('#%02x%02x%02x'):format(level(r), level(g), level(b))
end
local l = 8 + 10 * (n - 232)
return ('#%02x%02x%02x'):format(l, l, l)
end
---@param s string|table
---@return string
function M.bytes_to_string(s)
if type(s) == 'string' then
return s
end
return table.concat(vim.tbl_map(string.char, s))
end
---@param fg table|nil
---@param bold boolean
---@param italic boolean
---@return string|nil
local function ensure_hl_for(fg, bold, italic)
if not fg and not bold and not italic then
return nil
end
local base = 'CpAnsi'
local suffix
local opts = {}
if fg and fg.kind == 'named' then
suffix = fg.name
elseif fg and fg.kind == 'xterm' then
suffix = ('X%03d'):format(fg.idx)
opts.fg = xterm_to_hex(fg.idx) or 'NONE'
elseif fg and fg.kind == 'rgb' then
suffix = ('Rgb%02x%02x%02x'):format(fg.r, fg.g, fg.b)
opts.fg = ('#%02x%02x%02x'):format(fg.r, fg.g, fg.b)
end
local parts = { base }
if bold then
table.insert(parts, 'Bold')
end
if italic then
table.insert(parts, 'Italic')
end
if suffix then
table.insert(parts, suffix)
end
local name = table.concat(parts)
if not dyn_hl_cache[name] then
if bold then
opts.bold = true
end
if italic then
opts.italic = true
end
vim.api.nvim_set_hl(0, name, opts)
dyn_hl_cache[name] = true
end
return name
end
---@param text string
---@return AnsiParseResult
function M.parse_ansi_text(text)
local clean_text = text:gsub('\027%[[%d;]*[a-zA-Z]', '')
local lines = vim.split(clean_text, '\n', { plain = true, trimempty = false })
local lines = vim.split(clean_text, '\n', { plain = true })
local highlights = {}
local line_num = 0
@ -32,22 +125,7 @@ function M.parse_ansi_text(text)
}
local function get_highlight_group()
if not ansi_state.bold and not ansi_state.italic and not ansi_state.foreground then
return nil
end
local parts = { 'CpAnsi' }
if ansi_state.bold then
table.insert(parts, 'Bold')
end
if ansi_state.italic then
table.insert(parts, 'Italic')
end
if ansi_state.foreground then
table.insert(parts, ansi_state.foreground)
end
return table.concat(parts)
return ensure_hl_for(ansi_state.foreground, ansi_state.bold, ansi_state.italic)
end
local function apply_highlight(start_line, start_col, end_col)
@ -131,6 +209,7 @@ end
---@param ansi_state table
---@param code_string string
---@return nil
function M.update_ansi_state(ansi_state, code_string)
if code_string == '' or code_string == '0' then
ansi_state.bold = false
@ -140,67 +219,80 @@ function M.update_ansi_state(ansi_state, code_string)
end
local codes = vim.split(code_string, ';', { plain = true })
local idx = 1
while idx <= #codes do
local num = tonumber(codes[idx])
for _, code in ipairs(codes) do
local num = tonumber(code)
if num then
if num == 1 then
ansi_state.bold = true
elseif num == 3 then
ansi_state.italic = true
elseif num == 22 then
ansi_state.bold = false
elseif num == 23 then
ansi_state.italic = false
elseif num >= 30 and num <= 37 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.foreground = colors[num - 29]
elseif num >= 90 and num <= 97 then
local colors = {
'BrightBlack',
'BrightRed',
'BrightGreen',
'BrightYellow',
'BrightBlue',
'BrightMagenta',
'BrightCyan',
'BrightWhite',
}
ansi_state.foreground = colors[num - 89]
elseif num == 39 then
ansi_state.foreground = nil
if num == 1 then
ansi_state.bold = true
elseif num == 3 then
ansi_state.italic = true
elseif num == 22 then
ansi_state.bold = false
elseif num == 23 then
ansi_state.italic = false
elseif num and num >= 30 and num <= 37 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.foreground = { kind = 'named', name = colors[num - 29] }
elseif num and num >= 90 and num <= 97 then
local colors = {
'BrightBlack',
'BrightRed',
'BrightGreen',
'BrightYellow',
'BrightBlue',
'BrightMagenta',
'BrightCyan',
'BrightWhite',
}
ansi_state.foreground = { kind = 'named', name = colors[num - 89] }
elseif num == 39 then
ansi_state.foreground = nil
elseif num == 38 or num == 48 then
local is_fg = (num == 38)
local mode = tonumber(codes[idx + 1] or '')
if mode == 5 and codes[idx + 2] then
local pal = tonumber(codes[idx + 2]) or 0
if is_fg then
ansi_state.foreground = { kind = 'xterm', idx = pal }
end
idx = idx + 2
elseif mode == 2 and codes[idx + 2] and codes[idx + 3] and codes[idx + 4] then
local r = tonumber(codes[idx + 2]) or 0
local g = tonumber(codes[idx + 3]) or 0
local b = tonumber(codes[idx + 4]) or 0
if is_fg then
ansi_state.foreground = { kind = 'rgb', r = r, g = g, b = b }
end
idx = idx + 4
end
end
idx = idx + 1
end
end
---@return nil
function M.setup_highlight_groups()
local color_map = {
Black = vim.g.terminal_color_0,
Red = vim.g.terminal_color_1,
Green = vim.g.terminal_color_2,
Yellow = vim.g.terminal_color_3,
Blue = vim.g.terminal_color_4,
Magenta = vim.g.terminal_color_5,
Cyan = vim.g.terminal_color_6,
White = vim.g.terminal_color_7,
BrightBlack = vim.g.terminal_color_8,
BrightRed = vim.g.terminal_color_9,
BrightGreen = vim.g.terminal_color_10,
BrightYellow = vim.g.terminal_color_11,
BrightBlue = vim.g.terminal_color_12,
BrightMagenta = vim.g.terminal_color_13,
BrightCyan = vim.g.terminal_color_14,
BrightWhite = vim.g.terminal_color_15,
Black = vim.g.terminal_color_0 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[0],
Red = vim.g.terminal_color_1 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[1],
Green = vim.g.terminal_color_2 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[2],
Yellow = vim.g.terminal_color_3 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[3],
Blue = vim.g.terminal_color_4 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[4],
Magenta = vim.g.terminal_color_5 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[5],
Cyan = vim.g.terminal_color_6 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[6],
White = vim.g.terminal_color_7 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[7],
BrightBlack = vim.g.terminal_color_8 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[8],
BrightRed = vim.g.terminal_color_9 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[9],
BrightGreen = vim.g.terminal_color_10 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[10],
BrightYellow = vim.g.terminal_color_11 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[11],
BrightBlue = vim.g.terminal_color_12 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[12],
BrightMagenta = vim.g.terminal_color_13 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[13],
BrightCyan = vim.g.terminal_color_14 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[14],
BrightWhite = vim.g.terminal_color_15 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[15],
}
if vim.tbl_count(color_map) < 16 then
logger.log(
'ansi terminal colors (vim.g.terminal_color_*) not configured. ANSI colors will not display properly. ',
vim.log.levels.WARN
)
end
local combinations = {
{ bold = false, italic = false },
{ bold = true, italic = false },
@ -212,7 +304,6 @@ function M.setup_highlight_groups()
for color_name, terminal_color in pairs(color_map) do
local parts = { 'CpAnsi' }
local opts = { fg = terminal_color or 'NONE' }
if combo.bold then
table.insert(parts, 'Bold')
opts.bold = true
@ -222,7 +313,6 @@ function M.setup_highlight_groups()
opts.italic = true
end
table.insert(parts, color_name)
local hl_name = table.concat(parts)
vim.api.nvim_set_hl(0, hl_name, opts)
end
@ -231,6 +321,51 @@ function M.setup_highlight_groups()
vim.api.nvim_set_hl(0, 'CpAnsiBold', { bold = true })
vim.api.nvim_set_hl(0, 'CpAnsiItalic', { italic = true })
vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true })
for _, combo in ipairs(combinations) do
for color_name, _ in pairs(color_map) do
local parts = { 'CpAnsi' }
if combo.bold then
table.insert(parts, 'Bold')
end
if combo.italic then
table.insert(parts, 'Italic')
end
table.insert(parts, color_name)
local hl_name = table.concat(parts)
dyn_hl_cache[hl_name] = true
end
end
dyn_hl_cache['CpAnsiBold'] = true
dyn_hl_cache['CpAnsiItalic'] = true
dyn_hl_cache['CpAnsiBoldItalic'] = true
end
---@param text string
---@return string[]
function M.debug_ansi_tokens(text)
local out = {}
local i = 1
while true do
local s, e, codes, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i)
if not s then
break
end
table.insert(out, ('ESC[%s%s'):format(codes, cmd))
i = e + 1
end
return out
end
---@param s string
---@return string
function M.hex_dump(s)
local t = {}
for i = 1, #s do
t[#t + 1] = ('%02X'):format(s:byte(i))
end
return table.concat(t, ' ')
end
return M

View file

@ -1,6 +1,6 @@
---@class DiffResult
---@field content string[]
---@field highlights table[]?
---@field highlights Highlight[]?
---@field raw_diff string?
---@class DiffBackend
@ -13,7 +13,7 @@ local M = {}
local vim_backend = {
name = 'vim',
render = function(_, actual)
local actual_lines = vim.split(actual, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual, '\n', { plain = true })
return {
content = actual_lines,
@ -22,6 +22,20 @@ local vim_backend = {
end,
}
---@type DiffBackend
local none_backend = {
name = 'none',
render = function(expected, actual)
local expected_lines = vim.split(expected, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual, '\n', { plain = true })
return {
content = { expected = expected_lines, actual = actual_lines },
highlights = {},
}
end,
}
---@type DiffBackend
local git_backend = {
name = 'git',
@ -50,7 +64,7 @@ local git_backend = {
if result.code == 0 then
return {
content = vim.split(actual, '\n', { plain = true, trimempty = true }),
content = vim.split(actual, '\n', { plain = true }),
highlights = {},
}
else
@ -143,6 +157,7 @@ local git_backend = {
---@type table<string, DiffBackend>
local backends = {
none = none_backend,
vim = vim_backend,
git = git_backend,
}

464
lua/cp/ui/edit.lua Normal file
View file

@ -0,0 +1,464 @@
local M = {}
local cache = require('cp.cache')
local config_module = require('cp.config')
local helpers = require('cp.helpers')
local logger = require('cp.log')
local state = require('cp.state')
local utils = require('cp.utils')
---@class TestBufferPair
---@field input_buf integer
---@field expected_buf integer
---@field input_win integer
---@field expected_win integer
---@class EditState
---@field test_buffers TestBufferPair[]
---@field test_cases TestCase[]
---@field constraints ProblemConstraints?
---@type EditState?
local edit_state = nil
local setup_keybindings
---@param bufnr integer
---@return integer? test_index
local function get_current_test_index(bufnr)
if not edit_state then
return nil
end
for i, pair in ipairs(edit_state.test_buffers) do
if pair.input_buf == bufnr or pair.expected_buf == bufnr then
return i
end
end
return nil
end
---@param index integer
local function jump_to_test(index)
if not edit_state then
return
end
local pair = edit_state.test_buffers[index]
if pair and vim.api.nvim_win_is_valid(pair.input_win) then
vim.api.nvim_set_current_win(pair.input_win)
end
end
---@param delta integer
local function navigate_test(delta)
local current_buf = vim.api.nvim_get_current_buf()
local current_index = get_current_test_index(current_buf)
if not current_index or not edit_state then
return
end
local new_index = current_index + delta
if new_index < 1 or new_index > #edit_state.test_buffers then
return
end
jump_to_test(new_index)
end
---@param test_index integer
local function load_test_into_buffer(test_index)
if not edit_state then
return
end
local tc = edit_state.test_cases[test_index]
local pair = edit_state.test_buffers[test_index]
if not tc or not pair then
return
end
local input_lines = vim.split(tc.input or '', '\n', { plain = true, trimempty = false })
vim.api.nvim_buf_set_lines(pair.input_buf, 0, -1, false, input_lines)
local expected_lines = vim.split(tc.expected or '', '\n', { plain = true, trimempty = false })
vim.api.nvim_buf_set_lines(pair.expected_buf, 0, -1, false, expected_lines)
vim.api.nvim_buf_set_name(pair.input_buf, string.format('cp://test-%d-input', test_index))
vim.api.nvim_buf_set_name(pair.expected_buf, string.format('cp://test-%d-expected', test_index))
end
local function delete_current_test()
if not edit_state then
return
end
if #edit_state.test_buffers == 1 then
logger.log('Problems must have at least one test case.', vim.log.levels.ERROR)
return
end
local current_buf = vim.api.nvim_get_current_buf()
local current_index = get_current_test_index(current_buf)
if not current_index then
return
end
local pair = edit_state.test_buffers[current_index]
if vim.api.nvim_win_is_valid(pair.input_win) then
vim.api.nvim_win_close(pair.input_win, true)
end
if vim.api.nvim_win_is_valid(pair.expected_win) then
vim.api.nvim_win_close(pair.expected_win, true)
end
if vim.api.nvim_buf_is_valid(pair.input_buf) then
vim.api.nvim_buf_delete(pair.input_buf, { force = true })
end
if vim.api.nvim_buf_is_valid(pair.expected_buf) then
vim.api.nvim_buf_delete(pair.expected_buf, { force = true })
end
table.remove(edit_state.test_buffers, current_index)
table.remove(edit_state.test_cases, current_index)
for i = current_index, #edit_state.test_buffers do
load_test_into_buffer(i)
end
local next_index = math.min(current_index, #edit_state.test_buffers)
jump_to_test(next_index)
logger.log(('Deleted test %d'):format(current_index))
end
local function add_new_test()
if not edit_state then
return
end
local last_pair = edit_state.test_buffers[#edit_state.test_buffers]
if not last_pair or not vim.api.nvim_win_is_valid(last_pair.input_win) then
return
end
vim.api.nvim_set_current_win(last_pair.input_win)
vim.cmd.vsplit()
local input_win = vim.api.nvim_get_current_win()
local input_buf = utils.create_buffer_with_options()
vim.api.nvim_win_set_buf(input_win, input_buf)
vim.bo[input_buf].modifiable = true
vim.bo[input_buf].readonly = false
vim.bo[input_buf].buftype = 'nofile'
vim.bo[input_buf].buflisted = false
helpers.clearcol(input_buf)
vim.api.nvim_set_current_win(last_pair.expected_win)
vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
local expected_buf = utils.create_buffer_with_options()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.bo[expected_buf].modifiable = true
vim.bo[expected_buf].readonly = false
vim.bo[expected_buf].buftype = 'nofile'
vim.bo[expected_buf].buflisted = false
helpers.clearcol(expected_buf)
local new_index = #edit_state.test_buffers + 1
local new_pair = {
input_buf = input_buf,
expected_buf = expected_buf,
input_win = input_win,
expected_win = expected_win,
}
table.insert(edit_state.test_buffers, new_pair)
table.insert(edit_state.test_cases, { index = new_index, input = '', expected = '' })
setup_keybindings(input_buf)
setup_keybindings(expected_buf)
load_test_into_buffer(new_index)
vim.api.nvim_set_current_win(input_win)
logger.log(('Added test %d'):format(new_index))
end
---@param buf integer
setup_keybindings = function(buf)
local config = config_module.get_config()
local keys = config.ui.edit
if keys.save_and_exit_key then
vim.keymap.set('n', keys.save_and_exit_key, function()
M.toggle_edit()
end, { buffer = buf, silent = true, desc = 'Save and exit test editor' })
end
if keys.next_test_key then
vim.keymap.set('n', keys.next_test_key, function()
navigate_test(1)
end, { buffer = buf, silent = true, desc = 'Next test' })
end
if keys.prev_test_key then
vim.keymap.set('n', keys.prev_test_key, function()
navigate_test(-1)
end, { buffer = buf, silent = true, desc = 'Previous test' })
end
if keys.delete_test_key then
vim.keymap.set(
'n',
keys.delete_test_key,
delete_current_test,
{ buffer = buf, silent = true, desc = 'Delete test' }
)
end
if keys.add_test_key then
vim.keymap.set(
'n',
keys.add_test_key,
add_new_test,
{ buffer = buf, silent = true, desc = 'Add test' }
)
end
local augroup = vim.api.nvim_create_augroup('cp_edit_guard', { clear = false })
vim.api.nvim_create_autocmd({ 'BufDelete', 'BufWipeout' }, {
group = augroup,
buffer = buf,
callback = function()
vim.schedule(function()
if not edit_state then
return
end
local is_tracked = false
for _, pair in ipairs(edit_state.test_buffers) do
if pair.input_buf == buf or pair.expected_buf == buf then
is_tracked = true
break
end
end
if is_tracked then
logger.log('Test buffer closed unexpectedly. Exiting editor.', vim.log.levels.WARN)
M.toggle_edit()
end
end)
end,
})
end
local function save_all_tests()
if not edit_state then
return
end
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
if not platform or not contest_id or not problem_id then
return
end
for i, pair in ipairs(edit_state.test_buffers) do
if
vim.api.nvim_buf_is_valid(pair.input_buf) and vim.api.nvim_buf_is_valid(pair.expected_buf)
then
local input_lines = vim.api.nvim_buf_get_lines(pair.input_buf, 0, -1, false)
local expected_lines = vim.api.nvim_buf_get_lines(pair.expected_buf, 0, -1, false)
edit_state.test_cases[i].input = table.concat(input_lines, '\n')
edit_state.test_cases[i].expected = table.concat(expected_lines, '\n')
end
end
local contest_data = cache.get_contest_data(platform, contest_id)
local is_multi_test = contest_data.problems[contest_data.index_map[problem_id]].multi_test
or false
-- Generate combined test from individual test cases
local combined_input = table.concat(
vim.tbl_map(function(tc)
return tc.input
end, edit_state.test_cases),
'\n'
)
local combined_expected = table.concat(
vim.tbl_map(function(tc)
return tc.expected
end, edit_state.test_cases),
'\n'
)
cache.set_test_cases(
platform,
contest_id,
problem_id,
{ input = combined_input, expected = combined_expected },
edit_state.test_cases,
edit_state.constraints and edit_state.constraints.timeout_ms or 0,
edit_state.constraints and edit_state.constraints.memory_mb or 0,
false,
is_multi_test
)
local config = config_module.get_config()
local base_name = config.filename and config.filename(platform, contest_id, problem_id, config)
or config_module.default_filename(contest_id, problem_id)
vim.fn.mkdir('io', 'p')
for i, tc in ipairs(edit_state.test_cases) do
local input_file = string.format('io/%s.%d.cpin', base_name, i)
local expected_file = string.format('io/%s.%d.cpout', base_name, i)
local input_content = (tc.input or ''):gsub('\r', '')
local expected_content = (tc.expected or ''):gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n', { trimempty = true }), input_file)
vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file)
end
logger.log('Saved all test cases')
end
function M.toggle_edit(test_index)
if edit_state then
save_all_tests()
edit_state = nil
pcall(vim.api.nvim_clear_autocmds, { group = 'cp_edit_guard' })
local saved = state.get_saved_session()
if saved then
vim.fn.delete(saved)
state.set_saved_session(nil)
end
vim.cmd.only({ mods = { silent = true } })
local source_file = state.get_source_file()
if source_file and vim.fn.filereadable(source_file) == 1 then
vim.cmd.edit(source_file)
end
local views = require('cp.ui.views')
views.ensure_io_view()
logger.log('Closed test editor')
return
end
local platform, contest_id, problem_id =
state.get_platform(), state.get_contest_id(), state.get_problem_id()
if not platform or not contest_id or not problem_id then
logger.log('No problem context. Run :CP <platform> <contest> first.', vim.log.levels.ERROR)
return
end
cache.load()
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if not test_cases or #test_cases == 0 then
logger.log('No test cases available for editing.', vim.log.levels.ERROR)
return
end
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
local constraints = (timeout_ms and memory_mb)
and { timeout_ms = timeout_ms, memory_mb = memory_mb }
or nil
local target_index = test_index or 1
if target_index < 1 or target_index > #test_cases then
logger.log(
('Test %d does not exist (only %d tests available)'):format(target_index, #test_cases),
vim.log.levels.ERROR
)
return
end
local io_view_state = state.get_io_view_state()
if io_view_state then
if io_view_state.output_buf and vim.api.nvim_buf_is_valid(io_view_state.output_buf) then
vim.api.nvim_buf_delete(io_view_state.output_buf, { force = true })
end
if io_view_state.input_buf and vim.api.nvim_buf_is_valid(io_view_state.input_buf) then
vim.api.nvim_buf_delete(io_view_state.input_buf, { force = true })
end
state.set_io_view_state(nil)
end
local session_file = vim.fn.tempname()
state.set_saved_session(session_file)
-- selene: allow(mixed_table)
vim.cmd.mksession({ session_file, bang = true })
vim.cmd.only({ mods = { silent = true } })
local test_buffers = {}
local num_tests = #test_cases
for _ = 1, num_tests - 1 do
vim.cmd.vsplit()
end
vim.cmd('1 wincmd w')
for col = 1, num_tests do
vim.cmd.split()
vim.cmd.wincmd('k')
local input_win = vim.api.nvim_get_current_win()
local input_buf = utils.create_buffer_with_options()
vim.api.nvim_win_set_buf(input_win, input_buf)
vim.bo[input_buf].modifiable = true
vim.bo[input_buf].readonly = false
vim.bo[input_buf].buftype = 'nofile'
vim.bo[input_buf].buflisted = false
helpers.clearcol(input_buf)
vim.cmd.wincmd('j')
local expected_win = vim.api.nvim_get_current_win()
local expected_buf = utils.create_buffer_with_options()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.bo[expected_buf].modifiable = true
vim.bo[expected_buf].readonly = false
vim.bo[expected_buf].buftype = 'nofile'
vim.bo[expected_buf].buflisted = false
helpers.clearcol(expected_buf)
test_buffers[col] = {
input_buf = input_buf,
expected_buf = expected_buf,
input_win = input_win,
expected_win = expected_win,
}
vim.cmd.wincmd('k')
vim.cmd.wincmd('l')
end
edit_state = {
test_buffers = test_buffers,
test_cases = test_cases,
constraints = constraints,
}
for i = 1, num_tests do
load_test_into_buffer(i)
end
for _, pair in ipairs(test_buffers) do
setup_keybindings(pair.input_buf)
setup_keybindings(pair.expected_buf)
end
if
test_buffers[target_index]
and vim.api.nvim_win_is_valid(test_buffers[target_index].input_win)
then
vim.api.nvim_set_current_win(test_buffers[target_index].input_win)
end
logger.log(('Editing %d test cases'):format(num_tests))
end
return M

View file

@ -26,7 +26,7 @@ local function parse_diff_line(text)
line = 0,
col_start = highlight_start,
col_end = #result_text,
highlight_group = 'CpDiffRemoved',
highlight_group = 'DiffDelete',
})
pos = removed_end + 1
else
@ -38,7 +38,7 @@ local function parse_diff_line(text)
line = 0,
col_start = highlight_start,
col_end = #result_text,
highlight_group = 'CpDiffAdded',
highlight_group = 'DiffAdd',
})
pos = added_end + 1
else

320
lua/cp/ui/layouts.lua Normal file
View file

@ -0,0 +1,320 @@
local M = {}
local helpers = require('cp.helpers')
local utils = require('cp.utils')
M.DIFF_MODES = {
['side-by-side'] = 'side-by-side',
vim = 'vim',
git = 'git',
}
local function create_side_by_side_layout(parent_win, expected_content, actual_content)
local expected_buf = utils.create_buffer_with_options()
local actual_buf = utils.create_buffer_with_options()
helpers.clearcol(expected_buf)
helpers.clearcol(actual_buf)
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local actual_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(actual_win, actual_buf)
vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
local label = M.DIFF_MODES['side-by-side']
vim.api.nvim_set_option_value(
'winbar',
('expected (diff: %s)'):format(label),
{ win = expected_win }
)
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(expected_buf, expected_lines, {})
utils.update_buffer_content(actual_buf, actual_lines, {})
return {
buffers = { expected_buf, actual_buf },
windows = { expected_win, actual_win },
mode = 'side-by-side',
cleanup = function()
pcall(vim.api.nvim_win_close, expected_win, true)
pcall(vim.api.nvim_win_close, actual_win, true)
pcall(vim.api.nvim_buf_delete, expected_buf, { force = true })
pcall(vim.api.nvim_buf_delete, actual_buf, { force = true })
end,
}
end
local function create_vim_diff_layout(parent_win, expected_content, actual_content)
local expected_buf = utils.create_buffer_with_options()
local actual_buf = utils.create_buffer_with_options()
helpers.clearcol(expected_buf)
helpers.clearcol(actual_buf)
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local actual_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(actual_win, actual_buf)
vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
local label = M.DIFF_MODES.vim
vim.api.nvim_set_option_value(
'winbar',
('expected (diff: %s)'):format(label),
{ win = expected_win }
)
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(expected_buf, expected_lines, {})
utils.update_buffer_content(actual_buf, actual_lines, {})
vim.api.nvim_set_option_value('diff', true, { win = expected_win })
vim.api.nvim_set_option_value('diff', true, { win = actual_win })
vim.api.nvim_win_call(expected_win, function()
vim.cmd.diffthis()
end)
vim.api.nvim_win_call(actual_win, function()
vim.cmd.diffthis()
end)
vim.api.nvim_set_option_value('foldcolumn', '0', { win = expected_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = actual_win })
return {
buffers = { expected_buf, actual_buf },
windows = { expected_win, actual_win },
mode = 'vim',
cleanup = function()
pcall(vim.api.nvim_win_close, expected_win, true)
pcall(vim.api.nvim_win_close, actual_win, true)
pcall(vim.api.nvim_buf_delete, expected_buf, { force = true })
pcall(vim.api.nvim_buf_delete, actual_buf, { force = true })
end,
}
end
local function create_git_diff_layout(parent_win, expected_content, actual_content)
local diff_buf = utils.create_buffer_with_options()
helpers.clearcol(diff_buf)
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local diff_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(diff_win, diff_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf })
local label = M.DIFF_MODES.git
vim.api.nvim_set_option_value('winbar', ('diff: %s'):format(label), { win = diff_win })
local diff_backend = require('cp.ui.diff')
local backend = diff_backend.get_best_backend('git')
local diff_result = backend.render(expected_content, actual_content)
local highlight = require('cp.ui.highlight')
local diff_namespace = highlight.create_namespace()
if diff_result.raw_diff and diff_result.raw_diff ~= '' then
highlight.parse_and_apply_diff(diff_buf, diff_result.raw_diff, diff_namespace)
else
local lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(diff_buf, lines, {})
end
return {
buffers = { diff_buf },
windows = { diff_win },
mode = 'git',
cleanup = function()
pcall(vim.api.nvim_win_close, diff_win, true)
pcall(vim.api.nvim_buf_delete, diff_buf, { force = true })
end,
}
end
local function create_single_layout(parent_win, content)
local buf = utils.create_buffer_with_options()
local lines = vim.split(content, '\n', { plain = true })
utils.update_buffer_content(buf, lines, {})
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = buf })
return {
buffers = { buf },
windows = { win },
mode = 'single',
cleanup = function()
pcall(vim.api.nvim_win_close, win, true)
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end,
}
end
function M.create_diff_layout(mode, parent_win, expected_content, actual_content)
if mode == 'single' then
return create_single_layout(parent_win, actual_content)
elseif mode == 'side-by-side' then
return create_side_by_side_layout(parent_win, expected_content, actual_content)
elseif mode == 'git' then
return create_git_diff_layout(parent_win, expected_content, actual_content)
elseif mode == 'vim' then
return create_vim_diff_layout(parent_win, expected_content, actual_content)
else
return create_side_by_side_layout(parent_win, expected_content, actual_content)
end
end
function M.update_diff_panes(
current_diff_layout,
current_mode,
main_win,
run,
config,
setup_keybindings_for_buffer
)
local test_state = run.get_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
if not current_test then
return current_diff_layout, current_mode
end
local expected_content = current_test.expected or ''
local actual_content = current_test.actual or '(not run yet)'
local actual_highlights = current_test.actual_highlights or {}
local is_compilation_failure = current_test.error
and current_test.error:match('Compilation failed')
local should_show_diff = current_test.status == 'fail'
and current_test.actual
and not is_compilation_failure
if not should_show_diff then
expected_content = expected_content
actual_content = actual_content
end
local default_mode = config.ui.panel.diff_modes[1]
local desired_mode = is_compilation_failure and 'single' or (current_mode or default_mode)
local highlight = require('cp.ui.highlight')
local diff_namespace = highlight.create_namespace()
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')
if current_diff_layout and current_diff_layout.mode ~= desired_mode then
local saved_pos = vim.api.nvim_win_get_cursor(0)
current_diff_layout.cleanup()
current_diff_layout = nil
current_mode = nil
current_diff_layout =
M.create_diff_layout(desired_mode, main_win, expected_content, actual_content)
current_mode = desired_mode
for _, buf in ipairs(current_diff_layout.buffers) do
setup_keybindings_for_buffer(buf)
end
pcall(vim.api.nvim_win_set_cursor, 0, saved_pos)
return current_diff_layout, current_mode
end
if not current_diff_layout then
current_diff_layout =
M.create_diff_layout(desired_mode, main_win, expected_content, actual_content)
current_mode = desired_mode
for _, buf in ipairs(current_diff_layout.buffers) do
setup_keybindings_for_buffer(buf)
end
else
if desired_mode == 'single' then
local lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(
current_diff_layout.buffers[1],
lines,
actual_highlights,
ansi_namespace
)
elseif desired_mode == 'git' then
local diff_backend = require('cp.ui.diff')
local backend = diff_backend.get_best_backend('git')
local diff_result = backend.render(expected_content, actual_content)
if diff_result.raw_diff and diff_result.raw_diff ~= '' then
highlight.parse_and_apply_diff(
current_diff_layout.buffers[1],
diff_result.raw_diff,
diff_namespace
)
else
local lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(
current_diff_layout.buffers[1],
lines,
actual_highlights,
ansi_namespace
)
end
elseif desired_mode == 'side-by-side' then
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})
utils.update_buffer_content(
current_diff_layout.buffers[2],
actual_lines,
actual_highlights,
ansi_namespace
)
else
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})
utils.update_buffer_content(
current_diff_layout.buffers[2],
actual_lines,
actual_highlights,
ansi_namespace
)
if should_show_diff then
vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[2] })
vim.api.nvim_win_call(current_diff_layout.windows[1], function()
vim.cmd.diffthis()
end)
vim.api.nvim_win_call(current_diff_layout.windows[2], function()
vim.cmd.diffthis()
end)
vim.api.nvim_set_option_value('foldcolumn', '0', { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = current_diff_layout.windows[2] })
else
vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[2] })
end
end
end
return current_diff_layout, current_mode
end
return M

1015
lua/cp/ui/views.lua Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,43 +2,364 @@ 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
local _time_path = nil
local _time_reason = nil
local _timeout_cached = false
local _timeout_path = nil
local _timeout_reason = nil
local function is_windows()
return uname.sysname == 'Windows_NT'
end
local function check_time_is_gnu_time(bin)
local ok = vim.fn.executable(bin) == 1
if not ok then
return false
end
local r = vim.system({ bin, '--version' }, { text = true }):wait()
if r and r.code == 0 and r.stdout and r.stdout:lower():find('gnu time', 1, true) then
return true
end
return false
end
local function find_gnu_time()
if _time_cached then
return _time_path, _time_reason
end
if is_windows() then
_time_cached = true
_time_path = nil
_time_reason = 'unsupported on Windows'
return _time_path, _time_reason
end
local candidates
if uname and uname.sysname == 'Darwin' then
candidates = { 'gtime', '/opt/homebrew/bin/gtime', '/usr/local/bin/gtime' }
else
candidates = { '/usr/bin/time', 'time' }
end
for _, bin in ipairs(candidates) do
if check_time_is_gnu_time(bin) then
_time_cached = true
_time_path = bin
_time_reason = nil
return _time_path, _time_reason
end
end
_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
return _time_path, _time_reason
end
---@return string|nil path to GNU time binary
function M.time_path()
local path = find_gnu_time()
return path
end
---@return {ok:boolean, path:string|nil, reason:string|nil}
function M.time_capability()
local path, reason = find_gnu_time()
return { ok = path ~= nil, path = path, reason = reason }
end
---@return string
function M.get_plugin_path()
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
end
---@return boolean
function M.is_nix_build()
return _nix_python ~= nil
end
---@return string|nil
function M.get_nix_python()
return _nix_python
end
---@return boolean
function M.is_nix_discovered()
return _nix_discovered
end
---@param module string
---@param plugin_path string
---@return string[]
function M.get_python_cmd(module, plugin_path)
if _nix_python then
return { _nix_python, '-m', 'scrapers.' .. module }
end
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
end
local python_env_setup = false
---@return boolean
local function discover_nix_python()
local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim'
local cache_file = cache_dir .. '/nix-python'
local f = io.open(cache_file, 'r')
if f then
local cached = f:read('*l')
f:close()
if cached and vim.fn.executable(cached) == 1 then
_nix_python = cached
return true
end
end
local plugin_path = M.get_plugin_path()
vim.notify('[cp.nvim] Building Python environment with nix...', vim.log.levels.INFO)
vim.cmd.redraw()
local result = vim
.system(
{ 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' },
{ text = true }
)
:wait()
if result.code ~= 0 then
logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN)
return false
end
local store_path = result.stdout:gsub('%s+$', '')
local python_path = store_path .. '/bin/python3'
if vim.fn.executable(python_path) ~= 1 then
logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN)
return false
end
vim.fn.mkdir(cache_dir, 'p')
f = io.open(cache_file, 'w')
if f then
f:write(python_path)
f:close()
end
_nix_python = python_path
_nix_discovered = true
return true
end
---@return boolean success
function M.setup_python_env()
if python_env_setup then
return true
end
local plugin_path = M.get_plugin_path()
local venv_dir = plugin_path .. '/.venv'
if vim.fn.executable('uv') == 0 then
logger.log(
'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/',
vim.log.levels.WARN
)
return false
if _nix_python then
logger.log('Python env: nix (python=' .. _nix_python .. ')')
python_env_setup = true
return true
end
if vim.fn.isdirectory(venv_dir) == 0 then
logger.log('setting up Python environment for scrapers...')
local result = vim.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true }):wait()
if vim.fn.executable('uv') == 1 then
local plugin_path = M.get_plugin_path()
logger.log('Python env: uv sync (dir=' .. plugin_path .. ')')
vim.notify('[cp.nvim] Setting up Python environment...', vim.log.levels.INFO)
vim.cmd.redraw()
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
env.CONDA_PREFIX = ''
local result = vim
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
:wait()
if result.code ~= 0 then
logger.log('failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
logger.log(
'Failed to setup Python environment: ' .. (result.stderr or ''),
vim.log.levels.ERROR
)
return false
end
logger.log('Python environment setup complete')
if result.stderr and result.stderr ~= '' then
logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', ''))
end
python_env_setup = true
return true
end
if vim.fn.executable('nix') == 1 then
logger.log('Python env: nix discovery')
if discover_nix_python() then
python_env_setup = true
return true
end
end
logger.log(
'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.',
vim.log.levels.WARN
)
return false
end
--- Configure the buffer with good defaults
---@param filetype? string
function M.create_buffer_with_options(filetype)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = buf })
vim.api.nvim_set_option_value('readonly', true, { buf = buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
if filetype then
vim.api.nvim_set_option_value('filetype', filetype, { buf = buf })
end
return buf
end
---@param bufnr integer
---@param lines string[]
---@param highlights? Highlight[]
---@param namespace? integer
function M.update_buffer_content(bufnr, lines, highlights, namespace)
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr })
vim.api.nvim_set_option_value('readonly', false, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr })
if highlights and namespace then
local highlight = require('cp.ui.highlight')
highlight.apply_highlights(bufnr, highlights, namespace)
end
end
function M.check_required_runtime()
if is_windows() then
return false, 'Windows is not supported'
end
if vim.fn.has('nvim-0.10.0') ~= 1 then
return false, 'Neovim 0.10.0+ required'
end
local time = M.time_capability()
if not time.ok then
return false, time.reason
end
local timeout = M.timeout_capability()
if not timeout.ok then
return false, timeout.reason
end
python_env_setup = true
return true
end
local function check_timeout_is_gnu_timeout(bin)
if vim.fn.executable(bin) ~= 1 then
return false
end
local r = vim.system({ bin, '--version' }, { text = true }):wait()
if r and r.code == 0 and r.stdout then
local s = r.stdout:lower()
if s:find('gnu coreutils', 1, true) or s:find('timeout %(gnu coreutils%)', 1, true) then
return true
end
end
return false
end
local function find_gnu_timeout()
if _timeout_cached then
return _timeout_path, _timeout_reason
end
if is_windows() then
_timeout_cached = true
_timeout_path = nil
_timeout_reason = 'unsupported on Windows'
return _timeout_path, _timeout_reason
end
local candidates
if uname and uname.sysname == 'Darwin' then
candidates = { 'gtimeout', '/opt/homebrew/bin/gtimeout', '/usr/local/bin/gtimeout' }
else
candidates = { '/usr/bin/timeout', 'timeout' }
end
for _, bin in ipairs(candidates) do
if check_timeout_is_gnu_timeout(bin) then
_timeout_cached = true
_timeout_path = bin
_timeout_reason = nil
return _timeout_path, _timeout_reason
end
end
_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
return _timeout_path, _timeout_reason
end
function M.timeout_path()
local path = find_gnu_timeout()
return path
end
function M.timeout_capability()
local path, reason = find_gnu_timeout()
return { ok = path ~= nil, path = path, reason = reason }
end
function M.cwd_executables()
local uv = vim.uv or vim.loop
local req = uv.fs_scandir('.')
if not req then
return {}
end
local out = {}
while true do
local name, t = uv.fs_scandir_next(req)
if not name then
break
end
if t == 'file' or t == 'link' then
local path = './' .. name
if vim.fn.executable(path) == 1 then
out[#out + 1] = name
end
end
end
table.sort(out)
return out
end
function M.ensure_dirs()
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
end
return M

View file

@ -20,54 +20,151 @@ end, {
num_args = num_args + 1
end
if num_args == 2 then
local candidates = {}
local cp = require('cp')
local context = cp.get_current_context()
if context.platform and context.contest_id then
vim.list_extend(candidates, actions)
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(context.platform, context.contest_id)
if contest_data and contest_data.problems then
for _, problem in ipairs(contest_data.problems) do
table.insert(candidates, problem.id)
end
end
else
vim.list_extend(candidates, platforms)
table.insert(candidates, 'cache')
table.insert(candidates, 'pick')
end
local function filter_candidates(candidates)
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, candidates)
end
local function get_enabled_languages(platform)
local config = require('cp.config').get_config()
if platform and config.platforms[platform] then
return config.platforms[platform].enabled_languages
end
return vim.tbl_keys(config.languages)
end
if num_args == 2 then
local candidates = {}
local state = require('cp.state')
local platform = state.get_platform()
local contest_id = state.get_contest_id()
vim.list_extend(candidates, platforms)
table.insert(candidates, 'cache')
table.insert(candidates, 'pick')
if platform and contest_id then
vim.list_extend(candidates, actions)
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if contest_data and contest_data.index_map then
local ids = vim.tbl_keys(contest_data.index_map)
table.sort(ids)
vim.list_extend(candidates, ids)
end
end
return filter_candidates(candidates)
elseif num_args == 3 then
if args[2] == 'cache' then
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, { 'clear' })
if vim.tbl_contains(platforms, args[2]) then
local cache = require('cp.cache')
cache.load()
local contests = cache.get_cached_contest_ids(args[2])
return filter_candidates(contests)
elseif args[2] == 'cache' then
return filter_candidates({ 'clear', 'read' })
elseif args[2] == 'interact' then
local utils = require('cp.utils')
return filter_candidates(utils.cwd_executables())
elseif args[2] == 'edit' then
local state = require('cp.state')
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
local candidates = {}
if platform and contest_id and problem_id then
local cache = require('cp.cache')
cache.load()
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if test_cases then
for i = 1, #test_cases do
table.insert(candidates, tostring(i))
end
end
end
return filter_candidates(candidates)
elseif args[2] == 'run' or args[2] == 'panel' then
local state = require('cp.state')
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
local candidates = { '--debug' }
if platform and contest_id and problem_id then
local cache = require('cp.cache')
cache.load()
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if test_cases then
for i = 1, #test_cases do
table.insert(candidates, tostring(i))
end
end
end
return filter_candidates(candidates)
elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then
return filter_candidates({ '--lang' })
else
local state = require('cp.state')
if state.get_platform() and state.get_contest_id() then
return filter_candidates({ '--lang' })
end
end
elseif num_args == 4 then
if args[2] == 'cache' and args[3] == 'clear' then
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, platforms)
local candidates = vim.list_extend({}, platforms)
table.insert(candidates, '')
return filter_candidates(candidates)
elseif args[3] == '--lang' then
local platform = require('cp.state').get_platform()
return filter_candidates(get_enabled_languages(platform))
elseif (args[2] == 'run' or args[2] == 'panel') and tonumber(args[3]) then
return filter_candidates({ '--debug' })
elseif vim.tbl_contains(platforms, args[2]) then
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(args[2], args[3])
local candidates = { '--lang' }
if contest_data and contest_data.problems then
local candidates = {}
for _, problem in ipairs(contest_data.problems) do
table.insert(candidates, problem.id)
end
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, candidates)
end
return filter_candidates(candidates)
end
elseif num_args == 5 then
if args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then
local cache = require('cp.cache')
cache.load()
local contests = cache.get_cached_contest_ids(args[4])
return filter_candidates(contests)
elseif vim.tbl_contains(platforms, args[2]) then
if args[4] == '--lang' then
return filter_candidates(get_enabled_languages(args[2]))
else
return filter_candidates({ '--lang' })
end
end
elseif num_args == 6 then
if vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
return filter_candidates(get_enabled_languages(args[2]))
end
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

@ -1,24 +1,29 @@
[project]
name = "scrapers"
version = "0.1.0"
description = "Add your description here"
description = "Competitive programming scrapers for a variety of web platforms."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"backoff>=2.2.1",
"beautifulsoup4>=4.13.5",
"cloudscraper>=1.2.71",
"curl-cffi>=0.13.0",
"httpx>=0.28.1",
"ndjson>=0.3.1",
"pydantic>=2.11.10",
"requests>=2.32.5",
]
[dependency-groups]
dev = [
"mypy>=1.18.2",
"types-beautifulsoup4>=4.12.0.20250516",
"types-requests>=2.32.4.20250913",
"pytest>=8.0.0",
"pytest-mock>=3.12.0",
"pre-commit>=4.3.0",
"basedpyright>=1.31.6",
"ruff>=0.14.2",
"ty>=0.0.1a32",
]
[tool.pytest.ini_options]

View file

@ -1,15 +1,22 @@
#!/usr/bin/env python3
import asyncio
import json
import re
import sys
from dataclasses import asdict
import time
from typing import Any
import backoff
import httpx
import requests
from bs4 import BeautifulSoup, Tag
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .base import BaseScraper
from .models import (
CombinedTest,
ContestListResult,
ContestSummary,
MetadataResult,
@ -18,413 +25,374 @@ from .models import (
TestsResult,
)
MIB_TO_MB = 1.048576
BASE_URL = "https://atcoder.jp"
ARCHIVE_URL = f"{BASE_URL}/contests/archive"
TIMEOUT_SECONDS = 30
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
RETRY_STATUS = {429, 502, 503, 504}
FATAL_STATUS = {400, 401, 403, 404, 410}
def extract_problem_limits(soup: BeautifulSoup) -> tuple[int, float]:
timeout_ms = None
memory_mb = None
paragraphs = soup.find_all("p")
for p in paragraphs:
text = p.get_text()
if "Time Limit:" in text and "Memory Limit:" in text:
time_match = re.search(r"Time Limit:\s*(\d+)\s*sec", text)
if time_match:
seconds = int(time_match.group(1))
timeout_ms = seconds * 1000
memory_match = re.search(r"Memory Limit:\s*(\d+)\s*MiB", text)
if memory_match:
memory_mib = int(memory_match.group(1))
memory_mb = round(memory_mib * 1.048576, 2)
break
if timeout_ms is None:
raise ValueError("Could not find valid timeout in problem constraints")
if memory_mb is None:
raise ValueError("Could not find valid memory limit in problem constraints")
return timeout_ms, memory_mb
_session = requests.Session()
_adapter = HTTPAdapter(
pool_connections=100,
pool_maxsize=100,
max_retries=Retry(total=0),
)
_session.mount("https://", _adapter)
_session.mount("http://", _adapter)
def parse_problem_url(contest_id: str, problem_letter: str) -> str:
task_id: str = f"{contest_id}_{problem_letter}"
return f"https://atcoder.jp/contests/{contest_id}/tasks/{task_id}"
def _give_up_requests(exc: Exception) -> bool:
if isinstance(exc, requests.HTTPError) and exc.response is not None:
return exc.response.status_code in FATAL_STATUS
return False
def extract_problem_from_row(row, contest_id: str) -> ProblemSummary | None:
cells = row.find_all("td")
if len(cells) < 2:
return None
task_link = cells[1].find("a")
if not task_link:
return None
task_name = task_link.get_text(strip=True)
task_href = task_link.get("href", "")
if not task_href:
return None
task_id = task_href.split("/")[-1]
if not task_id.startswith(contest_id + "_"):
return None
problem_letter = task_id[len(contest_id) + 1 :]
if not problem_letter or not task_name:
return None
return ProblemSummary(id=problem_letter.lower(), name=task_name)
def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]:
try:
contest_url = f"https://atcoder.jp/contests/{contest_id}/tasks"
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = requests.get(contest_url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
task_table = soup.find("table", class_="table")
if not task_table or not isinstance(task_table, Tag):
return []
rows = task_table.find_all("tr")[1:]
problems: list[ProblemSummary] = []
for row in rows:
problem = extract_problem_from_row(row, contest_id)
if problem:
problems.append(problem)
return problems
except Exception as e:
print(f"Failed to scrape AtCoder contest problems: {e}", file=sys.stderr)
return []
def extract_test_case_from_headers(sample_headers, i: int) -> tuple[str, str] | None:
if i >= len(sample_headers):
return None
header = sample_headers[i]
if "input" not in header.get_text().lower():
return None
input_pre = header.find_next("pre")
if not input_pre or i + 1 >= len(sample_headers):
return None
next_header = sample_headers[i + 1]
if "output" not in next_header.get_text().lower():
return None
output_pre = next_header.find_next("pre")
if not output_pre:
return None
input_text = input_pre.get_text().strip().replace("\r", "")
output_text = output_pre.get_text().strip().replace("\r", "")
if not input_text or not output_text:
return None
return (input_text, output_text)
def scrape(url: str) -> list[TestCase]:
try:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
sample_headers = soup.find_all(
"h3", string=lambda x: x and "sample" in x.lower() if x else False
)
tests: list[TestCase] = []
i = 0
while i < len(sample_headers):
test_case = extract_test_case_from_headers(sample_headers, i)
if test_case:
input_text, output_text = test_case
tests.append(TestCase(input=input_text, expected=output_text))
i += 2
else:
i += 1
return tests
except Exception as e:
print(f"Error scraping AtCoder: {e}", file=sys.stderr)
return []
def scrape_contests() -> list[ContestSummary]:
import concurrent.futures
def get_max_pages() -> int:
try:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = requests.get(
"https://atcoder.jp/contests/archive", headers=headers, timeout=10
)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
pagination = soup.find("ul", class_="pagination")
if not pagination or not isinstance(pagination, Tag):
return 15
lis = pagination.find_all("li")
if lis and isinstance(lis[-1], Tag):
last_li_text = lis[-1].get_text().strip()
try:
return int(last_li_text)
except ValueError:
return 15
return 15
except Exception:
return 15
def scrape_page(page: int) -> list[ContestSummary]:
@backoff.on_exception(
backoff.expo,
(requests.exceptions.RequestException, requests.exceptions.HTTPError),
max_tries=4,
jitter=backoff.random_jitter,
on_backoff=lambda details: print(
f"Request failed on page {page} (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}",
file=sys.stderr,
),
)
@backoff.on_predicate(
backoff.expo,
lambda response: response.status_code == 429,
max_tries=4,
jitter=backoff.random_jitter,
on_backoff=lambda details: print(
f"Rate limited on page {page}, retrying in {details['wait']:.1f}s",
file=sys.stderr,
),
)
def make_request() -> requests.Response:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
url = f"https://atcoder.jp/contests/archive?page={page}"
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response
try:
response = make_request()
except Exception:
return []
soup = BeautifulSoup(response.text, "html.parser")
table = soup.find("table", class_="table")
if not table:
return []
tbody = table.find("tbody")
if not tbody or not isinstance(tbody, Tag):
return []
rows = tbody.find_all("tr")
if not rows:
return []
contests = []
for row in rows:
cells = row.find_all("td")
if len(cells) < 2:
continue
contest_cell = cells[1]
link = contest_cell.find("a")
if not link or not link.get("href"):
continue
href = link.get("href")
contest_id = href.split("/")[-1]
name = link.get_text().strip()
def _retry_after_requests(details):
exc = details.get("exception")
if isinstance(exc, requests.HTTPError) and exc.response is not None:
ra = exc.response.headers.get("Retry-After")
if ra:
try:
name = name.encode().decode("unicode_escape")
except (UnicodeDecodeError, UnicodeEncodeError):
time.sleep(max(0.0, float(ra)))
except ValueError:
pass
name = (
name.replace("\uff08", "(")
.replace("\uff09", ")")
.replace("\u3000", " ")
@backoff.on_exception(
backoff.expo,
(requests.ConnectionError, requests.Timeout, requests.HTTPError),
max_tries=5,
jitter=backoff.full_jitter,
giveup=_give_up_requests,
on_backoff=_retry_after_requests,
)
def _fetch(url: str) -> str:
r = _session.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS)
if r.status_code in RETRY_STATUS:
raise requests.HTTPError(response=r)
r.raise_for_status()
return r.text
def _giveup_httpx(exc: Exception) -> bool:
return (
isinstance(exc, httpx.HTTPStatusError)
and exc.response is not None
and (exc.response.status_code in FATAL_STATUS)
)
@backoff.on_exception(
backoff.expo,
(httpx.ConnectError, httpx.ReadTimeout, httpx.HTTPStatusError),
max_tries=5,
jitter=backoff.full_jitter,
giveup=_giveup_httpx,
)
async def _get_async(client: httpx.AsyncClient, url: str) -> str:
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS)
r.raise_for_status()
return r.text
def _text_from_pre(pre: Tag) -> str:
return (
pre.get_text(separator="\n", strip=False)
.replace("\r", "")
.replace("\xa0", " ")
.rstrip("\n")
)
def _parse_last_page(html: str) -> int:
soup = BeautifulSoup(html, "html.parser")
nav = soup.select_one("ul.pagination")
if not nav:
return 1
nums = []
for a in nav.select("a"):
s = a.get_text(strip=True)
if s.isdigit():
nums.append(int(s))
return max(nums) if nums else 1
def _parse_archive_contests(html: str) -> list[ContestSummary]:
soup = BeautifulSoup(html, "html.parser")
tbody = soup.select_one("table.table-default tbody") or soup.select_one("tbody")
if not tbody:
return []
out: list[ContestSummary] = []
for tr in tbody.select("tr"):
a = tr.select_one("a[href^='/contests/']")
if not a:
continue
href_attr = a.get("href")
if not isinstance(href_attr, str):
continue
m = re.search(r"/contests/([^/?#]+)", href_attr)
if not m:
continue
cid = m.group(1)
name = a.get_text(strip=True)
out.append(ContestSummary(id=cid, name=name, display_name=name))
return out
def _parse_tasks_list(html: str) -> list[dict[str, str]]:
soup = BeautifulSoup(html, "html.parser")
tbody = soup.select_one("table tbody")
if not tbody:
return []
rows: list[dict[str, str]] = []
for tr in tbody.select("tr"):
tds = tr.select("td")
if len(tds) < 2:
continue
letter = tds[0].get_text(strip=True)
a = tds[1].select_one("a[href*='/tasks/']")
if not a:
continue
href_attr = a.get("href")
if not isinstance(href_attr, str):
continue
m = re.search(r"/contests/[^/]+/tasks/([^/?#]+)", href_attr)
if not m:
continue
slug = m.group(1)
title = a.get_text(strip=True)
rows.append({"letter": letter, "title": title, "slug": slug})
return rows
def _extract_problem_info(html: str) -> tuple[int, float, bool]:
soup = BeautifulSoup(html, "html.parser")
txt = soup.get_text(" ", strip=True)
timeout_ms = 0
memory_mb = 0.0
ts = re.search(r"Time\s*Limit:\s*([\d.]+)\s*sec", txt, flags=re.I)
if ts:
timeout_ms = int(float(ts.group(1)) * 1000)
ms = re.search(r"Memory\s*Limit:\s*(\d+)\s*MiB", txt, flags=re.I)
if ms:
memory_mb = float(ms.group(1)) * MIB_TO_MB
div = soup.select_one("#problem-statement")
txt = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True)
interactive = "This is an interactive" in txt
return timeout_ms, memory_mb, interactive
def _extract_samples(html: str) -> list[TestCase]:
soup = BeautifulSoup(html, "html.parser")
root = soup.select_one("#task-statement") or soup
inputs: dict[str, str] = {}
outputs: dict[str, str] = {}
for h in root.find_all(re.compile(r"h[2-4]")):
title = h.get_text(" ", strip=True)
pre = h.find_next("pre")
if not pre:
continue
t = _text_from_pre(pre)
mi = re.search(r"Sample\s*Input\s*(\d+)", title, flags=re.I)
mo = re.search(r"Sample\s*Output\s*(\d+)", title, flags=re.I)
if mi:
inputs[mi.group(1)] = t.strip()
elif mo:
outputs[mo.group(1)] = t.strip()
cases: list[TestCase] = []
for k in sorted(set(inputs) & set(outputs), key=lambda s: int(s)):
cases.append(TestCase(input=inputs[k], expected=outputs[k]))
return cases
def _scrape_tasks_sync(contest_id: str) -> list[dict[str, str]]:
html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks")
return _parse_tasks_list(html)
def _scrape_problem_page_sync(contest_id: str, slug: str) -> dict[str, Any]:
html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks/{slug}")
try:
tests = _extract_samples(html)
except Exception:
tests = []
timeout_ms, memory_mb, interactive = _extract_problem_info(html)
return {
"tests": tests,
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
}
def _to_problem_summaries(rows: list[dict[str, str]]) -> list[ProblemSummary]:
out: list[ProblemSummary] = []
for r in rows:
letter = (r.get("letter") or "").strip().upper()
title = r.get("title") or ""
if not letter:
continue
pid = letter.lower()
out.append(ProblemSummary(id=pid, name=title))
return out
async def _fetch_all_contests_async() -> list[ContestSummary]:
async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=100, max_keepalive_connections=100),
) as client:
first_html = await _get_async(client, ARCHIVE_URL)
last = _parse_last_page(first_html)
out = _parse_archive_contests(first_html)
if last <= 1:
return out
tasks = [
asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}"))
for p in range(2, last + 1)
]
for coro in asyncio.as_completed(tasks):
html = await coro
out.extend(_parse_archive_contests(html))
return out
class AtcoderScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "atcoder"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
try:
rows = await asyncio.to_thread(_scrape_tasks_sync, contest_id)
problems = _to_problem_summaries(rows)
if not problems:
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s",
)
name = re.sub(
r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name
except Exception as e:
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
try:
contests = await _fetch_all_contests_async()
if not contests:
return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests)
except Exception as e:
return self._contests_error(str(e))
async def stream_tests_for_category_async(self, category_id: str) -> None:
rows = await asyncio.to_thread(_scrape_tasks_sync, category_id)
async def emit(row: dict[str, str]) -> None:
letter = (row.get("letter") or "").strip().lower()
slug = row.get("slug") or ""
if not letter or not slug:
return
data = await asyncio.to_thread(_scrape_problem_page_sync, category_id, slug)
tests: list[TestCase] = data.get("tests", [])
combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = "\n".join(t.expected for t in tests) if tests else ""
print(
json.dumps(
{
"problem_id": letter,
"combined": {
"input": combined_input,
"expected": combined_expected,
},
"tests": [
{"input": t.input, "expected": t.expected} for t in tests
],
"timeout_ms": data.get("timeout_ms", 0),
"memory_mb": data.get("memory_mb", 0),
"interactive": bool(data.get("interactive")),
"multi_test": False,
}
),
flush=True,
)
contests.append(ContestSummary(id=contest_id, name=name, display_name=name))
return contests
max_pages = get_max_pages()
page_results = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
future_to_page = {
executor.submit(scrape_page, page): page for page in range(1, max_pages + 1)
}
for future in concurrent.futures.as_completed(future_to_page):
page = future_to_page[future]
page_contests = future.result()
page_results[page] = page_contests
all_contests = []
for page in sorted(page_results.keys()):
all_contests.extend(page_results[page])
return all_contests
await asyncio.gather(*(emit(r) for r in rows))
def main() -> None:
async def main_async() -> int:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> <problem_letter> OR atcoder.py contests",
error="Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> OR atcoder.py contests",
url="",
)
print(json.dumps(asdict(result)))
sys.exit(1)
print(result.model_dump_json())
return 1
mode: str = sys.argv[1]
scraper = AtcoderScraper()
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False,
error="Usage: atcoder.py metadata <contest_id>",
)
print(json.dumps(asdict(result)))
sys.exit(1)
contest_id: str = sys.argv[2]
problems: list[ProblemSummary] = scrape_contest_problems(contest_id)
if not problems:
result = MetadataResult(
success=False,
error=f"No problems found for contest {contest_id}",
)
print(json.dumps(asdict(result)))
sys.exit(1)
result = MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
)
print(json.dumps(asdict(result)))
elif mode == "tests":
if len(sys.argv) != 4:
tests_result = TestsResult(
success=False,
error="Usage: atcoder.py tests <contest_id> <problem_letter>",
problem_id="",
url="",
)
print(result.model_dump_json())
return 1
contest_id = sys.argv[2]
result = await scraper.scrape_contest_metadata(contest_id)
print(result.model_dump_json())
return 0 if result.success else 1
if mode == "tests":
if len(sys.argv) != 3:
tests_result = TestsResult(
success=False,
error="Usage: atcoder.py tests <contest_id>",
problem_id="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
print(tests_result.model_dump_json())
return 1
contest_id = sys.argv[2]
await scraper.stream_tests_for_category_async(contest_id)
return 0
test_contest_id: str = sys.argv[2]
problem_letter: str = sys.argv[3]
problem_id: str = f"{test_contest_id}_{problem_letter.lower()}"
url: str = parse_problem_url(test_contest_id, problem_letter)
tests: list[TestCase] = scrape(url)
try:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
timeout_ms, memory_mb = extract_problem_limits(soup)
except Exception as e:
tests_result = TestsResult(
success=False,
error=f"Failed to extract constraints: {e}",
problem_id=problem_id,
url=url,
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
if not tests:
tests_result = TestsResult(
success=False,
error=f"No tests found for {test_contest_id} {problem_letter}",
problem_id=problem_id,
url=url,
tests=[],
timeout_ms=timeout_ms,
memory_mb=memory_mb,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
tests_result = TestsResult(
success=True,
error="",
problem_id=problem_id,
url=url,
tests=tests,
timeout_ms=timeout_ms,
memory_mb=memory_mb,
)
print(json.dumps(asdict(tests_result)))
elif mode == "contests":
if mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: atcoder.py contests"
)
print(json.dumps(asdict(contest_result)))
sys.exit(1)
print(contest_result.model_dump_json())
return 1
contest_result = await scraper.scrape_contest_list()
print(contest_result.model_dump_json())
return 0 if contest_result.success else 1
contests = scrape_contests()
if not contests:
contest_result = ContestListResult(success=False, error="No contests found")
print(json.dumps(asdict(contest_result)))
sys.exit(1)
result = MetadataResult(
success=False,
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
url="",
)
print(result.model_dump_json())
return 1
contest_result = ContestListResult(success=True, error="", contests=contests)
print(json.dumps(asdict(contest_result)))
else:
result = MetadataResult(
success=False,
error=f"Unknown mode: {mode}. Use 'metadata', 'tests', or 'contests'",
)
print(json.dumps(asdict(result)))
sys.exit(1)
def main() -> None:
sys.exit(asyncio.run(main_async()))
if __name__ == "__main__":

83
scrapers/base.py Normal file
View file

@ -0,0 +1,83 @@
import asyncio
import sys
from abc import ABC, abstractmethod
from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
class BaseScraper(ABC):
@property
@abstractmethod
def platform_name(self) -> str: ...
@abstractmethod
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: ...
@abstractmethod
async def scrape_contest_list(self) -> ContestListResult: ...
@abstractmethod
async def stream_tests_for_category_async(self, category_id: str) -> None: ...
def _usage(self) -> str:
name = self.platform_name
return f"Usage: {name}.py metadata <id> | tests <id> | contests"
def _metadata_error(self, msg: str) -> MetadataResult:
return MetadataResult(success=False, error=msg, url="")
def _tests_error(self, msg: str) -> TestsResult:
return TestsResult(
success=False,
error=msg,
problem_id="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
def _contests_error(self, msg: str) -> ContestListResult:
return ContestListResult(success=False, error=msg)
async def _run_cli_async(self, args: list[str]) -> int:
if len(args) < 2:
print(self._metadata_error(self._usage()).model_dump_json())
return 1
mode = args[1]
match mode:
case "metadata":
if len(args) != 3:
print(self._metadata_error(self._usage()).model_dump_json())
return 1
result = await self.scrape_contest_metadata(args[2])
print(result.model_dump_json())
return 0 if result.success else 1
case "tests":
if len(args) != 3:
print(self._tests_error(self._usage()).model_dump_json())
return 1
await self.stream_tests_for_category_async(args[2])
return 0
case "contests":
if len(args) != 2:
print(self._contests_error(self._usage()).model_dump_json())
return 1
result = await self.scrape_contest_list()
print(result.model_dump_json())
return 0 if result.success else 1
case _:
print(
self._metadata_error(
f"Unknown mode: {mode}. {self._usage()}"
).model_dump_json()
)
return 1
def run_cli(self) -> None:
sys.exit(asyncio.run(self._run_cli_async(sys.argv)))

253
scrapers/codechef.py Normal file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env python3
import asyncio
import json
import re
from typing import Any
import httpx
from curl_cffi import requests as curl_requests
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
)
BASE_URL = "https://www.codechef.com"
API_CONTESTS_ALL = "/api/list/contests/all"
API_CONTEST = "/api/contests/{contest_id}"
API_PROBLEM = "/api/contests/{contest_id}/problems/{problem_id}"
PROBLEM_URL = "https://www.codechef.com/problems/{problem_id}"
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
TIMEOUT_S = 15.0
CONNECTIONS = 8
MEMORY_LIMIT_RE = re.compile(
r"Memory\s+[Ll]imit.*?([0-9.]+)\s*(MB|GB)", re.IGNORECASE | re.DOTALL
)
async def fetch_json(client: httpx.AsyncClient, path: str) -> dict:
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S)
r.raise_for_status()
return r.json()
def _extract_memory_limit(html: str) -> float:
m = MEMORY_LIMIT_RE.search(html)
if not m:
return 256.0
value = float(m.group(1))
unit = m.group(2).upper()
if unit == "GB":
return value * 1024.0
return value
def _fetch_html_sync(url: str) -> str:
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S)
response.raise_for_status()
return response.text
class CodeChefScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "codechef"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
try:
async with httpx.AsyncClient() as client:
data = await fetch_json(
client, API_CONTEST.format(contest_id=contest_id)
)
if not data.get("problems"):
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
problems = []
for problem_code, problem_data in data["problems"].items():
if problem_data.get("category_name") == "main":
problems.append(
ProblemSummary(
id=problem_code,
name=problem_data.get("name", problem_code),
)
)
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"{BASE_URL}/{contest_id}",
)
except Exception as e:
return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}")
async def scrape_contest_list(self) -> ContestListResult:
async with httpx.AsyncClient() as client:
try:
data = await fetch_json(client, API_CONTESTS_ALL)
except httpx.HTTPStatusError as e:
return self._contests_error(f"Failed to fetch contests: {e}")
all_contests = data.get("future_contests", []) + data.get(
"past_contests", []
)
max_num = 0
for contest in all_contests:
contest_code = contest.get("contest_code", "")
if contest_code.startswith("START"):
match = re.match(r"START(\d+)", contest_code)
if match:
num = int(match.group(1))
max_num = max(max_num, num)
if max_num == 0:
return self._contests_error("No Starters contests found")
contests = []
sem = asyncio.Semaphore(CONNECTIONS)
async def fetch_divisions(i: int) -> list[ContestSummary]:
parent_id = f"START{i}"
async with sem:
try:
parent_data = await fetch_json(
client, API_CONTEST.format(contest_id=parent_id)
)
except Exception as e:
import sys
print(f"Error fetching {parent_id}: {e}", file=sys.stderr)
return []
child_contests = parent_data.get("child_contests", {})
if not child_contests:
return []
base_name = f"Starters {i}"
divisions = []
for div_key, div_data in child_contests.items():
div_code = div_data.get("contest_code", "")
div_num = div_data.get("div", {}).get("div_number", "")
if div_code and div_num:
divisions.append(
ContestSummary(
id=div_code,
name=base_name,
display_name=f"{base_name} (Div. {div_num})",
)
)
return divisions
tasks = [fetch_divisions(i) for i in range(1, max_num + 1)]
for coro in asyncio.as_completed(tasks):
divisions = await coro
contests.extend(divisions)
return ContestListResult(success=True, error="", contests=contests)
async def stream_tests_for_category_async(self, category_id: str) -> None:
async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=CONNECTIONS)
) as client:
try:
contest_data = await fetch_json(
client, API_CONTEST.format(contest_id=category_id)
)
except Exception as e:
print(
json.dumps(
{"error": f"Failed to fetch contest {category_id}: {str(e)}"}
),
flush=True,
)
return
all_problems = contest_data.get("problems", {})
if not all_problems:
print(
json.dumps(
{"error": f"No problems found for contest {category_id}"}
),
flush=True,
)
return
problems = {
code: data
for code, data in all_problems.items()
if data.get("category_name") == "main"
}
if not problems:
print(
json.dumps(
{"error": f"No main problems found for contest {category_id}"}
),
flush=True,
)
return
sem = asyncio.Semaphore(CONNECTIONS)
async def run_one(problem_code: str) -> dict[str, Any]:
async with sem:
try:
problem_data = await fetch_json(
client,
API_PROBLEM.format(
contest_id=category_id, problem_id=problem_code
),
)
sample_tests = (
problem_data.get("problemComponents", {}).get(
"sampleTestCases", []
)
or []
)
tests = [
TestCase(
input=t.get("input", "").strip(),
expected=t.get("output", "").strip(),
)
for t in sample_tests
if not t.get("isDeleted", False)
]
time_limit_str = problem_data.get("max_timelimit", "1")
timeout_ms = int(float(time_limit_str) * 1000)
problem_url = PROBLEM_URL.format(problem_id=problem_code)
loop = asyncio.get_event_loop()
html = await loop.run_in_executor(
None, _fetch_html_sync, problem_url
)
memory_mb = _extract_memory_limit(html)
interactive = False
except Exception:
tests = []
timeout_ms = 1000
memory_mb = 256.0
interactive = False
combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = (
"\n".join(t.expected for t in tests) if tests else ""
)
return {
"problem_id": problem_code,
"combined": {
"input": combined_input,
"expected": combined_expected,
},
"tests": [
{"input": t.input, "expected": t.expected} for t in tests
],
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": False,
}
tasks = [run_one(problem_code) for problem_code in problems.keys()]
for coro in asyncio.as_completed(tasks):
payload = await coro
print(json.dumps(payload), flush=True)
if __name__ == "__main__":
CodeChefScraper().run_cli()

View file

@ -1,374 +1,273 @@
#!/usr/bin/env python3
import asyncio
import json
import re
import sys
from dataclasses import asdict
from typing import Any
import cloudscraper
import requests
from bs4 import BeautifulSoup, Tag
from curl_cffi import requests as curl_requests
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
TestsResult,
)
def scrape(url: str) -> list[TestCase]:
try:
scraper = cloudscraper.create_scraper()
response = scraper.get(url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
input_sections = soup.find_all("div", class_="input")
output_sections = soup.find_all("div", class_="output")
individual_inputs: dict[str, list[str]] = {}
individual_outputs: dict[str, list[str]] = {}
for inp_section in input_sections:
inp_pre = inp_section.find("pre")
if not inp_pre or not isinstance(inp_pre, Tag):
continue
test_line_divs = inp_pre.find_all(
"div", class_=lambda x: x and "test-example-line-" in x
)
if not test_line_divs:
continue
for div in test_line_divs:
classes = div.get("class", [])
class_name = next(
(
cls
for cls in classes
if "test-example-line-" in cls and cls.split("-")[-1].isdigit()
),
None,
)
if not class_name:
continue
test_num = class_name.replace("test-example-line-", "")
if test_num not in individual_inputs:
individual_inputs[test_num] = []
individual_inputs[test_num].append(div.get_text().strip())
for out_section in output_sections:
out_pre = out_section.find("pre")
if not out_pre or not isinstance(out_pre, Tag):
continue
test_line_divs = out_pre.find_all(
"div", class_=lambda x: x and "test-example-line-" in x
)
if not test_line_divs:
continue
for div in test_line_divs:
classes = div.get("class", [])
class_name = next(
(
cls
for cls in classes
if "test-example-line-" in cls and cls.split("-")[-1].isdigit()
),
None,
)
if not class_name:
continue
test_num = class_name.replace("test-example-line-", "")
if test_num not in individual_outputs:
individual_outputs[test_num] = []
individual_outputs[test_num].append(div.get_text().strip())
if individual_inputs and individual_outputs:
common_tests = set(individual_inputs.keys()) & set(
individual_outputs.keys()
)
if common_tests:
tests = []
for test_num in sorted(common_tests):
input_text = "\n".join(individual_inputs[test_num])
output_text = "\n".join(individual_outputs[test_num])
prefixed_input = "1\n" + input_text
tests.append(TestCase(input=prefixed_input, expected=output_text))
return tests
all_inputs = []
all_outputs = []
for inp_section in input_sections:
inp_pre = inp_section.find("pre")
if not inp_pre or not isinstance(inp_pre, Tag):
continue
divs = inp_pre.find_all("div")
if divs:
lines = [div.get_text().strip() for div in divs if isinstance(div, Tag)]
text = "\n".join(lines)
else:
text = inp_pre.get_text().replace("\r", "").strip()
all_inputs.append(text)
for out_section in output_sections:
out_pre = out_section.find("pre")
if not out_pre or not isinstance(out_pre, Tag):
continue
divs = out_pre.find_all("div")
if divs:
lines = [div.get_text().strip() for div in divs if isinstance(div, Tag)]
text = "\n".join(lines)
else:
text = out_pre.get_text().replace("\r", "").strip()
all_outputs.append(text)
if not all_inputs or not all_outputs:
return []
combined_input = "\n".join(all_inputs)
combined_output = "\n".join(all_outputs)
return [TestCase(input=combined_input, expected=combined_output)]
except Exception as e:
print(f"CloudScraper failed: {e}", file=sys.stderr)
return []
BASE_URL = "https://codeforces.com"
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
TIMEOUT_SECONDS = 30
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
}
def parse_problem_url(contest_id: str, problem_letter: str) -> str:
def _text_from_pre(pre: Tag) -> str:
return (
f"https://codeforces.com/contest/{contest_id}/problem/{problem_letter.upper()}"
pre.get_text(separator="\n", strip=False)
.replace("\r", "")
.replace("\xa0", " ")
.strip()
)
def extract_problem_limits(soup: BeautifulSoup) -> tuple[int, float]:
timeout_ms = None
memory_mb = None
time_limit_div = soup.find("div", class_="time-limit")
if time_limit_div:
text = time_limit_div.get_text().strip()
match = re.search(r"(\d+) seconds?", text)
if match:
seconds = int(match.group(1))
timeout_ms = seconds * 1000
if timeout_ms is None:
raise ValueError("Could not find valid timeout in time-limit section")
memory_limit_div = soup.find("div", class_="memory-limit")
if memory_limit_div:
text = memory_limit_div.get_text().strip()
match = re.search(r"(\d+) megabytes", text)
if match:
memory_mb = float(match.group(1))
if memory_mb is None:
raise ValueError("Could not find valid memory limit in memory-limit section")
def _extract_limits(block: Tag) -> tuple[int, float]:
tdiv = block.find("div", class_="time-limit")
mdiv = block.find("div", class_="memory-limit")
timeout_ms = 0
memory_mb = 0.0
if tdiv:
ttxt = tdiv.get_text(" ", strip=True)
ts = re.search(r"(\d+)\s*seconds?", ttxt)
if ts:
timeout_ms = int(ts.group(1)) * 1000
if mdiv:
mtxt = mdiv.get_text(" ", strip=True)
ms = re.search(r"(\d+)\s*megabytes?", mtxt)
if ms:
memory_mb = float(ms.group(1))
return timeout_ms, memory_mb
def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]:
try:
contest_url: str = f"https://codeforces.com/contest/{contest_id}"
scraper = cloudscraper.create_scraper()
response = scraper.get(contest_url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
problems: list[ProblemSummary] = []
problem_links = soup.find_all(
"a", href=lambda x: x and f"/contest/{contest_id}/problem/" in x
)
for link in problem_links:
if not isinstance(link, Tag):
continue
href: str = str(link.get("href", ""))
if f"/contest/{contest_id}/problem/" in href:
problem_letter: str = href.split("/")[-1].lower()
problem_name: str = link.get_text(strip=True)
if problem_letter and problem_name:
problems.append(
ProblemSummary(id=problem_letter, name=problem_name)
)
seen: set[str] = set()
unique_problems: list[ProblemSummary] = []
for p in problems:
if p.id not in seen:
seen.add(p.id)
unique_problems.append(p)
return unique_problems
except Exception as e:
print(f"Failed to scrape contest problems: {e}", file=sys.stderr)
return []
def _group_lines_by_id(pre: Tag) -> dict[int, list[str]]:
groups: dict[int, list[str]] = {}
for div in pre.find_all("div", class_="test-example-line"):
cls = " ".join(div.get("class", []))
m = re.search(r"\btest-example-line-(\d+)\b", cls)
if not m:
continue
gid = int(m.group(1))
groups.setdefault(gid, []).append(div.get_text("", strip=False))
return groups
def scrape_sample_tests(url: str) -> list[TestCase]:
print(f"Scraping: {url}", file=sys.stderr)
return scrape(url)
def _extract_title(block: Tag) -> tuple[str, str]:
t = block.find("div", class_="title")
if not t:
return "", ""
s = t.get_text(" ", strip=True)
parts = s.split(".", 1)
if len(parts) != 2:
return "", s.strip()
return parts[0].strip().upper(), parts[1].strip()
def scrape_contests() -> list[ContestSummary]:
try:
scraper = cloudscraper.create_scraper()
response = scraper.get("https://codeforces.com/api/contest.list", timeout=10)
response.raise_for_status()
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
st = block.find("div", class_="sample-test")
if not isinstance(st, Tag):
return [], False
data = response.json()
if data["status"] != "OK":
return []
input_pres: list[Tag] = [
inp.find("pre")
for inp in st.find_all("div", class_="input")
if isinstance(inp, Tag) and inp.find("pre")
]
output_pres: list[Tag] = [
out.find("pre")
for out in st.find_all("div", class_="output")
if isinstance(out, Tag) and out.find("pre")
]
input_pres = [p for p in input_pres if isinstance(p, Tag)]
output_pres = [p for p in output_pres if isinstance(p, Tag)]
contests = []
for contest in data["result"]:
contest_id = str(contest["id"])
name = contest["name"]
has_grouped = any(
p.find("div", class_="test-example-line") for p in input_pres + output_pres
)
if has_grouped:
inputs_by_gid: dict[int, list[str]] = {}
outputs_by_gid: dict[int, list[str]] = {}
for p in input_pres:
g = _group_lines_by_id(p)
for k, v in g.items():
inputs_by_gid.setdefault(k, []).extend(v)
for p in output_pres:
g = _group_lines_by_id(p)
for k, v in g.items():
outputs_by_gid.setdefault(k, []).extend(v)
inputs_by_gid.pop(0, None)
outputs_by_gid.pop(0, None)
keys = sorted(set(inputs_by_gid.keys()) & set(outputs_by_gid.keys()))
if keys:
samples = [
TestCase(
input="\n".join(inputs_by_gid[k]).strip(),
expected="\n".join(outputs_by_gid[k]).strip(),
)
for k in keys
]
return samples, True
contests.append(ContestSummary(id=contest_id, name=name, display_name=name))
return contests
except Exception as e:
print(f"Failed to fetch contests: {e}", file=sys.stderr)
return []
inputs = [_text_from_pre(p) for p in input_pres]
outputs = [_text_from_pre(p) for p in output_pres]
n = min(len(inputs), len(outputs))
return [TestCase(input=inputs[i], expected=outputs[i]) for i in range(n)], False
def main() -> None:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: codeforces.py metadata <contest_id> OR codeforces.py tests <contest_id> <problem_letter> OR codeforces.py contests",
)
print(json.dumps(asdict(result)))
sys.exit(1)
def _is_interactive(block: Tag) -> bool:
ps = block.find("div", class_="problem-statement")
txt = ps.get_text(" ", strip=True) if ps else block.get_text(" ", strip=True)
return "This is an interactive problem" in txt
mode: str = sys.argv[1]
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False, error="Usage: codeforces.py metadata <contest_id>"
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
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
soup = BeautifulSoup(html, "html.parser")
blocks = soup.find_all("div", class_="problem-statement")
out: list[dict[str, Any]] = []
for b in blocks:
holder = b.find_parent("div", class_="problemindexholder")
letter = (holder.get("problemindex") if holder else "").strip().upper()
name = _extract_title(b)[1]
if not letter:
continue
raw_samples, is_grouped = _extract_samples(b)
timeout_ms, memory_mb = _extract_limits(b)
interactive = _is_interactive(b)
if is_grouped and raw_samples:
combined_input = f"{len(raw_samples)}\n" + "\n".join(
tc.input for tc in raw_samples
)
print(json.dumps(asdict(result)))
sys.exit(1)
combined_expected = "\n".join(tc.expected for tc in raw_samples)
individual_tests = [
TestCase(input=f"1\n{tc.input}", expected=tc.expected)
for tc in raw_samples
]
else:
combined_input = "\n".join(tc.input for tc in raw_samples)
combined_expected = "\n".join(tc.expected for tc in raw_samples)
individual_tests = raw_samples
contest_id: str = sys.argv[2]
problems: list[ProblemSummary] = scrape_contest_problems(contest_id)
if not problems:
result = MetadataResult(
success=False, error=f"No problems found for contest {contest_id}"
)
print(json.dumps(asdict(result)))
sys.exit(1)
result = MetadataResult(
success=True, error="", contest_id=contest_id, problems=problems
out.append(
{
"letter": letter,
"name": name,
"combined_input": combined_input,
"combined_expected": combined_expected,
"tests": individual_tests,
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": is_grouped,
}
)
print(json.dumps(asdict(result)))
return out
elif mode == "tests":
if len(sys.argv) != 4:
tests_result = TestsResult(
success=False,
error="Usage: codeforces.py tests <contest_id> <problem_letter>",
problem_id="",
url="",
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
tests_contest_id: str = sys.argv[2]
problem_letter: str = sys.argv[3]
problem_id: str = tests_contest_id + problem_letter.lower()
def _scrape_contest_problems_sync(contest_id: str) -> list[ProblemSummary]:
html = _fetch_problems_html(contest_id)
blocks = _parse_all_blocks(html)
problems: list[ProblemSummary] = []
for b in blocks:
pid = b["letter"].upper()
problems.append(ProblemSummary(id=pid.lower(), name=b["name"]))
return problems
url: str = parse_problem_url(tests_contest_id, problem_letter)
tests: list[TestCase] = scrape_sample_tests(url)
class CodeforcesScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "codeforces"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
try:
scraper = cloudscraper.create_scraper()
response = scraper.get(url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
timeout_ms, memory_mb = extract_problem_limits(soup)
problems = await asyncio.to_thread(
_scrape_contest_problems_sync, contest_id
)
if not problems:
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"https://codeforces.com/contest/{contest_id}/problem/%s",
)
except Exception as e:
tests_result = TestsResult(
success=False,
error=f"Failed to extract constraints: {e}",
problem_id=problem_id,
url=url,
tests=[],
timeout_ms=0,
memory_mb=0,
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
try:
r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK":
return self._contests_error("Invalid API response")
contests: list[ContestSummary] = []
for c in data["result"]:
if c.get("phase") != "FINISHED":
continue
cid = str(c["id"])
name = c["name"]
contests.append(ContestSummary(id=cid, name=name, display_name=name))
if not contests:
return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests)
except Exception as e:
return self._contests_error(str(e))
async def stream_tests_for_category_async(self, category_id: str) -> None:
html = await asyncio.to_thread(_fetch_problems_html, category_id)
blocks = await asyncio.to_thread(_parse_all_blocks, html)
for b in blocks:
pid = b["letter"].lower()
tests: list[TestCase] = b.get("tests", [])
print(
json.dumps(
{
"problem_id": pid,
"combined": {
"input": b.get("combined_input", ""),
"expected": b.get("combined_expected", ""),
},
"tests": [
{"input": t.input, "expected": t.expected} for t in tests
],
"timeout_ms": b.get("timeout_ms", 0),
"memory_mb": b.get("memory_mb", 0),
"interactive": bool(b.get("interactive")),
"multi_test": bool(b.get("multi_test", False)),
}
),
flush=True,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
if not tests:
tests_result = TestsResult(
success=False,
error=f"No tests found for {tests_contest_id} {problem_letter}",
problem_id=problem_id,
url=url,
tests=[],
timeout_ms=timeout_ms,
memory_mb=memory_mb,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
tests_result = TestsResult(
success=True,
error="",
problem_id=problem_id,
url=url,
tests=tests,
timeout_ms=timeout_ms,
memory_mb=memory_mb,
)
print(json.dumps(asdict(tests_result)))
elif mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: codeforces.py contests"
)
print(json.dumps(asdict(contest_result)))
sys.exit(1)
contests = scrape_contests()
if not contests:
contest_result = ContestListResult(success=False, error="No contests found")
print(json.dumps(asdict(contest_result)))
sys.exit(1)
contest_result = ContestListResult(success=True, error="", contests=contests)
print(json.dumps(asdict(contest_result)))
else:
result = MetadataResult(
success=False,
error=f"Unknown mode: {mode}. Use 'metadata', 'tests', or 'contests'",
)
print(json.dumps(asdict(result)))
sys.exit(1)
if __name__ == "__main__":
main()
CodeforcesScraper().run_cli()

612
scrapers/cses.py Executable file → Normal file
View file

@ -1,468 +1,262 @@
#!/usr/bin/env python3
import asyncio
import json
import re
import sys
from dataclasses import asdict
from typing import Any
import backoff
import requests
from bs4 import BeautifulSoup, Tag
import httpx
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
TestsResult,
)
BASE_URL = "https://cses.fi"
INDEX_PATH = "/problemset"
TASK_PATH = "/problemset/task/{id}"
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
TIMEOUT_S = 15.0
CONNECTIONS = 8
def normalize_category_name(category_name: str) -> str:
return category_name.lower().replace(" ", "_").replace("&", "and")
def denormalize_category_name(category_id: str) -> str:
category_map = {
"introductory_problems": "Introductory Problems",
"sorting_and_searching": "Sorting and Searching",
"dynamic_programming": "Dynamic Programming",
"graph_algorithms": "Graph Algorithms",
"range_queries": "Range Queries",
"tree_algorithms": "Tree Algorithms",
"mathematics": "Mathematics",
"string_algorithms": "String Algorithms",
"geometry": "Geometry",
"advanced_techniques": "Advanced Techniques",
def snake_to_title(name: str) -> str:
small_words = {
"a",
"an",
"the",
"and",
"but",
"or",
"nor",
"for",
"so",
"yet",
"at",
"by",
"in",
"of",
"on",
"per",
"to",
"vs",
"via",
}
words: list[str] = name.split("_")
n = len(words)
return category_map.get(category_id, category_id.replace("_", " ").title())
def fix_word(i_word):
i, word = i_word
lw = word.lower()
return lw.capitalize() if i == 0 or i == n - 1 or lw not in small_words else lw
return " ".join(map(fix_word, enumerate(words)))
@backoff.on_exception(
backoff.expo,
(requests.exceptions.RequestException, requests.exceptions.HTTPError),
max_tries=4,
jitter=backoff.random_jitter,
on_backoff=lambda details: print(
f"Request failed (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}",
file=sys.stderr,
),
async def fetch_text(client: httpx.AsyncClient, path: str) -> str:
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S)
r.raise_for_status()
return r.text
CATEGORY_BLOCK_RE = re.compile(
r'<h2>(?P<cat>[^<]+)</h2>\s*<ul\s+class="task-list">(?P<body>.*?)</ul>',
re.DOTALL,
)
@backoff.on_predicate(
backoff.expo,
lambda response: response.status_code == 429,
max_tries=4,
jitter=backoff.random_jitter,
on_backoff=lambda details: print(
f"Rate limited, retrying in {details['wait']:.1f}s", file=sys.stderr
),
TASK_LINK_RE = re.compile(
r'<li\s+class="task">\s*<a\s+href="/problemset/task/(?P<id>\d+)/?">(?P<title>[^<]+)</a\s*>',
re.DOTALL,
)
def make_request(url: str, headers: dict) -> requests.Response:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response
TITLE_RE = re.compile(
r'<div\s+class="title-block">.*?<h1>(?P<title>[^<]+)</h1>', re.DOTALL
)
TIME_RE = re.compile(r"<li>\s*<b>Time limit:</b>\s*([0-9.]+)\s*s\s*</li>")
MEM_RE = re.compile(r"<li>\s*<b>Memory limit:</b>\s*(\d+)\s*MB\s*</li>")
SIDEBAR_CAT_RE = re.compile(
r'<div\s+class="nav sidebar">.*?<h4>(?P<cat>[^<]+)</h4>', re.DOTALL
)
MD_BLOCK_RE = re.compile(r'<div\s+class="md">(.*?)</div>', re.DOTALL | re.IGNORECASE)
EXAMPLE_SECTION_RE = re.compile(
r"<h[1-6][^>]*>\s*example[s]?:?\s*</h[1-6]>\s*(?P<section>.*?)(?=<h[1-6][^>]*>|$)",
re.DOTALL | re.IGNORECASE,
)
LABELED_IO_RE = re.compile(
r"input\s*:\s*</p>\s*<pre>(?P<input>.*?)</pre>.*?output\s*:\s*</p>\s*<pre>(?P<output>.*?)</pre>",
re.DOTALL | re.IGNORECASE,
)
PRE_RE = re.compile(r"<pre>(.*?)</pre>", re.DOTALL | re.IGNORECASE)
def scrape_category_problems(category_id: str) -> list[ProblemSummary]:
category_name = denormalize_category_name(category_id)
try:
problemset_url = "https://cses.fi/problemset/"
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = make_request(problemset_url, headers)
soup = BeautifulSoup(response.text, "html.parser")
current_category = None
problems = []
target_found = False
for element in soup.find_all(["h1", "h2", "ul"]):
if not isinstance(element, Tag):
continue
if element.name in ["h1", "h2"]:
text = element.get_text(strip=True)
if not text or text.startswith("CSES") or text == "CSES Problem Set":
continue
if target_found and current_category != text:
break
current_category = text
if text.lower() == category_name.lower():
target_found = True
elif element.name == "ul" and current_category and target_found:
problem_links = element.find_all(
"a", href=lambda x: x and "/problemset/task/" in x
)
for link in problem_links:
href = link.get("href", "")
if not href:
continue
problem_id = href.split("/")[-1]
problem_name = link.get_text(strip=True)
if not problem_id.isdigit() or not problem_name:
continue
problems.append(ProblemSummary(id=problem_id, name=problem_name))
return problems
except Exception as e:
print(f"Failed to scrape CSES category {category_id}: {e}", file=sys.stderr)
return []
def parse_problem_url(problem_input: str) -> str | None:
if problem_input.startswith("https://cses.fi/problemset/task/"):
return problem_input
elif problem_input.isdigit():
return f"https://cses.fi/problemset/task/{problem_input}"
return None
def extract_problem_limits(soup: BeautifulSoup) -> tuple[int, float]:
timeout_ms = None
memory_mb = None
constraints_ul = soup.find("ul", class_="task-constraints")
if not constraints_ul or not isinstance(constraints_ul, Tag):
raise ValueError("Could not find task-constraints section")
for li in constraints_ul.find_all("li"):
text = li.get_text()
if "Time limit:" in text:
match = re.search(r"Time limit:\s*(\d+(?:\.\d+)?)\s*s", text)
if match:
seconds = float(match.group(1))
timeout_ms = int(seconds * 1000)
if "Memory limit:" in text:
match = re.search(r"Memory limit:\s*(\d+)\s*MB", text)
if match:
memory_mb = float(match.group(1))
if timeout_ms is None:
raise ValueError("Could not find valid timeout in task-constraints section")
if memory_mb is None:
raise ValueError(
"Could not find valid memory limit in task-constraints section"
)
return timeout_ms, memory_mb
def scrape_categories() -> list[ContestSummary]:
try:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = make_request("https://cses.fi/problemset/", headers)
soup = BeautifulSoup(response.text, "html.parser")
categories = []
for h2 in soup.find_all("h2"):
category_name = h2.get_text().strip()
if category_name == "General":
continue
category_id = normalize_category_name(category_name)
display_name = category_name
categories.append(
ContestSummary(
id=category_id, name=category_name, display_name=display_name
)
def parse_categories(html: str) -> list[ContestSummary]:
out: list[ContestSummary] = []
for m in CATEGORY_BLOCK_RE.finditer(html):
cat = m.group("cat").strip()
if cat == "General":
continue
out.append(
ContestSummary(
id=normalize_category_name(cat),
name=cat,
display_name=cat,
)
return categories
except Exception as e:
print(f"Failed to scrape CSES categories: {e}", file=sys.stderr)
return []
def process_problem_element(
element,
current_category: str | None,
all_categories: dict[str, list[ProblemSummary]],
) -> str | None:
if element.name == "h1":
category_name = element.get_text().strip()
if category_name not in all_categories:
all_categories[category_name] = []
return category_name
if element.name != "a" or "/problemset/task/" not in element.get("href", ""):
return current_category
href = element.get("href", "")
if not href:
return current_category
problem_id = href.split("/")[-1]
problem_name = element.get_text(strip=True)
if not (problem_id.isdigit() and problem_name and current_category):
return current_category
problem = ProblemSummary(id=problem_id, name=problem_name)
all_categories[current_category].append(problem)
return current_category
def scrape_all_problems() -> dict[str, list[ProblemSummary]]:
try:
problemset_url = "https://cses.fi/problemset/"
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = requests.get(problemset_url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
all_categories: dict[str, list[ProblemSummary]] = {}
current_category = None
for element in soup.find_all(["h1", "h2", "ul"]):
if not isinstance(element, Tag):
continue
if element.name in ["h1", "h2"]:
text = element.get_text(strip=True)
if text and not text.startswith("CSES") and text != "CSES Problem Set":
current_category = text
if current_category not in all_categories:
all_categories[current_category] = []
print(f"Found category: {current_category}", file=sys.stderr)
elif element.name == "ul" and current_category:
problem_links = element.find_all(
"a", href=lambda x: x and "/problemset/task/" in x
)
for link in problem_links:
href = link.get("href", "")
if href:
problem_id = href.split("/")[-1]
problem_name = link.get_text(strip=True)
if problem_id.isdigit() and problem_name:
problem = ProblemSummary(id=problem_id, name=problem_name)
all_categories[current_category].append(problem)
print(
f"Found {len(all_categories)} categories with {sum(len(probs) for probs in all_categories.values())} problems",
file=sys.stderr,
)
return all_categories
except Exception as e:
print(f"Failed to scrape CSES problems: {e}", file=sys.stderr)
return {}
return out
def extract_example_test_case(soup) -> tuple[str, str] | None:
example_header = soup.find("h1", string="Example")
if not example_header:
return None
current = example_header.find_next_sibling()
input_text = None
output_text = None
while current:
if current.name == "p" and "Input:" in current.get_text():
input_pre = current.find_next_sibling("pre")
if input_pre:
input_text = input_pre.get_text().strip()
elif current.name == "p" and "Output:" in current.get_text():
output_pre = current.find_next_sibling("pre")
if output_pre:
output_text = output_pre.get_text().strip()
break
current = current.find_next_sibling()
if not input_text or not output_text:
return None
return (input_text, output_text)
def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary]:
want = snake_to_title(category_id)
for m in CATEGORY_BLOCK_RE.finditer(html):
cat = m.group("cat").strip()
if cat != want:
continue
body = m.group("body")
return [
ProblemSummary(id=mm.group("id"), name=mm.group("title"))
for mm in TASK_LINK_RE.finditer(body)
]
return []
def scrape(url: str) -> list[TestCase]:
try:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
def _extract_problem_info(html: str) -> tuple[int, int, bool]:
tm = TIME_RE.search(html)
mm = MEM_RE.search(html)
t = int(round(float(tm.group(1)) * 1000)) if tm else 0
m = int(mm.group(1)) if mm else 0
md = MD_BLOCK_RE.search(html)
interactive = False
if md:
body = md.group(1)
interactive = "This is an interactive problem." in body
return t, m, interactive
response = make_request(url, headers)
soup = BeautifulSoup(response.text, "html.parser")
def parse_title(html: str) -> str:
mt = TITLE_RE.search(html)
return mt.group("title").strip() if mt else ""
test_case = extract_example_test_case(soup)
if not test_case:
return []
input_text, output_text = test_case
return [TestCase(input=input_text, expected=output_text)]
def parse_category_from_sidebar(html: str) -> str | None:
m = SIDEBAR_CAT_RE.search(html)
return m.group("cat").strip() if m else None
except Exception as e:
print(f"Error scraping CSES: {e}", file=sys.stderr)
def parse_tests(html: str) -> list[TestCase]:
md = MD_BLOCK_RE.search(html)
if not md:
return []
block = md.group(1)
msec = EXAMPLE_SECTION_RE.search(block)
section = msec.group("section") if msec else block
mlabel = LABELED_IO_RE.search(section)
if mlabel:
a = mlabel.group("input").strip()
b = mlabel.group("output").strip()
return [TestCase(input=a, expected=b)]
pres = PRE_RE.findall(section)
if len(pres) >= 2:
return [TestCase(input=pres[0].strip(), expected=pres[1].strip())]
return []
def main() -> None:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: cses.py metadata <category_id> OR cses.py tests <category> <problem_id> OR cses.py contests",
)
print(json.dumps(asdict(result)))
sys.exit(1)
def task_path(problem_id: str | int) -> str:
return TASK_PATH.format(id=str(problem_id))
mode: str = sys.argv[1]
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False,
error="Usage: cses.py metadata <category_id>",
)
print(json.dumps(asdict(result)))
sys.exit(1)
category_id = sys.argv[2]
problems = scrape_category_problems(category_id)
class CSESScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "cses"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
async with httpx.AsyncClient() as client:
html = await fetch_text(client, INDEX_PATH)
problems = parse_category_problems(contest_id, html)
if not problems:
result = MetadataResult(
return MetadataResult(
success=False,
error=f"No problems found for category: {category_id}",
)
print(json.dumps(asdict(result)))
return
result = MetadataResult(success=True, error="", problems=problems)
print(json.dumps(asdict(result)))
elif mode == "tests":
if len(sys.argv) != 4:
tests_result = TestsResult(
success=False,
error="Usage: cses.py tests <category> <problem_id>",
problem_id="",
error=f"{self.platform_name}: No problems found for category: {contest_id}",
url="",
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
problem_input: str = sys.argv[3]
url: str | None = parse_problem_url(problem_input)
if not url:
tests_result = TestsResult(
success=False,
error=f"Invalid problem input: {problem_input}. Use either problem ID (e.g., 1068) or full URL",
problem_id=problem_input if problem_input.isdigit() else "",
url="",
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
tests: list[TestCase] = scrape(url)
problem_id: str = (
problem_input if problem_input.isdigit() else problem_input.split("/")[-1]
)
try:
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
timeout_ms, memory_mb = extract_problem_limits(soup)
except Exception as e:
tests_result = TestsResult(
success=False,
error=f"Failed to extract constraints: {e}",
problem_id=problem_id,
url=url,
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
if not tests:
tests_result = TestsResult(
success=False,
error=f"No tests found for {problem_input}",
problem_id=problem_id,
url=url,
tests=[],
timeout_ms=timeout_ms,
memory_mb=memory_mb,
)
print(json.dumps(asdict(tests_result)))
sys.exit(1)
test_cases = tests
tests_result = TestsResult(
return MetadataResult(
success=True,
error="",
problem_id=problem_id,
url=url,
tests=test_cases,
timeout_ms=timeout_ms,
memory_mb=memory_mb,
contest_id=contest_id,
problems=problems,
url="https://cses.fi/problemset/task/%s",
)
print(json.dumps(asdict(tests_result)))
elif mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: cses.py contests"
async def scrape_contest_list(self) -> ContestListResult:
async with httpx.AsyncClient() as client:
html = await fetch_text(client, INDEX_PATH)
cats = parse_categories(html)
if not cats:
return ContestListResult(
success=False, error=f"{self.platform_name}: No contests found"
)
print(json.dumps(asdict(contest_result)))
sys.exit(1)
return ContestListResult(success=True, error="", contests=cats)
categories = scrape_categories()
if not categories:
contest_result = ContestListResult(success=False, error="No contests found")
print(json.dumps(asdict(contest_result)))
sys.exit(1)
async def stream_tests_for_category_async(self, category_id: str) -> None:
async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=CONNECTIONS)
) as client:
index_html = await fetch_text(client, INDEX_PATH)
problems = parse_category_problems(category_id, index_html)
if not problems:
return
contest_result = ContestListResult(success=True, error="", contests=categories)
print(json.dumps(asdict(contest_result)))
sem = asyncio.Semaphore(CONNECTIONS)
else:
result = MetadataResult(
success=False,
error=f"Unknown mode: {mode}. Use 'metadata <category>', 'tests <category> <problem_id>', or 'contests'",
)
print(json.dumps(asdict(result)))
sys.exit(1)
async def run_one(pid: str) -> dict[str, Any]:
async with sem:
try:
html = await fetch_text(client, task_path(pid))
tests = parse_tests(html)
timeout_ms, memory_mb, interactive = _extract_problem_info(html)
except Exception:
tests = []
timeout_ms, memory_mb, interactive = 0, 0, False
combined_input = "\n".join(t.input for t in tests) if tests else ""
combined_expected = (
"\n".join(t.expected for t in tests) if tests else ""
)
return {
"problem_id": pid,
"combined": {
"input": combined_input,
"expected": combined_expected,
},
"tests": [
{"input": t.input, "expected": t.expected} for t in tests
],
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": False,
}
tasks = [run_one(p.id) for p in problems]
for coro in asyncio.as_completed(tasks):
payload = await coro
print(json.dumps(payload), flush=True)
if __name__ == "__main__":
main()
CSESScraper().run_cli()

View file

@ -1,47 +1,72 @@
from dataclasses import dataclass, field
from pydantic import BaseModel, ConfigDict, Field
@dataclass
class TestCase:
class TestCase(BaseModel):
input: str
expected: str
model_config = ConfigDict(extra="forbid")
@dataclass
class ProblemSummary:
class CombinedTest(BaseModel):
input: str
expected: str
model_config = ConfigDict(extra="forbid")
class ProblemSummary(BaseModel):
id: str
name: str
model_config = ConfigDict(extra="forbid")
@dataclass
class ContestSummary:
class ContestSummary(BaseModel):
id: str
name: str
display_name: str
display_name: str | None = None
model_config = ConfigDict(extra="forbid")
@dataclass
class ScrapingResult:
class ScrapingResult(BaseModel):
success: bool
error: str
model_config = ConfigDict(extra="forbid")
@dataclass
class MetadataResult(ScrapingResult):
contest_id: str = ""
problems: list[ProblemSummary] = field(default_factory=list)
categories: dict[str, list[ProblemSummary]] = field(default_factory=dict)
problems: list[ProblemSummary] = Field(default_factory=list)
url: str
model_config = ConfigDict(extra="forbid")
@dataclass
class ContestListResult(ScrapingResult):
contests: list[ContestSummary] = field(default_factory=list)
contests: list[ContestSummary] = Field(default_factory=list)
model_config = ConfigDict(extra="forbid")
@dataclass
class TestsResult(ScrapingResult):
problem_id: str
url: str
tests: list[TestCase]
combined: CombinedTest
tests: list[TestCase] = Field(default_factory=list)
timeout_ms: int
memory_mb: float
interactive: bool = False
multi_test: bool = False
model_config = ConfigDict(extra="forbid")
class ScraperConfig(BaseModel):
timeout_seconds: int = 30
max_retries: int = 3
backoff_base: float = 2.0
rate_limit_delay: float = 1.0
model_config = ConfigDict(extra="forbid")

58
scripts/interact.py Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
import asyncio
import shlex
import sys
from collections.abc import Sequence
async def pump(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter | None
) -> None:
while True:
data = await reader.readline()
if not data:
break
_ = sys.stdout.buffer.write(data)
_ = sys.stdout.flush()
if writer:
writer.write(data)
await writer.drain()
async def main(interactor_cmd: Sequence[str], interactee_cmd: Sequence[str]) -> None:
interactor = await asyncio.create_subprocess_exec(
*interactor_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)
interactee = await asyncio.create_subprocess_exec(
*interactee_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)
assert (
interactor.stdout
and interactor.stdin
and interactee.stdout
and interactee.stdin
)
tasks = [
asyncio.create_task(pump(interactor.stdout, interactee.stdin)),
asyncio.create_task(pump(interactee.stdout, interactor.stdin)),
]
_ = await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
_ = await interactor.wait()
_ = await interactee.wait()
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: interact.py <interactor> <interactee>", file=sys.stderr)
sys.exit(1)
interactor_cmd = shlex.split(sys.argv[1])
interactee_cmd = shlex.split(sys.argv[2])
_ = asyncio.run(main(interactor_cmd, interactee_cmd))

View file

@ -1,243 +0,0 @@
describe('ansi parser', function()
local ansi = require('cp.ui.ansi')
describe('bytes_to_string', function()
it('returns string as-is', function()
local input = 'hello world'
assert.equals('hello world', ansi.bytes_to_string(input))
end)
it('converts byte array to string', function()
local input = { 104, 101, 108, 108, 111 }
assert.equals('hello', ansi.bytes_to_string(input))
end)
end)
describe('parse_ansi_text', function()
it('strips ansi codes from simple text', function()
local input = 'Hello \027[31mworld\027[0m!'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello world!', table.concat(result.lines, '\n'))
end)
it('handles text without ansi codes', function()
local input = 'Plain text'
local result = ansi.parse_ansi_text(input)
assert.equals('Plain text', table.concat(result.lines, '\n'))
assert.equals(0, #result.highlights)
end)
it('creates correct highlight for simple colored text', function()
local input = 'Hello \027[31mworld\027[0m!'
local result = ansi.parse_ansi_text(input)
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals(0, highlight.line)
assert.equals(6, highlight.col_start)
assert.equals(11, highlight.col_end)
assert.equals('CpAnsiRed', highlight.highlight_group)
end)
it('handles bold text', function()
local input = 'Hello \027[1mbold\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBold', highlight.highlight_group)
end)
it('handles italic text', function()
local input = 'Hello \027[3mitalic\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello italic world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiItalic', highlight.highlight_group)
end)
it('handles bold + color combination', function()
local input = 'Hello \027[1;31mbold red\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold red world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBoldRed', highlight.highlight_group)
assert.equals(6, highlight.col_start)
assert.equals(14, highlight.col_end)
end)
it('handles italic + color combination', function()
local input = 'Hello \027[3;32mitalic green\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello italic green world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiItalicGreen', highlight.highlight_group)
end)
it('handles bold + italic + color combination', function()
local input = 'Hello \027[1;3;33mbold italic yellow\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold italic yellow world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBoldItalicYellow', highlight.highlight_group)
end)
it('handles sequential attribute setting', function()
local input = 'Hello \027[1m\027[31mbold red\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold red world', table.concat(result.lines, '\n'))
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBoldRed', highlight.highlight_group)
end)
it('handles selective attribute reset', function()
local input = 'Hello \027[1;31mbold red\027[22mno longer bold\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals('Hello bold redno longer bold world', table.concat(result.lines, '\n'))
assert.equals(2, #result.highlights)
local bold_red = result.highlights[1]
assert.equals('CpAnsiBoldRed', bold_red.highlight_group)
assert.equals(6, bold_red.col_start)
assert.equals(14, bold_red.col_end)
local just_red = result.highlights[2]
assert.equals('CpAnsiRed', just_red.highlight_group)
assert.equals(14, just_red.col_start)
assert.equals(28, just_red.col_end)
end)
it('handles bright colors', function()
local input = 'Hello \027[91mbright red\027[0m world'
local result = ansi.parse_ansi_text(input)
assert.equals(1, #result.highlights)
local highlight = result.highlights[1]
assert.equals('CpAnsiBrightRed', highlight.highlight_group)
end)
it('handles compiler-like output with complex formatting', function()
local input =
"error.cpp:10:5: \027[1m\027[31merror:\027[0m\027[1m 'undefined' was not declared\027[0m"
local result = ansi.parse_ansi_text(input)
local clean_text = table.concat(result.lines, '\n')
assert.equals("error.cpp:10:5: error: 'undefined' was not declared", clean_text)
assert.equals(2, #result.highlights)
local error_highlight = result.highlights[1]
assert.equals('CpAnsiBoldRed', error_highlight.highlight_group)
assert.equals(16, error_highlight.col_start)
assert.equals(22, error_highlight.col_end)
local message_highlight = result.highlights[2]
assert.equals('CpAnsiBold', message_highlight.highlight_group)
assert.equals(22, message_highlight.col_start)
assert.equals(51, message_highlight.col_end)
end)
it('handles multiline with persistent state', function()
local input = '\027[1;31mline1\nline2\nline3\027[0m'
local result = ansi.parse_ansi_text(input)
assert.equals('line1\nline2\nline3', table.concat(result.lines, '\n'))
assert.equals(3, #result.highlights)
for i, highlight in ipairs(result.highlights) do
assert.equals('CpAnsiBoldRed', highlight.highlight_group)
assert.equals(i - 1, highlight.line)
assert.equals(0, highlight.col_start)
assert.equals(5, highlight.col_end)
end
end)
end)
describe('update_ansi_state', function()
it('resets all state on reset code', function()
local state = { bold = true, italic = true, foreground = 'Red' }
ansi.update_ansi_state(state, '0')
assert.is_false(state.bold)
assert.is_false(state.italic)
assert.is_nil(state.foreground)
end)
it('sets individual attributes', function()
local state = { bold = false, italic = false, foreground = nil }
ansi.update_ansi_state(state, '1')
assert.is_true(state.bold)
ansi.update_ansi_state(state, '3')
assert.is_true(state.italic)
ansi.update_ansi_state(state, '31')
assert.equals('Red', state.foreground)
end)
it('handles compound codes', function()
local state = { bold = false, italic = false, foreground = nil }
ansi.update_ansi_state(state, '1;3;31')
assert.is_true(state.bold)
assert.is_true(state.italic)
assert.equals('Red', state.foreground)
end)
it('handles selective resets', function()
local state = { bold = true, italic = true, foreground = 'Red' }
ansi.update_ansi_state(state, '22')
assert.is_false(state.bold)
assert.is_true(state.italic)
assert.equals('Red', state.foreground)
ansi.update_ansi_state(state, '39')
assert.is_false(state.bold)
assert.is_true(state.italic)
assert.is_nil(state.foreground)
end)
end)
describe('setup_highlight_groups', function()
it('creates highlight groups with fallback colors when terminal colors are nil', function()
local original_colors = {}
for i = 0, 15 do
original_colors[i] = vim.g['terminal_color_' .. i]
vim.g['terminal_color_' .. i] = nil
end
ansi.setup_highlight_groups()
local highlight = vim.api.nvim_get_hl(0, { name = 'CpAnsiRed' })
assert.is_nil(highlight.fg)
for i = 0, 15 do
vim.g['terminal_color_' .. i] = original_colors[i]
end
end)
it('creates highlight groups with proper colors when terminal colors are set', function()
vim.g.terminal_color_1 = '#ff0000'
ansi.setup_highlight_groups()
local highlight = vim.api.nvim_get_hl(0, { name = 'CpAnsiRed' })
assert.equals(0xff0000, highlight.fg)
end)
end)
end)

View file

@ -1,193 +0,0 @@
describe('cp.cache', function()
local cache
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
cache = require('cp.cache')
cache.load()
end)
after_each(function()
spec_helper.teardown()
cache.clear_contest_data('atcoder', 'test_contest')
cache.clear_contest_data('codeforces', 'test_contest')
cache.clear_contest_data('cses', 'test_contest')
end)
describe('load and save', function()
it('loads without error when cache file exists', function()
assert.has_no_errors(function()
cache.load()
end)
end)
it('saves and persists data', function()
local problems = { { id = 'A', name = 'Problem A' } }
assert.has_no_errors(function()
cache.set_contest_data('atcoder', 'test_contest', problems)
end)
local result = cache.get_contest_data('atcoder', 'test_contest')
assert.is_not_nil(result)
assert.equals('A', result.problems[1].id)
end)
end)
describe('contest data', function()
it('stores and retrieves contest data', function()
local problems = {
{ id = 'A', name = 'First Problem' },
{ id = 'B', name = 'Second Problem' },
}
cache.set_contest_data('codeforces', 'test_contest', problems)
local result = cache.get_contest_data('codeforces', 'test_contest')
assert.is_not_nil(result)
assert.equals(2, #result.problems)
assert.equals('A', result.problems[1].id)
assert.equals('Second Problem', result.problems[2].name)
end)
it('returns nil for missing contest', function()
local result = cache.get_contest_data('atcoder', 'nonexistent_contest')
assert.is_nil(result)
end)
it('clears contest data', function()
local problems = { { id = 'A' } }
cache.set_contest_data('atcoder', 'test_contest', problems)
cache.clear_contest_data('atcoder', 'test_contest')
local result = cache.get_contest_data('atcoder', 'test_contest')
assert.is_nil(result)
end)
it('handles cses expiry correctly', function()
local problems = { { id = 'A' } }
cache.set_contest_data('cses', 'test_contest', problems)
local result = cache.get_contest_data('cses', 'test_contest')
assert.is_not_nil(result)
assert.is_not_nil(result.expires_at)
end)
end)
describe('test cases', function()
it('stores and retrieves test cases', function()
local test_cases = {
{ index = 1, input = '1 2', expected = '3' },
{ index = 2, input = '4 5', expected = '9' },
}
cache.set_test_cases('atcoder', 'test_contest', 'A', test_cases)
local result = cache.get_test_cases('atcoder', 'test_contest', 'A')
assert.is_not_nil(result)
assert.equals(2, #result)
assert.equals('1 2', result[1].input)
assert.equals('9', result[2].expected)
end)
it('handles contest-level test cases', function()
local test_cases = { { input = 'test', expected = 'output' } }
cache.set_test_cases('cses', 'test_contest', nil, test_cases)
local result = cache.get_test_cases('cses', 'test_contest', nil)
assert.is_not_nil(result)
assert.equals(1, #result)
assert.equals('test', result[1].input)
end)
it('returns nil for missing test cases', function()
local result = cache.get_test_cases('atcoder', 'nonexistent', 'A')
assert.is_nil(result)
end)
end)
describe('file state', function()
it('stores and retrieves file state', function()
local file_path = '/tmp/test.cpp'
cache.set_file_state(file_path, 'atcoder', 'abc123', 'a', 'cpp')
local result = cache.get_file_state(file_path)
assert.is_not_nil(result)
assert.equals('atcoder', result.platform)
assert.equals('abc123', result.contest_id)
assert.equals('a', result.problem_id)
assert.equals('cpp', result.language)
end)
it('handles cses file state without problem_id', function()
local file_path = '/tmp/cses.py'
cache.set_file_state(file_path, 'cses', '1068', nil, 'python')
local result = cache.get_file_state(file_path)
assert.is_not_nil(result)
assert.equals('cses', result.platform)
assert.equals('1068', result.contest_id)
assert.is_nil(result.problem_id)
assert.equals('python', result.language)
end)
it('returns nil for missing file state', function()
local result = cache.get_file_state('/nonexistent/file.cpp')
assert.is_nil(result)
end)
it('overwrites existing file state', function()
local file_path = '/tmp/overwrite.cpp'
cache.set_file_state(file_path, 'atcoder', 'abc123', 'a', 'cpp')
cache.set_file_state(file_path, 'codeforces', '1934', 'b', 'python')
local result = cache.get_file_state(file_path)
assert.is_not_nil(result)
assert.equals('codeforces', result.platform)
assert.equals('1934', result.contest_id)
assert.equals('b', result.problem_id)
assert.equals('python', result.language)
end)
end)
describe('cache management', function()
it('clears all cache data', function()
cache.set_contest_data('atcoder', 'test_contest', { { id = 'A' } })
cache.set_contest_data('codeforces', 'test_contest', { { id = 'B' } })
cache.set_file_state('/tmp/test.cpp', 'atcoder', 'abc123', 'a', 'cpp')
cache.clear_all()
assert.is_nil(cache.get_contest_data('atcoder', 'test_contest'))
assert.is_nil(cache.get_contest_data('codeforces', 'test_contest'))
assert.is_nil(cache.get_file_state('/tmp/test.cpp'))
end)
it('clears cache for specific platform', function()
cache.set_contest_data('atcoder', 'test_contest', { { id = 'A' } })
cache.set_contest_data('codeforces', 'test_contest', { { id = 'B' } })
cache.set_contest_list('atcoder', { { id = '123', name = 'Test' } })
cache.set_contest_list('codeforces', { { id = '456', name = 'Test' } })
cache.clear_platform('atcoder')
assert.is_nil(cache.get_contest_data('atcoder', 'test_contest'))
assert.is_nil(cache.get_contest_list('atcoder'))
assert.is_not_nil(cache.get_contest_data('codeforces', 'test_contest'))
assert.is_not_nil(cache.get_contest_list('codeforces'))
end)
it('handles clear platform for non-existent platform', function()
assert.has_no_errors(function()
cache.clear_platform('nonexistent')
end)
end)
end)
end)

View file

@ -1,637 +0,0 @@
describe('cp command parsing', function()
local cp
local logged_messages
before_each(function()
logged_messages = {}
local mock_logger = {
log = function(msg, level)
table.insert(logged_messages, { msg = msg, level = level })
end,
set_config = function() end,
}
package.loaded['cp.log'] = mock_logger
cp = require('cp')
cp.setup({
contests = {
atcoder = {
default_language = 'cpp',
cpp = { extension = 'cpp' },
},
cses = {
default_language = 'cpp',
cpp = { extension = 'cpp' },
},
},
})
end)
after_each(function()
package.loaded['cp.log'] = nil
end)
describe('empty arguments', function()
it('attempts file state restoration for no arguments', function()
local opts = { fargs = {} }
cp.handle_command(opts)
assert.is_true(#logged_messages > 0)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if
log_entry.level == vim.log.levels.ERROR
and log_entry.msg:match('No file is currently open')
then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
end)
describe('action commands', function()
it('handles test action without error', function()
local opts = { fargs = { 'run' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles next action without error', function()
local opts = { fargs = { 'next' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles prev action without error', function()
local opts = { fargs = { 'prev' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
end)
describe('platform commands', function()
it('handles platform-only command', function()
local opts = { fargs = { 'atcoder' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles contest setup command', function()
local opts = { fargs = { 'atcoder', 'abc123' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles cses problem command', function()
local opts = { fargs = { 'cses', 'sorting_and_searching', '1234' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles full setup command', function()
local opts = { fargs = { 'atcoder', 'abc123', 'a' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('logs error for too many arguments', function()
local opts = { fargs = { 'atcoder', 'abc123', 'a', 'b', 'extra' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if log_entry.level == vim.log.levels.ERROR then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
end)
describe('language flag parsing', function()
it('logs error for --lang flag missing value', function()
local opts = { fargs = { 'run', '--lang' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if
log_entry.level == vim.log.levels.ERROR and log_entry.msg:match('--lang requires a value')
then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
it('handles language with equals format', function()
local opts = { fargs = { 'atcoder', '--lang=python' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles language with space format', function()
local opts = { fargs = { 'atcoder', '--lang', 'cpp' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles contest with language flag', function()
local opts = { fargs = { 'atcoder', 'abc123', '--lang=python' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
end)
describe('debug flag parsing', function()
it('handles debug flag without error', function()
local opts = { fargs = { 'run', '--debug' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles combined language and debug flags', function()
local opts = { fargs = { 'run', '--lang=cpp', '--debug' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
end)
describe('restore from file', function()
it('returns restore_from_file type for empty args', function()
local opts = { fargs = {} }
local logged_error = false
cp.handle_command(opts)
for _, log in ipairs(logged_messages) do
if log.level == vim.log.levels.ERROR and log.msg:match('No file is currently open') then
logged_error = true
end
end
assert.is_true(logged_error)
end)
end)
describe('invalid commands', function()
it('logs error for invalid platform', function()
local opts = { fargs = { 'invalid_platform' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if log_entry.level == vim.log.levels.ERROR then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
it('logs error for invalid action', function()
local opts = { fargs = { 'invalid_action' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if log_entry.level == vim.log.levels.ERROR then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
end)
describe('edge cases', function()
it('handles empty string arguments', function()
local opts = { fargs = { '' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if log_entry.level == vim.log.levels.ERROR then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
it('handles flag order variations', function()
local opts = { fargs = { '--debug', 'run', '--lang=python' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
it('handles multiple language flags', function()
local opts = { fargs = { 'run', '--lang=cpp', '--lang=python' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end)
end)
describe('command validation', function()
it('validates platform names against constants', function()
local constants = require('cp.constants')
for _, platform in ipairs(constants.PLATFORMS) do
local opts = { fargs = { platform } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end
end)
it('validates action names against constants', function()
local constants = require('cp.constants')
for _, action in ipairs(constants.ACTIONS) do
local opts = { fargs = { action } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
end
end)
end)
describe('cache commands', function()
it('handles cache clear without platform', function()
local opts = { fargs = { 'cache', 'clear' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
local success_logged = false
for _, log_entry in ipairs(logged_messages) do
if log_entry.msg and log_entry.msg:match('cleared all cache') then
success_logged = true
break
end
end
assert.is_true(success_logged)
end)
it('handles cache clear with valid platform', function()
local opts = { fargs = { 'cache', 'clear', 'atcoder' } }
assert.has_no_errors(function()
cp.handle_command(opts)
end)
local success_logged = false
for _, log_entry in ipairs(logged_messages) do
if log_entry.msg and log_entry.msg:match('cleared cache for atcoder') then
success_logged = true
break
end
end
assert.is_true(success_logged)
end)
it('logs error for cache clear with invalid platform', function()
local opts = { fargs = { 'cache', 'clear', 'invalid_platform' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if log_entry.level == vim.log.levels.ERROR and log_entry.msg:match('unknown platform') then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
it('logs error for cache command without subcommand', function()
local opts = { fargs = { 'cache' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if
log_entry.level == vim.log.levels.ERROR
and log_entry.msg:match('cache command requires subcommand')
then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
it('logs error for invalid cache subcommand', function()
local opts = { fargs = { 'cache', 'invalid' } }
cp.handle_command(opts)
local error_logged = false
for _, log_entry in ipairs(logged_messages) do
if
log_entry.level == vim.log.levels.ERROR
and log_entry.msg:match('unknown cache subcommand')
then
error_logged = true
break
end
end
assert.is_true(error_logged)
end)
end)
describe('CP command completion', function()
local complete_fn
before_each(function()
package.loaded['cp'] = nil
package.loaded['cp.cache'] = nil
complete_fn = function(ArgLead, CmdLine, _)
local constants = require('cp.constants')
local platforms = constants.PLATFORMS
local actions = constants.ACTIONS
local args = vim.split(vim.trim(CmdLine), '%s+')
local num_args = #args
if CmdLine:sub(-1) == ' ' then
num_args = num_args + 1
end
if num_args == 2 then
local candidates = {}
local cp_mod = require('cp')
local context = cp_mod.get_current_context()
if context.platform and context.contest_id then
vim.list_extend(candidates, actions)
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(context.platform, context.contest_id)
if contest_data and contest_data.problems then
for _, problem in ipairs(contest_data.problems) do
table.insert(candidates, problem.id)
end
end
else
vim.list_extend(candidates, platforms)
table.insert(candidates, 'cache')
table.insert(candidates, 'pick')
end
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, candidates)
elseif num_args == 3 then
if args[2] == 'cache' then
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, { 'clear' })
end
elseif num_args == 4 then
if args[2] == 'cache' and args[3] == 'clear' then
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, platforms)
elseif vim.tbl_contains(platforms, args[2]) then
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(args[2], args[3])
if contest_data and contest_data.problems then
local candidates = {}
for _, problem in ipairs(contest_data.problems) do
table.insert(candidates, problem.id)
end
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, candidates)
end
end
end
return {}
end
package.loaded['cp'] = {
get_current_context = function()
return { platform = nil, contest_id = nil }
end,
}
package.loaded['cp.cache'] = {
load = function() end,
get_contest_data = function()
return nil
end,
}
end)
after_each(function()
package.loaded['cp'] = nil
package.loaded['cp.cache'] = nil
end)
it('completes platforms and global actions when no contest context', function()
local result = complete_fn('', 'CP ', 3)
assert.is_table(result)
local has_atcoder = false
local has_codeforces = false
local has_cses = false
local has_cache = false
local has_pick = false
local has_run = false
local has_next = false
local has_prev = false
for _, item in ipairs(result) do
if item == 'atcoder' then
has_atcoder = true
end
if item == 'codeforces' then
has_codeforces = true
end
if item == 'cses' then
has_cses = true
end
if item == 'cache' then
has_cache = true
end
if item == 'pick' then
has_pick = true
end
if item == 'run' then
has_run = true
end
if item == 'next' then
has_next = true
end
if item == 'prev' then
has_prev = true
end
end
assert.is_true(has_atcoder)
assert.is_true(has_codeforces)
assert.is_true(has_cses)
assert.is_true(has_cache)
assert.is_true(has_pick)
assert.is_false(has_run)
assert.is_false(has_next)
assert.is_false(has_prev)
end)
it('completes all actions and problems when contest context exists', function()
package.loaded['cp'] = {
get_current_context = function()
return { platform = 'atcoder', contest_id = 'abc350' }
end,
}
package.loaded['cp.cache'] = {
load = function() end,
get_contest_data = function()
return {
problems = {
{ id = 'a' },
{ id = 'b' },
{ id = 'c' },
},
}
end,
}
local result = complete_fn('', 'CP ', 3)
assert.is_table(result)
local items = {}
for _, item in ipairs(result) do
items[item] = true
end
assert.is_true(items['run'])
assert.is_true(items['next'])
assert.is_true(items['prev'])
assert.is_true(items['pick'])
assert.is_true(items['cache'])
assert.is_true(items['a'])
assert.is_true(items['b'])
assert.is_true(items['c'])
end)
it('completes cache subcommands', function()
local result = complete_fn('c', 'CP cache c', 10)
assert.is_table(result)
assert.equals(1, #result)
assert.equals('clear', result[1])
end)
it('completes cache subcommands with exact match', function()
local result = complete_fn('clear', 'CP cache clear', 14)
assert.is_table(result)
assert.equals(1, #result)
assert.equals('clear', result[1])
end)
it('completes platforms for cache clear', function()
local result = complete_fn('a', 'CP cache clear a', 16)
assert.is_table(result)
local has_atcoder = false
local has_cache = false
for _, item in ipairs(result) do
if item == 'atcoder' then
has_atcoder = true
end
if item == 'cache' then
has_cache = true
end
end
assert.is_true(has_atcoder)
assert.is_false(has_cache)
end)
it('filters completions based on current input', function()
local result = complete_fn('at', 'CP at', 5)
assert.is_table(result)
assert.equals(1, #result)
assert.equals('atcoder', result[1])
end)
it('returns empty array when no matches', function()
local result = complete_fn('xyz', 'CP xyz', 6)
assert.is_table(result)
assert.equals(0, #result)
end)
it('handles problem completion for platform contest', function()
package.loaded['cp.cache'] = {
load = function() end,
get_contest_data = function(platform, contest)
if platform == 'atcoder' and contest == 'abc350' then
return {
problems = {
{ id = 'a' },
{ id = 'b' },
},
}
end
return nil
end,
}
local result = complete_fn('a', 'CP atcoder abc350 a', 18)
assert.is_table(result)
assert.equals(1, #result)
assert.equals('a', result[1])
end)
end)
end)

View file

@ -1,312 +0,0 @@
describe('cp.config', function()
local config
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
config = require('cp.config')
end)
after_each(function()
spec_helper.teardown()
end)
describe('setup', function()
it('returns defaults with nil input', function()
local result = config.setup()
assert.equals('table', type(result.contests))
assert.equals('table', type(result.snippets))
assert.equals('table', type(result.hooks))
assert.equals('table', type(result.scrapers))
assert.is_false(result.debug)
assert.is_nil(result.filename)
end)
it('merges user config with defaults', function()
local user_config = {
debug = true,
contests = { test_contest = { cpp = { extension = 'cpp' } } },
}
local result = config.setup(user_config)
assert.is_true(result.debug)
assert.equals('table', type(result.contests.test_contest))
assert.equals('table', type(result.scrapers))
end)
it('allows custom extensions', function()
local custom_config = {
contests = {
test_contest = {
cpp = { extension = 'custom' },
},
},
}
assert.has_no.errors(function()
config.setup(custom_config)
end)
end)
it('validates scraper platforms', function()
local invalid_config = {
scrapers = { 'invalid_platform' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('validates scraper values are strings', function()
local invalid_config = {
scrapers = { 123 },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('validates hook functions', function()
local invalid_config = {
hooks = { before_run = 'not_a_function' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
describe('run_panel config validation', function()
it('validates ansi is boolean', function()
local invalid_config = {
run_panel = { ansi = 'invalid' },
}
assert.has_error(function()
config.setup(invalid_config)
end, 'ansi: expected ansi color parsing must be enabled xor disabled, got string')
end)
it('validates diff_mode values', function()
local invalid_config = {
run_panel = { diff_mode = 'invalid' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('validates next_test_key is non-empty string', function()
local invalid_config = {
run_panel = { next_test_key = '' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('validates prev_test_key is non-empty string', function()
local invalid_config = {
run_panel = { prev_test_key = '' },
}
assert.has_error(function()
config.setup(invalid_config)
end)
end)
it('accepts valid run_panel config', function()
local valid_config = {
run_panel = {
ansi = false,
diff_mode = 'git',
next_test_key = 'j',
prev_test_key = 'k',
},
}
assert.has_no.errors(function()
config.setup(valid_config)
end)
end)
end)
describe('auto-configuration', function()
it('sets default extensions for cpp and python', function()
local user_config = {
contests = {
test = {
cpp = { compile = { 'g++' } },
python = { test = { 'python3' } },
},
},
}
local result = config.setup(user_config)
assert.equals('cpp', result.contests.test.cpp.extension)
assert.equals('py', result.contests.test.python.extension)
end)
it('sets default_language to cpp when available', function()
local user_config = {
contests = {
test = {
cpp = { compile = { 'g++' } },
python = { test = { 'python3' } },
},
},
}
local result = config.setup(user_config)
assert.equals('cpp', result.contests.test.default_language)
end)
it('sets default_language to single available language when only one configured', function()
local user_config = {
contests = {
test = {
python = { test = { 'python3' } },
},
},
}
local result = config.setup(user_config)
assert.equals('python', result.contests.test.default_language)
end)
it('sets default_language to single available language even when not cpp', function()
local user_config = {
contests = {
test = {
rust = {
test = { './target/release/solution' },
extension = 'rs',
},
},
},
}
local result = config.setup(user_config)
assert.equals('rust', result.contests.test.default_language)
end)
it('uses first available language when multiple configured', function()
local user_config = {
contests = {
test = {
python = { test = { 'python3' } },
cpp = { compile = { 'g++' } },
},
},
}
local result = config.setup(user_config)
assert.is_true(vim.tbl_contains({ 'cpp', 'python' }, result.contests.test.default_language))
end)
it('preserves explicit default_language', function()
local user_config = {
contests = {
test = {
cpp = { compile = { 'g++' } },
python = { test = { 'python3' } },
default_language = 'python',
},
},
}
local result = config.setup(user_config)
assert.equals('python', result.contests.test.default_language)
end)
it('errors when no language configurations exist', function()
local invalid_config = {
contests = {
test = {},
},
}
assert.has_error(function()
config.setup(invalid_config)
end, 'No language configurations found')
end)
it('allows custom language names', function()
local user_config = {
contests = {
test = {
rust = {
compile = { 'rustc', '{source}', '-o', '{binary}' },
test = { '{binary}' },
extension = 'rs',
},
cpp = { compile = { 'g++' } },
},
},
}
assert.has_no.errors(function()
local result = config.setup(user_config)
assert.equals('cpp', result.contests.test.default_language)
end)
end)
end)
describe('picker validation', function()
it('validates picker is valid value', function()
local invalid_config = {
picker = 'invalid_picker',
}
assert.has_error(function()
config.setup(invalid_config)
end, "Invalid picker 'invalid_picker'. Must be 'telescope' or 'fzf-lua'")
end)
it('allows nil picker', function()
assert.has_no.errors(function()
local result = config.setup({ picker = nil })
assert.is_nil(result.picker)
end)
end)
it('allows telescope picker without checking availability', function()
assert.has_no.errors(function()
local result = config.setup({ picker = 'telescope' })
assert.equals('telescope', result.picker)
end)
end)
it('allows fzf-lua picker without checking availability', function()
assert.has_no.errors(function()
local result = config.setup({ picker = 'fzf-lua' })
assert.equals('fzf-lua', result.picker)
end)
end)
end)
end)
describe('default_filename', function()
it('generates lowercase contest filename', function()
local result = config.default_filename('ABC123')
assert.equals('abc123', result)
end)
it('combines contest and problem ids', function()
local result = config.default_filename('ABC123', 'A')
assert.equals('abc123a', result)
end)
end)
end)

View file

@ -1,200 +0,0 @@
describe('cp.diff', function()
local spec_helper = require('spec.spec_helper')
local diff
before_each(function()
spec_helper.setup()
diff = require('cp.ui.diff')
end)
after_each(function()
spec_helper.teardown()
end)
describe('get_available_backends', function()
it('returns vim and git backends', function()
local backends = diff.get_available_backends()
table.sort(backends)
assert.same({ 'git', 'vim' }, backends)
end)
end)
describe('get_backend', function()
it('returns vim backend by name', function()
local backend = diff.get_backend('vim')
assert.is_not_nil(backend)
assert.equals('vim', backend.name)
end)
it('returns git backend by name', function()
local backend = diff.get_backend('git')
assert.is_not_nil(backend)
assert.equals('git', backend.name)
end)
it('returns nil for invalid name', function()
local backend = diff.get_backend('invalid')
assert.is_nil(backend)
end)
end)
describe('is_git_available', function()
it('returns true when git command succeeds', function()
local mock_system = stub(vim, 'system')
mock_system.returns({
wait = function()
return { code = 0 }
end,
})
local result = diff.is_git_available()
assert.is_true(result)
mock_system:revert()
end)
it('returns false when git command fails', function()
local mock_system = stub(vim, 'system')
mock_system.returns({
wait = function()
return { code = 1 }
end,
})
local result = diff.is_git_available()
assert.is_false(result)
mock_system:revert()
end)
end)
describe('get_best_backend', function()
it('returns preferred backend when available', function()
local mock_is_available = stub(diff, 'is_git_available')
mock_is_available.returns(true)
local backend = diff.get_best_backend('git')
assert.equals('git', backend.name)
mock_is_available:revert()
end)
it('falls back to vim when git unavailable', function()
local mock_is_available = stub(diff, 'is_git_available')
mock_is_available.returns(false)
local backend = diff.get_best_backend('git')
assert.equals('vim', backend.name)
mock_is_available:revert()
end)
it('defaults to vim backend', function()
local backend = diff.get_best_backend()
assert.equals('vim', backend.name)
end)
end)
describe('vim backend', function()
it('returns content as-is', function()
local backend = diff.get_backend('vim')
local result = backend.render('expected', 'actual')
assert.same({ 'actual' }, result.content)
assert.is_nil(result.highlights)
end)
end)
describe('git backend', function()
it('creates temp files for diff', function()
local mock_system = stub(vim, 'system')
local mock_tempname = stub(vim.fn, 'tempname')
local mock_writefile = stub(vim.fn, 'writefile')
local mock_delete = stub(vim.fn, 'delete')
mock_tempname.returns('/tmp/expected', '/tmp/actual')
mock_system.returns({
wait = function()
return { code = 1, stdout = 'diff output' }
end,
})
local backend = diff.get_backend('git')
backend.render('expected text', 'actual text')
assert.stub(mock_writefile).was_called(2)
assert.stub(mock_delete).was_called(2)
mock_system:revert()
mock_tempname:revert()
mock_writefile:revert()
mock_delete:revert()
end)
it('returns raw diff output', function()
local mock_system = stub(vim, 'system')
local mock_tempname = stub(vim.fn, 'tempname')
local mock_writefile = stub(vim.fn, 'writefile')
local mock_delete = stub(vim.fn, 'delete')
mock_tempname.returns('/tmp/expected', '/tmp/actual')
mock_system.returns({
wait = function()
return { code = 1, stdout = 'git diff output' }
end,
})
local backend = diff.get_backend('git')
local result = backend.render('expected', 'actual')
assert.equals('git diff output', result.raw_diff)
mock_system:revert()
mock_tempname:revert()
mock_writefile:revert()
mock_delete:revert()
end)
it('handles no differences', function()
local mock_system = stub(vim, 'system')
local mock_tempname = stub(vim.fn, 'tempname')
local mock_writefile = stub(vim.fn, 'writefile')
local mock_delete = stub(vim.fn, 'delete')
mock_tempname.returns('/tmp/expected', '/tmp/actual')
mock_system.returns({
wait = function()
return { code = 0 }
end,
})
local backend = diff.get_backend('git')
local result = backend.render('same', 'same')
assert.same({ 'same' }, result.content)
assert.same({}, result.highlights)
mock_system:revert()
mock_tempname:revert()
mock_writefile:revert()
mock_delete:revert()
end)
end)
describe('render_diff', function()
it('uses best available backend', function()
local mock_backend = {
render = function()
return {}
end,
}
local mock_get_best = stub(diff, 'get_best_backend')
mock_get_best.returns(mock_backend)
diff.render_diff('expected', 'actual', 'vim')
assert.stub(mock_get_best).was_called_with('vim')
mock_get_best:revert()
end)
end)
end)

View file

@ -1,451 +0,0 @@
describe('cp.execute', function()
local execute
local mock_system_calls
local temp_files
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
execute = require('cp.runner.execute')
mock_system_calls = {}
temp_files = {}
vim.system = function(cmd, opts)
table.insert(mock_system_calls, { cmd = cmd, opts = opts })
if not cmd or #cmd == 0 then
return {
wait = function()
return { code = 0, stdout = '', stderr = '' }
end,
}
end
local result = { code = 0, stdout = '', stderr = '' }
if cmd[1] == 'mkdir' then
result = { code = 0 }
elseif cmd[1] == 'g++' or cmd[1] == 'gcc' then
result = { code = 0, stderr = '' }
elseif cmd[1]:match('%.run$') or cmd[1] == 'python' then
result = { code = 0, stdout = '42\n', stderr = '' }
end
return {
wait = function()
return result
end,
}
end
local original_fn = vim.fn
vim.fn = vim.tbl_extend('force', vim.fn, {
filereadable = function(path)
return temp_files[path] and 1 or 0
end,
readfile = function(path)
return temp_files[path] or {}
end,
fnamemodify = function(path, modifier)
if modifier == ':e' then
return path:match('%.([^.]+)$') or ''
end
return original_fn.fnamemodify(path, modifier)
end,
})
vim.uv = vim.tbl_extend('force', vim.uv or {}, {
hrtime = function()
return 1000000000
end,
})
end)
after_each(function()
vim.system = vim.system_original or vim.system
spec_helper.teardown()
temp_files = {}
end)
describe('template substitution', function()
it('substitutes placeholders correctly', function()
local language_config = {
compile = { 'g++', '{source_file}', '-o', '{binary_file}' },
}
local substitutions = {
source_file = 'test.cpp',
binary_file = 'test.run',
}
local result = execute.compile_generic(language_config, substitutions)
assert.equals(0, result.code)
assert.is_true(#mock_system_calls > 0)
local compile_call = mock_system_calls[1]
assert.equals('sh', compile_call.cmd[1])
assert.equals('-c', compile_call.cmd[2])
assert.is_not_nil(string.find(compile_call.cmd[3], 'g%+%+ test%.cpp %-o test%.run'))
assert.is_not_nil(string.find(compile_call.cmd[3], '2>&1'))
end)
it('handles multiple substitutions in single argument', function()
local language_config = {
compile = { 'g++', '{source_file}', '-o{binary_file}' },
}
local substitutions = {
source_file = 'main.cpp',
binary_file = 'main.out',
}
execute.compile_generic(language_config, substitutions)
local compile_call = mock_system_calls[1]
assert.is_not_nil(string.find(compile_call.cmd[3], '%-omain%.out'))
end)
end)
describe('compilation', function()
it('skips compilation when not required', function()
local language_config = {}
local substitutions = {}
local result = execute.compile_generic(language_config, substitutions)
assert.equals(0, result.code)
assert.equals('', result.stderr)
assert.equals(0, #mock_system_calls)
end)
it('compiles cpp files correctly', function()
local language_config = {
compile = { 'g++', '{source_file}', '-o', '{binary_file}', '-std=c++17' },
}
local substitutions = {
source_file = 'solution.cpp',
binary_file = 'build/solution.run',
}
local result = execute.compile_generic(language_config, substitutions)
assert.equals(0, result.code)
assert.is_true(#mock_system_calls > 0)
local compile_call = mock_system_calls[1]
assert.equals('sh', compile_call.cmd[1])
assert.is_not_nil(string.find(compile_call.cmd[3], '%-std=c%+%+17'))
end)
it('handles compilation errors gracefully', function()
vim.system = function()
return {
wait = function()
return { code = 1, stderr = 'error: undefined variable' }
end,
}
end
local language_config = {
compile = { 'g++', '{source_file}', '-o', '{binary_file}' },
}
local substitutions = { source_file = 'bad.cpp', binary_file = 'bad.run' }
local result = execute.compile_generic(language_config, substitutions)
assert.equals(1, result.code)
assert.is_not_nil(result.stderr:match('undefined variable'))
end)
it('measures compilation time', function()
local start_time = 1000000000
local end_time = 1500000000
local call_count = 0
vim.uv.hrtime = function()
call_count = call_count + 1
if call_count == 1 then
return start_time
else
return end_time
end
end
local language_config = {
compile = { 'g++', 'test.cpp', '-o', 'test.run' },
}
execute.compile_generic(language_config, {})
assert.is_true(call_count >= 2)
end)
end)
describe('test execution', function()
it('executes commands with input data', function()
vim.system = function(cmd, opts)
table.insert(mock_system_calls, { cmd = cmd, opts = opts })
return {
wait = function()
return { code = 0, stdout = '3\n', stderr = '' }
end,
}
end
local language_config = {
run = { '{binary_file}' },
}
execute.compile_generic(language_config, { binary_file = './test.run' })
end)
it('handles command execution', function()
vim.system = function(_, opts)
if opts then
assert.equals(false, opts.text)
end
return {
wait = function()
return { code = 124, stdout = '', stderr = '' }
end,
}
end
local language_config = {
compile = { 'timeout', '1', 'sleep', '2' },
}
local result = execute.compile_generic(language_config, {})
assert.equals(124, result.code)
end)
it('captures stderr output', function()
vim.system = function()
return {
wait = function()
return { code = 1, stdout = '', stderr = 'runtime error\n' }
end,
}
end
local language_config = {
compile = { 'false' },
}
local result = execute.compile_generic(language_config, {})
assert.equals(1, result.code)
assert.is_not_nil(result.stderr:match('runtime error'))
end)
end)
describe('parameter validation', function()
it('validates language_config parameter', function()
assert.has_error(function()
execute.compile_generic(nil, {})
end)
assert.has_error(function()
execute.compile_generic('not_table', {})
end)
end)
it('validates substitutions parameter', function()
assert.has_error(function()
execute.compile_generic({}, nil)
end)
assert.has_error(function()
execute.compile_generic({}, 'not_table')
end)
end)
end)
describe('directory creation', function()
it('creates build and io directories', function()
local language_config = {
compile = { 'mkdir', '-p', 'build', 'io' },
}
execute.compile_generic(language_config, {})
local mkdir_call = mock_system_calls[1]
assert.equals('sh', mkdir_call.cmd[1])
assert.is_not_nil(string.find(mkdir_call.cmd[3], 'mkdir'))
assert.is_not_nil(string.find(mkdir_call.cmd[3], 'build'))
assert.is_not_nil(string.find(mkdir_call.cmd[3], 'io'))
end)
end)
describe('language detection', function()
it('detects cpp from extension', function()
vim.fn.fnamemodify = function()
return 'cpp'
end
assert.has_no_errors(function()
execute.compile_generic({}, {})
end)
end)
it('falls back to default language', function()
vim.fn.fnamemodify = function(_, modifier)
if modifier == ':e' then
return 'unknown'
end
return ''
end
assert.has_no_errors(function()
execute.compile_generic({}, {})
end)
end)
end)
describe('edge cases', function()
it('handles empty command templates', function()
local language_config = {
compile = {},
}
local result = execute.compile_generic(language_config, {})
assert.equals(0, result.code)
end)
it('handles commands with no substitutions needed', function()
local language_config = {
compile = { 'echo', 'hello' },
}
local result = execute.compile_generic(language_config, {})
assert.equals(0, result.code)
local echo_call = mock_system_calls[1]
assert.equals('sh', echo_call.cmd[1])
assert.is_not_nil(string.find(echo_call.cmd[3], 'echo hello'))
end)
it('handles multiple consecutive substitutions', function()
local language_config = {
compile = { '{compiler}{compiler}', '{file}{file}' },
}
local substitutions = {
compiler = 'g++',
file = 'test.cpp',
}
execute.compile_generic(language_config, substitutions)
local call = mock_system_calls[1]
assert.equals('sh', call.cmd[1])
assert.is_not_nil(string.find(call.cmd[3], 'g%+%+g%+%+ test%.cpptest%.cpp'))
end)
end)
describe('stderr/stdout redirection', function()
it('should use stderr redirection (2>&1)', function()
local original_system = vim.system
local captured_command = nil
vim.system = function(cmd, _)
captured_command = cmd
return {
wait = function()
return { code = 0, stdout = '', stderr = '' }
end,
}
end
local language_config = {
compile = { 'g++', '-std=c++17', '-o', '{binary}', '{source}' },
}
local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' }
execute.compile_generic(language_config, substitutions)
assert.is_not_nil(captured_command)
assert.equals('sh', captured_command[1])
assert.equals('-c', captured_command[2])
assert.is_not_nil(
string.find(captured_command[3], '2>&1'),
'Command should contain 2>&1 redirection'
)
vim.system = original_system
end)
it('should return combined stdout+stderr in result', function()
local original_system = vim.system
local test_output = 'STDOUT: Hello\nSTDERR: Error message\n'
vim.system = function(_, _)
return {
wait = function()
return { code = 1, stdout = test_output, stderr = '' }
end,
}
end
local language_config = {
compile = { 'g++', '-std=c++17', '-o', '{binary}', '{source}' },
}
local substitutions = { source = 'test.cpp', binary = 'build/test', version = '17' }
local result = execute.compile_generic(language_config, substitutions)
assert.equals(1, result.code)
assert.equals(test_output, result.stdout)
vim.system = original_system
end)
end)
describe('integration with execute_command function', function()
it('tests the full execute_command flow with stderr/stdout combination', function()
local cmd = { 'echo', 'test output' }
local input_data = 'test input'
local timeout_ms = 1000
local original_system = vim.system
vim.system = function(shell_cmd, opts)
assert.equals('sh', shell_cmd[1])
assert.equals('-c', shell_cmd[2])
assert.is_not_nil(string.find(shell_cmd[3], '2>&1'))
assert.equals(input_data, opts.stdin)
assert.equals(timeout_ms, opts.timeout)
assert.is_true(opts.text)
return {
wait = function()
return { code = 0, stdout = 'combined output from stdout and stderr', stderr = '' }
end,
}
end
local execute_command = require('cp.runner.execute').execute_command
or function(command, stdin_data, timeout)
local redirected_cmd = vim.deepcopy(command)
if #redirected_cmd > 0 then
redirected_cmd[#redirected_cmd] = redirected_cmd[#redirected_cmd] .. ' 2>&1'
end
local result = vim
.system({ 'sh', '-c', table.concat(redirected_cmd, ' ') }, {
stdin = stdin_data,
timeout = timeout,
text = true,
})
:wait()
return {
stdout = result.stdout or '',
stderr = result.stderr or '',
code = result.code or 0,
time_ms = 0,
timed_out = result.code == 124,
}
end
local result = execute_command(cmd, input_data, timeout_ms)
assert.equals(0, result.code)
assert.equals('combined output from stdout and stderr', result.stdout)
vim.system = original_system
end)
end)
end)

View file

@ -1,215 +0,0 @@
describe('extmarks', function()
local spec_helper = require('spec.spec_helper')
local highlight
before_each(function()
spec_helper.setup()
highlight = require('cp.ui.highlight')
end)
after_each(function()
spec_helper.teardown()
end)
describe('buffer deletion', function()
it('clears namespace on buffer delete', function()
local bufnr = 1
local namespace = 100
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
highlight.apply_highlights(bufnr, {
{
line = 0,
col_start = 0,
col_end = 5,
highlight_group = 'CpDiffAdded',
},
}, namespace)
assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1)
mock_clear:revert()
mock_extmark:revert()
end)
it('handles invalid buffer gracefully', function()
local bufnr = 999
local namespace = 100
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
mock_clear.on_call_with(bufnr, namespace, 0, -1).invokes(function()
error('Invalid buffer')
end)
local success = pcall(highlight.apply_highlights, bufnr, {
{
line = 0,
col_start = 0,
col_end = 5,
highlight_group = 'CpDiffAdded',
},
}, namespace)
assert.is_false(success)
mock_clear:revert()
mock_extmark:revert()
end)
end)
describe('namespace isolation', function()
it('creates unique namespaces', function()
local mock_create = stub(vim.api, 'nvim_create_namespace')
mock_create.on_call_with('cp_diff_highlights').returns(100)
mock_create.on_call_with('cp_test_list').returns(200)
mock_create.on_call_with('cp_ansi_highlights').returns(300)
local diff_ns = highlight.create_namespace()
local test_ns = vim.api.nvim_create_namespace('cp_test_list')
local ansi_ns = vim.api.nvim_create_namespace('cp_ansi_highlights')
assert.equals(100, diff_ns)
assert.equals(200, test_ns)
assert.equals(300, ansi_ns)
mock_create:revert()
end)
it('clears specific namespace independently', function()
local bufnr = 1
local ns1 = 100
local ns2 = 200
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
highlight.apply_highlights(bufnr, {
{ line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' },
}, ns1)
highlight.apply_highlights(bufnr, {
{ line = 1, col_start = 0, col_end = 3, highlight_group = 'CpDiffRemoved' },
}, ns2)
assert.stub(mock_clear).was_called_with(bufnr, ns1, 0, -1)
assert.stub(mock_clear).was_called_with(bufnr, ns2, 0, -1)
assert.stub(mock_clear).was_called(2)
mock_clear:revert()
mock_extmark:revert()
end)
end)
describe('multiple updates', function()
it('clears previous extmarks on each update', function()
local bufnr = 1
local namespace = 100
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
highlight.apply_highlights(bufnr, {
{ line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' },
}, namespace)
highlight.apply_highlights(bufnr, {
{ line = 1, col_start = 0, col_end = 3, highlight_group = 'CpDiffRemoved' },
}, namespace)
assert.stub(mock_clear).was_called(2)
assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1)
assert.stub(mock_extmark).was_called(2)
mock_clear:revert()
mock_extmark:revert()
end)
it('handles empty highlights', function()
local bufnr = 1
local namespace = 100
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
highlight.apply_highlights(bufnr, {
{ line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' },
}, namespace)
highlight.apply_highlights(bufnr, {}, namespace)
assert.stub(mock_clear).was_called(2)
assert.stub(mock_extmark).was_called(1)
mock_clear:revert()
mock_extmark:revert()
end)
it('skips invalid highlights', function()
local bufnr = 1
local namespace = 100
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
highlight.apply_highlights(bufnr, {
{ line = 0, col_start = 5, col_end = 5, highlight_group = 'CpDiffAdded' },
{ line = 1, col_start = 7, col_end = 3, highlight_group = 'CpDiffAdded' },
{ line = 2, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' },
}, namespace)
assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1)
assert.stub(mock_extmark).was_called(1)
assert.stub(mock_extmark).was_called_with(bufnr, namespace, 2, 0, {
end_col = 5,
hl_group = 'CpDiffAdded',
priority = 100,
})
mock_clear:revert()
mock_extmark:revert()
end)
end)
describe('error handling', function()
it('fails when clear_namespace fails', function()
local bufnr = 1
local namespace = 100
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
mock_clear.on_call_with(bufnr, namespace, 0, -1).invokes(function()
error('Namespace clear failed')
end)
local success = pcall(highlight.apply_highlights, bufnr, {
{ line = 0, col_start = 0, col_end = 5, highlight_group = 'CpDiffAdded' },
}, namespace)
assert.is_false(success)
assert.stub(mock_extmark).was_not_called()
mock_clear:revert()
mock_extmark:revert()
end)
end)
describe('parse_and_apply_diff cleanup', function()
it('clears namespace before applying parsed diff', function()
local bufnr = 1
local namespace = 100
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines')
local mock_get_option = stub(vim.api, 'nvim_get_option_value')
local mock_set_option = stub(vim.api, 'nvim_set_option_value')
mock_get_option.returns(false)
highlight.parse_and_apply_diff(bufnr, '+hello {+world+}', namespace)
assert.stub(mock_clear).was_called_with(bufnr, namespace, 0, -1)
mock_clear:revert()
mock_extmark:revert()
mock_set_lines:revert()
mock_get_option:revert()
mock_set_option:revert()
end)
end)
end)

View file

@ -1,31 +0,0 @@
describe('cp.fzf_lua', function()
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
package.preload['fzf-lua'] = function()
return {
fzf_exec = function(_, _) end,
}
end
end)
after_each(function()
spec_helper.teardown()
end)
describe('module loading', function()
it('loads fzf-lua integration without error', function()
assert.has_no.errors(function()
require('cp.pickers.fzf_lua')
end)
end)
it('returns module with platform_picker function', function()
local fzf_lua_cp = require('cp.pickers.fzf_lua')
assert.is_table(fzf_lua_cp)
assert.is_function(fzf_lua_cp.platform_picker)
end)
end)
end)

View file

@ -1,192 +0,0 @@
describe('cp.highlight', function()
local spec_helper = require('spec.spec_helper')
local highlight
before_each(function()
spec_helper.setup()
highlight = require('cp.ui.highlight')
end)
after_each(function()
spec_helper.teardown()
end)
describe('parse_git_diff', function()
it('skips git diff headers', function()
local diff_output = [[diff --git a/test b/test
index 1234567..abcdefg 100644
--- a/test
+++ b/test
@@ -1,3 +1,3 @@
hello
+world
-goodbye]]
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'hello', 'world' }, result.content)
end)
it('processes added lines', function()
local diff_output = '+hello w{+o+}rld'
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'hello world' }, result.content)
assert.equals(1, #result.highlights)
assert.equals('CpDiffAdded', result.highlights[1].highlight_group)
end)
it('ignores removed lines', function()
local diff_output = 'hello\n-removed line\n+kept line'
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'hello', 'kept line' }, result.content)
end)
it('handles unchanged lines', function()
local diff_output = 'unchanged line\n+added line'
local result = highlight.parse_git_diff(diff_output)
assert.same({ 'unchanged line', 'added line' }, result.content)
end)
it('sets correct line numbers', function()
local diff_output = '+first {+added+}\n+second {+text+}'
local result = highlight.parse_git_diff(diff_output)
assert.equals(0, result.highlights[1].line)
assert.equals(1, result.highlights[2].line)
end)
it('handles empty diff output', function()
local result = highlight.parse_git_diff('')
assert.same({}, result.content)
assert.same({}, result.highlights)
end)
end)
describe('apply_highlights', function()
it('clears existing highlights', function()
local mock_clear = spy.on(vim.api, 'nvim_buf_clear_namespace')
local bufnr = 1
local namespace = 100
highlight.apply_highlights(bufnr, {}, namespace)
assert.spy(mock_clear).was_called_with(bufnr, namespace, 0, -1)
mock_clear:revert()
end)
it('applies extmarks with correct positions', function()
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local bufnr = 1
local namespace = 100
local highlights = {
{
line = 0,
col_start = 5,
col_end = 10,
highlight_group = 'CpDiffAdded',
},
}
highlight.apply_highlights(bufnr, highlights, namespace)
assert.stub(mock_extmark).was_called_with(bufnr, namespace, 0, 5, {
end_col = 10,
hl_group = 'CpDiffAdded',
priority = 100,
})
mock_extmark:revert()
mock_clear:revert()
end)
it('uses correct highlight groups', function()
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
local highlights = {
{
line = 0,
col_start = 0,
col_end = 5,
highlight_group = 'CpDiffAdded',
},
}
highlight.apply_highlights(1, highlights, 100)
assert.stub(mock_extmark).was_called_with(1, 100, 0, 0, {
end_col = 5,
hl_group = 'CpDiffAdded',
priority = 100,
})
mock_extmark:revert()
mock_clear:revert()
end)
it('handles empty highlights', function()
local mock_extmark = stub(vim.api, 'nvim_buf_set_extmark')
local mock_clear = stub(vim.api, 'nvim_buf_clear_namespace')
highlight.apply_highlights(1, {}, 100)
assert.stub(mock_extmark).was_not_called()
mock_extmark:revert()
mock_clear:revert()
end)
end)
describe('create_namespace', function()
it('creates unique namespace', function()
local mock_create = stub(vim.api, 'nvim_create_namespace')
mock_create.returns(42)
local result = highlight.create_namespace()
assert.equals(42, result)
assert.stub(mock_create).was_called_with('cp_diff_highlights')
mock_create:revert()
end)
end)
describe('parse_and_apply_diff', function()
it('parses diff and applies to buffer', function()
local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines')
local mock_apply = stub(highlight, 'apply_highlights')
local bufnr = 1
local namespace = 100
local diff_output = '+hello {+world+}'
local result = highlight.parse_and_apply_diff(bufnr, diff_output, namespace)
assert.same({ 'hello world' }, result)
assert.stub(mock_set_lines).was_called_with(bufnr, 0, -1, false, { 'hello world' })
assert.stub(mock_apply).was_called()
mock_set_lines:revert()
mock_apply:revert()
end)
it('sets buffer content', function()
local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines')
local mock_apply = stub(highlight, 'apply_highlights')
highlight.parse_and_apply_diff(1, '+test line', 100)
assert.stub(mock_set_lines).was_called_with(1, 0, -1, false, { 'test line' })
mock_set_lines:revert()
mock_apply:revert()
end)
it('applies highlights', function()
local mock_set_lines = stub(vim.api, 'nvim_buf_set_lines')
local mock_apply = stub(highlight, 'apply_highlights')
highlight.parse_and_apply_diff(1, '+hello {+world+}', 100)
assert.stub(mock_apply).was_called()
mock_set_lines:revert()
mock_apply:revert()
end)
it('returns content lines', function()
local result = highlight.parse_and_apply_diff(1, '+first\n+second', 100)
assert.same({ 'first', 'second' }, result)
end)
end)
end)

View file

@ -1,208 +0,0 @@
describe('cp.picker', function()
local picker
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
picker = require('cp.pickers')
end)
after_each(function()
spec_helper.teardown()
end)
describe('get_platforms', function()
it('returns platform list with display names', function()
local platforms = picker.get_platforms()
assert.is_table(platforms)
assert.is_true(#platforms > 0)
for _, platform in ipairs(platforms) do
assert.is_string(platform.id)
assert.is_string(platform.display_name)
assert.is_not_nil(platform.display_name:match('^%u'))
end
end)
it('includes expected platforms with correct display names', function()
local platforms = picker.get_platforms()
local platform_map = {}
for _, p in ipairs(platforms) do
platform_map[p.id] = p.display_name
end
assert.equals('CodeForces', platform_map['codeforces'])
assert.equals('AtCoder', platform_map['atcoder'])
assert.equals('CSES', platform_map['cses'])
end)
end)
describe('get_contests_for_platform', function()
it('returns empty list when scraper fails', function()
vim.system = function(_, _)
return {
wait = function()
return { code = 1, stderr = 'test error' }
end,
}
end
local contests = picker.get_contests_for_platform('test_platform')
assert.is_table(contests)
assert.equals(0, #contests)
end)
it('returns empty list when JSON is invalid', function()
vim.system = function(_, _)
return {
wait = function()
return { code = 0, stdout = 'invalid json' }
end,
}
end
local contests = picker.get_contests_for_platform('test_platform')
assert.is_table(contests)
assert.equals(0, #contests)
end)
it('returns contest list when scraper succeeds', function()
local cache = require('cp.cache')
local utils = require('cp.utils')
cache.load = function() end
cache.get_contest_list = function()
return nil
end
cache.set_contest_list = function() end
utils.setup_python_env = function()
return true
end
utils.get_plugin_path = function()
return '/test/path'
end
vim.system = function(_, _)
return {
wait = function()
return {
code = 0,
stdout = vim.json.encode({
success = true,
contests = {
{
id = 'abc123',
name = 'AtCoder Beginner Contest 123',
display_name = 'Beginner Contest 123 (ABC)',
},
{
id = '1951',
name = 'Educational Round 168',
display_name = 'Educational Round 168',
},
},
}),
}
end,
}
end
local contests = picker.get_contests_for_platform('test_platform')
assert.is_table(contests)
assert.equals(2, #contests)
assert.equals('abc123', contests[1].id)
assert.equals('AtCoder Beginner Contest 123', contests[1].name)
assert.equals('Beginner Contest 123 (ABC)', contests[1].display_name)
end)
end)
describe('get_problems_for_contest', function()
it('returns problems from cache when available', function()
local cache = require('cp.cache')
cache.load = function() end
cache.get_contest_data = function(_, _)
return {
problems = {
{ id = 'a', name = 'Problem A' },
{ id = 'b', name = 'Problem B' },
},
}
end
local problems = picker.get_problems_for_contest('test_platform', 'test_contest')
assert.is_table(problems)
assert.equals(2, #problems)
assert.equals('a', problems[1].id)
assert.equals('Problem A', problems[1].name)
assert.equals('Problem A', problems[1].display_name)
end)
it('falls back to scraping when cache miss', function()
local cache = require('cp.cache')
local scrape = require('cp.scrape')
cache.load = function() end
cache.get_contest_data = function(_, _)
return nil
end
scrape.scrape_contest_metadata = function(_, _)
return {
success = true,
problems = {
{ id = 'x', name = 'Problem X' },
},
}
end
local problems = picker.get_problems_for_contest('test_platform', 'test_contest')
assert.is_table(problems)
assert.equals(1, #problems)
assert.equals('x', problems[1].id)
end)
it('returns empty list when scraping fails', function()
local cache = require('cp.cache')
local scrape = require('cp.scrape')
cache.load = function() end
cache.get_contest_data = function(_, _)
return nil
end
scrape.scrape_contest_metadata = function(_, _)
return {
success = false,
error = 'test error',
}
end
local problems = picker.get_problems_for_contest('test_platform', 'test_contest')
assert.is_table(problems)
assert.equals(0, #problems)
end)
end)
describe('setup_problem', function()
it('calls cp.handle_command with correct arguments', function()
local cp = require('cp')
local called_with = nil
cp.handle_command = function(opts)
called_with = opts
end
picker.setup_problem('codeforces', '1951', 'a')
vim.wait(100, function()
return called_with ~= nil
end)
assert.is_table(called_with)
assert.is_table(called_with.fargs)
assert.equals('codeforces', called_with.fargs[1])
assert.equals('1951', called_with.fargs[2])
assert.equals('a', called_with.fargs[3])
end)
end)
end)

View file

@ -1,146 +0,0 @@
describe('cp.problem', function()
local problem
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
problem = require('cp.problem')
end)
after_each(function()
spec_helper.teardown()
end)
describe('create_context', function()
local base_config = {
contests = {
atcoder = {
default_language = 'cpp',
cpp = { extension = 'cpp' },
python = { extension = 'py' },
},
codeforces = {
default_language = 'cpp',
cpp = { extension = 'cpp' },
},
},
}
it('creates basic context with required fields', function()
local context = problem.create_context('atcoder', 'abc123', 'a', base_config)
assert.equals('atcoder', context.contest)
assert.equals('abc123', context.contest_id)
assert.equals('a', context.problem_id)
assert.equals('abc123a', context.problem_name)
assert.equals('abc123a.cpp', context.source_file)
assert.equals('build/abc123a.run', context.binary_file)
assert.equals('io/abc123a.cpin', context.input_file)
assert.equals('io/abc123a.cpout', context.output_file)
assert.equals('io/abc123a.expected', context.expected_file)
end)
it('handles context without problem_id', function()
local context = problem.create_context('codeforces', '1933', nil, base_config)
assert.equals('codeforces', context.contest)
assert.equals('1933', context.contest_id)
assert.is_nil(context.problem_id)
assert.equals('1933', context.problem_name)
assert.equals('1933.cpp', context.source_file)
assert.equals('build/1933.run', context.binary_file)
end)
it('uses default language from contest config', function()
local context = problem.create_context('atcoder', 'abc123', 'a', base_config)
assert.equals('abc123a.cpp', context.source_file)
end)
it('respects explicit language parameter', function()
local context = problem.create_context('atcoder', 'abc123', 'a', base_config, 'python')
assert.equals('abc123a.py', context.source_file)
end)
it('uses custom filename function when provided', function()
local config_with_custom = vim.tbl_deep_extend('force', base_config, {
filename = function(contest, contest_id, problem_id)
return contest .. '_' .. contest_id .. (problem_id and ('_' .. problem_id) or '')
end,
})
local context = problem.create_context('atcoder', 'abc123', 'a', config_with_custom)
assert.equals('atcoder_abc123_a.cpp', context.source_file)
assert.equals('atcoder_abc123_a', context.problem_name)
end)
it('validates required parameters', function()
assert.has_error(function()
problem.create_context(nil, 'abc123', 'a', base_config)
end)
assert.has_error(function()
problem.create_context('atcoder', nil, 'a', base_config)
end)
assert.has_error(function()
problem.create_context('atcoder', 'abc123', 'a', nil)
end)
end)
it('validates contest exists in config', function()
assert.has_error(function()
problem.create_context('invalid_contest', 'abc123', 'a', base_config)
end)
end)
it('validates language exists in contest config', function()
assert.has_error(function()
problem.create_context('atcoder', 'abc123', 'a', base_config, 'invalid_language')
end)
end)
it('validates default language exists', function()
local bad_config = {
contests = {
test_contest = {
default_language = 'nonexistent',
},
},
}
assert.has_error(function()
problem.create_context('test_contest', 'abc123', 'a', bad_config)
end)
end)
it('validates language extension is configured', function()
local bad_config = {
contests = {
test_contest = {
default_language = 'cpp',
cpp = {},
},
},
}
assert.has_error(function()
problem.create_context('test_contest', 'abc123', 'a', bad_config)
end)
end)
it('handles complex contest and problem ids', function()
local context = problem.create_context('atcoder', 'arc123', 'f', base_config)
assert.equals('arc123f', context.problem_name)
assert.equals('arc123f.cpp', context.source_file)
assert.equals('build/arc123f.run', context.binary_file)
end)
it('generates correct io file paths', function()
local context = problem.create_context('atcoder', 'abc123', 'a', base_config)
assert.equals('io/abc123a.cpin', context.input_file)
assert.equals('io/abc123a.cpout', context.output_file)
assert.equals('io/abc123a.expected', context.expected_file)
end)
end)
end)

View file

@ -1,207 +0,0 @@
describe('cp.run_render', function()
local run_render = require('cp.runner.run_render')
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
end)
after_each(function()
spec_helper.teardown()
end)
describe('get_status_info', function()
it('returns AC for pass status', function()
local test_case = { status = 'pass' }
local result = run_render.get_status_info(test_case)
assert.equals('AC', result.text)
assert.equals('CpTestAC', result.highlight_group)
end)
it('returns WA for fail status with normal exit codes', function()
local test_case = { status = 'fail', code = 1 }
local result = run_render.get_status_info(test_case)
assert.equals('WA', result.text)
assert.equals('CpTestWA', result.highlight_group)
end)
it('returns TLE for timeout status', function()
local test_case = { status = 'timeout' }
local result = run_render.get_status_info(test_case)
assert.equals('TLE', result.text)
assert.equals('CpTestTLE', result.highlight_group)
end)
it('returns TLE for timed out fail status', function()
local test_case = { status = 'fail', timed_out = true }
local result = run_render.get_status_info(test_case)
assert.equals('TLE', result.text)
assert.equals('CpTestTLE', result.highlight_group)
end)
it('returns RTE for fail with signal codes (>= 128)', function()
local test_case = { status = 'fail', code = 139 }
local result = run_render.get_status_info(test_case)
assert.equals('RTE', result.text)
assert.equals('CpTestRTE', result.highlight_group)
end)
it('returns empty for pending status', function()
local test_case = { status = 'pending' }
local result = run_render.get_status_info(test_case)
assert.equals('', result.text)
assert.equals('CpTestPending', result.highlight_group)
end)
it('returns running indicator for running status', function()
local test_case = { status = 'running' }
local result = run_render.get_status_info(test_case)
assert.equals('...', result.text)
assert.equals('CpTestPending', result.highlight_group)
end)
end)
describe('render_test_list', function()
it('renders table with headers and borders', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '5' },
{ status = 'fail', code = 1, input = '3' },
},
current_index = 1,
}
local result = run_render.render_test_list(test_state)
assert.is_true(result[1]:find('^┌') ~= nil)
assert.is_true(result[2]:find('│.*#.*│.*Status.*│.*Time.*│.*Exit Code.*│') ~= nil)
assert.is_true(result[3]:find('^├') ~= nil)
end)
it('shows current test with > prefix in table', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '' },
{ status = 'pass', input = '' },
},
current_index = 2,
}
local result = run_render.render_test_list(test_state)
local found_current = false
for _, line in ipairs(result) do
if line:match('│.*> 2.*│') then
found_current = true
break
end
end
assert.is_true(found_current)
end)
it('displays input only for current test', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '5 3' },
{ status = 'pass', input = '2 4' },
},
current_index = 1,
}
local result = run_render.render_test_list(test_state)
local found_input = false
for _, line in ipairs(result) do
if line:match('│5 3') then
found_input = true
break
end
end
assert.is_true(found_input)
end)
it('handles empty test cases', function()
local test_state = { test_cases = {}, current_index = 1 }
local result = run_render.render_test_list(test_state)
assert.equals(3, #result)
end)
it('preserves input line breaks', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '5\n3\n1' },
},
current_index = 1,
}
local result = run_render.render_test_list(test_state)
local input_lines = {}
for _, line in ipairs(result) do
if line:match('^│[531]') then
table.insert(input_lines, line:match('│([531])'))
end
end
assert.same({ '5', '3', '1' }, input_lines)
end)
end)
describe('render_status_bar', function()
it('formats time and exit code', function()
local test_case = { time_ms = 45.7, code = 0 }
local result = run_render.render_status_bar(test_case)
assert.equals('45.70ms │ Exit: 0', result)
end)
it('handles missing time', function()
local test_case = { code = 0 }
local result = run_render.render_status_bar(test_case)
assert.equals('Exit: 0', result)
end)
it('handles missing exit code', function()
local test_case = { time_ms = 123 }
local result = run_render.render_status_bar(test_case)
assert.equals('123.00ms', result)
end)
it('returns empty for nil test case', function()
local result = run_render.render_status_bar(nil)
assert.equals('', result)
end)
end)
describe('setup_highlights', function()
it('sets up all highlight groups', function()
local mock_set_hl = spy.on(vim.api, 'nvim_set_hl')
run_render.setup_highlights()
assert.spy(mock_set_hl).was_called(7)
assert.spy(mock_set_hl).was_called_with(0, 'CpTestAC', { fg = '#10b981' })
assert.spy(mock_set_hl).was_called_with(0, 'CpTestWA', { fg = '#ef4444' })
assert.spy(mock_set_hl).was_called_with(0, 'CpTestTLE', { fg = '#f59e0b' })
assert.spy(mock_set_hl).was_called_with(0, 'CpTestRTE', { fg = '#8b5cf6' })
mock_set_hl:revert()
end)
end)
describe('highlight positioning', function()
it('generates correct highlight positions for status text', function()
local test_state = {
test_cases = {
{ status = 'pass', input = '' },
{ status = 'fail', code = 1, input = '' },
},
current_index = 1,
}
local lines, highlights = run_render.render_test_list(test_state)
assert.equals(2, #highlights)
for _, hl in ipairs(highlights) do
assert.is_not_nil(hl.line)
assert.is_not_nil(hl.col_start)
assert.is_not_nil(hl.col_end)
assert.is_not_nil(hl.highlight_group)
assert.is_true(hl.col_end > hl.col_start)
local line_content = lines[hl.line + 1]
local highlighted_text = line_content:sub(hl.col_start + 1, hl.col_end)
assert.is_true(highlighted_text == 'AC' or highlighted_text == 'WA')
end
end)
end)
end)

View file

@ -1,27 +0,0 @@
describe('run module', function()
local run = require('cp.runner.run')
describe('basic functionality', function()
it('has required functions', function()
assert.is_function(run.load_test_cases)
assert.is_function(run.run_test_case)
assert.is_function(run.run_all_test_cases)
assert.is_function(run.get_run_panel_state)
assert.is_function(run.handle_compilation_failure)
end)
it('can get panel state', function()
local state = run.get_run_panel_state()
assert.is_table(state)
assert.is_table(state.test_cases)
end)
it('handles compilation failure', function()
local compilation_output = 'error.cpp:1:1: error: undefined variable'
assert.does_not_error(function()
run.handle_compilation_failure(compilation_output)
end)
end)
end)
end)

View file

@ -1,472 +0,0 @@
describe('cp.scrape', function()
local scrape
local mock_cache
local mock_utils
local mock_system_calls
local temp_files
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
temp_files = {}
mock_system_calls = {}
mock_cache = {
load = function() end,
get_contest_data = function()
return nil
end,
set_contest_data = function() end,
set_test_cases = function() end,
}
mock_utils = {
setup_python_env = function()
return true
end,
get_plugin_path = function()
return '/test/plugin/path'
end,
}
vim.system = function(cmd, opts)
table.insert(mock_system_calls, { cmd = cmd, opts = opts })
local result = { code = 0, stdout = '{}', stderr = '' }
if cmd[1] == 'ping' then
result = { code = 0 }
elseif cmd[1] == 'uv' and cmd[2] == 'sync' then
result = { code = 0, stdout = '', stderr = '' }
elseif cmd[1] == 'uv' and cmd[2] == 'run' then
if vim.tbl_contains(cmd, 'metadata') then
result.stdout = '{"success": true, "problems": [{"id": "a", "name": "Test Problem"}]}'
elseif vim.tbl_contains(cmd, 'tests') then
result.stdout =
'{"success": true, "tests": [{"input": "1 2", "expected": "3"}], "url": "https://example.com", "timeout_ms": 2000, "memory_mb": 256.0}'
end
end
return {
wait = function()
return result
end,
}
end
package.loaded['cp.cache'] = mock_cache
package.loaded['cp.utils'] = mock_utils
package.loaded['cp.scrape'] = nil
scrape = require('cp.scrape')
local original_fn = vim.fn
vim.fn = vim.tbl_extend('force', vim.fn, {
executable = function(cmd)
if cmd == 'uv' then
return 1
end
return original_fn.executable(cmd)
end,
isdirectory = function(path)
if path:match('%.venv$') then
return 1
end
return original_fn.isdirectory(path)
end,
filereadable = function(path)
if temp_files[path] then
return 1
end
return 0
end,
readfile = function(path)
return temp_files[path] or {}
end,
writefile = function(lines, path)
temp_files[path] = lines
end,
mkdir = function() end,
fnamemodify = function(path, modifier)
if modifier == ':r' then
return path:gsub('%..*$', '')
end
return original_fn.fnamemodify(path, modifier)
end,
})
end)
after_each(function()
package.loaded['cp.cache'] = nil
vim.system = vim.system_original or vim.system
spec_helper.teardown()
temp_files = {}
end)
describe('cache integration', function()
it('returns cached data when available', function()
mock_cache.get_contest_data = function(platform, contest_id)
if platform == 'atcoder' and contest_id == 'abc123' then
return { problems = { { id = 'a', name = 'Cached Problem' } } }
end
return nil
end
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_true(result.success)
assert.equals(1, #result.problems)
assert.equals('Cached Problem', result.problems[1].name)
assert.equals(0, #mock_system_calls)
end)
it('stores scraped data in cache after successful scrape', function()
local stored_data = nil
mock_cache.set_contest_data = function(platform, contest_id, problems)
stored_data = { platform = platform, contest_id = contest_id, problems = problems }
end
package.loaded['cp.scrape'] = nil
scrape = require('cp.scrape')
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_true(result.success)
assert.is_not_nil(stored_data)
assert.equals('atcoder', stored_data.platform)
assert.equals('abc123', stored_data.contest_id)
assert.equals(1, #stored_data.problems)
end)
end)
describe('system dependency checks', function()
it('handles missing uv executable', function()
local cache = require('cp.cache')
local utils = require('cp.utils')
cache.load = function() end
cache.get_contest_data = function()
return nil
end
vim.fn.executable = function(cmd)
return cmd == 'uv' and 0 or 1
end
utils.setup_python_env = function()
return vim.fn.executable('uv') == 1
end
vim.system = function(cmd)
if cmd[1] == 'ping' then
return {
wait = function()
return { code = 0 }
end,
}
end
return {
wait = function()
return { code = 0 }
end,
}
end
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_false(result.success)
assert.is_not_nil(result.error)
end)
it('handles python environment setup failure', function()
local cache = require('cp.cache')
cache.load = function() end
cache.get_contest_data = function()
return nil
end
mock_utils.setup_python_env = function()
return false
end
vim.system = function(cmd)
if cmd[1] == 'ping' then
return {
wait = function()
return { code = 0 }
end,
}
end
return {
wait = function()
return { code = 0 }
end,
}
end
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_false(result.success)
assert.equals('Python environment setup failed', result.error)
end)
it('handles network connectivity issues', function()
vim.system = function(cmd)
if cmd[1] == 'ping' then
return {
wait = function()
return { code = 1 }
end,
}
end
return {
wait = function()
return { code = 0 }
end,
}
end
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_false(result.success)
assert.equals('No internet connection available', result.error)
end)
end)
describe('subprocess execution', function()
it('constructs correct command for atcoder metadata', function()
scrape.scrape_contest_metadata('atcoder', 'abc123')
local metadata_call = nil
for _, call in ipairs(mock_system_calls) do
if vim.tbl_contains(call.cmd, 'metadata') then
metadata_call = call
break
end
end
assert.is_not_nil(metadata_call)
assert.equals('uv', metadata_call.cmd[1])
assert.equals('run', metadata_call.cmd[2])
assert.is_true(vim.tbl_contains(metadata_call.cmd, 'metadata'))
assert.is_true(vim.tbl_contains(metadata_call.cmd, 'abc123'))
end)
it('constructs correct command for cses metadata', function()
scrape.scrape_contest_metadata('cses', 'sorting_and_searching')
local metadata_call = nil
for _, call in ipairs(mock_system_calls) do
if vim.tbl_contains(call.cmd, 'metadata') then
metadata_call = call
break
end
end
assert.is_not_nil(metadata_call)
assert.equals('uv', metadata_call.cmd[1])
assert.is_true(vim.tbl_contains(metadata_call.cmd, 'metadata'))
assert.is_true(vim.tbl_contains(metadata_call.cmd, 'sorting_and_searching'))
end)
it('handles subprocess execution failure', function()
vim.system = function(cmd)
if cmd[1] == 'ping' then
return {
wait = function()
return { code = 0 }
end,
}
elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then
return {
wait = function()
return { code = 1, stderr = 'execution failed' }
end,
}
end
return {
wait = function()
return { code = 0 }
end,
}
end
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_false(result.success)
assert.is_not_nil(result.error:match('Failed to run metadata scraper'))
assert.is_not_nil(result.error:match('execution failed'))
end)
end)
describe('json parsing', function()
it('handles invalid json output', function()
vim.system = function(cmd)
if cmd[1] == 'ping' then
return {
wait = function()
return { code = 0 }
end,
}
elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then
return {
wait = function()
return { code = 0, stdout = 'invalid json' }
end,
}
end
return {
wait = function()
return { code = 0 }
end,
}
end
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_false(result.success)
assert.is_not_nil(result.error:match('Failed to parse metadata scraper output'))
end)
it('handles scraper-reported failures', function()
vim.system = function(cmd)
if cmd[1] == 'ping' then
return {
wait = function()
return { code = 0 }
end,
}
elseif cmd[1] == 'uv' and vim.tbl_contains(cmd, 'metadata') then
return {
wait = function()
return {
code = 0,
stdout = '{"success": false, "error": "contest not found"}',
}
end,
}
end
return {
wait = function()
return { code = 0 }
end,
}
end
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
assert.is_false(result.success)
assert.equals('contest not found', result.error)
end)
end)
describe('problem scraping', function()
local test_context
before_each(function()
test_context = {
contest = 'atcoder',
contest_id = 'abc123',
problem_id = 'a',
problem_name = 'abc123a',
input_file = 'io/abc123a.cpin',
expected_file = 'io/abc123a.expected',
}
end)
it('uses existing files when available', function()
temp_files['io/abc123a.cpin'] = { '1 2' }
temp_files['io/abc123a.expected'] = { '3' }
temp_files['io/abc123a.1.cpin'] = { '4 5' }
temp_files['io/abc123a.1.cpout'] = { '9' }
local result = scrape.scrape_problem(test_context)
assert.is_true(result.success)
assert.equals('abc123a', result.problem_id)
assert.equals(1, result.test_count)
assert.equals(0, #mock_system_calls)
end)
it('scrapes and writes test case files', function()
local result = scrape.scrape_problem(test_context)
assert.is_true(result.success)
assert.equals('abc123a', result.problem_id)
assert.equals(1, result.test_count)
assert.is_not_nil(temp_files['io/abc123a.1.cpin'])
assert.is_not_nil(temp_files['io/abc123a.1.cpout'])
assert.equals('1 2', table.concat(temp_files['io/abc123a.1.cpin'], '\n'))
assert.equals('3', table.concat(temp_files['io/abc123a.1.cpout'], '\n'))
end)
it('constructs correct command for atcoder problem tests', function()
scrape.scrape_problem(test_context)
local tests_call = nil
for _, call in ipairs(mock_system_calls) do
if vim.tbl_contains(call.cmd, 'tests') then
tests_call = call
break
end
end
assert.is_not_nil(tests_call)
assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests'))
assert.is_true(vim.tbl_contains(tests_call.cmd, 'abc123'))
assert.is_true(vim.tbl_contains(tests_call.cmd, 'a'))
end)
it('constructs correct command for cses problem tests', function()
test_context.contest = 'cses'
test_context.contest_id = 'sorting_and_searching'
test_context.problem_id = '1001'
scrape.scrape_problem(test_context)
local tests_call = nil
for _, call in ipairs(mock_system_calls) do
if vim.tbl_contains(call.cmd, 'tests') then
tests_call = call
break
end
end
assert.is_not_nil(tests_call)
assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests'))
assert.is_true(vim.tbl_contains(tests_call.cmd, '1001'))
assert.is_true(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching'))
end)
end)
describe('error scenarios', function()
it('validates input parameters', function()
assert.has_error(function()
scrape.scrape_contest_metadata(nil, 'abc123')
end)
assert.has_error(function()
scrape.scrape_contest_metadata('atcoder', nil)
end)
end)
it('handles file system errors gracefully', function()
vim.fn.mkdir = function()
error('permission denied')
end
local ctx = {
contest = 'atcoder',
contest_id = 'abc123',
problem_id = 'a',
problem_name = 'abc123a',
input_file = 'io/abc123a.cpin',
expected_file = 'io/abc123a.expected',
}
assert.has_error(function()
scrape.scrape_problem(ctx)
end)
end)
end)
end)

View file

@ -1,262 +0,0 @@
describe('cp.snippets', function()
local snippets
local mock_luasnip
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
package.loaded['cp.snippets'] = nil
snippets = require('cp.snippets')
mock_luasnip = {
snippet = function(trigger, body)
return { trigger = trigger, body = body }
end,
insert_node = function(pos)
return { type = 'insert', pos = pos }
end,
add_snippets = function(filetype, snippet_list)
mock_luasnip.added = mock_luasnip.added or {}
mock_luasnip.added[filetype] = snippet_list
end,
added = {},
}
mock_luasnip.extras = {
fmt = {
fmt = function(template, nodes)
return { template = template, nodes = nodes }
end,
},
}
package.loaded['luasnip'] = mock_luasnip
package.loaded['luasnip.extras.fmt'] = mock_luasnip.extras.fmt
end)
after_each(function()
spec_helper.teardown()
package.loaded['cp.snippets'] = nil
package.loaded['luasnip'] = nil
package.loaded['luasnip.extras.fmt'] = nil
end)
describe('setup without luasnip', function()
it('handles missing luasnip gracefully', function()
package.loaded['luasnip'] = nil
assert.has_no_errors(function()
snippets.setup({})
end)
end)
end)
describe('setup with luasnip available', function()
it('sets up default cpp snippets for all contests', function()
local config = { snippets = {} }
snippets.setup(config)
assert.is_not_nil(mock_luasnip.added.cpp)
assert.is_true(#mock_luasnip.added.cpp >= 3)
local triggers = {}
for _, snippet in ipairs(mock_luasnip.added.cpp) do
table.insert(triggers, snippet.trigger)
end
assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/codeforces.cpp'))
assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/atcoder.cpp'))
assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/cses.cpp'))
end)
it('sets up default python snippets for all contests', function()
local config = { snippets = {} }
snippets.setup(config)
assert.is_not_nil(mock_luasnip.added.python)
assert.is_true(#mock_luasnip.added.python >= 3)
local triggers = {}
for _, snippet in ipairs(mock_luasnip.added.python) do
table.insert(triggers, snippet.trigger)
end
assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/codeforces.python'))
assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/atcoder.python'))
assert.is_true(vim.tbl_contains(triggers, 'cp.nvim/cses.python'))
end)
it('includes template content with placeholders', function()
local config = { snippets = {} }
snippets.setup(config)
local cpp_snippets = mock_luasnip.added.cpp or {}
local codeforces_snippet = nil
for _, snippet in ipairs(cpp_snippets) do
if snippet.trigger == 'cp.nvim/codeforces.cpp' then
codeforces_snippet = snippet
break
end
end
assert.is_not_nil(codeforces_snippet)
assert.is_not_nil(codeforces_snippet.body)
assert.equals('table', type(codeforces_snippet.body))
assert.is_not_nil(codeforces_snippet.body.template:match('#include'))
assert.is_not_nil(codeforces_snippet.body.template:match('void solve'))
end)
it('respects user snippet overrides', function()
local custom_snippet = {
trigger = 'cp.nvim/custom.cpp',
body = 'custom template',
}
local config = {
snippets = { custom_snippet },
}
snippets.setup(config)
local cpp_snippets = mock_luasnip.added.cpp or {}
local found_custom = false
for _, snippet in ipairs(cpp_snippets) do
if snippet.trigger == 'cp.nvim/custom.cpp' then
found_custom = true
assert.equals('custom template', snippet.body)
break
end
end
assert.is_true(found_custom)
end)
it('filters user snippets by language', function()
local cpp_snippet = {
trigger = 'cp.nvim/custom.cpp',
body = 'cpp template',
}
local python_snippet = {
trigger = 'cp.nvim/custom.python',
body = 'python template',
}
local config = {
snippets = { cpp_snippet, python_snippet },
}
snippets.setup(config)
local cpp_snippets = mock_luasnip.added.cpp or {}
local python_snippets = mock_luasnip.added.python or {}
local cpp_has_custom = false
for _, snippet in ipairs(cpp_snippets) do
if snippet.trigger == 'cp.nvim/custom.cpp' then
cpp_has_custom = true
break
end
end
local python_has_custom = false
for _, snippet in ipairs(python_snippets) do
if snippet.trigger == 'cp.nvim/custom.python' then
python_has_custom = true
break
end
end
assert.is_true(cpp_has_custom)
assert.is_true(python_has_custom)
end)
it('handles empty config gracefully', function()
assert.has_no_errors(function()
snippets.setup({})
end)
assert.is_not_nil(mock_luasnip.added.cpp)
assert.is_not_nil(mock_luasnip.added.python)
end)
it('handles empty config gracefully', function()
assert.has_no_errors(function()
snippets.setup({ snippets = {} })
end)
end)
it('creates templates for correct filetypes', function()
local config = { snippets = {} }
snippets.setup(config)
assert.is_not_nil(mock_luasnip.added.cpp)
assert.is_not_nil(mock_luasnip.added.python)
assert.is_nil(mock_luasnip.added.c)
assert.is_nil(mock_luasnip.added.py)
end)
it('excludes overridden default snippets', function()
local override_snippet = {
trigger = 'cp.nvim/codeforces.cpp',
body = 'overridden template',
}
local config = {
snippets = { override_snippet },
}
snippets.setup(config)
local cpp_snippets = mock_luasnip.added.cpp or {}
local codeforces_count = 0
for _, snippet in ipairs(cpp_snippets) do
if snippet.trigger == 'cp.nvim/codeforces.cpp' then
codeforces_count = codeforces_count + 1
end
end
assert.equals(1, codeforces_count)
end)
it('handles case-insensitive snippet triggers', function()
local mixed_case_snippet = {
trigger = 'cp.nvim/CodeForces.cpp',
body = 'mixed case template',
}
local upper_case_snippet = {
trigger = 'cp.nvim/ATCODER.cpp',
body = 'upper case template',
}
local config = {
snippets = { mixed_case_snippet, upper_case_snippet },
}
snippets.setup(config)
local cpp_snippets = mock_luasnip.added.cpp or {}
local has_mixed_case = false
local has_upper_case = false
local default_codeforces_count = 0
local default_atcoder_count = 0
for _, snippet in ipairs(cpp_snippets) do
if snippet.trigger == 'cp.nvim/CodeForces.cpp' then
has_mixed_case = true
assert.equals('mixed case template', snippet.body)
elseif snippet.trigger == 'cp.nvim/ATCODER.cpp' then
has_upper_case = true
assert.equals('upper case template', snippet.body)
elseif snippet.trigger == 'cp.nvim/codeforces.cpp' then
default_codeforces_count = default_codeforces_count + 1
elseif snippet.trigger == 'cp.nvim/atcoder.cpp' then
default_atcoder_count = default_atcoder_count + 1
end
end
assert.is_true(has_mixed_case)
assert.is_true(has_upper_case)
assert.equals(0, default_codeforces_count, 'Default codeforces snippet should be overridden')
assert.equals(0, default_atcoder_count, 'Default atcoder snippet should be overridden')
end)
end)
end)

View file

@ -1,14 +0,0 @@
local M = {}
function M.setup()
package.loaded['cp.log'] = {
log = function() end,
set_config = function() end,
}
end
function M.teardown()
package.loaded['cp.log'] = nil
end
return M

View file

@ -1,78 +0,0 @@
describe('cp.telescope', function()
local spec_helper = require('spec.spec_helper')
before_each(function()
spec_helper.setup()
package.preload['telescope'] = function()
return {
register_extension = function(ext_config)
return ext_config
end,
}
end
package.preload['telescope.pickers'] = function()
return {
new = function(_, _)
return {
find = function() end,
}
end,
}
end
package.preload['telescope.finders'] = function()
return {
new_table = function(opts)
return opts
end,
}
end
package.preload['telescope.config'] = function()
return {
values = {
generic_sorter = function()
return {}
end,
},
}
end
package.preload['telescope.actions'] = function()
return {
select_default = {
replace = function() end,
},
close = function() end,
}
end
package.preload['telescope.actions.state'] = function()
return {
get_selected_entry = function()
return nil
end,
}
end
end)
after_each(function()
spec_helper.teardown()
end)
describe('module loading', function()
it('registers telescope extension without error', function()
assert.has_no.errors(function()
require('cp.pickers.telescope')
end)
end)
it('returns module with platform_picker function', function()
local telescope_cp = require('cp.pickers.telescope')
assert.is_table(telescope_cp)
assert.is_function(telescope_cp.platform_picker)
end)
end)
end)

275
tests/conftest.py Normal file
View file

@ -0,0 +1,275 @@
import asyncio
import importlib.util
import io
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import httpx
import pytest
import requests
from curl_cffi import requests as curl_requests
ROOT = Path(__file__).resolve().parent.parent
FIX = Path(__file__).resolve().parent / "fixtures"
@pytest.fixture
def fixture_text():
def _load(name: str) -> str:
p = FIX / name
return p.read_text(encoding="utf-8")
return _load
def _load_scraper_module(module_path: Path, module_name: str):
spec = importlib.util.spec_from_file_location(
f"scrapers.{module_name}", module_path
)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load module {module_name}")
module = importlib.util.module_from_spec(spec)
sys.modules[f"scrapers.{module_name}"] = module
spec.loader.exec_module(module)
return module
def _capture_stdout(coro):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
rc = asyncio.run(coro)
out = buf.getvalue()
finally:
sys.stdout = old
return rc, out
@pytest.fixture
def run_scraper_offline(fixture_text):
def _router_cses(*, path: str | None = None, url: str | None = None) -> str:
if not path and not url:
raise AssertionError("CSES expects path or url")
target = path or url
if target is None:
raise AssertionError(f"No target for CSES (path={path!r}, url={url!r})")
if target.startswith("https://cses.fi"):
target = target.removeprefix("https://cses.fi")
if target.strip("/") == "problemset":
return fixture_text("cses/contests.html")
if target.startswith("/problemset/task/") or target.startswith(
"problemset/task/"
):
pid = target.rstrip("/").split("/")[-1]
return fixture_text(f"cses/task_{pid}.html")
raise AssertionError(f"No fixture for CSES path={path!r} url={url!r}")
def _router_atcoder(*, path: str | None = None, url: str | None = None) -> str:
if not url:
raise AssertionError("AtCoder expects url routing")
if "/contests/archive" in url:
return fixture_text("atcoder/contests.html")
if url.endswith("/tasks"):
return fixture_text("atcoder/abc100_tasks.html")
if "/tasks/" in url:
slug = url.rsplit("/", 1)[-1]
return fixture_text(f"atcoder/task_{slug}.html")
raise AssertionError(f"No fixture for AtCoder url={url!r}")
def _router_codeforces(*, path: str | None = None, url: str | None = None) -> str:
if not url:
raise AssertionError("Codeforces expects url routing")
if "/contest/" in url and url.endswith("/problems"):
contest_id = url.rstrip("/").split("/")[-2]
return fixture_text(f"codeforces/{contest_id}_problems.html")
if "/contests" in url and "/problem/" not in url:
return fixture_text("codeforces/contests.html")
if "/problem/" in url:
parts = url.rstrip("/").split("/")
contest_id, index = parts[-3], parts[-1]
return fixture_text(f"codeforces/{contest_id}_{index}.html")
if "/problemset/problem/" in url:
parts = url.rstrip("/").split("/")
contest_id, index = parts[-2], parts[-1]
return fixture_text(f"codeforces/{contest_id}_{index}.html")
raise AssertionError(f"No fixture for Codeforces url={url!r}")
def _make_offline_fetches(scraper_name: str):
match scraper_name:
case "cses":
async def __offline_fetch_text(client, path: str, **kwargs):
html = _router_cses(path=path)
return SimpleNamespace(
text=html,
status_code=200,
raise_for_status=lambda: None,
)
return {
"__offline_fetch_text": __offline_fetch_text,
}
case "atcoder":
def __offline_fetch(url: str, *args, **kwargs):
html = _router_atcoder(url=url)
return html
async def __offline_get_async(client, url: str, **kwargs):
return _router_atcoder(url=url)
return {
"_fetch": __offline_fetch,
"_get_async": __offline_get_async,
}
case "codeforces":
class MockCurlResponse:
def __init__(self, html: str):
self.text = html
def raise_for_status(self):
pass
def _mock_curl_get(url: str, **kwargs):
return MockCurlResponse(_router_codeforces(url=url))
def _mock_requests_get(url: str, **kwargs):
if "api/contest.list" in url:
data = {
"status": "OK",
"result": [
{
"id": 1550,
"name": "Educational Codeforces Round 155 (Rated for Div. 2)",
"phase": "FINISHED",
},
{
"id": 1000,
"name": "Codeforces Round #1000",
"phase": "FINISHED",
},
],
}
class R:
def json(self_inner):
return data
def raise_for_status(self_inner):
return None
return R()
raise AssertionError(f"Unexpected requests.get call: {url}")
return {
"curl_requests.get": _mock_curl_get,
"requests.get": _mock_requests_get,
}
case "codechef":
class MockResponse:
def __init__(self, json_data):
self._json_data = json_data
self.status_code = 200
def json(self):
return self._json_data
def raise_for_status(self):
pass
async def __offline_get_async(client, url: str, **kwargs):
if "/api/list/contests/all" in url:
data = json.loads(fixture_text("codechef/contests.json"))
return MockResponse(data)
if "/api/contests/START" in url and "/problems/" not in url:
contest_id = url.rstrip("/").split("/")[-1]
try:
data = json.loads(
fixture_text(f"codechef/{contest_id}.json")
)
return MockResponse(data)
except FileNotFoundError:
raise AssertionError(f"No fixture for CodeChef url={url!r}")
if "/api/contests/START" in url and "/problems/" in url:
parts = url.rstrip("/").split("/")
contest_id = parts[-3]
problem_id = parts[-1]
data = json.loads(
fixture_text(f"codechef/{contest_id}_{problem_id}.json")
)
return MockResponse(data)
raise AssertionError(f"No fixture for CodeChef url={url!r}")
class MockCodeChefCurlResponse:
def __init__(self, html: str):
self.text = html
def raise_for_status(self):
pass
def _mock_curl_get(url: str, **kwargs):
if "/problems/" in url:
problem_id = url.rstrip("/").split("/")[-1]
html = fixture_text(f"codechef/{problem_id}.html")
return MockCodeChefCurlResponse(html)
raise AssertionError(f"No fixture for CodeChef url={url!r}")
return {
"__offline_get_async": __offline_get_async,
"curl_requests.get": _mock_curl_get,
}
case _:
raise AssertionError(f"Unknown scraper: {scraper_name}")
scraper_classes = {
"cses": "CSESScraper",
"atcoder": "AtcoderScraper",
"codeforces": "CodeforcesScraper",
"codechef": "CodeChefScraper",
}
def _run(scraper_name: str, mode: str, *args: str):
mod_path = ROOT / "scrapers" / f"{scraper_name}.py"
ns = _load_scraper_module(mod_path, scraper_name)
offline_fetches = _make_offline_fetches(scraper_name)
if scraper_name == "codeforces":
curl_requests.get = offline_fetches["curl_requests.get"]
requests.get = offline_fetches["requests.get"]
elif scraper_name == "atcoder":
ns._fetch = offline_fetches["_fetch"]
ns._get_async = offline_fetches["_get_async"]
elif scraper_name == "cses":
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"]
scraper_class = getattr(ns, scraper_classes[scraper_name])
scraper = scraper_class()
argv = [str(mod_path), mode, *args]
rc, out = _capture_stdout(scraper._run_cli_async(argv))
json_lines: list[Any] = []
for line in (_line for _line in out.splitlines() if _line.strip()):
json_lines.append(json.loads(line))
return rc, json_lines
return _run

519
tests/fixtures/atcoder/abc100_tasks.html vendored Normal file
View file

@ -0,0 +1,519 @@
<!doctype html>
<html>
<head>
<title>Tasks - AtCoder Beginner Contest 100</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="Tasks - AtCoder Beginner Contest 100" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta
property="twitter:title"
content="Tasks - AtCoder Beginner Contest 100"
/>
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'q+4tZ4tLQh/4nobcpVAuiml6OVEZOdZDZURhPenxPbc='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks"
>
<input
type="hidden"
name="csrf_token"
value="q&#43;4tZ4tLQh/4nobcpVAuiml6OVEZOdZDZURhPenxPbc="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<h2>Tasks</h2>
<hr />
<div class="panel panel-default table-responsive">
<table class="table table-bordered table-striped">
<thead>
<tr>
<th width="3%" class="text-center"></th>
<th>Task Name</th>
<th width="10%" class="text-right no-break">Time Limit</th>
<th width="10%" class="text-right no-break">
Memory Limit
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_a">A</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_a"
>Happy Birthday!</a
>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_b">B</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_b"
>Ringo&#39;s Favorite Numbers</a
>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_c">C</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_c">*3 or /2</a>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_d">D</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_d"
>Patisserie ABC</a
>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
</tbody>
</table>
</div>
<p class="btn-text-group">
<a class="btn-text" href="/contests/abc100/tasks_print"
>Tasks for printing</a
>
</p>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks?lang=en"
data-a2a-title="Tasks - AtCoder Beginner Contest 100"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

1902
tests/fixtures/atcoder/contests.html vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,885 @@
<!doctype html>
<html>
<head>
<title>A - Happy Birthday!</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="A - Happy Birthday!" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks/abc100_a"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta property="twitter:title" content="A - Happy Birthday!" />
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'RRHWPnDZqM+tdZpgbKRjH5FxiX5spw9S3/HKRbnyqok='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2.min.css"
rel="stylesheet"
/>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2-bootstrap.min.css"
rel="stylesheet"
/>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/select2.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ace.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ext-language_tools.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/run_prettify.js"></script>
<link
rel="stylesheet"
href="//img.atcoder.jp/public/6372bb3/css/cdn/katex.min.css"
/>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/katex.min.js"
></script>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/auto-render.min.js"
></script>
<script>
$(function () {
$('var').each(function () {
var html = $(this)
.html()
.replace(/<sub>/g, '_{')
.replace(/<\/sub>/g, '}')
$(this).html('\\(' + html + '\\)')
})
})
</script>
<script>
var katexOptions = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
ignoredTags: [
'script',
'noscript',
'style',
'textarea',
'code',
'option'
],
ignoredClasses: ['prettyprint', 'source-code-for-copy'],
throwOnError: false
}
document.addEventListener('DOMContentLoaded', function () {
renderMathInElement(document.body, katexOptions)
})
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks/abc100_a?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks/abc100_a?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_a"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_a"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_a"
>
<input
type="hidden"
name="csrf_token"
value="RRHWPnDZqM&#43;tdZpgbKRjH5FxiX5spw9S3/HKRbnyqok="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<span class="h2">
A - Happy Birthday!
<a
class="btn btn-default btn-sm"
href="/contests/abc100/tasks/abc100_a/editorial"
>Editorial</a
>
</span>
<span id="task-lang-btn" class="pull-right"
><span data-lang="ja"
><img src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/></span>
/
<span data-lang="en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" /></span
></span>
<script>
$(function () {
var ts = $('#task-statement span.lang')
if (ts.children('span').size() <= 1) {
$('#task-lang-btn').hide()
ts.children('span').show()
return
}
var REMEMBER_LB = 5
var LS_KEY = 'task_lang'
var taskLang = getLS(LS_KEY) || ''
function isTaskLangSet(taskLang) {
return taskLang === 'ja' || taskLang === 'en'
}
if (isTaskLangSet(taskLang)) {
const params = new URLSearchParams(window.location.search)
if (params.get('lang')) {
setLS(LS_KEY, REMEMBER_LB)
taskLang = LANG
}
} else {
taskLang = LANG
}
ts.children('span.lang-' + taskLang).show()
$('#task-lang-btn span').click(function () {
var l = $(this).data('lang')
ts.children('span').hide()
ts.children('span.lang-' + l).show()
taskLang = getLS(LS_KEY) || ''
let changeTimes = 0
if (isTaskLangSet(taskLang)) {
changeTimes = REMEMBER_LB
} else {
changeTimes = parseInt(taskLang, 10)
if (isNaN(changeTimes)) changeTimes = 0
changeTimes++
}
if (changeTimes < REMEMBER_LB) setLS(LS_KEY, changeTimes)
else setLS(LS_KEY, l)
})
})
</script>
<hr />
<p>Time Limit: 2 sec / Memory Limit: 976 MiB</p>
<div id="task-statement">
<span class="lang">
<span class="lang-ja">
<p>配点: <var>100</var></p>
<div class="part">
<section>
<h3>問題文</h3>
<p>
もうすぐ E869120 君と square1001 君の
<var>16</var> 才の誕生日が来る.<br />
そこで, AtCoder 王国の高橋君は, 円形のケーキ
<var>1</var> 個に放射状に切れ目を入れ
<var>16</var> 等分したものを, 彼らにプレゼントした.
</p>
<p>
E869120 君はそのうち <var>A</var> 切れ、square1001 君は
<var>B</var> 切れを食べようとした.<br />
しかし, ケーキと一緒についていた紙を見ると,
「同じ人が隣り合う
<var>2</var>
切れのケーキを両方取ってはならない」と書かれていた.
</p>
<p>
さて、彼らは紙に書かれたことを守って、<var>2</var>
人とも食べたい数のケーキを取ることができるだろうか?
</p>
</section>
</div>
<div class="part">
<section>
<h3>制約</h3>
<ul>
<li>
<var>A, B</var><var>1</var> 以上
<var>16</var> 以下の整数
</li>
<li><var>A+B</var><var>16</var> 以下である.</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>入力</h3>
<p>入力は以下の形式で標準入力から与えられる.</p>
<pre><var>A</var> <var>B</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>出力</h3>
<p>
紙に書かれたことを守って, E869120 君と square1001
君両方が, 食べたい数のケーキを取ることができるならば
<code>Yay!</code>, そうでなければ
<code>:(</code> と出力しなさい.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 1</h3>
<pre>
5 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 1</h3>
<pre>
Yay!
</pre
>
<p>
下の図のようにケーキを取れば、<var>2</var>
人とも目標を達成することができる.<br />
<img
alt=" "
src="https://img.atcoder.jp/abc100/e87fa456a900ac9ae36671ae8bd5eeea.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 2</h3>
<pre>
8 8
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 2</h3>
<pre>
Yay!
</pre
>
<p>
下の図のようにケーキを取れば、<var>2</var>
人とも目標を達成することができる.<br />
<img
alt=" "
src="https://img.atcoder.jp/abc100/a7989ac033e6ba86e14078864c20d9c5.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 3</h3>
<pre>
11 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 3</h3>
<pre>
:(
</pre
>
<p>
この場合, 残念ながら目標を達成する方法は
<var>1</var> つもない.
</p>
</section>
</div>
</span>
<span class="lang-en">
<p>Score: <var>100</var> points</p>
<div class="part">
<section>
<h3>Problem Statement</h3>
<p>
E869120's and square1001's <var>16</var>-th birthday is
coming soon.<br />
Takahashi from AtCoder Kingdom gave them a round cake
cut into <var>16</var> equal fan-shaped pieces.
</p>
<p>
E869120 and square1001 were just about to eat
<var>A</var> and <var>B</var> of those pieces,
respectively,<br />
when they found a note attached to the cake saying that
"the same person should not take two adjacent pieces of
cake".
</p>
<p>
Can both of them obey the instruction in the note and
take desired numbers of pieces of cake?
</p>
</section>
</div>
<div class="part">
<section>
<h3>Constraints</h3>
<ul>
<li>
<var>A</var> and <var>B</var> are integers between
<var>1</var> and <var>16</var> (inclusive).
</li>
<li><var>A+B</var> is at most <var>16</var>.</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>Input</h3>
<p>
Input is given from Standard Input in the following
format:
</p>
<pre><var>A</var> <var>B</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>Output</h3>
<p>
If both E869120 and square1001 can obey the
instruction in the note and take desired numbers of
pieces of cake, print <code>Yay!</code>; otherwise,
print <code>:(</code>.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 1</h3>
<pre>
5 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 1</h3>
<pre>
Yay!
</pre
>
<p>
Both of them can take desired number of pieces as
follows:
<img
alt=" "
src="https://img.atcoder.jp/abc100/e87fa456a900ac9ae36671ae8bd5eeea.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 2</h3>
<pre>
8 8
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 2</h3>
<pre>
Yay!
</pre
>
<p>
Both of them can take desired number of pieces as
follows:
<img
alt=" "
src="https://img.atcoder.jp/abc100/a7989ac033e6ba86e14078864c20d9c5.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 3</h3>
<pre>
11 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 3</h3>
<pre>
:(
</pre
>
<p>
In this case, there is no way for them to take desired
number of pieces, unfortunately.
</p>
</section>
</div>
</span>
</span>
</div>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks/abc100_a?lang=en"
data-a2a-title="A - Happy Birthday!"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

View file

@ -0,0 +1,887 @@
<!doctype html>
<html>
<head>
<title>B - Ringo&#39;s Favorite Numbers</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="B - Ringo&#39;s Favorite Numbers" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks/abc100_b"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta property="twitter:title" content="B - Ringo&#39;s Favorite Numbers" />
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'jcqZoHtgdlDPapU/uheo04+cw+2+EssGGNrF7tJr004='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2.min.css"
rel="stylesheet"
/>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2-bootstrap.min.css"
rel="stylesheet"
/>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/select2.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ace.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ext-language_tools.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/run_prettify.js"></script>
<link
rel="stylesheet"
href="//img.atcoder.jp/public/6372bb3/css/cdn/katex.min.css"
/>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/katex.min.js"
></script>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/auto-render.min.js"
></script>
<script>
$(function () {
$('var').each(function () {
var html = $(this)
.html()
.replace(/<sub>/g, '_{')
.replace(/<\/sub>/g, '}')
$(this).html('\\(' + html + '\\)')
})
})
</script>
<script>
var katexOptions = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
ignoredTags: [
'script',
'noscript',
'style',
'textarea',
'code',
'option'
],
ignoredClasses: ['prettyprint', 'source-code-for-copy'],
throwOnError: false
}
document.addEventListener('DOMContentLoaded', function () {
renderMathInElement(document.body, katexOptions)
})
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks/abc100_b?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks/abc100_b?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_b"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_b"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_b"
>
<input
type="hidden"
name="csrf_token"
value="jcqZoHtgdlDPapU/uheo04&#43;cw&#43;2&#43;EssGGNrF7tJr004="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<span class="h2">
B - Ringo&#39;s Favorite Numbers
<a
class="btn btn-default btn-sm"
href="/contests/abc100/tasks/abc100_b/editorial"
>Editorial</a
>
</span>
<span id="task-lang-btn" class="pull-right"
><span data-lang="ja"
><img src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/></span>
/
<span data-lang="en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" /></span
></span>
<script>
$(function () {
var ts = $('#task-statement span.lang')
if (ts.children('span').size() <= 1) {
$('#task-lang-btn').hide()
ts.children('span').show()
return
}
var REMEMBER_LB = 5
var LS_KEY = 'task_lang'
var taskLang = getLS(LS_KEY) || ''
function isTaskLangSet(taskLang) {
return taskLang === 'ja' || taskLang === 'en'
}
if (isTaskLangSet(taskLang)) {
const params = new URLSearchParams(window.location.search)
if (params.get('lang')) {
setLS(LS_KEY, REMEMBER_LB)
taskLang = LANG
}
} else {
taskLang = LANG
}
ts.children('span.lang-' + taskLang).show()
$('#task-lang-btn span').click(function () {
var l = $(this).data('lang')
ts.children('span').hide()
ts.children('span.lang-' + l).show()
taskLang = getLS(LS_KEY) || ''
let changeTimes = 0
if (isTaskLangSet(taskLang)) {
changeTimes = REMEMBER_LB
} else {
changeTimes = parseInt(taskLang, 10)
if (isNaN(changeTimes)) changeTimes = 0
changeTimes++
}
if (changeTimes < REMEMBER_LB) setLS(LS_KEY, changeTimes)
else setLS(LS_KEY, l)
})
})
</script>
<hr />
<p>Time Limit: 2 sec / Memory Limit: 976 MiB</p>
<div id="task-statement">
<span class="lang">
<span class="lang-ja">
<p>配点: <var>200</var></p>
<div class="part">
<section>
<h3>問題文</h3>
<p>
今日は, 記念すべき AtCoder Beginner Contest 100
が開催される. そのため, 高橋君はりんごさんに,
ある整数をプレゼントしようと思った.<br />
今日のコンテストは「AtCoder Beginner Contest
100」なので, りんごさんは <var>100</var>
<strong>ちょうど</strong>
<var>D</var>
回割りきれる正の整数をプレゼントされると喜ぶ.
</p>
<p>
さて, りんごさんがプレゼントされると喜ぶような整数のうち
<var>N</var> 番目に小さいものを求めなさい.
</p>
</section>
</div>
<div class="part">
<section>
<h3>制約</h3>
<ul>
<li>
<var>D</var><var>0, 1, 2</var> のいずれかである
</li>
<li>
<var>N</var><var>1</var> 以上
<var>100</var> 以下の整数
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>入力</h3>
<p>入力は以下の形式で標準入力から与えられる.</p>
<pre><var>D</var> <var>N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>出力</h3>
<p>
<var>100</var> でちょうど
<var>D</var> 回割りきれる正の整数の中で
<var>N</var> 番目に小さいものを出力しなさい.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 1</h3>
<pre>
0 5
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 1</h3>
<pre>
5
</pre
>
<p>
<var>100</var> でちょうど
<var>0</var> 回割り切れる(すなわち,
<var>100</var> で割り切れない)整数は, <var>1</var>,
<var>2</var>, <var>3</var>, <var>4</var>, <var>5</var>,
<var>6</var>, <var>7</var>, ... と続く.<br />
よって, <var>5</var> 番目に小さいりんごさんが喜ぶ整数は
<var>5</var> である.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 2</h3>
<pre>
1 11
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 2</h3>
<pre>
1100
</pre
>
<p>
<var>100</var> でちょうど
<var>1</var> 回割り切れる整数は, <var>100</var>,
<var>200</var>, <var>300</var>, <var>400</var>,
<var>500</var>, <var>600</var>, <var>700</var>,
<var>800</var>, <var>900</var>, <var>1 \ 000</var>,
<var>1 \ 100</var>, ... と続く.<br />
よって, 求めたい整数は <var>1 \ 100</var> である.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 3</h3>
<pre>
2 85
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 3</h3>
<pre>
850000
</pre
>
<p>
<var>100</var> でちょうど
<var>2</var> 回割り切れる整数は, <var>10 \ 000</var>,
<var>20 \ 000</var>, <var>30 \ 000</var>, ... と続く.<br />
よって, 求めたい整数は <var>850 \ 000</var> である.
</p>
</section>
</div>
</span>
<span class="lang-en">
<p>Score: <var>200</var> points</p>
<div class="part">
<section>
<h3>Problem Statement</h3>
<p>
Today, the memorable AtCoder Beginner Contest 100 takes
place. On this occasion, Takahashi would like to give an
integer to Ringo.<br />
As the name of the contest is AtCoder Beginner Contest
100, Ringo would be happy if he is given a positive
integer that can be divided by <var>100</var>
<strong>exactly</strong> <var>D</var> times.
</p>
<p>
Find the <var>N</var>-th smallest integer that would
make Ringo happy.
</p>
</section>
</div>
<div class="part">
<section>
<h3>Constraints</h3>
<ul>
<li>
<var>D</var> is <var>0</var>, <var>1</var> or
<var>2</var>.
</li>
<li>
<var>N</var> is an integer between <var>1</var> and
<var>100</var> (inclusive).
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>Input</h3>
<p>
Input is given from Standard Input in the following
format:
</p>
<pre><var>D</var> <var>N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>Output</h3>
<p>
Print the <var>N</var>-th smallest integer that can be
divided by <var>100</var> exactly <var>D</var> times.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 1</h3>
<pre>
0 5
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 1</h3>
<pre>
5
</pre
>
<p>
The integers that can be divided by
<var>100</var> exactly <var>0</var> times (that is, not
divisible by <var>100</var>) are as follows:
<var>1</var>, <var>2</var>, <var>3</var>, <var>4</var>,
<var>5</var>, <var>6</var>, <var>7</var>, ...<br />
Thus, the <var>5</var>-th smallest integer that would
make Ringo happy is <var>5</var>.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 2</h3>
<pre>
1 11
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 2</h3>
<pre>
1100
</pre
>
<p>
The integers that can be divided by
<var>100</var> exactly once are as follows:
<var>100</var>, <var>200</var>, <var>300</var>,
<var>400</var>, <var>500</var>, <var>600</var>,
<var>700</var>, <var>800</var>, <var>900</var>,
<var>1 \ 000</var>, <var>1 \ 100</var>, ...<br />
Thus, the integer we are seeking is <var>1 \ 100</var>.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 3</h3>
<pre>
2 85
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 3</h3>
<pre>
850000
</pre
>
<p>
The integers that can be divided by
<var>100</var> exactly twice are as follows:
<var>10 \ 000</var>, <var>20 \ 000</var>,
<var>30 \ 000</var>, ...<br />
Thus, the integer we are seeking is
<var>850 \ 000</var>.
</p>
</section>
</div>
</span>
</span>
</div>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks/abc100_b?lang=en"
data-a2a-title="B - Ringo&#39;s Favorite Numbers"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

View file

@ -0,0 +1,904 @@
<!doctype html>
<html>
<head>
<title>C - *3 or /2</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="C - *3 or /2" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks/abc100_c"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta property="twitter:title" content="C - *3 or /2" />
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'KwoiS7wTPLvccvgUDoQZ6H++fkjXMCchJrW6/YFqOJM='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2.min.css"
rel="stylesheet"
/>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2-bootstrap.min.css"
rel="stylesheet"
/>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/select2.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ace.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ext-language_tools.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/run_prettify.js"></script>
<link
rel="stylesheet"
href="//img.atcoder.jp/public/6372bb3/css/cdn/katex.min.css"
/>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/katex.min.js"
></script>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/auto-render.min.js"
></script>
<script>
$(function () {
$('var').each(function () {
var html = $(this)
.html()
.replace(/<sub>/g, '_{')
.replace(/<\/sub>/g, '}')
$(this).html('\\(' + html + '\\)')
})
})
</script>
<script>
var katexOptions = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
ignoredTags: [
'script',
'noscript',
'style',
'textarea',
'code',
'option'
],
ignoredClasses: ['prettyprint', 'source-code-for-copy'],
throwOnError: false
}
document.addEventListener('DOMContentLoaded', function () {
renderMathInElement(document.body, katexOptions)
})
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks/abc100_c?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks/abc100_c?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_c"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_c"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_c"
>
<input
type="hidden"
name="csrf_token"
value="KwoiS7wTPLvccvgUDoQZ6H&#43;&#43;fkjXMCchJrW6/YFqOJM="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<span class="h2">
C - *3 or /2
<a
class="btn btn-default btn-sm"
href="/contests/abc100/tasks/abc100_c/editorial"
>Editorial</a
>
</span>
<span id="task-lang-btn" class="pull-right"
><span data-lang="ja"
><img src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/></span>
/
<span data-lang="en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" /></span
></span>
<script>
$(function () {
var ts = $('#task-statement span.lang')
if (ts.children('span').size() <= 1) {
$('#task-lang-btn').hide()
ts.children('span').show()
return
}
var REMEMBER_LB = 5
var LS_KEY = 'task_lang'
var taskLang = getLS(LS_KEY) || ''
function isTaskLangSet(taskLang) {
return taskLang === 'ja' || taskLang === 'en'
}
if (isTaskLangSet(taskLang)) {
const params = new URLSearchParams(window.location.search)
if (params.get('lang')) {
setLS(LS_KEY, REMEMBER_LB)
taskLang = LANG
}
} else {
taskLang = LANG
}
ts.children('span.lang-' + taskLang).show()
$('#task-lang-btn span').click(function () {
var l = $(this).data('lang')
ts.children('span').hide()
ts.children('span.lang-' + l).show()
taskLang = getLS(LS_KEY) || ''
let changeTimes = 0
if (isTaskLangSet(taskLang)) {
changeTimes = REMEMBER_LB
} else {
changeTimes = parseInt(taskLang, 10)
if (isNaN(changeTimes)) changeTimes = 0
changeTimes++
}
if (changeTimes < REMEMBER_LB) setLS(LS_KEY, changeTimes)
else setLS(LS_KEY, l)
})
})
</script>
<hr />
<p>Time Limit: 2 sec / Memory Limit: 976 MiB</p>
<div id="task-statement">
<span class="lang">
<span class="lang-ja">
<p>配点: <var>300</var></p>
<div class="part">
<section>
<h3>問題文</h3>
<p>
AtCoder Beginner Contest 100 の開催にともなって, AtCoder
社では長さ <var>N</var> の数列 <var>a = </var>{<var
>a_1, a_2, a_3, ..., a_N</var
>} が飾られることになった. <br />
社員のすぬけ君は, この数列で遊んでみようと思った.
</p>
<p>
具体的には,
以下の操作をできるだけ多くの回数繰り返そうと思った.
</p>
<pre><var>1 \leq i \leq N</var> を満たす全ての <var>i</var> に対して, それぞれ「<var>a_i</var> の値を <var>2</var> で割る」「<var>a_i</var> の値を <var>3</var> 倍する」のどちらかを行う.
ただし, 全ての <var>i</var> に対して <var>3</var> 倍することはできず, 操作後の <var>a_i</var> の値は整数でなければならない.
</pre>
<p>最大で何回の操作が可能か, 求めなさい.</p>
</section>
</div>
<div class="part">
<section>
<h3>制約</h3>
<ul>
<li>
<var>N</var><var>1</var> 以上
<var>10 \ 000</var> 以下の整数
</li>
<li>
<var>a_i</var><var>1</var> 以上
<var>1 \ 000 \ 000 \ 000</var> 以下の整数
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>入力</h3>
<p>入力は以下の形式で標準入力から与えられる.</p>
<pre><var>N</var>
<var>a_1</var> <var>a_2</var> <var>a_3</var> <var>...</var> <var>a_N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>出力</h3>
<p>すぬけ君が行える最大の操作回数を出力しなさい.</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 1</h3>
<pre>
3
5 2 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 1</h3>
<pre>
3
</pre
>
<p>
最初, 数列は <var>{5, 2, 4}</var> であるが,
以下のように操作すれば
<var>3</var> 回の操作を行うことができる.
</p>
<ul>
<li>
最初に, <var>a_1</var><var>3</var> 倍し,
<var>a_2</var><var>3</var> 倍し, <var>a_3</var>
<var>2</var> で割る. すると数列は
<var>{15, 6, 2}</var> となる.
</li>
<li>
次に, <var>a_1</var><var>3</var> 倍し,
<var>a_2</var><var>2</var> で割り,
<var>a_3</var><var>3</var> 倍する. すると数列は
<var>{45, 3, 6}</var> となる.
</li>
<li>
最後に, <var>a_1</var><var>3</var> 倍し,
<var>a_2</var><var>3</var> 倍し, <var>a_3</var>
<var>2</var> で割る. すると数列は
<var>{135, 9, 3}</var> となる.
</li>
</ul>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 2</h3>
<pre>
4
631 577 243 199
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 2</h3>
<pre>
0
</pre
>
<p>
全ての要素が奇数なので, 操作はできない. よって答えは
<var>0</var> である.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 3</h3>
<pre>
10
2184 2126 1721 1800 1024 2528 3360 1945 1280 1776
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 3</h3>
<pre>
39
</pre
>
</section>
</div>
</span>
<span class="lang-en">
<p>Score: <var>300</var> points</p>
<div class="part">
<section>
<h3>Problem Statement</h3>
<p>
As AtCoder Beginner Contest 100 is taking place, the
office of AtCoder, Inc. is decorated with a sequence of
length <var>N</var>, <var>a = </var>{<var
>a_1, a_2, a_3, ..., a_N</var
>}.<br />
Snuke, an employee, would like to play with this
sequence.
</p>
<p>
Specifically, he would like to repeat the following
operation as many times as possible:
</p>
<pre>For every <var>i</var> satisfying <var>1 \leq i \leq N</var>, perform one of the following: &quot;divide <var>a_i</var> by <var>2</var>&quot; and &quot;multiply <var>a_i</var> by <var>3</var>&quot;.
Here, choosing &quot;multiply <var>a_i</var> by <var>3</var>&quot; for every <var>i</var> is not allowed, and the value of <var>a_i</var> after the operation must be an integer.
</pre>
<p>At most how many operations can be performed?</p>
</section>
</div>
<div class="part">
<section>
<h3>Constraints</h3>
<ul>
<li>
<var>N</var> is an integer between <var>1</var> and
<var>10 \ 000</var> (inclusive).
</li>
<li>
<var>a_i</var> is an integer between <var>1</var> and
<var>1 \ 000 \ 000 \ 000</var> (inclusive).
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>Input</h3>
<p>
Input is given from Standard Input in the following
format:
</p>
<pre><var>N</var>
<var>a_1</var> <var>a_2</var> <var>a_3</var> <var>...</var> <var>a_N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>Output</h3>
<p>
Print the maximum number of operations that Snuke can
perform.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 1</h3>
<pre>
3
5 2 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 1</h3>
<pre>
3
</pre
>
<p>
The sequence is initially <var>{5, 2, 4}</var>. Three
operations can be performed as follows:
</p>
<ul>
<li>
First, multiply <var>a_1</var> by <var>3</var>,
multiply <var>a_2</var> by <var>3</var> and divide
<var>a_3</var> by <var>2</var>. The sequence is now
<var>{15, 6, 2}</var>.
</li>
<li>
Next, multiply <var>a_1</var> by <var>3</var>, divide
<var>a_2</var> by <var>2</var> and multiply
<var>a_3</var> by <var>3</var>. The sequence is now
<var>{45, 3, 6}</var>.
</li>
<li>
Finally, multiply <var>a_1</var> by <var>3</var>,
multiply <var>a_2</var> by <var>3</var> and divide
<var>a_3</var> by <var>2</var>. The sequence is now
<var>{135, 9, 3}</var>.
</li>
</ul>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 2</h3>
<pre>
4
631 577 243 199
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 2</h3>
<pre>
0
</pre
>
<p>
No operation can be performed since all the elements are
odd. Thus, the answer is <var>0</var>.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 3</h3>
<pre>
10
2184 2126 1721 1800 1024 2528 3360 1945 1280 1776
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 3</h3>
<pre>
39
</pre
>
</section>
</div>
</span>
</span>
</div>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks/abc100_c?lang=en"
data-a2a-title="C - *3 or /2"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

1086
tests/fixtures/atcoder/task_abc100_d.html vendored Normal file

File diff suppressed because it is too large Load diff

4343
tests/fixtures/codechef/P1209.html vendored Normal file

File diff suppressed because it is too large Load diff

116
tests/fixtures/codechef/START209.json vendored Normal file
View file

@ -0,0 +1,116 @@
{
"status": "success",
"user": { "username": null },
"code": "START209",
"isRatedContest": "1",
"isParentContestRated": "0",
"name": "Starters 209 (Rated till 5 star)",
"problems": [],
"banner": "https:\/\/cdn.codechef.com\/download\/small-banner\/START209\/1760933061.png",
"rules": "<h4>CodeChef: A Platform for Aspiring Programmers<\/h4>\n<p class=\"last\">CodeChef was created as a platform to help programmers make it big in the world of algorithms, computer programming, and programming contests. At CodeChef, our dedicated efforts are aimed at reviving the inner geek within you, as we proudly host a thrilling programming (coding) contest every Wednesday.<\/p>\n<h4>About CodeChef Starters:<\/h4>\n<p>CodeChef Starters is a short programming contest which takes place on every Wednesday\u00a0<\/p>\n<h4>Contest Details:<\/h4>\n<ul class=\"last\">\n<li><strong>D<\/strong><strong>uration: <\/strong>\u00a02.00 hours\u00a0<\/li>\n<li><strong>Start Date: <\/strong>Wednesday, 22nd October , 2025 at 20:00 HRS (IST)<\/li>\n<li><strong>End Date: <\/strong>Wednesday, 22nd October, 2025 at 22:00 HRS (IST)<\/li>\n<li>Check your timezone <a href=\"https:\/\/www.timeanddate.com\/worldclock\/fixedtime.html?msg=CodeChef+Starters+209&amp;iso=20251022T20&amp;p1=44&amp;ah=2\" target=\"_blank\" rel=\"nofollow noreferrer noopener\">here<\/a>.<\/li>\n<\/ul>\n<h4>Eligibility Criteria: Anyone with a knack for programming<\/h4>\n<p class=\"last\">Our contests are open to all programmers across the globe.<\/p>\n<h4>What's in it for you?<\/h4>\n<p>The idea behind these programming contests is that we want you to learn while competing. Also, we believe that it is alright to refer to tutorials, books, and other materials, learn a concept, and then apply the same to solve a problem during a contest. But it is <strong>not alright to copy other people's solutions or seek other people's help to solve a problem. <\/strong>All the participants are expected to abide to <a class=\"button blue\" href=\"..\/codeofconduct\">CodeChef's Code Of Conduct<\/a>.<\/p>\n<h4>Rules and Regulations:<\/h4>\n<ul>\n<li>This is an IOI-style contest. This means that the problems will be partially graded. You will get the score for passing certain test data.<\/li>\n<li>The details of the failed test cases will also be visible on your solution page.<\/li>\n<li>You can submit solutions as many times as you'd like, there are no penalties for incorrect submissions. Only your best correct submission will be considered.<\/li>\n<li>Those who achieve the score first will be placed higher in the ranklist in case of a tie.<\/li>\n<li><strong>We have removed all the Institutions that we could not identify from our database. We request you to update your institutions once again by going to your profile page.<\/strong><\/li>\n<li>You can also send in your queries in an email to <a href=\"mailto:help@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\">help@codechef.com<\/a>, during the contest.<\/li>\n<li>Please do not discuss strategy, suggestions, or tips in the comments during a live contest. Posting questions clarifying the problem statement is ok. If you are unsure, email us at <a href=\"mailto:feedback@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\"> feedback@codechef.com<\/a>.<\/li>\n<li>Discussing CodeChef's problems or any aspect of a problem, on any other platform on the web, on identification, could lead to the disabling of the respective account and banning from the community.<\/li>\n<\/ul>\n<p><strong>Note: You can now \"Code, Compile, and Run\" your codes on our <a href=\"..\/ide\">Online IDE<\/a>.<\/strong><\/p>\n<p>However, if you are using any other online development environment, make sure that other contestants don't have access to your code. As a contestant, you are responsible for making sure others don't access the code that you submit. If you use Ideone, make sure to mark your submission \"private\" (not secret)\".<\/p>",
"time": {
"start": 1761143400,
"end": 1761150600,
"freezing": 0,
"current": 1761370410
},
"ip": "2603:7000:3900:1358:3959:b692:6cf3:cb03",
"announcements": "<p><strong>CodeChef \u00d7 Coding Club League (2025-26)<\/strong><br \/><br \/>Partner with CodeChef to build a strong coding culture on campus!<\/p>\n<p><strong>Benefits for Clubs:<\/strong><\/p>\n<ul>\n<li>Platform access and support for Annual Technical events \/ hackathons<\/li>\n<li>Pro access for winners<\/li>\n<li>Dashboard to track member progress<\/li>\n<li>Discounts on CodeChef Pro for all members<\/li>\n<li>Co-branding &amp; promotion on CodeChef channels<br \/><br \/>\u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0<strong style=\"text-align:center;\"><a class=\"button blue\" href=\"codechef-coding-club\" target=\"_blank\" rel=\"noreferrer noopener\">\u00a0Click Here To Know More<\/a><\/strong><\/li>\n<\/ul>\n<p><strong>\u00a0<\/strong><\/p>",
"problemsstats": {
"attempted": [],
"partially_solved": [],
"solved": [],
"locked": []
},
"todos": [],
"stats": null,
"partial_scores": [],
"isRanklistFrozen": false,
"rank_and_score": { "score": "NA", "rank": "NA" },
"is_a_parent_contest": true,
"is_contest_elements_visible": true,
"is_OTP_required": false,
"is_linked_problems_contest": "0",
"custom_contest_page_title": "",
"custom_contest_page_meta_desc": "",
"contest_introduction": "https:\/\/discuss.codechef.com\/t\/invitation-to-codechef-starters-209-rated-upto-5-stars-22nd-october\/124401",
"contest_editorials": "https:\/\/discuss.codechef.com\/tag\/start209",
"contest_video_editorials": "",
"is_older_rating_based_division_system": false,
"division_generation": 3,
"isAssessmentContest": false,
"penalisedUsersCount": 0,
"ttl": 60,
"child_contests": {
"div_1": {
"div": {
"div_number": "1",
"code": "div_1",
"min_rating": 2000,
"max_rating": 50000,
"name": "Division 1",
"description": "Users with rating above 2000"
},
"division_generation": 3,
"contest_code": "START209A",
"contest_link": "\/START209A"
},
"div_2": {
"div": {
"div_number": "2",
"code": "div_2",
"min_rating": 1600,
"max_rating": 1999,
"name": "Division 2",
"description": "Users with rating between 1600 and 1999"
},
"division_generation": 3,
"contest_code": "START209B",
"contest_link": "\/START209B"
},
"div_3": {
"div": {
"div_number": "3",
"code": "div_3",
"min_rating": 1400,
"max_rating": 1599,
"name": "Division 3",
"description": "Users with rating upto 1599"
},
"division_generation": 3,
"contest_code": "START209C",
"contest_link": "\/START209C"
},
"div_4": {
"div": {
"div_number": "4",
"code": "div_4",
"min_rating": 0,
"max_rating": 1399,
"name": "Division 4",
"description": "Users with rating upto 1399"
},
"division_generation": 3,
"contest_code": "START209D",
"contest_link": "\/START209D"
}
},
"user_rating_div": {
"rating": -1,
"div": {
"code": "all",
"min_rating": 0,
"max_rating": 50000,
"name": "All",
"description": "All the users"
}
},
"user_contest_code": null,
"show_div_based_contest": false,
"is_registration_enabled_contest": false,
"is_flexi_time_contest": false,
"duration": "120",
"is_proctored": false,
"autoRefresh": true,
"visitedContests": []
}

202
tests/fixtures/codechef/START209D.json vendored Normal file
View file

@ -0,0 +1,202 @@
{
"status": "success",
"user": { "username": null },
"code": "START209D",
"isRatedContest": "1",
"isParentContestRated": "1",
"name": "Starters 209 (Rated)",
"problems": {
"P1209": {
"code": "P1209",
"name": "Bitcoin Market",
"type": "3",
"successful_submissions": "25131",
"allow_submission": false,
"accuracy": 85.680000000000007,
"problem_url": "\/problems\/P1209",
"submit_url": "\/problems\/P1209",
"status_url": "\/status\/P1209",
"is_added_to_practice": true,
"total_submissions": "33093",
"category_name": "main",
"is_direct_submittable": false
},
"P2209": {
"code": "P2209",
"name": "Divisible Duel",
"type": "3",
"successful_submissions": "21888",
"allow_submission": false,
"accuracy": 64.159999999999997,
"problem_url": "\/problems\/P2209",
"submit_url": "\/problems\/P2209",
"status_url": "\/status\/P2209",
"is_added_to_practice": true,
"total_submissions": "37437",
"category_name": "main",
"is_direct_submittable": false
},
"P3209": {
"code": "P3209",
"name": "Small GCD Sort",
"type": "3",
"successful_submissions": "13450",
"allow_submission": false,
"accuracy": 76.239999999999995,
"problem_url": "\/problems\/P3209",
"submit_url": "\/problems\/P3209",
"status_url": "\/status\/P3209",
"is_added_to_practice": true,
"total_submissions": "19164",
"category_name": "main",
"is_direct_submittable": false
},
"P4209": {
"code": "P4209",
"name": "Tactical Conversion",
"type": "3",
"successful_submissions": "1567",
"allow_submission": false,
"accuracy": 8.4499999999999993,
"problem_url": "\/problems\/P4209",
"submit_url": "\/problems\/P4209",
"status_url": "\/status\/P4209",
"is_added_to_practice": true,
"total_submissions": "20535",
"category_name": "main",
"is_direct_submittable": false
},
"P5209": {
"code": "P5209",
"name": "Binary Love",
"type": "3",
"successful_submissions": "3271",
"allow_submission": false,
"accuracy": 33.530000000000001,
"problem_url": "\/problems\/P5209",
"submit_url": "\/problems\/P5209",
"status_url": "\/status\/P5209",
"is_added_to_practice": true,
"total_submissions": "11128",
"category_name": "main",
"is_direct_submittable": false
},
"P6209E": {
"code": "P6209E",
"name": "High Score (Easy Version)",
"type": "3",
"successful_submissions": "285",
"allow_submission": false,
"accuracy": 7.2800000000000002,
"problem_url": "\/problems\/P6209E",
"submit_url": "\/problems\/P6209E",
"status_url": "\/status\/P6209E",
"is_added_to_practice": true,
"total_submissions": "4535",
"category_name": "main",
"is_direct_submittable": false
},
"P6209": {
"code": "P6209",
"name": "High Score (Hard Version)",
"type": "3",
"successful_submissions": "34",
"allow_submission": false,
"accuracy": 3.1899999999999999,
"problem_url": "\/problems\/P6209",
"submit_url": "\/problems\/P6209",
"status_url": "\/status\/P6209",
"is_added_to_practice": true,
"total_submissions": "1159",
"category_name": "main",
"is_direct_submittable": false
},
"P7209": {
"code": "P7209",
"name": "Easy Grid Game",
"type": "3",
"successful_submissions": "80",
"allow_submission": false,
"accuracy": 5.1100000000000003,
"problem_url": "\/problems\/P7209",
"submit_url": "\/problems\/P7209",
"status_url": "\/status\/P7209",
"is_added_to_practice": true,
"total_submissions": "1740",
"category_name": "main",
"is_direct_submittable": false
},
"P8209": {
"code": "P8209",
"name": "Counting Is Fun",
"type": "3",
"successful_submissions": "22",
"allow_submission": false,
"accuracy": 1.8200000000000001,
"problem_url": "\/problems\/P8209",
"submit_url": "\/problems\/P8209",
"status_url": "\/status\/P8209",
"is_added_to_practice": true,
"total_submissions": "1261",
"category_name": "main",
"is_direct_submittable": false
}
},
"banner": "https:\/\/cdn.codechef.com\/download\/small-banner\/START209D\/1760933097.png",
"rules": "<h4>CodeChef: A Platform for Aspiring Programmers<\/h4>\n<p class=\"last\">CodeChef was created as a platform to help programmers make it big in the world of algorithms, computer programming, and programming contests. At CodeChef, our dedicated efforts are aimed at reviving the inner geek within you, as we proudly host a thrilling programming (coding) contest every Wednesday.<\/p>\n<h4>About CodeChef Starters:<\/h4>\n<p>CodeChef Starters is a short programming contest which takes place on every Wednesday\u00a0<\/p>\n<h4>Contest Details:<\/h4>\n<ul class=\"last\">\n<li><strong>D<\/strong><strong>uration: <\/strong>\u00a02.00 hours\u00a0<\/li>\n<li><strong>Start Date: <\/strong>Wednesday, 22nd October , 2025 at 20:00 HRS (IST)<\/li>\n<li><strong>End Date: <\/strong>Wednesday, 22nd October, 2025 at 22:00 HRS (IST)<\/li>\n<li>Check your timezone <a href=\"https:\/\/www.timeanddate.com\/worldclock\/fixedtime.html?msg=CodeChef+Starters+209&amp;iso=20251022T20&amp;p1=44&amp;ah=2\" target=\"_blank\" rel=\"nofollow noreferrer noopener\">here<\/a>.<\/li>\n<\/ul>\n<h4>Eligibility Criteria: Anyone with a knack for programming<\/h4>\n<p class=\"last\">Our contests are open to all programmers across the globe.<\/p>\n<h4>What's in it for you?<\/h4>\n<p>The idea behind these programming contests is that we want you to learn while competing. Also, we believe that it is alright to refer to tutorials, books, and other materials, learn a concept, and then apply the same to solve a problem during a contest. But it is <strong>not alright to copy other people's solutions or seek other people's help to solve a problem. <\/strong>All the participants are expected to abide to <a class=\"button blue\" href=\"..\/codeofconduct\">CodeChef's Code Of Conduct<\/a>.<\/p>\n<h4>Rules and Regulations:<\/h4>\n<ul>\n<li>This is an IOI-style contest. This means that the problems will be partially graded. You will get the score for passing certain test data.<\/li>\n<li>The details of the failed test cases will also be visible on your solution page.<\/li>\n<li>You can submit solutions as many times as you'd like, there are no penalties for incorrect submissions. Only your best correct submission will be considered.<\/li>\n<li>Those who achieve the score first will be placed higher in the ranklist in case of a tie.<\/li>\n<li><strong>We have removed all the Institutions that we could not identify from our database. We request you to update your institutions once again by going to your profile page.<\/strong><\/li>\n<li>You can also send in your queries in an email to <a href=\"mailto:help@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\">help@codechef.com<\/a>, during the contest.<\/li>\n<li>Please do not discuss strategy, suggestions, or tips in the comments during a live contest. Posting questions clarifying the problem statement is ok. If you are unsure, email us at <a href=\"mailto:feedback@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\"> feedback@codechef.com<\/a>.<\/li>\n<li>Discussing CodeChef's problems or any aspect of a problem, on any other platform on the web, on identification, could lead to the disabling of the respective account and banning from the community.<\/li>\n<\/ul>\n<p><strong>Note: You can now \"Code, Compile, and Run\" your codes on our <a href=\"..\/ide\">Online IDE<\/a>.<\/strong><\/p>\n<p>However, if you are using any other online development environment, make sure that other contestants don't have access to your code. As a contestant, you are responsible for making sure others don't access the code that you submit. If you use Ideone, make sure to mark your submission \"private\" (not secret)\".<\/p>",
"time": {
"start": 1761143406,
"end": 1761150606,
"freezing": 0,
"current": 1761365589
},
"ip": "2603:7000:3900:1358:3959:b692:6cf3:cb03",
"announcements": "<p><strong>CodeChef \u00d7 Coding Club League (2025-26)<\/strong><br \/><br \/>Partner with CodeChef to build a strong coding culture on campus!<\/p>\n<p><strong>Benefits for Clubs:<\/strong><\/p>\n<ul>\n<li>Platform access and support for Annual Technical events \/ hackathons<\/li>\n<li>Pro access for winners<\/li>\n<li>Dashboard to track member progress<\/li>\n<li>Discounts on CodeChef Pro for all members<\/li>\n<li>Co-branding &amp; promotion on CodeChef channels<br \/><br \/>\u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0<strong style=\"text-align:center;\"><a class=\"button blue\" href=\"codechef-coding-club\" target=\"_blank\" rel=\"noreferrer noopener\">\u00a0Click Here To Know More<\/a><\/strong><\/li>\n<\/ul>\n<p><strong>\u00a0<\/strong><\/p>\n<p>\u00a0<\/p>",
"problemsstats": {
"attempted": [],
"partially_solved": [],
"solved": [],
"locked": []
},
"todos": [],
"stats": null,
"partial_scores": {
"P7209": [{ "score": "100", "count": "80" }],
"P5209": [{ "score": "100", "count": "3271" }],
"P4209": [{ "score": "100", "count": "1567" }],
"P1209": [{ "score": "100", "count": "25131" }],
"P3209": [{ "score": "100", "count": "13450" }],
"P2209": [{ "score": "100", "count": "21888" }],
"P8209": [{ "score": "100", "count": "22" }],
"P6209": [{ "score": "100", "count": "34" }],
"P6209E": [{ "score": "100", "count": "285" }]
},
"isRanklistFrozen": false,
"rank_and_score": { "score": "NA", "rank": "NA" },
"is_a_parent_contest": false,
"is_contest_elements_visible": true,
"is_OTP_required": false,
"is_linked_problems_contest": "0",
"custom_contest_page_title": "",
"custom_contest_page_meta_desc": "",
"contest_introduction": "https:\/\/discuss.codechef.com\/t\/invitation-to-codechef-starters-209-rated-upto-5-stars-22nd-october\/124401",
"contest_editorials": "https:\/\/discuss.codechef.com\/tag\/start209",
"contest_video_editorials": "",
"is_older_rating_based_division_system": false,
"division_generation": 3,
"isAssessmentContest": false,
"penalisedUsersCount": 0,
"ttl": 60,
"scorable_heading": "Scorable Problems for Division 4",
"scorable_message": "",
"division": "Division 4",
"non_scorable_heading": "Non Scorable Problems for Practice",
"non_scorable_message": "<p>The following problems are <b>NOT part of the contest<\/b>, and will not be counted towards your rankings and ratings. These are problems from the other Division(s), made available for you to practice. Click <a href='\/blogs\/how-does-codechef-rating-system-work'>here<\/a> to know more. They will be considered for plagiarism though.<\/p>",
"is_registration_enabled_contest": false,
"is_flexi_time_contest": false,
"duration": "120",
"is_proctored": false,
"autoRefresh": true,
"visitedContests": [],
"user_live_ratings_update_frequency": 15
}

View file

@ -0,0 +1,99 @@
{
"category_name": "main",
"contest_code": "START209D",
"contest_name": "Starters 209 (Rated)",
"status": "success",
"submit_error": "You need to login to submit.",
"is_verified": false,
"problem_code": "P1209",
"contest_category": "9",
"problem_name": "Bitcoin Market",
"intended_contest_code": "START209",
"body": "This is an example problem statement in markdown, and a mini guide on writing statements. Please make sure to remove everything here before publishing your problem.\n\n- Codechef uses markdown for its problem statements. Markdown syntax can be found [here](https:\/\/github.com\/showdownjs\/showdown\/wiki\/Showdown's-Markdown-syntax). Note the `[text](link)` syntax to insert a hyperlink.\n- Codechef also uses $\\LaTeX$ to render mathematical expressions, and you are advised to make liberal use of it to make your statement look good.\n- Text can be made **bold** or *italicized*.\n- **Do not** use HTML tags (p, ul, li, pre, br, ...) in the statement.\n- To insert an image, first upload it to an online hosting service (for an official contest, ask a Codechef admin to do this for you \u2014 this is important) and then use the following syntax: `![alt text](link-to-image)`.\n- If your problem doesn't contain subtasks, ensure that the Subtasks section below is disabled and **all content is deleted from it**.\n\nIf you face any issues, either contact a Codechef admin directly or send us an email at help@codechef.com.\n\nBelow is an example problem statement that uses some of the above-mentioned features.\n\n---------\n\nChef has a simple undirected graph $G$ with $N$ vertices and $M$ edges. A [subgraph](https:\/\/mathworld.wolfram.com\/Subgraph.html) $H$ of $G$ is called *good* if:\n- $H$ is connected\n- $H$ contains all $N$ vertices of $G$\n- There is a unique path between any two vertices in $H$, using only edges in $H$\n\nCount the number of *good* subgraphs of $G$. Since this number might be large, report it modulo $10^9 + 7$.\n\nIn other news, here's a completely unrelated image:\n\n![](https:\/\/s3.amazonaws.com\/codechef_shared\/download\/Images\/START41\/ss3.png).\n\n\n<aside style='background: #f8f8f8;padding: 10px 15px;'><div>All submissions for this problem are available.<\/div><\/aside>",
"problemComponents": {
"constraints": "- $1 \\leq R \\leq 10$",
"constraintsState": true,
"subtasks": "- **Subtask 1 (10 points):** $1 \\leq M \\leq 10$\n- **Subtask 2 (20 points):** The sum of $N$ across all test cases won't exceed $20$.\n- **Subtask 3 (70 points):** No further constraints.",
"subtasksState": false,
"statement": "Chef has recently started investing in **Bitcoin**. \nHe assigns a **market risk level** $R$ (from $1$ to $10$), where: \n\n- $1$ means the market is *very safe*, \n- $10$ means the market is *very risky*. \n\nChef will **buy Bitcoin** only if the risk level is **$4$ or less**. \n\nGiven the current risk level $R$, determine whether Chef should buy Bitcoin.\n\nPrint **\"YES\"** if Chef should buy, otherwise print **\"NO\"**.",
"inputFormat": "- The first and only line of input contains a single integer $R$ \u2014 the current market risk level.",
"inputFormatState": true,
"outputFormat": "Print `YES` if Chef should buy Bitcoin, Otherwise, print `NO`.\n\nYou may print each character of the string in uppercase or lowercase (for example, the strings `YES`, `yEs`, `yes`, and `yeS` will all be treated as identical).\n",
"outputFormatState": true,
"sampleTestCases": [
{
"id": "1",
"input": "2",
"output": "YES",
"explanation": "The current market risk is $2$. \nSince $2$ is not larger than $4$, the risk is small enough, and Chef will buy Bitcoin.",
"isDeleted": false
},
{
"id": "2",
"input": "4",
"output": "YES",
"explanation": "The current market risk is $4$. \nSince $4$ is not larger than $4$, the risk is small enough, and Chef will buy Bitcoin.",
"isDeleted": false
},
{
"id": "3",
"input": "5",
"output": "NO",
"explanation": "The current market risk is $5$. \nSince $5$ is larger than $4$, the risk is too much, and Chef will **not** buy Bitcoin.",
"isDeleted": false
}
]
},
"gumlet_video_url": "",
"video_editorial_url": "https:\/\/youtu.be\/tjUCV9Ld1Kw?si=minop9943wecj1bh",
"text_editorial_body": "<h1><a name=\"problem-link-1\" class=\"anchor\" href=\"#problem-link-1\"><\/a>PROBLEM LINK:<\/h1>\n<p><a href=\"https:\/\/www.codechef.com\/problems\/P1209\">Practice<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209A\/problems\/P1209\">Contest: Division 1<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209B\/problems\/P1209\">Contest: Division 2<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209C\/problems\/P1209\">Contest: Division 3<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209D\/problems\/P1209\">Contest: Division 4<\/a><\/p>\n<p><em><strong>Author:<\/strong><\/em> <a href=\"https:\/\/www.codechef.com\/users\/pols_agyi_pols\">pols_agyi_pols<\/a><br>\n<em><strong>Tester:<\/strong><\/em> <a href=\"https:\/\/www.codechef.com\/users\/kingmessi\">kingmessi<\/a><br>\n<em><strong>Editorialist:<\/strong><\/em> <a href=\"https:\/\/www.codechef.com\/users\/iceknight1093\">iceknight1093<\/a><\/p>\n<h1><a name=\"difficulty-2\" class=\"anchor\" href=\"#difficulty-2\"><\/a>DIFFICULTY:<\/h1>\n<p>Cakewalk<\/p>\n<h1><a name=\"prerequisites-3\" class=\"anchor\" href=\"#prerequisites-3\"><\/a>PREREQUISITES:<\/h1>\n<p>None<\/p>\n<h1><a name=\"problem-4\" class=\"anchor\" href=\"#problem-4\"><\/a>PROBLEM:<\/h1>\n<p>Chef will buy bitcoin if the market risk level is no more than <span class=\"math\">4<\/span>.<br>\nThe current market risk level is <span class=\"math\">R<\/span>.<br>\nWill Chef buy bitcoin?<\/p>\n<h1><a name=\"explanation-5\" class=\"anchor\" href=\"#explanation-5\"><\/a>EXPLANATION:<\/h1>\n<p>The answer is <code>Yes<\/code> if <span class=\"math\">R \\le 4<\/span> and <code>No<\/code> otherwise.<br>\nThis can be checked using an <code>if<\/code> condition.<\/p>\n<h1><a name=\"time-complexity-6\" class=\"anchor\" href=\"#time-complexity-6\"><\/a>TIME COMPLEXITY:<\/h1>\n<p><span class=\"math\">\\mathcal{O}(1)<\/span> per testcase.<\/p>\n<h1><a name=\"code-7\" class=\"anchor\" href=\"#code-7\"><\/a>CODE:<\/h1>\n<details>\n<summary>\nEditorialist's code (PyPy3)<\/summary>\n<pre><code class=\"lang-python\">r = int(input())\nprint('Yes' if r &lt;= 4 else 'No')\n<\/code><\/pre>\n<\/details>",
"text_editorial_is_markdown": 0,
"text_editorial_topic_id": 124410,
"languages_supported": "CPP20, PYTH 3, C, JAVA, PYP3, CS2, NODEJS, GO, TS, PHP, kotlin, rust, R",
"max_timelimit": "1",
"source_sizelimit": "50000",
"problem_author": "archit_adm",
"problem_display_authors": ["archit_adm"],
"problem_display_authors_html_handle": "<div class=\"multiple-usernames-container\"><a href='\/users\/archit_adm'>archit_adm<\/a><\/div>",
"problem_tester": null,
"problem_testers_usernames": ["kingmessi"],
"problem_tester_html_handle": "<div class=\"multiple-usernames-container\"><a href='\/users\/kingmessi'><span \n class='rating' \n style='display: inline-block; \n font-size: 10px; \n background: #D0011B;\n padding: 0 3px; \n line-height: 1.3; \n color: white;\n margin-right: 2px;'>7&#9733;<\/span><span class='m-username--link'>kingmessi<\/span><\/a><\/div>",
"problem_editorialist": "iceknight1093",
"date_added": "20-10-2025",
"ready_for_debug": false,
"problem_stats": {
"accuracy": 85.780000000000001,
"successful_submissions": "25325",
"total_submissions": "33327"
},
"user_tags": ["archit_adm", "cakewalk", "start209"],
"computed_tags": [],
"difficulty_rating": "172",
"best_tag": "",
"editorial_url": "",
"time": {
"view_start_date": 1761143406,
"submit_start_date": 1761143406,
"visible_start_date": 1761150606,
"end_date": 1761150606,
"current": 1761365589,
"practice_submission_allowed": false
},
"user": { "username": null, "access": "default", "isPremiumUser": false },
"bookmark_status": false,
"contest_problem_status": "unattempted",
"problem_status": "unattempted",
"is_direct_submittable": false,
"problemDiscussURL": "https:\/\/discuss.codechef.com\/search?q=P1209",
"is_a_practice_or_college_contest": false,
"votes_data": {
"SolutionVoteData": { "upvote_count": 0, "user_vote": 0 },
"HintsVoteData": { "upvote_count": 0, "user_vote": 0 },
"ProblemStatementVoteData": { "upvote_count": 26, "user_vote": 0 },
"DoubtSupportVoteData": { "upvote_count": 0, "user_vote": 0 }
},
"is_proctored": false,
"is_user_verified_for_proctoring": false,
"visitedContests": [],
"isSupportedByJudge": true
}

330
tests/fixtures/codechef/contests.json vendored Normal file
View file

@ -0,0 +1,330 @@
{
"status": "success",
"message": "All contests list",
"present_contests": [
{
"contest_code": "DEVWEEKEND21",
"contest_name": "Weekend Dev Challenge 21: Full Stack Projects using MERN",
"contest_start_date": "25 Oct 2025 00:00:00",
"contest_end_date": "27 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-25T00:00:00+05:30",
"contest_end_date_iso": "2025-10-27T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 8
}
],
"future_contests": [
{
"contest_code": "START210",
"contest_name": "Starters 210",
"contest_start_date": "29 Oct 2025 20:00:00",
"contest_end_date": "29 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-29T20:00:00+05:30",
"contest_end_date_iso": "2025-10-29T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 0
},
{
"contest_code": "START211",
"contest_name": "Starters 211",
"contest_start_date": "05 Nov 2025 20:00:00",
"contest_end_date": "05 Nov 2025 22:00:00",
"contest_start_date_iso": "2025-11-05T20:00:00+05:30",
"contest_end_date_iso": "2025-11-05T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 0
}
],
"practice_contests": [],
"past_contests": [
{
"contest_code": "START209",
"contest_name": "Starters 209 (Rated till 5 star)",
"contest_start_date": "22 Oct 2025 20:00:00",
"contest_end_date": "22 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-22T20:00:00+05:30",
"contest_end_date_iso": "2025-10-22T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 30408
},
{
"contest_code": "DSAMONDAY08",
"contest_name": "Monday Munch - DSA Challenge 08",
"contest_start_date": "20 Oct 2025 18:00:31",
"contest_end_date": "20 Oct 2025 21:00:31",
"contest_start_date_iso": "2025-10-20T18:00:31+05:30",
"contest_end_date_iso": "2025-10-20T21:00:31+05:30",
"contest_duration": "180",
"distinct_users": 653
},
{
"contest_code": "DEVWEEKEND20",
"contest_name": "Weekend Dev Challenge 20: Full Stack Projects using MERN",
"contest_start_date": "18 Oct 2025 00:00:00",
"contest_end_date": "20 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-18T00:00:00+05:30",
"contest_end_date_iso": "2025-10-20T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 318
},
{
"contest_code": "START208",
"contest_name": "Starters 208 (Rated till 6 star)",
"contest_start_date": "15 Oct 2025 20:00:00",
"contest_end_date": "15 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-15T20:00:00+05:30",
"contest_end_date_iso": "2025-10-15T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 37727
},
{
"contest_code": "DSAMONDAY07",
"contest_name": "Monday Munch - DSA Challenge 07",
"contest_start_date": "13 Oct 2025 18:00:00",
"contest_end_date": "13 Oct 2025 21:00:00",
"contest_start_date_iso": "2025-10-13T18:00:00+05:30",
"contest_end_date_iso": "2025-10-13T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 4934
},
{
"contest_code": "DEVWEEKEND19",
"contest_name": "Weekend Dev Challenge 19: Full Stack Projects using MERN",
"contest_start_date": "11 Oct 2025 00:00:00",
"contest_end_date": "13 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-11T00:00:00+05:30",
"contest_end_date_iso": "2025-10-13T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 5376
},
{
"contest_code": "START207",
"contest_name": "Starters 207 (Rated till 5 star)",
"contest_start_date": "08 Oct 2025 20:00:00",
"contest_end_date": "08 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-08T20:00:00+05:30",
"contest_end_date_iso": "2025-10-08T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 32785
},
{
"contest_code": "DSAMONDAY06",
"contest_name": "Monday Munch - DSA Challenge 06",
"contest_start_date": "06 Oct 2025 18:00:02",
"contest_end_date": "06 Oct 2025 21:00:02",
"contest_start_date_iso": "2025-10-06T18:00:02+05:30",
"contest_end_date_iso": "2025-10-06T21:00:02+05:30",
"contest_duration": "180",
"distinct_users": 892
},
{
"contest_code": "DEVWEEKEND18",
"contest_name": "Weekend Dev Challenge 18: Full Stack Projects using MERN",
"contest_start_date": "04 Oct 2025 00:00:00",
"contest_end_date": "06 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-04T00:00:00+05:30",
"contest_end_date_iso": "2025-10-06T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 223
},
{
"contest_code": "START206",
"contest_name": "Starters 206 (Rated till 5 star)",
"contest_start_date": "01 Oct 2025 20:00:00",
"contest_end_date": "01 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-01T20:00:00+05:30",
"contest_end_date_iso": "2025-10-01T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 23977
},
{
"contest_code": "DSAMONDAY05",
"contest_name": "Monday Munch - DSA Challenge 05",
"contest_start_date": "29 Sep 2025 18:00:00",
"contest_end_date": "29 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-29T18:00:00+05:30",
"contest_end_date_iso": "2025-09-29T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 1160
},
{
"contest_code": "DEVWEEKEND17",
"contest_name": "Weekend Dev Challenge 17: GenAI Projects using LLM",
"contest_start_date": "27 Sep 2025 00:00:00",
"contest_end_date": "29 Sep 2025 00:00:00",
"contest_start_date_iso": "2025-09-27T00:00:00+05:30",
"contest_end_date_iso": "2025-09-29T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 130
},
{
"contest_code": "START205",
"contest_name": "Starters 205 (Rated till 6 star)",
"contest_start_date": "24 Sep 2025 20:00:00",
"contest_end_date": "24 Sep 2025 22:00:00",
"contest_start_date_iso": "2025-09-24T20:00:00+05:30",
"contest_end_date_iso": "2025-09-24T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 32552
},
{
"contest_code": "DSAMONDAY04",
"contest_name": "Monday Munch - DSA Challenge 04",
"contest_start_date": "22 Sep 2025 18:00:00",
"contest_end_date": "22 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-22T18:00:00+05:30",
"contest_end_date_iso": "2025-09-22T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 759
},
{
"contest_code": "DEVWEEKEND16",
"contest_name": "Weekend Dev Challenge 16: GenAI Projects using LLM",
"contest_start_date": "20 Sep 2025 00:00:00",
"contest_end_date": "22 Sep 2025 00:00:00",
"contest_start_date_iso": "2025-09-20T00:00:00+05:30",
"contest_end_date_iso": "2025-09-22T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 171
},
{
"contest_code": "START204",
"contest_name": "Starters 204 (Rated till 5 star)",
"contest_start_date": "17 Sep 2025 20:00:00",
"contest_end_date": "17 Sep 2025 22:00:00",
"contest_start_date_iso": "2025-09-17T20:00:00+05:30",
"contest_end_date_iso": "2025-09-17T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 36282
},
{
"contest_code": "DSAMONDAY03",
"contest_name": "Monday Munch - DSA Challenge 03",
"contest_start_date": "15 Sep 2025 18:00:00",
"contest_end_date": "15 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-15T18:00:00+05:30",
"contest_end_date_iso": "2025-09-15T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 657
},
{
"contest_code": "DEVWEEKEND15",
"contest_name": "Weekend Dev Challenge 15: Classify images using Deep Learning",
"contest_start_date": "13 Sep 2025 00:00:00",
"contest_end_date": "14 Sep 2025 00:00:00",
"contest_start_date_iso": "2025-09-13T00:00:00+05:30",
"contest_end_date_iso": "2025-09-14T00:00:00+05:30",
"contest_duration": "1440",
"distinct_users": 112
},
{
"contest_code": "START203",
"contest_name": "Starters 203 (Rated till 5 star)",
"contest_start_date": "10 Sep 2025 20:00:00",
"contest_end_date": "10 Sep 2025 22:00:00",
"contest_start_date_iso": "2025-09-10T20:00:00+05:30",
"contest_end_date_iso": "2025-09-10T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 36512
},
{
"contest_code": "DSAMONDAY02",
"contest_name": "Monday Munch - DSA Challenge 02",
"contest_start_date": "08 Sep 2025 18:00:00",
"contest_end_date": "08 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-08T18:00:00+05:30",
"contest_end_date_iso": "2025-09-08T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 737
}
],
"skill_tests": [
{
"contest_code": "basic-python",
"contest_name": "Python Online Test & Quiz",
"contest_start_date": "27 Mar 2024 15:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-27T15:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 61244
},
{
"contest_code": "basic-java",
"contest_name": "Java Online Test & Quiz",
"contest_start_date": "28 Mar 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-28T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 49993
},
{
"contest_code": "basic-c-language",
"contest_name": "C language online test",
"contest_start_date": "28 Mar 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-28T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 41373
},
{
"contest_code": "basic-c-plus-plus",
"contest_name": "C++ Online Test and Quiz",
"contest_start_date": "28 Mar 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-28T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 32507
},
{
"contest_code": "basic-sql",
"contest_name": "SQL Online Test and Quiz",
"contest_start_date": "01 Jun 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:00:00",
"contest_start_date_iso": "2024-06-01T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:00:00+05:30",
"contest_duration": "60",
"problem_count": 17,
"distinct_users": 17426
},
{
"contest_code": "operating-systems",
"contest_name": "Operating Systems Skill Test",
"contest_start_date": "01 Jun 2024 00:00:00",
"contest_end_date": "01 Jan 2027 00:45:00",
"contest_start_date_iso": "2024-06-01T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T00:45:00+05:30",
"contest_duration": "45",
"problem_count": 30,
"distinct_users": 8751
},
{
"contest_code": "c-language-dsa",
"contest_name": "Data structures and Algorithms in C test",
"contest_start_date": "01 Apr 2024 12:00:00",
"contest_end_date": "01 Jan 2027 02:00:00",
"contest_start_date_iso": "2024-04-01T12:00:00+05:30",
"contest_end_date_iso": "2027-01-01T02:00:00+05:30",
"contest_duration": "120",
"problem_count": 28,
"distinct_users": 6611
}
],
"banners": [
{
"image": "1760933050.png",
"link": "https:\/\/www.codechef.com\/START209"
},
{
"image": "1719492535.png",
"link": "https:\/\/www.codechef.com\/roadmap\/data-structures-and-algorithms"
}
]
}

8210
tests/fixtures/codeforces/1550_A.html vendored Normal file

File diff suppressed because it is too large Load diff

4724
tests/fixtures/codeforces/1550_B.html vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

10
tests/fixtures/codeforces/contests.html vendored Normal file

File diff suppressed because one or more lines are too long

2141
tests/fixtures/cses/contests.html vendored Normal file

File diff suppressed because it is too large Load diff

156
tests/fixtures/cses/task_1068.html vendored Normal file
View file

@ -0,0 +1,156 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet " type="text/css" href="/cses.css?0" id="styles" />
<link
rel="stylesheet alternate"
type="text/css"
href="/cses-dark.css?0"
id="styles-dark"
/>
<meta name="theme-color" content="white" id="theme-color" />
<script type="application/json" id="darkmode-enabled">
false
</script>
<script src="/ui.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/lib/fontawesome/css/all.min.css"
/>
</head>
<body class="with-sidebar">
<div class="header">
<div>
<a href="/" class="logo"><img src="/logo.png?1" alt="CSES" /></a>
<a
class="menu-toggle"
onclick="document.body.classList.toggle('menu-open')"
>
<i class="fas fa-bars"></i>
</a>
<div class="controls">
<a class="account" href="/login">Login</a>
<span>&mdash;</span>
<a
href="/darkmode"
title="Toggle dark mode"
onclick="return toggle_theme()"
><i aria-label="Dark mode" class="fas fa-adjust"></i
><span>Dark mode</span></a
>
</div>
</div>
</div>
<div class="skeleton">
<div class="navigation">
<div class="title-block">
<h3><a href="/problemset/list/">CSES Problem Set</a></h3>
<h1>Weird Algorithm</h1>
<ul class="nav">
<li><a href="/problemset/task/1068/" class="current">Task</a></li>
<li><a href="/problemset/stats/1068/">Statistics</a></li>
</ul>
</div>
</div>
<div class="content-wrapper">
<div class="content">
<title>CSES - Weird Algorithm</title
><link rel="stylesheet" href="/lib/katex/katex.min.css" />
<script defer src="/lib/katex/katex.min.js"></script>
<script defer src="/lib/katex/contrib/copy-tex.min.js"></script>
<script
defer
src="/lib/google-code-prettify/run_prettify.js"
></script>
<script>
addEventListener('DOMContentLoaded', function (e) {
const mathElements = document.getElementsByClassName('math')
const macros = {}
for (let element of mathElements) {
katex.render(element.textContent, element, {
displayMode: element.classList.contains('math-display'),
throwOnError: false,
globalGroup: true,
macros
})
}
})
</script>
<ul class="task-constraints">
<li><b>Time limit:</b> 1.00 s</li>
<li><b>Memory limit:</b> 512 MB</li>
</ul>
<div class="md">
<p>
Consider an algorithm that takes as input a positive integer
<span class="math math-inline">n</span>. If
<span class="math math-inline">n</span> is even, the algorithm
divides it by two, and if
<span class="math math-inline">n</span> is odd, the algorithm
multiplies it by three and adds one. The algorithm repeats this,
until <span class="math math-inline">n</span> is one. For example,
the sequence for <span class="math math-inline">n=3</span> is as
follows:
<span class="math math-display">
3 \rightarrow 10 \rightarrow 5 \rightarrow 16 \rightarrow 8
\rightarrow 4 \rightarrow 2 \rightarrow 1</span
>
Your task is to simulate the execution of the algorithm for a
given value of <span class="math math-inline">n</span>.
</p>
<h1 id="input">Input</h1>
<p>
The only input line contains an integer
<span class="math math-inline">n</span>.
</p>
<h1 id="output">Output</h1>
<p>
Print a line that contains all values of
<span class="math math-inline">n</span> during the algorithm.
</p>
<h1 id="constraints">Constraints</h1>
<ul>
<li><span class="math math-inline">1 \le n \le 10^6</span></li>
</ul>
<h1 id="example">Example</h1>
<p>Input:</p>
<pre>
3
</pre
>
<p>Output:</p>
<pre>
3 10 5 16 8 4 2 1
</pre
>
</div>
</div>
<div class="nav sidebar">
<h4>Introductory Problems</h4>
<a class="current" href="/problemset/task/1068"
>Weird Algorithm<span class="task-score icon"></span></a
><a href="/problemset/task/1083"
>Missing Number<span class="task-score icon"></span></a
><a href="/problemset/task/1069"
>Repetitions<span class="task-score icon"></span></a
><a href="/problemset/task/1094"
>Increasing Array<span class="task-score icon"></span></a
><a href="/problemset/task/1070"
>Permutations<span class="task-score icon"></span></a
><a href="/problemset/task/1071"
>Number Spiral<span class="task-score icon"></span></a
><a href="/problemset/task/1072"
>Two Knights<span class="task-score icon"></span></a
><a href="/problemset/task/1092"
>Two Sets<span class="task-score icon"></span></a
>...
<hr />
</div>
</div>
</div>
</body>
</html>

150
tests/fixtures/cses/task_1621.html vendored Normal file
View file

@ -0,0 +1,150 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet " type="text/css" href="/cses.css?0" id="styles" />
<link
rel="stylesheet alternate"
type="text/css"
href="/cses-dark.css?0"
id="styles-dark"
/>
<meta name="theme-color" content="white" id="theme-color" />
<script type="application/json" id="darkmode-enabled">
false
</script>
<script src="/ui.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/lib/fontawesome/css/all.min.css"
/>
</head>
<body class="with-sidebar">
<div class="header">
<div>
<a href="/" class="logo"><img src="/logo.png?1" alt="CSES" /></a>
<a
class="menu-toggle"
onclick="document.body.classList.toggle('menu-open')"
>
<i class="fas fa-bars"></i>
</a>
<div class="controls">
<a class="account" href="/login">Login</a>
<span>&mdash;</span>
<a
href="/darkmode"
title="Toggle dark mode"
onclick="return toggle_theme()"
><i aria-label="Dark mode" class="fas fa-adjust"></i
><span>Dark mode</span></a
>
</div>
</div>
</div>
<div class="skeleton">
<div class="navigation">
<div class="title-block">
<h3><a href="/problemset/list/">CSES Problem Set</a></h3>
<h1>Distinct Numbers</h1>
<ul class="nav">
<li><a href="/problemset/task/1621/" class="current">Task</a></li>
<li><a href="/problemset/stats/1621/">Statistics</a></li>
</ul>
</div>
</div>
<div class="content-wrapper">
<div class="content">
<title>CSES - Distinct Numbers</title
><link rel="stylesheet" href="/lib/katex/katex.min.css" />
<script defer src="/lib/katex/katex.min.js"></script>
<script defer src="/lib/katex/contrib/copy-tex.min.js"></script>
<script
defer
src="/lib/google-code-prettify/run_prettify.js"
></script>
<script>
addEventListener('DOMContentLoaded', function (e) {
const mathElements = document.getElementsByClassName('math')
const macros = {}
for (let element of mathElements) {
katex.render(element.textContent, element, {
displayMode: element.classList.contains('math-display'),
throwOnError: false,
globalGroup: true,
macros
})
}
})
</script>
<ul class="task-constraints">
<li><b>Time limit:</b> 1.00 s</li>
<li><b>Memory limit:</b> 512 MB</li>
</ul>
<div class="md">
<p>
You are given a list of
<span class="math math-inline">n</span> integers, and your task is
to calculate the number of <em>distinct</em> values in the list.
</p>
<h1 id="input">Input</h1>
<p>
The first input line has an integer
<span class="math math-inline">n</span>: the number of values.
</p>
<p>
The second line has
<span class="math math-inline">n</span> integers
<span class="math math-inline">x_1,x_2,\dots,x_n</span>.
</p>
<h1 id="output">Output</h1>
<p>Print one integers: the number of distinct values.</p>
<h1 id="constraints">Constraints</h1>
<ul>
<li>
<span class="math math-inline">1 \le n \le 2 \cdot 10^5</span>
</li>
<li><span class="math math-inline">1 \le x_i \le 10^9</span></li>
</ul>
<h1 id="example">Example</h1>
<p>Input:</p>
<pre>
5
2 3 2 2 3
</pre
>
<p>Output:</p>
<pre>
2
</pre
>
</div>
</div>
<div class="nav sidebar">
<h4>Sorting and Searching</h4>
<a class="current" href="/problemset/task/1621"
>Distinct Numbers<span class="task-score icon"></span></a
><a href="/problemset/task/1084"
>Apartments<span class="task-score icon"></span></a
><a href="/problemset/task/1090"
>Ferris Wheel<span class="task-score icon"></span></a
><a href="/problemset/task/1091"
>Concert Tickets<span class="task-score icon"></span></a
><a href="/problemset/task/1619"
>Restaurant Customers<span class="task-score icon"></span></a
><a href="/problemset/task/1629"
>Movie Festival<span class="task-score icon"></span></a
><a href="/problemset/task/1640"
>Sum of Two Values<span class="task-score icon"></span></a
><a href="/problemset/task/1643"
>Maximum Subarray Sum<span class="task-score icon"></span></a
>...
<hr />
</div>
</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show more