Compare commits

...

258 commits
v0.4.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
75 changed files with 42854 additions and 2756 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:
@ -90,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
@ -104,7 +104,7 @@ jobs:
- name: Install ruff
run: uv tool install ruff
- name: Lint Python files with ruff
run: ruff check scripts/ scrapers/ tests/scrapers/
run: ruff check .
python-typecheck:
name: Python Type Check
@ -115,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 scripts/ scrapers/ tests/scrapers/
- name: Type check Python files with ty
run: uvx ty check .
markdown-format:
name: Markdown Format Check
@ -138,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:
@ -31,25 +31,10 @@ jobs:
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
@ -59,9 +44,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies with pytest
- name: Install dependencies
run: uv sync --dev
- name: Fetch camoufox data
run: uv run camoufox fetch
- name: Run Python tests
run: uv run pytest tests/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

@ -2,22 +2,38 @@
**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.
https://github.com/user-attachments/assets/50b19481-8e6d-47b4-bebc-15e16c61a9c9
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**: 256 color ANSI support for compiler errors and program output
- **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
- GNU [time](https://www.gnu.org/software/time/) and [timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html)
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
@ -25,20 +41,20 @@ 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>`
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
@ -46,7 +62,14 @@ cp.nvim follows a simple principle: **solve locally, submit remotely**.
: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
@ -54,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

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

File diff suppressed because it is too large Load diff

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

@ -9,18 +9,25 @@
---@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 TestCase
@ -91,14 +98,36 @@ function M.get_contest_data(platform, contest_id)
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 {}
end
local contest_ids = {}
for contest_id, _ in pairs(cache_data[platform]) do
table.insert(contest_ids, contest_id)
end
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' },
})
cache_data[platform] = cache_data[platform] or {}
@ -109,6 +138,7 @@ function M.set_contest_data(platform, contest_id, problems)
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
@ -156,38 +186,64 @@ function M.get_test_cases(platform, contest_id, 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
---@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
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 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
M.save()
end

View file

@ -39,7 +39,21 @@ function M.handle_cache_command(cmd)
vim.api.nvim_set_current_buf(buf)
elseif cmd.subcommand == 'clear' then
cache.load()
if cmd.platform then
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(

View file

@ -16,6 +16,12 @@ local actions = constants.ACTIONS
---@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
@ -37,10 +43,12 @@ local function parse_command(args)
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 }
@ -52,8 +60,149 @@ local function parse_command(args)
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
return { type = 'action', action = first }
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
@ -69,13 +218,18 @@ local function parse_command(args)
platform = first,
contest = args[2],
}
elseif #args == 3 then
elseif #args == 4 and args[3] == '--lang' then
return {
type = 'error',
message = 'Setup contests with :CP <platform> <contest_id>.',
type = 'contest_setup',
platform = first,
contest = args[2],
language = args[4],
}
else
return { type = 'error', message = 'Too many arguments' }
return {
type = 'error',
message = 'Invalid arguments. Usage: :CP <platform> <contest> [--lang <language>]',
}
end
end
@ -84,6 +238,12 @@ local function parse_command(args)
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.' }
@ -104,21 +264,27 @@ function M.handle_command(opts)
restore.restore_from_current_file()
elseif cmd.type == 'action' then
local setup = require('cp.setup')
local ui = require('cp.ui.panel')
local ui = require('cp.ui.views')
if cmd.action == 'interact' then
ui.toggle_interactive(cmd.interactor_cmd)
elseif cmd.action == 'run' then
ui.toggle_run_panel()
elseif cmd.action == 'debug' then
ui.toggle_run_panel({ debug = true })
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)
setup.navigate_problem(1, cmd.language)
elseif cmd.action == 'prev' then
setup.navigate_problem(-1)
setup.navigate_problem(-1, cmd.language)
elseif cmd.action == 'pick' then
local picker = require('cp.commands.picker')
picker.handle_pick_action()
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()
@ -147,15 +313,13 @@ function M.handle_command(opts)
end
local setup = require('cp.setup')
setup.setup_contest(platform, contest_id, problem_id)
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')
if setup.set_platform(cmd.platform) then
setup.setup_contest(cmd.platform, cmd.contest, nil)
end
setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language)
return
end
end

View file

@ -4,8 +4,9 @@ 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()
function M.handle_pick_action(language)
local config = config_module.get_config()
if not (config.ui and config.ui.picker) then
@ -39,7 +40,7 @@ function M.handle_pick_action()
local ok, _ = pcall(require, 'fzf-lua')
if not ok then
logger.log(
'fzf-lua is not available. Install fzf-lua xor change your picker config',
'fzf-lua is not available. Install fzf-lua or change your picker config',
vim.log.levels.ERROR
)
return
@ -53,7 +54,7 @@ function M.handle_pick_action()
picker = fzf_picker
end
picker.pick()
picker.pick(language)
end
return M

View file

@ -17,9 +17,8 @@
---@field default_language string
---@field overrides? table<string, CpPlatformOverrides>
---@class RunPanelConfig
---@field ansi boolean
---@field diff_mode "none"|"vim"|"git"
---@class PanelConfig
---@field diff_modes string[]
---@field max_output_lines integer
---@class DiffGitConfig
@ -32,9 +31,52 @@
---@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 run_panel RunPanelConfig
---@field ansi boolean
---@field run RunConfig
---@field edit EditConfig
---@field panel PanelConfig
---@field diff DiffConfig
---@field picker string|nil
@ -43,6 +85,7 @@
---@field platforms table<string, CpPlatform>
---@field hooks Hooks
---@field debug 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 ui CpUI
@ -53,11 +96,13 @@
local M = {}
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 = {
open_url = false,
languages = {
cpp = {
extension = 'cc',
@ -94,17 +139,41 @@ M.defaults = {
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 },
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,
ui = {
run_panel = { ansi = true, diff_mode = 'none', max_output_lines = 50 },
ansi = true,
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' },
@ -145,6 +214,11 @@ local function validate_language(id, lang)
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
@ -218,36 +292,146 @@ end
---@return cp.Config
function M.setup(user_config)
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } })
local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {})
local defaults = vim.deepcopy(M.defaults)
if user_config and user_config.platforms then
for plat in pairs(defaults.platforms) do
if not user_config.platforms[plat] then
defaults.platforms[plat] = nil
end
end
end
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
if not next(cfg.languages) then
error('[cp.nvim] At least one language must be configured')
end
if not next(cfg.platforms) then
error('[cp.nvim] At least one platform must be configured')
end
vim.validate({
hooks = { cfg.hooks, { 'table' } },
ui = { cfg.ui, { 'table' } },
})
vim.validate({
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 = { cfg.ui.run_panel.ansi, 'boolean' },
diff_mode = {
cfg.ui.run_panel.diff_mode,
ansi = { cfg.ui.ansi, 'boolean' },
diff_modes = {
cfg.ui.panel.diff_modes,
function(v)
return vim.tbl_contains({ 'none', 'vim', 'git' }, v)
if type(v) ~= 'table' then
return false
end
for _, mode in ipairs(v) do
if not layouts.DIFF_MODES[mode] then
return false
end
end
return true
end,
"diff_mode must be 'none', 'vim', or 'git'",
('one of {%s}'):format(table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',')),
},
max_output_lines = {
cfg.ui.run_panel.max_output_lines,
cfg.ui.panel.max_output_lines,
function(v)
return type(v) == 'number' and v > 0 and v == math.floor(v)
end,
'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'",
},
})
for id, lang in pairs(cfg.languages) do
@ -274,6 +458,66 @@ 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

View file

@ -1,10 +1,11 @@
local M = {}
M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' }
M.ACTIONS = { 'run', 'next', 'prev', 'pick', 'cache', 'interact' }
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',
}

View file

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

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

View file

@ -1,35 +1,54 @@
local M = {}
local config_module = require('cp.config')
local helpers = require('cp.helpers')
local logger = require('cp.log')
M.helpers = helpers
if vim.fn.has('nvim-0.10.0') == 0 then
logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR)
return {}
end
local user_config = {}
local config = nil
local initialized = false
--- Root handler for all `:CP ...` commands
---@return nil
function M.handle_command(opts)
local commands = require('cp.commands')
commands.handle_command(opts)
local function ensure_initialized()
if initialized then
return true
end
local user_config = vim.g.cp or {}
local ok, result = pcall(config_module.setup, user_config)
if not ok then
local msg = tostring(result):gsub('^.+:%d+: ', '')
vim.notify(msg, vim.log.levels.ERROR)
return false
end
config_module.set_current_config(result)
initialized = true
return true
end
function M.setup(opts)
opts = opts or {}
user_config = opts
config = config_module.setup(user_config)
config_module.set_current_config(config)
initialized = true
---@return nil
function M.handle_command(opts)
if not ensure_initialized() then
return
end
local commands = require('cp.commands')
commands.handle_command(opts)
end
function M.is_initialized()
return initialized
end
---@deprecated Use `vim.g.cp` instead
function M.setup(user_config)
vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false)
if user_config then
vim.g.cp = vim.tbl_deep_extend('force', vim.g.cp or {}, user_config)
end
end
return M

View file

@ -2,7 +2,7 @@ local picker_utils = require('cp.pickers')
local M = {}
local function contest_picker(platform, refresh)
local function contest_picker(platform, refresh, language)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
local fzf = require('fzf-lua')
@ -42,19 +42,22 @@ local function contest_picker(platform, refresh)
if contest then
local cp = require('cp')
cp.handle_command({ fargs = { platform, contest.id } })
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, true)
contest_picker(platform, true, language)
end,
},
})
end
function M.pick()
function M.pick(language)
local fzf = require('fzf-lua')
local platforms = picker_utils.get_platforms()
local entries = vim.tbl_map(function(platform)
@ -79,7 +82,7 @@ function M.pick()
end
if platform then
contest_picker(platform.id)
contest_picker(platform.id, false, language)
end
end,
},

View file

@ -8,7 +8,7 @@ local picker_utils = require('cp.pickers')
local M = {}
local function contest_picker(opts, platform, refresh)
local function contest_picker(opts, platform, refresh, language)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
local contests = picker_utils.get_platform_contests(platform, refresh)
@ -43,13 +43,18 @@ local function contest_picker(opts, platform, refresh)
if selection then
local cp = require('cp')
cp.handle_command({ fargs = { platform, selection.value.id } })
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()
actions.close(prompt_bufnr)
contest_picker(opts, platform, true)
contest_picker(opts, platform, true, language)
end)
return true
@ -58,9 +63,8 @@ local function contest_picker(opts, platform, refresh)
:find()
end
function M.pick(opts)
opts = opts or {}
function M.pick(language)
local opts = {}
local platforms = picker_utils.get_platforms()
pickers
@ -83,7 +87,7 @@ function M.pick(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

View file

@ -16,8 +16,6 @@ function M.restore_from_current_file()
end
local setup = require('cp.setup')
setup.set_platform(file_state.platform)
state.set_contest_id(file_state.contest_id)
state.set_problem_id(file_state.problem_id)
setup.setup_contest(
file_state.platform,

View file

@ -39,24 +39,28 @@ end
---@param compile_cmd string[]
---@param substitutions SubstitutableCommand
function M.compile(compile_cmd, substitutions)
---@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)
local t0 = vim.uv.hrtime()
local r = vim.system({ 'sh', '-c', sh }, { text = false }):wait()
local dt = (vim.uv.hrtime() - t0) / 1e6
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 ansi = require('cp.ui.ansi')
r.stdout = ansi.bytes_to_string(r.stdout or '')
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
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
return r
vim.schedule(function()
on_complete(r)
end)
end)
end
local function parse_and_strip_time_v(output)
@ -73,13 +77,19 @@ local function parse_and_strip_time_v(output)
return s, 0
end
local k = last_i - 1
while k >= 1 do
local ch = s:sub(k, k)
if ch ~= ' ' and ch ~= '\t' then
break
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
k = k - 1
end
local head = s:sub(1, k)
@ -97,7 +107,8 @@ local function parse_and_strip_time_v(output)
return head, peak_mb
end
function M.run(cmd, stdin, timeout_ms, memory_mb)
---@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()
@ -109,76 +120,94 @@ function M.run(cmd, stdin, timeout_ms, memory_mb)
local sec = math.ceil(timeout_ms / 1000)
local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec)
local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog)
logger.log('run: ' .. sh)
local t0 = vim.uv.hrtime()
local r = vim
.system({ 'sh', '-c', sh }, {
stdin = stdin,
text = true,
})
:wait()
local dt = (vim.uv.hrtime() - t0) / 1e6
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 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
local signal = nil
if code >= 128 then
signal = constants.signal_codes[code]
end
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 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 and not tled)
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
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
return {
stdout = cleaned,
code = code,
time_ms = dt,
tled = tled,
mled = mled,
peak_mb = peak_mb,
signal = signal,
}
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.compile_problem()
---@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() or ''
local language = config.platforms[platform].default_language
local platform = state.get_platform()
local language = state.get_language() or config.platforms[platform].default_language
local eff = config.runtime.effective[platform][language]
local compile_config = eff and eff.commands and eff.commands.build
local source_file = state.get_source_file()
if source_file then
local buf = vim.fn.bufnr(source_file)
if buf ~= -1 and vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].modified then
vim.api.nvim_buf_call(buf, function()
vim.cmd.write({ mods = { silent = true, noautocmd = true } })
end)
end
end
local compile_config = (debug and eff.commands.debug) or eff.commands.build
if not compile_config then
return { success = true, output = nil }
on_complete({ success = true, output = nil })
return
end
local substitutions = { source = state.get_source_file(), binary = state.get_binary_file() }
local r = M.compile(compile_config, substitutions)
require('cp.utils').ensure_dirs()
if r.code ~= 0 then
return { success = false, output = r.stdout or 'unknown error' }
end
return { success = true, output = nil }
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)
end
return M

View file

@ -20,7 +20,7 @@
---@field timeout_ms number
---@field memory_mb number
---@class RunPanelState
---@class PanelState
---@field test_cases RanTestCase[]
---@field current_index number
---@field buffer number?
@ -37,8 +37,8 @@ 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,
@ -100,81 +100,82 @@ local function build_command(cmd, substitutions)
end
---@param test_case RanTestCase
---@return { 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)
---@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 binary_file = state.get_binary_file()
local binary_file = debug and state.get_debug_file() or state.get_binary_file()
local substitutions = { source = source_file, binary = binary_file }
local platform_config = config.platforms[state.get_platform() or '']
local language = platform_config.default_language
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 = (run_panel_state.constraints and run_panel_state.constraints.timeout_ms) or 0
local memory_mb = run_panel_state.constraints and run_panel_state.constraints.memory_mb or 0
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
local r = execute.run(cmd, stdin_content, timeout_ms, memory_mb)
execute.run(cmd, stdin_content, timeout_ms, memory_mb, function(r)
local ansi = require('cp.ui.ansi')
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
local ansi = require('cp.ui.ansi')
local out = r.stdout or ''
local highlights = {}
if out ~= '' then
if config.ui.run_panel.ansi then
local parsed = ansi.parse_ansi_text(out)
out = table.concat(parsed.lines, '\n')
highlights = parsed.highlights
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 expected = test_case.expected or ''
local ok = normalize_lines(out) == normalize_lines(expected)
local signal = r.signal
if not signal and r.code and r.code >= 128 then
signal = constants.signal_codes[r.code]
end
local status
if r.tled then
status = 'tle'
elseif r.mled then
status = 'mle'
elseif ok then
status = 'pass'
else
out = out:gsub('\027%[[%d;]*[a-zA-Z]', '')
status = 'fail'
end
end
local max_lines = config.ui.run_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 expected = test_case.expected or ''
local ok = normalize_lines(out) == normalize_lines(expected)
local signal = r.signal
if not signal and r.code and r.code >= 128 then
signal = constants.signal_codes[r.code]
end
local status
if r.tled then
status = 'tle'
elseif r.mled then
status = 'mle'
elseif ok then
status = 'pass'
else
status = 'fail'
end
return {
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,
}
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
@ -185,9 +186,9 @@ function M.load_test_cases()
state.get_problem_id()
)
run_panel_state.test_cases = create_sentinal_panel_data(tcs)
run_panel_state.current_index = 1
run_panel_state.constraints = load_constraints_from_cache(
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()
@ -197,46 +198,109 @@ function M.load_test_cases()
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 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 index number
---@return boolean
function M.run_test_case(index)
local tc = run_panel_state.test_cases[index]
---@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
return false
on_complete(false)
return
end
tc.status = 'running'
local r = run_single_test_case(tc)
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
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
return true
on_complete(true)
end)
end
---@return RanTestCase[]
function M.run_all_test_cases()
local results = {}
for i = 1, #run_panel_state.test_cases do
M.run_test_case(i)
results[i] = 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
---@param output string|nil
@ -247,7 +311,7 @@ function M.handle_compilation_failure(output)
local txt
local hl = {}
if config.ui.run_panel.ansi then
if config.ui.ansi then
local p = ansi.parse_ansi_text(output or '')
txt = table.concat(p.lines, '\n')
hl = p.highlights
@ -255,7 +319,7 @@ function M.handle_compilation_failure(output)
txt = (output or ''):gsub('\027%[[%d;]*[a-zA-Z]', '')
end
for _, tc in ipairs(run_panel_state.test_cases) do
for _, tc in ipairs(panel_state.test_cases) do
tc.status = 'fail'
tc.actual = txt
tc.actual_highlights = hl

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',
@ -26,6 +30,12 @@ local exit_code_names = {
---@param ran_test_case RanTestCase
---@return StatusInfo
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
@ -34,7 +44,7 @@ function M.get_status_info(ran_test_case)
return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif ran_test_case.mled then
return { text = 'MLE', highlight_group = 'CpTestMLE' }
elseif ran_test_case.code > 0 and ran_test_case.code >= 128 then
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' }
@ -63,24 +73,24 @@ local function compute_cols(test_state)
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.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, #(' ' .. rss_str .. ' '))
w.memory = math.max(w.memory, #(' ' .. memory_str .. ' '))
w.exit = math.max(w.exit, #(' ' .. format_exit_code(tc.code) .. ' '))
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.rss = math.max(w.rss, #' RSS (MB) ')
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.rss + w.memory + w.exit
local inner = sum + 6
@ -89,7 +99,7 @@ local function compute_cols(test_state)
end
local function center(text, width)
local pad = width - #text
local pad = width - strwidth(text)
if pad <= 0 then
return text
end
@ -101,7 +111,7 @@ local function format_num_column(prefix, idx, width)
local num_str = tostring(idx)
local content = (#num_str == 1) and (' ' .. prefix .. ' ' .. num_str .. ' ')
or (' ' .. prefix .. num_str .. ' ')
local total_pad = width - #content
local total_pad = width - strwidth(content)
if total_pad <= 0 then
return content
end
@ -275,11 +285,14 @@ local function data_row(c, idx, tc, is_current, test_state)
return line, hi
end
---@param test_state RunPanelState
---@return string[], Highlight[] 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))
@ -289,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)
@ -306,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
@ -327,7 +345,7 @@ function M.render_test_list(test_state)
end
end
return lines, highlights
return lines, highlights, current_test_line or 1
end
---@param ran_test_case RanTestCase?
@ -349,14 +367,12 @@ end
---@return table<string, table>
function M.get_highlight_groups()
return {
CpTestAC = { fg = '#10b981' },
CpTestWA = { fg = '#ef4444' },
CpTestTLE = { fg = '#f59e0b' },
CpTestMLE = { fg = '#f59e0b' },
CpTestRTE = { fg = '#8b5cf6' },
CpTestNA = { 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,6 +1,6 @@
local M = {}
local constants = require('cp.log')
local constants = require('cp.constants')
local logger = require('cp.log')
local utils = require('cp.utils')
@ -25,10 +25,27 @@ end
---@param args string[]
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) }
local function run_scraper(platform, subcommand, args, opts)
if not utils.setup_python_env() then
local msg = 'no Python environment available (install uv or nix)'
logger.log(msg, vim.log.levels.ERROR)
if opts and opts.on_exit then
opts.on_exit({ success = false, error = msg })
end
return { success = false, error = msg }
end
local plugin_path = utils.get_plugin_path()
local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand }
local cmd = utils.get_python_cmd(platform, plugin_path)
vim.list_extend(cmd, { subcommand })
vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
env.CONDA_PREFIX = ''
if opts and opts.ndjson then
local uv = vim.loop
local stdout = uv.new_pipe(false)
@ -36,31 +53,32 @@ local function run_scraper(platform, subcommand, args, opts)
local buf = ''
local handle
handle = uv.spawn(
cmd[1],
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr } },
function(code, signal)
if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if ok_tail then
opts.on_event(ev_tail)
end
buf = ''
end
if opts.on_exit then
opts.on_exit({ success = (code == 0), code = code, signal = signal })
end
if not stdout:is_closing() then
stdout:close()
end
if not stderr:is_closing() then
stderr:close()
end
if handle and not handle:is_closing() then
handle:close()
handle = uv.spawn(cmd[1], {
args = vim.list_slice(cmd, 2),
stdio = { nil, stdout, stderr },
env = 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)
@ -97,7 +115,7 @@ local function run_scraper(platform, subcommand, args, opts)
return
end
local sysopts = { text = true, timeout = 30000 }
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)
@ -168,7 +186,11 @@ function M.scrape_all_tests(platform, contest_id, callback)
end
if ev.error and ev.problem_id then
logger.log(
("Failed to load tests for problem '%s': %s"):format(contest_id, ev.problem_id, ev.error),
("Failed to load tests for problem '%s' in contest '%s': %s"):format(
ev.problem_id,
contest_id,
ev.error
),
vim.log.levels.WARN
)
return
@ -177,7 +199,7 @@ function M.scrape_all_tests(platform, contest_id, callback)
return
end
vim.schedule(function()
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
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
@ -185,15 +207,17 @@ function M.scrape_all_tests(platform, contest_id, callback)
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', { trimempty = true }), input_file)
vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file)
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

View file

@ -2,20 +2,50 @@ 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')
local constants = require('cp.constants')
local platforms = constants.PLATFORMS
function M.set_platform(platform)
if not vim.tbl_contains(platforms, platform) then
logger.log(("Unknown platform '%s'"):format(platform), vim.log.levels.ERROR)
return false
---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
state.set_platform(platform)
return true
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
@ -33,59 +63,146 @@ end
---@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 problem_id string|nil
---@param language? string|nil
---@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 = problems[(problem_id and contest_data.index_map[problem_id] or 1)].id
local pid = problem_id and problem_id or problems[1].id
M.setup_problem(pid, language)
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 test cases...'):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,
cached_tests,
ev.timeout_ms or 0,
ev.memory_mb or 0,
ev.interactive
)
logger.log('Test cases loaded.')
end)
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 contest_data or not contest_data.problems then
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, function(result)
local problems = result.problems or {}
cache.set_contest_data(platform, contest_id, problems)
logger.log(('Found %d problems for %s contest %s.'):format(#problems, platform, contest_id))
proceed(cache.get_contest_data(platform, contest_id))
end)
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
@ -97,36 +214,109 @@ end
function M.setup_problem(problem_id, language)
local platform = state.get_platform()
if not platform then
logger.log('No platform set.', vim.log.levels.ERROR)
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)
local config = config_module.get_config()
vim.schedule(function()
vim.cmd.only({ mods = { silent = true } })
local lang = language or config.platforms[platform].default_language
local source_file = state.get_source_file(lang)
vim.cmd.e(source_file)
if config.hooks and config.hooks.setup_code then
config.hooks.setup_code(state)
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)
cache.set_file_state(
vim.fn.expand('%:p'),
platform,
state.get_contest_id() or '',
state.get_problem_id() or '',
lang
)
end)
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
function M.navigate_problem(direction)
---@param direction integer
---@param language? string
function M.navigate_problem(direction, language)
if direction == 0 then
return
end
@ -135,7 +325,6 @@ function M.navigate_problem(direction)
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
@ -143,7 +332,7 @@ function M.navigate_problem(direction)
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if not contest_data or not contest_data.problems then
if not is_metadata_ready(contest_data) then
logger.log(
('No data available for %s contest %s.'):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
@ -161,8 +350,45 @@ function M.navigate_problem(direction)
return
end
require('cp.ui.panel').disable()
M.setup_contest(platform, contest_id, problems[new_index].id)
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,3 +1,17 @@
---@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)
@ -5,50 +19,80 @@
---@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(): 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
@ -65,10 +109,8 @@ function M.get_base_name()
end
end
function M.get_language()
return
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
@ -81,43 +123,100 @@ function M.get_source_file(language)
if not platform_cfg then
return nil
end
local target_language = language or platform_cfg.default_language
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

@ -321,6 +321,25 @@ 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

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

View file

@ -1,14 +1,23 @@
local M = {}
local helpers = require('cp.helpers')
local utils = require('cp.utils')
local function create_none_diff_layout(parent_win, expected_content, actual_content)
M.DIFF_MODES = {
['side-by-side'] = 'side-by-side',
vim = 'vim',
git = 'git',
}
local function create_side_by_side_layout(parent_win, expected_content, actual_content)
local expected_buf = utils.create_buffer_with_options()
local 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))
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)
@ -18,8 +27,13 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win })
vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win })
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 })
@ -30,6 +44,7 @@ local function create_none_diff_layout(parent_win, expected_content, actual_cont
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)
@ -42,10 +57,12 @@ 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))
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)
@ -55,8 +72,13 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
vim.api.nvim_set_option_value('winbar', 'Expected', { win = expected_win })
vim.api.nvim_set_option_value('winbar', 'Actual', { win = actual_win })
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 })
@ -78,6 +100,7 @@ local function create_vim_diff_layout(parent_win, expected_content, actual_conte
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)
@ -89,15 +112,17 @@ 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))
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 })
vim.api.nvim_set_option_value('winbar', 'Expected vs Actual', { win = diff_win })
local label = M.DIFF_MODES.git
vim.api.nvim_set_option_value('winbar', ('diff: %s'):format(label), { win = diff_win })
local diff_backend = require('cp.ui.diff')
local backend = diff_backend.get_best_backend('git')
@ -115,6 +140,7 @@ local function create_git_diff_layout(parent_win, expected_content, actual_conte
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 })
@ -129,7 +155,7 @@ local function create_single_layout(parent_win, content)
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd('resize ' .. math.floor(vim.o.lines * 0.35))
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 })
@ -137,6 +163,7 @@ local function create_single_layout(parent_win, content)
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 })
@ -147,12 +174,14 @@ 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 == 'none' then
return create_none_diff_layout(parent_win, expected_content, 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)
else
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
@ -164,7 +193,7 @@ function M.update_diff_panes(
config,
setup_keybindings_for_buffer
)
local test_state = run.get_run_panel_state()
local test_state = run.get_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
if not current_test then
@ -185,12 +214,13 @@ function M.update_diff_panes(
actual_content = actual_content
end
local desired_mode = is_compilation_failure and 'single' or config.ui.run_panel.diff_mode
local default_mode = config.ui.panel.diff_modes[1]
local desired_mode = is_compilation_failure and 'single' or (current_mode or default_mode)
local highlight = require('cp.ui.highlight')
local diff_namespace = highlight.create_namespace()
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')
if current_diff_layout and current_mode ~= desired_mode then
if current_diff_layout and current_diff_layout.mode ~= desired_mode then
local saved_pos = vim.api.nvim_win_get_cursor(0)
current_diff_layout.cleanup()
current_diff_layout = nil
@ -245,7 +275,7 @@ function M.update_diff_panes(
ansi_namespace
)
end
elseif desired_mode == 'none' then
elseif desired_mode == 'side-by-side' then
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})

View file

@ -1,387 +0,0 @@
local M = {}
---@class RunOpts
---@field debug? boolean
local config_module = require('cp.config')
local constants = require('cp.constants')
local layouts = require('cp.ui.layouts')
local logger = require('cp.log')
local state = require('cp.state')
local utils = require('cp.utils')
local current_diff_layout = nil
local current_mode = nil
function M.disable()
local active_panel = state.get_active_panel()
if not active_panel then
logger.log('No active panel to close')
return
end
if active_panel == 'run' then
M.toggle_run_panel()
elseif active_panel == 'interactive' then
M.toggle_interactive()
else
logger.log(('Unknown panel type: %s'):format(tostring(active_panel)))
end
end
---@param interactor_cmd? string
function M.toggle_interactive(interactor_cmd)
if state.get_active_panel() == 'interactive' then
if state.interactive_buf and vim.api.nvim_buf_is_valid(state.interactive_buf) then
local job = vim.b[state.interactive_buf].terminal_job_id
if job then
vim.fn.jobstop(job)
end
end
if state.saved_interactive_session then
vim.cmd(('source %s'):format(state.saved_interactive_session))
vim.fn.delete(state.saved_interactive_session)
state.saved_interactive_session = nil
end
state.set_active_panel(nil)
return
end
if state.get_active_panel() then
logger.log('Another panel is already active.', vim.log.levels.WARN)
return
end
local platform, contest_id = state.get_platform(), state.get_contest_id()
if not platform then
logger.log('No platform configured.', vim.log.levels.ERROR)
return
end
if not contest_id then
logger.log(
("No contest %s configured for platform '%s'."):format(
contest_id,
constants.PLATFORM_DISPLAY_NAMES[platform]
),
vim.log.levels.ERROR
)
return
end
local problem_id = state.get_problem_id()
if not problem_id then
logger.log('No problem is 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
or not contest_data.index_map
or not contest_data.problems[contest_data.index_map[problem_id]]
or not contest_data.problems[contest_data.index_map[problem_id]].interactive
then
logger.log('This problem is not interactive. Use :CP run.', vim.log.levels.ERROR)
return
end
state.saved_interactive_session = vim.fn.tempname()
vim.cmd(('mksession! %s'):format(state.saved_interactive_session))
vim.cmd('silent only')
local execute = require('cp.runner.execute')
local compile_result = execute.compile_problem()
if not compile_result.success then
require('cp.runner.run').handle_compilation_failure(compile_result.output)
return
end
local binary = state.get_binary_file()
if not binary or binary == '' then
logger.log('No binary produced.', vim.log.levels.ERROR)
return
end
local cmdline
if interactor_cmd and interactor_cmd ~= '' then
local interactor = interactor_cmd
if not interactor:find('/') then
interactor = './' .. interactor
end
if vim.fn.executable(interactor) ~= 1 then
logger.log(
("Interactor '%s' is not executable."):format(interactor_cmd),
vim.log.levels.ERROR
)
if state.saved_interactive_session then
vim.cmd(('source %s'):format(state.saved_interactive_session))
vim.fn.delete(state.saved_interactive_session)
state.saved_interactive_session = nil
end
return
end
local orchestrator = vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
else
cmdline = vim.fn.shellescape(binary)
end
vim.cmd('terminal ' .. cmdline)
local term_buf = vim.api.nvim_get_current_buf()
local term_win = vim.api.nvim_get_current_win()
local cleaned = false
local function cleanup()
if cleaned then
return
end
cleaned = true
if term_buf and vim.api.nvim_buf_is_valid(term_buf) then
local job = vim.b[term_buf] and vim.b[term_buf].terminal_job_id or nil
if job then
pcall(vim.fn.jobstop, job)
end
end
if state.saved_interactive_session then
vim.cmd(('source %s'):format(state.saved_interactive_session))
vim.fn.delete(state.saved_interactive_session)
state.saved_interactive_session = nil
end
state.interactive_buf = nil
state.interactive_win = nil
state.set_active_panel(nil)
end
vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, {
buffer = term_buf,
callback = function()
cleanup()
end,
})
vim.api.nvim_create_autocmd('WinClosed', {
callback = function()
if cleaned then
return
end
local any = false
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then
any = true
break
end
end
if not any then
cleanup()
end
end,
})
vim.api.nvim_create_autocmd('TermClose', {
buffer = term_buf,
callback = function()
vim.b[term_buf].cp_interactive_exited = true
end,
})
vim.keymap.set('t', '<c-q>', function()
cleanup()
end, { buffer = term_buf, silent = true })
vim.keymap.set('n', '<c-q>', function()
cleanup()
end, { buffer = term_buf, silent = true })
state.interactive_buf = term_buf
state.interactive_win = term_win
state.set_active_panel('interactive')
end
---@param run_opts? RunOpts
function M.toggle_run_panel(run_opts)
if state.get_active_panel() == 'run' then
if current_diff_layout then
current_diff_layout.cleanup()
current_diff_layout = nil
current_mode = nil
end
if state.saved_session then
vim.cmd(('source %s'):format(state.saved_session))
vim.fn.delete(state.saved_session)
state.saved_session = nil
end
state.set_active_panel(nil)
return
end
if state.get_active_panel() then
logger.log('another panel is already active', vim.log.levels.ERROR)
return
end
local platform, contest_id = state.get_platform(), state.get_contest_id()
if not platform then
logger.log(
'No platform configured. Use :CP <platform> <contest> [...] first.',
vim.log.levels.ERROR
)
return
end
if not contest_id then
logger.log(
("No contest '%s' configured for platform '%s'."):format(
contest_id,
constants.PLATFORM_DISPLAY_NAMES[platform]
),
vim.log.levels.ERROR
)
return
end
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if
contest_data
and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive
then
logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN)
return
end
local config = config_module.get_config()
local run = require('cp.runner.run')
local input_file = state.get_input_file()
logger.log(('run panel: checking test cases for %s'):format(input_file or 'none'))
if not run.load_test_cases() then
logger.log('no test cases found', vim.log.levels.WARN)
return
end
state.saved_session = vim.fn.tempname()
vim.cmd(('mksession! %s'):format(state.saved_session))
vim.cmd('silent only')
local tab_buf = utils.create_buffer_with_options()
local main_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(main_win, tab_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = tab_buf })
local test_windows = { tab_win = main_win }
local test_buffers = { tab_buf = tab_buf }
local test_list_namespace = vim.api.nvim_create_namespace('cp_test_list')
local setup_keybindings_for_buffer
local function update_diff_panes()
current_diff_layout, current_mode = layouts.update_diff_panes(
current_diff_layout,
current_mode,
main_win,
run,
config,
setup_keybindings_for_buffer
)
end
local function refresh_run_panel()
if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
return
end
local run_render = require('cp.runner.run_render')
run_render.setup_highlights()
local test_state = run.get_run_panel_state()
local tab_lines, tab_highlights = run_render.render_test_list(test_state)
utils.update_buffer_content(
test_buffers.tab_buf,
tab_lines,
tab_highlights,
test_list_namespace
)
update_diff_panes()
end
local function navigate_test_case(delta)
local test_state = run.get_run_panel_state()
if vim.tbl_isempty(test_state.test_cases) then
return
end
test_state.current_index = (test_state.current_index + delta - 1) % #test_state.test_cases + 1
refresh_run_panel()
end
setup_keybindings_for_buffer = function(buf)
vim.keymap.set('n', 'q', function()
M.toggle_run_panel()
end, { buffer = buf, silent = true })
vim.keymap.set('n', 't', function()
local modes = { 'none', 'git', 'vim' }
local current_idx = 1
for i, mode in ipairs(modes) do
if config.ui.run_panel.diff_mode == mode then
current_idx = i
break
end
end
config.ui.run_panel.diff_mode = modes[(current_idx % #modes) + 1]
refresh_run_panel()
end, { buffer = buf, silent = true })
vim.keymap.set('n', '<c-n>', function()
navigate_test_case(1)
end, { buffer = buf, silent = true })
vim.keymap.set('n', '<c-p>', function()
navigate_test_case(-1)
end, { buffer = buf, silent = true })
end
setup_keybindings_for_buffer(test_buffers.tab_buf)
if config.hooks and config.hooks.before_run then
vim.schedule_wrap(function()
config.hooks.before_run(state)
end)
end
if run_opts and run_opts.debug and config.hooks and config.hooks.before_debug then
vim.schedule_wrap(function()
config.hooks.before_debug(state)
end)
end
local execute = require('cp.runner.execute')
local compile_result = execute.compile_problem()
if compile_result.success then
run.run_all_test_cases()
else
run.handle_compilation_failure(compile_result.output)
end
refresh_run_panel()
vim.schedule(function()
if config.ui.run_panel.ansi then
local ansi = require('cp.ui.ansi')
ansi.setup_highlight_groups()
end
if current_diff_layout then
update_diff_panes()
end
end)
vim.api.nvim_set_current_win(test_windows.tab_win)
state.test_buffers = test_buffers
state.test_windows = test_windows
state.set_active_panel('run')
logger.log('test panel opened')
end
return M

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -3,8 +3,6 @@ if vim.g.loaded_cp then
end
vim.g.loaded_cp = 1
local utils = require('cp.utils')
vim.api.nvim_create_user_command('CP', function(opts)
local cp = require('cp')
cp.handle_command(opts)
@ -22,12 +20,30 @@ end, {
num_args = num_args + 1
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')
@ -39,46 +55,116 @@ end, {
table.sort(ids)
vim.list_extend(candidates, ids)
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)
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', 'read' })
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 cands = utils.cwd_executables()
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, cands)
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,7 +1,7 @@
[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 = [
@ -10,19 +10,20 @@ dependencies = [
"curl-cffi>=0.13.0",
"httpx>=0.28.1",
"ndjson>=0.3.1",
"pydantic>=2.11.10",
"requests>=2.32.5",
"scrapling[fetchers]>=0.3.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

@ -5,7 +5,6 @@ import json
import re
import sys
import time
from dataclasses import asdict
from typing import Any
import backoff
@ -17,6 +16,7 @@ from urllib3.util.retry import Retry
from .base import BaseScraper
from .models import (
CombinedTest,
ContestListResult,
ContestSummary,
MetadataResult,
@ -231,23 +231,19 @@ def _scrape_problem_page_sync(contest_id: str, slug: str) -> dict[str, Any]:
def _to_problem_summaries(rows: list[dict[str, str]]) -> list[ProblemSummary]:
out: list[ProblemSummary] = []
seen: set[str] = set()
for r in rows:
letter = (r.get("letter") or "").strip().upper()
title = r.get("title") or ""
if not letter:
continue
pid = letter.lower()
if pid in seen:
continue
seen.add(pid)
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)
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)
@ -270,42 +266,31 @@ class AtcoderScraper(BaseScraper):
return "atcoder"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
async def impl(cid: str) -> MetadataResult:
try:
rows = await asyncio.to_thread(_scrape_tasks_sync, cid)
except requests.HTTPError as e:
if e.response is not None and e.response.status_code == 404:
return self._create_metadata_error(
f"No problems found for contest {cid}", cid
)
raise
try:
rows = await asyncio.to_thread(_scrape_tasks_sync, contest_id)
problems = _to_problem_summaries(rows)
if not problems:
return self._create_metadata_error(
f"No problems found for contest {cid}", cid
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
return MetadataResult(
success=True,
error="",
contest_id=cid,
contest_id=contest_id,
problems=problems,
url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s",
)
return await self._safe_execute("metadata", impl, contest_id)
except Exception as e:
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
async def impl() -> ContestListResult:
try:
contests = await _fetch_all_contests_async()
except Exception as e:
return self._create_contests_error(str(e))
try:
contests = await _fetch_all_contests_async()
if not contests:
return self._create_contests_error("No contests found")
return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests)
return await self._safe_execute("contests", impl)
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)
@ -317,16 +302,23 @@ class AtcoderScraper(BaseScraper):
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,
@ -340,8 +332,9 @@ async def main_async() -> int:
result = MetadataResult(
success=False,
error="Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> OR atcoder.py contests",
url="",
)
print(json.dumps(asdict(result)))
print(result.model_dump_json())
return 1
mode: str = sys.argv[1]
@ -350,13 +343,15 @@ async def main_async() -> int:
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False, error="Usage: atcoder.py metadata <contest_id>"
success=False,
error="Usage: atcoder.py metadata <contest_id>",
url="",
)
print(json.dumps(asdict(result)))
print(result.model_dump_json())
return 1
contest_id = sys.argv[2]
result = await scraper.scrape_contest_metadata(contest_id)
print(json.dumps(asdict(result)))
print(result.model_dump_json())
return 0 if result.success else 1
if mode == "tests":
@ -365,12 +360,12 @@ async def main_async() -> int:
success=False,
error="Usage: atcoder.py tests <contest_id>",
problem_id="",
url="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
print(tests_result.model_dump_json())
return 1
contest_id = sys.argv[2]
await scraper.stream_tests_for_category_async(contest_id)
@ -381,17 +376,18 @@ async def main_async() -> int:
contest_result = ContestListResult(
success=False, error="Usage: atcoder.py contests"
)
print(json.dumps(asdict(contest_result)))
print(contest_result.model_dump_json())
return 1
contest_result = await scraper.scrape_contest_list()
print(json.dumps(asdict(contest_result)))
print(contest_result.model_dump_json())
return 0 if contest_result.success else 1
result = MetadataResult(
success=False,
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
url="",
)
print(json.dumps(asdict(result)))
print(result.model_dump_json())
return 1

View file

@ -1,20 +1,8 @@
from __future__ import annotations
import asyncio
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, ParamSpec, cast
from .models import ContestListResult, MetadataResult, TestsResult
P = ParamSpec("P")
@dataclass
class ScraperConfig:
timeout_seconds: int = 30
max_retries: int = 3
backoff_base: float = 2.0
rate_limit_delay: float = 1.0
from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
class BaseScraper(ABC):
@ -31,50 +19,65 @@ class BaseScraper(ABC):
@abstractmethod
async def stream_tests_for_category_async(self, category_id: str) -> None: ...
def _create_metadata_error(
self, error_msg: str, contest_id: str = ""
) -> MetadataResult:
return MetadataResult(
success=False,
error=f"{self.platform_name}: {error_msg}",
contest_id=contest_id,
)
def _usage(self) -> str:
name = self.platform_name
return f"Usage: {name}.py metadata <id> | tests <id> | contests"
def _create_tests_error(
self, error_msg: str, problem_id: str = "", url: str = ""
) -> TestsResult:
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=f"{self.platform_name}: {error_msg}",
problem_id=problem_id,
url=url,
error=msg,
problem_id="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
def _create_contests_error(self, error_msg: str) -> ContestListResult:
return ContestListResult(
success=False, error=f"{self.platform_name}: {error_msg}"
)
def _contests_error(self, msg: str) -> ContestListResult:
return ContestListResult(success=False, error=msg)
async def _safe_execute(
self,
operation: str,
func: Callable[P, Awaitable[Any]],
*args: P.args,
**kwargs: P.kwargs,
):
try:
return await func(*args, **kwargs)
except Exception as e:
if operation == "metadata":
contest_id = cast(str, args[0]) if args else ""
return self._create_metadata_error(str(e), contest_id)
elif operation == "tests":
problem_id = cast(str, args[1]) if len(args) > 1 else ""
return self._create_tests_error(str(e), problem_id)
elif operation == "contests":
return self._create_contests_error(str(e))
else:
raise
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

@ -2,15 +2,12 @@
import asyncio
import json
import logging
import re
import sys
from dataclasses import asdict
from typing import Any
import requests
from bs4 import BeautifulSoup, Tag
from scrapling.fetchers import StealthyFetcher
from curl_cffi import requests as curl_requests
from .base import BaseScraper
from .models import (
@ -19,13 +16,8 @@ from .models import (
MetadataResult,
ProblemSummary,
TestCase,
TestsResult,
)
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
BASE_URL = "https://codeforces.com"
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
TIMEOUT_SECONDS = 30
@ -63,8 +55,6 @@ def _extract_limits(block: Tag) -> tuple[int, float]:
def _group_lines_by_id(pre: Tag) -> dict[int, list[str]]:
groups: dict[int, list[str]] = {}
if not isinstance(pre, Tag):
return groups
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)
@ -86,19 +76,19 @@ def _extract_title(block: Tag) -> tuple[str, str]:
return parts[0].strip().upper(), parts[1].strip()
def _extract_samples(block: Tag) -> list[TestCase]:
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
st = block.find("div", class_="sample-test")
if not st:
return []
if not isinstance(st, Tag):
return [], False
input_pres: list[Tag] = [ # type: ignore[misc]
inp.find("pre") # type: ignore[misc]
for inp in st.find_all("div", class_="input") # type: ignore[union-attr]
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") # type: ignore[misc]
for out in st.find_all("div", class_="output") # type: ignore[union-attr]
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)]
@ -122,18 +112,19 @@ def _extract_samples(block: Tag) -> list[TestCase]:
outputs_by_gid.pop(0, None)
keys = sorted(set(inputs_by_gid.keys()) & set(outputs_by_gid.keys()))
if keys:
return [
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
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)]
return [TestCase(input=inputs[i], expected=outputs[i]) for i in range(n)], False
def _is_interactive(block: Tag) -> bool:
@ -144,12 +135,9 @@ def _is_interactive(block: Tag) -> bool:
def _fetch_problems_html(contest_id: str) -> str:
url = f"{BASE_URL}/contest/{contest_id}/problems"
page = StealthyFetcher.fetch(
url,
headless=True,
solve_cloudflare=True,
)
return page.html_content
response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS)
response.raise_for_status()
return response.text
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
@ -159,20 +147,38 @@ def _parse_all_blocks(html: str) -> 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] # keep your name extraction
name = _extract_title(b)[1]
if not letter:
continue
tests = _extract_samples(b)
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
)
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
out.append(
{
"letter": letter,
"name": name,
"tests": tests,
"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,
}
)
return out
@ -182,12 +188,8 @@ def _scrape_contest_problems_sync(contest_id: str) -> list[ProblemSummary]:
html = _fetch_problems_html(contest_id)
blocks = _parse_all_blocks(html)
problems: list[ProblemSummary] = []
seen: set[str] = set()
for b in blocks:
pid = b["letter"].upper()
if pid in seen:
continue
seen.add(pid)
problems.append(ProblemSummary(id=pid.lower(), name=b["name"]))
return problems
@ -198,45 +200,46 @@ class CodeforcesScraper(BaseScraper):
return "codeforces"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
async def impl(cid: str) -> MetadataResult:
problems = await asyncio.to_thread(_scrape_contest_problems_sync, cid)
try:
problems = await asyncio.to_thread(
_scrape_contest_problems_sync, contest_id
)
if not problems:
return self._create_metadata_error(
f"No problems found for contest {cid}", cid
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
return MetadataResult(
success=True, error="", contest_id=cid, problems=problems
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"https://codeforces.com/contest/{contest_id}/problem/%s",
)
return await self._safe_execute("metadata", impl, contest_id)
except Exception as e:
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
async def impl() -> 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._create_contests_error("Invalid API response")
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)
)
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._create_contests_error("No contests found")
if not contests:
return self._contests_error("No contests found")
return ContestListResult(success=True, error="", contests=contests)
except Exception as e:
return self._create_contests_error(str(e))
return await self._safe_execute("contests", impl)
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)
@ -249,81 +252,22 @@ class CodeforcesScraper(BaseScraper):
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,
)
async def main_async() -> int:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: codeforces.py metadata <contest_id> OR codeforces.py tests <contest_id> OR codeforces.py contests",
)
print(json.dumps(asdict(result)))
return 1
mode: str = sys.argv[1]
scraper = CodeforcesScraper()
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False, error="Usage: codeforces.py metadata <contest_id>"
)
print(json.dumps(asdict(result)))
return 1
contest_id = sys.argv[2]
result = await scraper.scrape_contest_metadata(contest_id)
print(json.dumps(asdict(result)))
return 0 if result.success else 1
if mode == "tests":
if len(sys.argv) != 3:
tests_result = TestsResult(
success=False,
error="Usage: codeforces.py tests <contest_id>",
problem_id="",
url="",
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
return 1
contest_id = sys.argv[2]
await scraper.stream_tests_for_category_async(contest_id)
return 0
if mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: codeforces.py contests"
)
print(json.dumps(asdict(contest_result)))
return 1
contest_result = await scraper.scrape_contest_list()
print(json.dumps(asdict(contest_result)))
return 0 if contest_result.success else 1
result = MetadataResult(
success=False,
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
)
print(json.dumps(asdict(result)))
return 1
def main() -> None:
sys.exit(asyncio.run(main_async()))
if __name__ == "__main__":
main()
CodeforcesScraper().run_cli()

View file

@ -3,8 +3,6 @@
import asyncio
import json
import re
import sys
from dataclasses import asdict
from typing import Any
import httpx
@ -16,11 +14,10 @@ from .models import (
MetadataResult,
ProblemSummary,
TestCase,
TestsResult,
)
BASE_URL = "https://cses.fi"
INDEX_PATH = "/problemset/list"
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"
@ -73,24 +70,24 @@ async def fetch_text(client: httpx.AsyncClient, path: str) -> str:
CATEGORY_BLOCK_RE = re.compile(
r'<h2>(?P<cat>[^<]+)</h2>\s*<ul class="task-list">(?P<body>.*?)</ul>',
r'<h2>(?P<cat>[^<]+)</h2>\s*<ul\s+class="task-list">(?P<body>.*?)</ul>',
re.DOTALL,
)
TASK_LINK_RE = re.compile(
r'<li class="task"><a href="/problemset/task/(?P<id>\d+)/?">(?P<title>[^<]+)</a>',
r'<li\s+class="task">\s*<a\s+href="/problemset/task/(?P<id>\d+)/?">(?P<title>[^<]+)</a\s*>',
re.DOTALL,
)
TITLE_RE = re.compile(
r'<div class="title-block">.*?<h1>(?P<title>[^<]+)</h1>', re.DOTALL
r'<div\s+class="title-block">.*?<h1>(?P<title>[^<]+)</h1>', re.DOTALL
)
TIME_RE = re.compile(r"<li><b>Time limit:</b>\s*([0-9.]+)\s*s</li>")
MEM_RE = re.compile(r"<li><b>Memory limit:</b>\s*(\d+)\s*MB</li>")
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 class="nav sidebar">.*?<h4>(?P<cat>[^<]+)</h4>', re.DOTALL
r'<div\s+class="nav sidebar">.*?<h4>(?P<cat>[^<]+)</h4>', re.DOTALL
)
MD_BLOCK_RE = re.compile(r'<div class="md">(.*?)</div>', re.DOTALL | re.IGNORECASE)
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,
@ -132,12 +129,17 @@ def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary]
return []
def parse_limits(html: str) -> tuple[int, int]:
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
return t, m
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
def parse_title(html: str) -> str:
@ -189,9 +191,14 @@ class CSESScraper(BaseScraper):
return MetadataResult(
success=False,
error=f"{self.platform_name}: No problems found for category: {contest_id}",
url="",
)
return MetadataResult(
success=True, error="", contest_id=contest_id, problems=problems
success=True,
error="",
contest_id=contest_id,
problems=problems,
url="https://cses.fi/problemset/task/%s",
)
async def scrape_contest_list(self) -> ContestListResult:
@ -220,18 +227,29 @@ class CSESScraper(BaseScraper):
try:
html = await fetch_text(client, task_path(pid))
tests = parse_tests(html)
timeout_ms, memory_mb = parse_limits(html)
timeout_ms, memory_mb, interactive = _extract_problem_info(html)
except Exception:
tests = []
timeout_ms, memory_mb = 0, 0
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": False,
"interactive": interactive,
"multi_test": False,
}
tasks = [run_one(p.id) for p in problems]
@ -240,69 +258,5 @@ class CSESScraper(BaseScraper):
print(json.dumps(payload), flush=True)
async def main_async() -> int:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: cses.py metadata <category_id> OR cses.py tests <category> OR cses.py contests",
)
print(json.dumps(asdict(result)))
return 1
mode: str = sys.argv[1]
scraper = CSESScraper()
if mode == "metadata":
if len(sys.argv) != 3:
result = MetadataResult(
success=False, error="Usage: cses.py metadata <category_id>"
)
print(json.dumps(asdict(result)))
return 1
category_id = sys.argv[2]
result = await scraper.scrape_contest_metadata(category_id)
print(json.dumps(asdict(result)))
return 0 if result.success else 1
if mode == "tests":
if len(sys.argv) != 3:
tests_result = TestsResult(
success=False,
error="Usage: cses.py tests <category>",
problem_id="",
url="",
tests=[],
timeout_ms=0,
memory_mb=0,
)
print(json.dumps(asdict(tests_result)))
return 1
category = sys.argv[2]
await scraper.stream_tests_for_category_async(category)
return 0
if mode == "contests":
if len(sys.argv) != 2:
contest_result = ContestListResult(
success=False, error="Usage: cses.py contests"
)
print(json.dumps(asdict(contest_result)))
return 1
contest_result = await scraper.scrape_contest_list()
print(json.dumps(asdict(contest_result)))
return 0 if contest_result.success else 1
result = MetadataResult(
success=False,
error=f"Unknown mode: {mode}. Use 'metadata <category>', 'tests <category>', or 'contests'",
)
print(json.dumps(asdict(result)))
return 1
def main() -> None:
sys.exit(asyncio.run(main_async()))
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)
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")

View file

@ -12,8 +12,8 @@ async def pump(
data = await reader.readline()
if not data:
break
sys.stdout.buffer.write(data)
sys.stdout.flush()
_ = sys.stdout.buffer.write(data)
_ = sys.stdout.flush()
if writer:
writer.write(data)
await writer.drain()
@ -42,9 +42,9 @@ async def main(interactor_cmd: Sequence[str], interactee_cmd: Sequence[str]) ->
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()
_ = await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
_ = await interactor.wait()
_ = await interactee.wait()
if __name__ == "__main__":
@ -55,4 +55,4 @@ if __name__ == "__main__":
interactor_cmd = shlex.split(sys.argv[1])
interactee_cmd = shlex.split(sys.argv[2])
asyncio.run(main(interactor_cmd, interactee_cmd))
_ = asyncio.run(main(interactor_cmd, interactee_cmd))

View file

@ -1,11 +0,0 @@
describe('run module', function()
local run = require('cp.runner.run')
describe('basic functionality', function()
it('can get panel state', function()
local state = run.get_run_panel_state()
assert.is_table(state)
assert.is_table(state.test_cases)
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>

View file

@ -1,2 +0,0 @@
def test():
assert 5 == 5

89
tests/test_scrapers.py Normal file
View file

@ -0,0 +1,89 @@
import pytest
from scrapers.models import (
ContestListResult,
MetadataResult,
TestsResult,
)
MATRIX = {
"cses": {
"metadata": ("introductory_problems",),
"tests": ("introductory_problems",),
"contests": tuple(),
},
"atcoder": {
"metadata": ("abc100",),
"tests": ("abc100",),
"contests": tuple(),
},
"codeforces": {
"metadata": ("1550",),
"tests": ("1550",),
"contests": tuple(),
},
"codechef": {
"metadata": ("START209D",),
"tests": ("START209D",),
"contests": tuple(),
},
}
@pytest.mark.parametrize("scraper", MATRIX.keys())
@pytest.mark.parametrize("mode", ["metadata", "tests", "contests"])
def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
args = MATRIX[scraper][mode]
rc, objs = run_scraper_offline(scraper, mode, *args)
assert rc in (0, 1), f"Bad exit code {rc}"
assert objs, f"No JSON output for {scraper}:{mode}"
if mode == "metadata":
model = MetadataResult.model_validate(objs[-1])
assert model.success is True
assert model.url
assert len(model.problems) >= 1
assert all(isinstance(p.id, str) and p.id for p in model.problems)
elif mode == "contests":
model = ContestListResult.model_validate(objs[-1])
assert model.success is True
assert len(model.contests) >= 1
else:
assert len(objs) >= 1, "No test objects returned"
validated_any = False
for obj in objs:
if "success" in obj and "tests" in obj and "problem_id" in obj:
tr = TestsResult.model_validate(obj)
assert tr.problem_id != ""
assert isinstance(tr.tests, list)
assert hasattr(tr, "combined"), "Missing combined field"
assert tr.combined is not None, "combined field is None"
assert hasattr(tr.combined, "input"), "combined missing input"
assert hasattr(tr.combined, "expected"), "combined missing expected"
assert isinstance(tr.combined.input, str), "combined.input not string"
assert isinstance(tr.combined.expected, str), (
"combined.expected not string"
)
assert hasattr(tr, "multi_test"), "Missing multi_test field"
assert isinstance(tr.multi_test, bool), "multi_test not boolean"
validated_any = True
else:
assert "problem_id" in obj
assert "tests" in obj and isinstance(obj["tests"], list)
assert (
"timeout_ms" in obj and "memory_mb" in obj and "interactive" in obj
)
assert "combined" in obj, "Missing combined field in raw JSON"
assert isinstance(obj["combined"], dict), "combined not a dict"
assert "input" in obj["combined"], "combined missing input key"
assert "expected" in obj["combined"], "combined missing expected key"
assert isinstance(obj["combined"]["input"], str), (
"combined.input not string"
)
assert isinstance(obj["combined"]["expected"], str), (
"combined.expected not string"
)
assert "multi_test" in obj, "Missing multi_test field in raw JSON"
assert isinstance(obj["multi_test"], bool), "multi_test not boolean"
validated_any = True
assert validated_any, "No valid tests payloads validated"

1608
uv.lock generated

File diff suppressed because it is too large Load diff