Merge pull request #83 from barrett-ruth/feat/picker
Picker Support with `:CP pick`
This commit is contained in:
commit
d4f1678b03
26 changed files with 1301 additions and 454 deletions
3
.github/workflows/quality.yml
vendored
3
.github/workflows/quality.yml
vendored
|
|
@ -27,8 +27,7 @@ jobs:
|
||||||
- 'ftdetect/**'
|
- 'ftdetect/**'
|
||||||
- '*.lua'
|
- '*.lua'
|
||||||
- '.luarc.json'
|
- '.luarc.json'
|
||||||
- 'stylua.toml'
|
- '*.toml'
|
||||||
- 'selene.toml'
|
|
||||||
python:
|
python:
|
||||||
- 'scrapers/**'
|
- 'scrapers/**'
|
||||||
- 'tests/scrapers/**'
|
- 'tests/scrapers/**'
|
||||||
|
|
|
||||||
219
doc/cp.txt
219
doc/cp.txt
|
|
@ -9,8 +9,7 @@ INTRODUCTION *cp* *cp.nvim*
|
||||||
cp.nvim is a competitive programming plugin that automates problem setup,
|
cp.nvim is a competitive programming plugin that automates problem setup,
|
||||||
compilation, and testing workflow for online judges.
|
compilation, and testing workflow for online judges.
|
||||||
|
|
||||||
Supported platforms: AtCoder, Codeforces, CSES
|
Supported platforms (for now!): AtCoder, Codeforces, CSES
|
||||||
Supported languages: C++, Python
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
REQUIREMENTS *cp-requirements*
|
REQUIREMENTS *cp-requirements*
|
||||||
|
|
@ -72,6 +71,9 @@ COMMANDS *cp-commands*
|
||||||
Use --debug flag to compile with debug flags.
|
Use --debug flag to compile with debug flags.
|
||||||
Requires contest setup first.
|
Requires contest setup first.
|
||||||
|
|
||||||
|
:CP pick Launch configured picker for interactive
|
||||||
|
platform/contest/problem selection.
|
||||||
|
|
||||||
Navigation Commands ~
|
Navigation Commands ~
|
||||||
:CP next Navigate to next problem in current contest.
|
:CP next Navigate to next problem in current contest.
|
||||||
Stops at last problem (no wrapping).
|
Stops at last problem (no wrapping).
|
||||||
|
|
@ -131,10 +133,11 @@ Here's an example configuration with lazy.nvim: >lua
|
||||||
default = {
|
default = {
|
||||||
cpp = {
|
cpp = {
|
||||||
compile = { 'g++', '{source}', '-o', '{binary}',
|
compile = { 'g++', '{source}', '-o', '{binary}',
|
||||||
'-std=c++17' },
|
'-std=c++17', '-fdiagnostic-colors=always' },
|
||||||
test = { '{binary}' },
|
test = { '{binary}' },
|
||||||
debug = { 'g++', '{source}', '-o', '{binary}',
|
debug = { 'g++', '{source}', '-o', '{binary}',
|
||||||
'-std=c++17', '-g',
|
'-std=c++17', '-g',
|
||||||
|
'-fdiagnostic-colors=always'
|
||||||
'-fsanitize=address,undefined' },
|
'-fsanitize=address,undefined' },
|
||||||
},
|
},
|
||||||
python = {
|
python = {
|
||||||
|
|
@ -143,14 +146,8 @@ Here's an example configuration with lazy.nvim: >lua
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
snippets = {},
|
snippets = {},
|
||||||
hooks = {
|
|
||||||
before_run = nil,
|
|
||||||
before_debug = nil,
|
|
||||||
setup_code = nil,
|
|
||||||
},
|
|
||||||
debug = false,
|
debug = false,
|
||||||
scrapers = { 'atcoder', 'codeforces', 'cses' },
|
scrapers = { 'atcoder', 'codeforces', 'cses' },
|
||||||
filename = default_filename, -- <contest id> + <problem id>
|
|
||||||
run_panel = {
|
run_panel = {
|
||||||
ansi = true,
|
ansi = true,
|
||||||
diff_mode = 'vim',
|
diff_mode = 'vim',
|
||||||
|
|
@ -165,6 +162,7 @@ Here's an example configuration with lazy.nvim: >lua
|
||||||
'--word-diff-regex=.', '--no-prefix' },
|
'--word-diff-regex=.', '--no-prefix' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
picker = 'telescope', -- 'telescope', 'fzf-lua', or nil (disabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<
|
<
|
||||||
|
|
@ -180,6 +178,9 @@ Here's an example configuration with lazy.nvim: >lua
|
||||||
Default: all scrapers enabled
|
Default: all scrapers enabled
|
||||||
{run_panel} (|RunPanelConfig|) Test panel behavior configuration.
|
{run_panel} (|RunPanelConfig|) Test panel behavior configuration.
|
||||||
{diff} (|DiffConfig|) Diff backend configuration.
|
{diff} (|DiffConfig|) Diff backend configuration.
|
||||||
|
{picker} (string, optional) Picker integration: "telescope",
|
||||||
|
"fzf-lua", or nil to disable. When enabled, provides
|
||||||
|
:CP pick for interactive platform/contest/problem selection.
|
||||||
{filename} (function, optional) Custom filename generation.
|
{filename} (function, optional) Custom filename generation.
|
||||||
function(contest, contest_id, problem_id, config, language)
|
function(contest, contest_id, problem_id, config, language)
|
||||||
Should return full filename with extension.
|
Should return full filename with extension.
|
||||||
|
|
@ -279,21 +280,6 @@ AtCoder ~
|
||||||
*cp-atcoder*
|
*cp-atcoder*
|
||||||
URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a
|
URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a
|
||||||
|
|
||||||
AtCoder contests use consistent naming patterns where contest ID and problem
|
|
||||||
ID are combined to form the task name.
|
|
||||||
|
|
||||||
Platform characteristics:
|
|
||||||
• Contest types: ABC (Beginner), ARC (Regular), AGC (Grand), etc.
|
|
||||||
• Problem naming: Contest ID + problem letter (e.g. "abc324_a")
|
|
||||||
• Multi-test problems: Handled with conditional compilation directives
|
|
||||||
• Template features: Includes fast I/O and common competitive programming
|
|
||||||
headers
|
|
||||||
|
|
||||||
In terms of cp.nvim, this corresponds to:
|
|
||||||
- Platform: atcoder
|
|
||||||
- Contest ID: abc123 (from URL path segment)
|
|
||||||
- Problem ID: a (single letter, extracted from task name)
|
|
||||||
|
|
||||||
Usage examples: >
|
Usage examples: >
|
||||||
:CP atcoder abc324 a " Full setup: problem A from contest ABC324
|
:CP atcoder abc324 a " Full setup: problem A from contest ABC324
|
||||||
:CP atcoder abc324 " Contest setup: load contest metadata only
|
:CP atcoder abc324 " Contest setup: load contest metadata only
|
||||||
|
|
@ -308,21 +294,6 @@ Codeforces ~
|
||||||
*cp-codeforces*
|
*cp-codeforces*
|
||||||
URL format: https://codeforces.com/contest/1234/problem/A
|
URL format: https://codeforces.com/contest/1234/problem/A
|
||||||
|
|
||||||
Codeforces uses numeric contest IDs with letter-based problem identifiers.
|
|
||||||
Educational rounds, gym contests, and regular contests all follow this pattern.
|
|
||||||
|
|
||||||
Platform characteristics:
|
|
||||||
• Contest types: Regular, Educational, Div. 1/2/3, Global rounds
|
|
||||||
• Problem naming: Numeric contest + problem letter
|
|
||||||
• Multi-test support: Template includes test case loop structure
|
|
||||||
• Interactive problems: Supported with flush handling
|
|
||||||
• Time/memory limits: Typically 1-2 seconds, 256 MB
|
|
||||||
|
|
||||||
In terms of cp.nvim, this corresponds to:
|
|
||||||
- Platform: codeforces
|
|
||||||
- Contest ID: 1234 (numeric, from URL)
|
|
||||||
- Problem ID: a (lowercase letter, normalized from URL)
|
|
||||||
|
|
||||||
Usage examples: >
|
Usage examples: >
|
||||||
:CP codeforces 1934 a " Full setup: problem A from contest 1934
|
:CP codeforces 1934 a " Full setup: problem A from contest 1934
|
||||||
:CP codeforces 1934 " Contest setup: load contest metadata only
|
:CP codeforces 1934 " Contest setup: load contest metadata only
|
||||||
|
|
@ -336,21 +307,6 @@ CSES ~
|
||||||
*cp-cses*
|
*cp-cses*
|
||||||
URL format: https://cses.fi/problemset/task/1068
|
URL format: https://cses.fi/problemset/task/1068
|
||||||
|
|
||||||
CSES (Code Submission Evaluation System) is organized by problem categories
|
|
||||||
rather than traditional contests. Problems are grouped by topic and difficulty.
|
|
||||||
|
|
||||||
Platform characteristics:
|
|
||||||
• Organization: Category-based (Introductory, Sorting, Dynamic Programming)
|
|
||||||
• Problem numbering: Sequential numeric IDs (1001, 1068, etc.)
|
|
||||||
• Difficulty progression: Problems increase in complexity within categories
|
|
||||||
• No time pressure: Educational focus rather than contest environment
|
|
||||||
• Cache expiry: 30 days (problems may be updated periodically)
|
|
||||||
|
|
||||||
In terms of cp.nvim, this corresponds to:
|
|
||||||
- Platform: cses
|
|
||||||
- Contest ID: Category name (introductory_problems, sorting_and_searching)
|
|
||||||
- Problem ID: Problem number (1068, 1640)
|
|
||||||
|
|
||||||
Usage examples: >
|
Usage examples: >
|
||||||
:CP cses dynamic_programming 1633 " Set up problem 1633 from DP category
|
:CP cses dynamic_programming 1633 " Set up problem 1633 from DP category
|
||||||
:CP cses dynamic_programming " Set up ALL problems from DP category
|
:CP cses dynamic_programming " Set up ALL problems from DP category
|
||||||
|
|
@ -361,7 +317,7 @@ Usage examples: >
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
|
|
||||||
COMPLETE WORKFLOW EXAMPLE *cp-example*
|
COMPLETE WORKFLOW EXAMPLE *cp-example*
|
||||||
|
|
||||||
Example: Setting up and solving AtCoder contest ABC324
|
Example: Setting up and solving AtCoder contest ABC324
|
||||||
|
|
||||||
|
|
@ -398,6 +354,39 @@ Example: Setting up and solving AtCoder contest ABC324
|
||||||
|
|
||||||
9. Submit solutions on AtCoder website
|
9. Submit solutions on AtCoder website
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
PICKER INTEGRATION *cp-picker*
|
||||||
|
|
||||||
|
When picker integration is enabled in configuration, cp.nvim provides interactive
|
||||||
|
platform, contest, and problem selection using telescope.nvim or fzf-lua.
|
||||||
|
|
||||||
|
:CP pick *:CP-pick*
|
||||||
|
Launch configured picker for interactive problem selection.
|
||||||
|
Control Flow: Select Platform → Contest → Problem → Code!
|
||||||
|
|
||||||
|
Requires picker = 'telescope' or picker = 'fzf-lua' in configuration.
|
||||||
|
Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed.
|
||||||
|
|
||||||
|
Picker Controls ~
|
||||||
|
*cp-picker-controls*
|
||||||
|
The picker interface provides several keyboard shortcuts for enhanced control:
|
||||||
|
|
||||||
|
<c-r> Force refresh contest list, bypassing cache
|
||||||
|
Useful when contest lists are outdated or incomplete
|
||||||
|
Shows loading indicator during refresh operation
|
||||||
|
|
||||||
|
Standard picker controls (telescope.nvim/fzf-lua):
|
||||||
|
<cr> Select current item and proceed to next step
|
||||||
|
<c-c> / <esc> Cancel picker and return to editor
|
||||||
|
<c-n> / <down> Navigate to next item
|
||||||
|
<c-p> / <up> Navigate to previous item
|
||||||
|
/ Start filtering/searching items
|
||||||
|
|
||||||
|
Notes ~
|
||||||
|
• Contest lists are fetched dynamically using scrapers with a TTL of 1 day
|
||||||
|
• Use <c-r> to force refresh
|
||||||
|
• Large contest lists may take time to load
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
RUN PANEL *cp-run*
|
RUN PANEL *cp-run*
|
||||||
|
|
||||||
|
|
@ -448,13 +437,13 @@ Test cases use competitive programming terminology with color highlighting:
|
||||||
<
|
<
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
|
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
|
||||||
|
|
||||||
cp.nvim provides comprehensive ANSI color support and highlighting for
|
cp.nvim provides comprehensive ANSI color support and highlighting for
|
||||||
compiler output, program stderr, and diff visualization.
|
compiler output, program stderr, and diff visualization.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
HIGHLIGHT GROUPS *cp-highlights*
|
HIGHLIGHT GROUPS *cp-highlights*
|
||||||
|
|
||||||
Test Status Groups ~
|
Test Status Groups ~
|
||||||
|
|
||||||
|
|
@ -514,20 +503,19 @@ These groups are automatically used by the git diff backend for character-level
|
||||||
difference visualization with optimal colorscheme integration.
|
difference visualization with optimal colorscheme integration.
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
|
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
|
||||||
|
|
||||||
ANSI colors automatically use your terminal's color palette through Neovim's
|
ANSI colors automatically use your terminal's color palette through Neovim's
|
||||||
vim.g.terminal_color_* variables. This ensures compiler colors match your
|
vim.g.terminal_color_* variables. This ensures compiler colors match your
|
||||||
colorscheme without manual configuration.
|
colorscheme without manual configuration.
|
||||||
|
|
||||||
If your colorscheme doesn't set terminal colors, cp.nvim falls back to
|
If your colorscheme doesn't set terminal colors, cp.nvim will warn you and
|
||||||
sensible defaults. You can override terminal colors in your configuration: >vim
|
ANSI colors won't display properly - set them like so: >vim
|
||||||
let g:terminal_color_1 = '#ff6b6b' " Custom red
|
let g:terminal_color_1 = '#ff6b6b'
|
||||||
let g:terminal_color_2 = '#51cf66' " Custom green
|
...
|
||||||
<
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
|
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
|
||||||
|
|
||||||
You can customize any highlight group by linking to existing groups or
|
You can customize any highlight group by linking to existing groups or
|
||||||
defining custom colors: >lua
|
defining custom colors: >lua
|
||||||
|
|
@ -552,7 +540,7 @@ prevent them from being overridden: >lua
|
||||||
<
|
<
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
RUN PANEL KEYMAPS *cp-test-keys*
|
RUN PANEL KEYMAPS *cp-test-keys*
|
||||||
<c-n> Navigate to next test case (configurable via
|
<c-n> Navigate to next test case (configurable via
|
||||||
run_panel.next_test_key)
|
run_panel.next_test_key)
|
||||||
<c-p> Navigate to previous test case (configurable via
|
<c-p> Navigate to previous test case (configurable via
|
||||||
|
|
@ -591,97 +579,6 @@ cp.nvim creates the following file structure upon problem setup: >
|
||||||
{problem_id}.n.cpout " nth program output
|
{problem_id}.n.cpout " nth program output
|
||||||
{problem_id}.expected " Expected output
|
{problem_id}.expected " Expected output
|
||||||
<
|
<
|
||||||
|
|
||||||
The plugin automatically manages this structure and navigation between problems
|
|
||||||
maintains proper file associations.
|
|
||||||
|
|
||||||
==============================================================================
|
|
||||||
CACHING SYSTEM *cp-caching*
|
|
||||||
|
|
||||||
cp.nvim maintains a persistent cache to improve performance and enable offline
|
|
||||||
functionality. The cache stores contest metadata, problem lists, test cases,
|
|
||||||
and file-to-context mappings.
|
|
||||||
|
|
||||||
Cache Location ~
|
|
||||||
*cp-cache-location*
|
|
||||||
Cache is stored at: >
|
|
||||||
vim.fn.stdpath('data') .. '/cp-nvim.json'
|
|
||||||
<
|
|
||||||
Cache Structure ~
|
|
||||||
*cp-cache-structure*
|
|
||||||
The cache contains four main sections:
|
|
||||||
|
|
||||||
contest_data Contest metadata and problem lists
|
|
||||||
• Indexed by: `platform:contest_id`
|
|
||||||
• Contains: Problem names, IDs, URLs, constraints
|
|
||||||
• Expiry: Platform-dependent (see |cp-cache-expiry|)
|
|
||||||
|
|
||||||
test_cases Scraped test case input/output pairs
|
|
||||||
• Indexed by: `platform:contest_id:problem_id`
|
|
||||||
• Contains: Input data, expected output, test case count
|
|
||||||
• Expiry: Never (local test data persists)
|
|
||||||
|
|
||||||
file_states File-to-context mapping
|
|
||||||
• Indexed by: Absolute file path
|
|
||||||
• Contains: Platform, contest_id, problem_id, language
|
|
||||||
• Purpose: Enables context restoration with `:CP`
|
|
||||||
|
|
||||||
timestamps Last update times for cache validation
|
|
||||||
• Tracks: When each cache entry was last refreshed
|
|
||||||
• Used for: Expiry checking and incremental updates
|
|
||||||
|
|
||||||
Cache Expiry Policy ~
|
|
||||||
*cp-cache-expiry*
|
|
||||||
Different data types have different expiry policies:
|
|
||||||
|
|
||||||
AtCoder/Codeforces contest data Never expires (contests are immutable)
|
|
||||||
CSES problem data 30 days (problems may be updated)
|
|
||||||
Test cases Never expires (local test data)
|
|
||||||
File states Never expires (tracks user workspace)
|
|
||||||
|
|
||||||
Manual Cache Management ~
|
|
||||||
*cp-cache-management*
|
|
||||||
While cache management is automatic, you can manually intervene:
|
|
||||||
|
|
||||||
Clear specific contest cache: >
|
|
||||||
:lua require('cp.cache').clear_contest('atcoder', 'abc324')
|
|
||||||
<
|
|
||||||
Clear all cache data: >
|
|
||||||
:lua require('cp.cache').clear_all()
|
|
||||||
<
|
|
||||||
Force refresh contest metadata: >
|
|
||||||
:lua require('cp.cache').refresh_contest('codeforces', '1934')
|
|
||||||
<
|
|
||||||
View cache statistics: >
|
|
||||||
:lua print(vim.inspect(require('cp.cache').get_stats()))
|
|
||||||
<
|
|
||||||
Note: Manual cache operations require Lua
|
|
||||||
knowledge and are primarily for debugging.
|
|
||||||
|
|
||||||
Offline Functionality ~
|
|
||||||
*cp-cache-offline*
|
|
||||||
The cache enables limited offline functionality:
|
|
||||||
|
|
||||||
✓ Restore context from cached file states
|
|
||||||
✓ Navigate between cached problems in a contest
|
|
||||||
✓ Access cached test cases for local development
|
|
||||||
✓ Use cached templates and configuration
|
|
||||||
|
|
||||||
✗ Scrape new problems without internet connection
|
|
||||||
✗ Download new contest metadata
|
|
||||||
✗ Update problem constraints or test cases
|
|
||||||
|
|
||||||
Performance Considerations ~
|
|
||||||
*cp-cache-performance*
|
|
||||||
The cache provides several performance benefits:
|
|
||||||
|
|
||||||
• Instant context restoration: No network requests needed
|
|
||||||
• Fast problem navigation: Problem lists loaded from cache
|
|
||||||
• Reduced scraping: Test cases cached after first download
|
|
||||||
• Batch operations: Multiple problems can be set up quickly
|
|
||||||
|
|
||||||
Cache size typically remains under 1MB even with extensive usage.
|
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
SNIPPETS *cp-snippets*
|
SNIPPETS *cp-snippets*
|
||||||
|
|
||||||
|
|
@ -690,7 +587,15 @@ snippets include basic C++ and Python templates for each contest type.
|
||||||
|
|
||||||
Snippet trigger names must match the following format exactly: >
|
Snippet trigger names must match the following format exactly: >
|
||||||
|
|
||||||
cp.nvim/{platform}
|
cp.nvim/{platform}.{language}
|
||||||
|
<
|
||||||
|
Where {platform} is the contest platform (atcoder, codeforces, cses) and
|
||||||
|
{language} is the programming language (cpp, python).
|
||||||
|
|
||||||
|
Examples: >
|
||||||
|
cp.nvim/atcoder.cpp
|
||||||
|
cp.nvim/codeforces.python
|
||||||
|
cp.nvim/cses.cpp
|
||||||
<
|
<
|
||||||
|
|
||||||
Custom snippets can be added via the `snippets` configuration field.
|
Custom snippets can be added via the `snippets` configuration field.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@
|
||||||
---@class CacheData
|
---@class CacheData
|
||||||
---@field [string] table<string, ContestData>
|
---@field [string] table<string, ContestData>
|
||||||
---@field file_states? table<string, FileState>
|
---@field file_states? table<string, FileState>
|
||||||
|
---@field contest_lists? table<string, ContestListData>
|
||||||
|
|
||||||
|
---@class ContestListData
|
||||||
|
---@field contests table[]
|
||||||
|
---@field cached_at number
|
||||||
|
---@field expires_at number
|
||||||
|
|
||||||
---@class ContestData
|
---@class ContestData
|
||||||
---@field problems Problem[]
|
---@field problems Problem[]
|
||||||
|
|
@ -33,6 +39,12 @@ local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
|
||||||
local cache_data = {}
|
local cache_data = {}
|
||||||
local loaded = false
|
local loaded = false
|
||||||
|
|
||||||
|
local CONTEST_LIST_TTL = {
|
||||||
|
cses = 7 * 24 * 60 * 60, -- 1 week
|
||||||
|
codeforces = 24 * 60 * 60, -- 1 day
|
||||||
|
atcoder = 24 * 60 * 60, -- 1 day
|
||||||
|
}
|
||||||
|
|
||||||
---@param platform string
|
---@param platform string
|
||||||
---@return number?
|
---@return number?
|
||||||
local function get_expiry_date(platform)
|
local function get_expiry_date(platform)
|
||||||
|
|
@ -277,4 +289,57 @@ function M.set_file_state(file_path, platform, contest_id, problem_id, language)
|
||||||
M.save()
|
M.save()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param platform string
|
||||||
|
---@return table[]?
|
||||||
|
function M.get_contest_list(platform)
|
||||||
|
vim.validate({
|
||||||
|
platform = { platform, 'string' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if not cache_data.contest_lists or not cache_data.contest_lists[platform] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local contest_list_data = cache_data.contest_lists[platform]
|
||||||
|
if os.time() >= contest_list_data.expires_at then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return contest_list_data.contests
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param platform string
|
||||||
|
---@param contests table[]
|
||||||
|
function M.set_contest_list(platform, contests)
|
||||||
|
vim.validate({
|
||||||
|
platform = { platform, 'string' },
|
||||||
|
contests = { contests, 'table' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if not cache_data.contest_lists then
|
||||||
|
cache_data.contest_lists = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local ttl = CONTEST_LIST_TTL[platform] or (24 * 60 * 60) -- Default 1 day
|
||||||
|
cache_data.contest_lists[platform] = {
|
||||||
|
contests = contests,
|
||||||
|
cached_at = os.time(),
|
||||||
|
expires_at = os.time() + ttl,
|
||||||
|
}
|
||||||
|
|
||||||
|
M.save()
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param platform string
|
||||||
|
function M.clear_contest_list(platform)
|
||||||
|
vim.validate({
|
||||||
|
platform = { platform, 'string' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if cache_data.contest_lists and cache_data.contest_lists[platform] then
|
||||||
|
cache_data.contest_lists[platform] = nil
|
||||||
|
M.save()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@
|
||||||
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
||||||
---@field run_panel RunPanelConfig
|
---@field run_panel RunPanelConfig
|
||||||
---@field diff DiffConfig
|
---@field diff DiffConfig
|
||||||
|
---@field picker "telescope"|"fzf-lua"|nil
|
||||||
|
|
||||||
---@class cp.UserConfig
|
---@class cp.UserConfig
|
||||||
---@field contests? table<string, PartialContestConfig>
|
---@field contests? table<string, PartialContestConfig>
|
||||||
|
|
@ -62,6 +63,7 @@
|
||||||
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
||||||
---@field run_panel? RunPanelConfig
|
---@field run_panel? RunPanelConfig
|
||||||
---@field diff? DiffConfig
|
---@field diff? DiffConfig
|
||||||
|
---@field picker? "telescope"|"fzf-lua"|nil
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
local constants = require('cp.constants')
|
local constants = require('cp.constants')
|
||||||
|
|
@ -110,6 +112,7 @@ M.defaults = {
|
||||||
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
|
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
picker = nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
---@param user_config cp.UserConfig|nil
|
---@param user_config cp.UserConfig|nil
|
||||||
|
|
@ -129,6 +132,7 @@ function M.setup(user_config)
|
||||||
filename = { user_config.filename, { 'function', 'nil' }, true },
|
filename = { user_config.filename, { 'function', 'nil' }, true },
|
||||||
run_panel = { user_config.run_panel, { 'table', 'nil' }, true },
|
run_panel = { user_config.run_panel, { 'table', 'nil' }, true },
|
||||||
diff = { user_config.diff, { 'table', 'nil' }, true },
|
diff = { user_config.diff, { 'table', 'nil' }, true },
|
||||||
|
picker = { user_config.picker, { 'string', 'nil' }, true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if user_config.contests then
|
if user_config.contests then
|
||||||
|
|
@ -164,6 +168,12 @@ function M.setup(user_config)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if user_config.picker then
|
||||||
|
if not vim.tbl_contains({ 'telescope', 'fzf-lua' }, user_config.picker) then
|
||||||
|
error(("Invalid picker '%s'. Must be 'telescope' or 'fzf-lua'"):format(user_config.picker))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local config = vim.tbl_deep_extend('force', M.defaults, user_config or {})
|
local config = vim.tbl_deep_extend('force', M.defaults, user_config or {})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' }
|
M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' }
|
||||||
M.ACTIONS = { 'run', 'next', 'prev' }
|
M.ACTIONS = { 'run', 'next', 'prev', 'pick' }
|
||||||
|
|
||||||
|
M.PLATFORM_DISPLAY_NAMES = {
|
||||||
|
atcoder = 'AtCoder',
|
||||||
|
codeforces = 'CodeForces',
|
||||||
|
cses = 'CSES',
|
||||||
|
}
|
||||||
|
|
||||||
M.CPP = 'cpp'
|
M.CPP = 'cpp'
|
||||||
M.PYTHON = 'python'
|
M.PYTHON = 'python'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
local utils = require('cp.utils')
|
||||||
|
|
||||||
local function check_nvim_version()
|
local function check_nvim_version()
|
||||||
if vim.fn.has('nvim-0.10.0') == 1 then
|
if vim.fn.has('nvim-0.10.0') == 1 then
|
||||||
vim.health.ok('Neovim 0.10.0+ detected')
|
vim.health.ok('Neovim 0.10.0+ detected')
|
||||||
|
|
@ -22,8 +24,7 @@ local function check_uv()
|
||||||
end
|
end
|
||||||
|
|
||||||
local function check_python_env()
|
local function check_python_env()
|
||||||
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
local plugin_path = utils.get_plugin_path()
|
||||||
plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
|
||||||
local venv_dir = plugin_path .. '/.venv'
|
local venv_dir = plugin_path .. '/.venv'
|
||||||
|
|
||||||
if vim.fn.isdirectory(venv_dir) == 1 then
|
if vim.fn.isdirectory(venv_dir) == 1 then
|
||||||
|
|
@ -34,8 +35,7 @@ local function check_python_env()
|
||||||
end
|
end
|
||||||
|
|
||||||
local function check_scrapers()
|
local function check_scrapers()
|
||||||
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
local plugin_path = utils.get_plugin_path()
|
||||||
plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
|
||||||
|
|
||||||
local scrapers = { 'atcoder.py', 'codeforces.py', 'cses.py' }
|
local scrapers = { 'atcoder.py', 'codeforces.py', 'cses.py' }
|
||||||
for _, scraper in ipairs(scrapers) do
|
for _, scraper in ipairs(scrapers) do
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,17 @@ local function setup_problem(contest_id, problem_id, language)
|
||||||
state.test_cases = cached_test_cases
|
state.test_cases = cached_test_cases
|
||||||
logger.log(('using cached test cases (%d)'):format(#cached_test_cases))
|
logger.log(('using cached test cases (%d)'):format(#cached_test_cases))
|
||||||
elseif vim.tbl_contains(config.scrapers, state.platform) then
|
elseif vim.tbl_contains(config.scrapers, state.platform) then
|
||||||
|
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[state.platform] or state.platform
|
||||||
|
logger.log(
|
||||||
|
('Scraping %s %s %s for test cases, this may take a few seconds...'):format(
|
||||||
|
platform_display_name,
|
||||||
|
contest_id,
|
||||||
|
problem_id
|
||||||
|
),
|
||||||
|
vim.log.levels.INFO,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
local scrape_result = scrape.scrape_problem(ctx)
|
local scrape_result = scrape.scrape_problem(ctx)
|
||||||
|
|
||||||
if not scrape_result.success then
|
if not scrape_result.success then
|
||||||
|
|
@ -562,9 +573,9 @@ local function toggle_run_panel(is_debug)
|
||||||
config.hooks.before_debug(ctx)
|
config.hooks.before_debug(ctx)
|
||||||
end
|
end
|
||||||
|
|
||||||
local execute_module = require('cp.execute')
|
local execute = require('cp.execute')
|
||||||
local contest_config = config.contests[state.platform]
|
local contest_config = config.contests[state.platform]
|
||||||
local compile_result = execute_module.compile_problem(ctx, contest_config, is_debug)
|
local compile_result = execute.compile_problem(ctx, contest_config, is_debug)
|
||||||
if compile_result.success then
|
if compile_result.success then
|
||||||
run.run_all_test_cases(ctx, contest_config, config)
|
run.run_all_test_cases(ctx, contest_config, config)
|
||||||
else
|
else
|
||||||
|
|
@ -698,6 +709,48 @@ local function navigate_problem(delta, language)
|
||||||
setup_problem(state.contest_id, new_problem.id, language)
|
setup_problem(state.contest_id, new_problem.id, language)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function handle_pick_action()
|
||||||
|
if not config.picker then
|
||||||
|
logger.log(
|
||||||
|
'No picker configured. Set picker = "telescope" or picker = "fzf-lua" in config',
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if config.picker == 'telescope' then
|
||||||
|
local ok = pcall(require, 'telescope')
|
||||||
|
if not ok then
|
||||||
|
logger.log(
|
||||||
|
'Telescope not available. Install telescope.nvim or change picker config',
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ok_cp, telescope_cp = pcall(require, 'cp.pickers.telescope')
|
||||||
|
if not ok_cp then
|
||||||
|
logger.log('Failed to load telescope integration', vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
telescope_cp.platform_picker()
|
||||||
|
elseif config.picker == 'fzf-lua' then
|
||||||
|
local ok, _ = pcall(require, 'fzf-lua')
|
||||||
|
if not ok then
|
||||||
|
logger.log(
|
||||||
|
'fzf-lua not available. Install fzf-lua or change picker config',
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ok_cp, fzf_cp = pcall(require, 'cp.pickers.fzf_lua')
|
||||||
|
if not ok_cp then
|
||||||
|
logger.log('Failed to load fzf-lua integration', vim.log.levels.ERROR)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
fzf_cp.platform_picker()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function restore_from_current_file()
|
local function restore_from_current_file()
|
||||||
local current_file = vim.fn.expand('%:p')
|
local current_file = vim.fn.expand('%:p')
|
||||||
if current_file == '' then
|
if current_file == '' then
|
||||||
|
|
@ -837,6 +890,8 @@ function M.handle_command(opts)
|
||||||
navigate_problem(1, cmd.language)
|
navigate_problem(1, cmd.language)
|
||||||
elseif cmd.action == 'prev' then
|
elseif cmd.action == 'prev' then
|
||||||
navigate_problem(-1, cmd.language)
|
navigate_problem(-1, cmd.language)
|
||||||
|
elseif cmd.action == 'pick' then
|
||||||
|
handle_pick_action()
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ function M.set_config(user_config)
|
||||||
config = user_config
|
config = user_config
|
||||||
end
|
end
|
||||||
|
|
||||||
function M.log(msg, level)
|
function M.log(msg, level, override)
|
||||||
level = level or vim.log.levels.INFO
|
level = level or vim.log.levels.INFO
|
||||||
if not config or config.debug or level >= vim.log.levels.WARN then
|
if not config or config.debug or level >= vim.log.levels.WARN or override then
|
||||||
vim.notify(('[cp.nvim]: %s'):format(msg), level)
|
vim.notify(('[cp.nvim]: %s'):format(msg), level)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
131
lua/cp/pickers/fzf_lua.lua
Normal file
131
lua/cp/pickers/fzf_lua.lua
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
local picker_utils = require('cp.pickers')
|
||||||
|
|
||||||
|
local function problem_picker(platform, contest_id)
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
local fzf = require('fzf-lua')
|
||||||
|
local problems = picker_utils.get_problems_for_contest(platform, contest_id)
|
||||||
|
|
||||||
|
if #problems == 0 then
|
||||||
|
vim.notify(
|
||||||
|
('No problems found for contest: %s %s'):format(platform_display_name, contest_id),
|
||||||
|
vim.log.levels.WARN
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local entries = vim.tbl_map(function(problem)
|
||||||
|
return problem.display_name
|
||||||
|
end, problems)
|
||||||
|
|
||||||
|
return fzf.fzf_exec(entries, {
|
||||||
|
prompt = ('Select Problem (%s %s)> '):format(platform_display_name, contest_id),
|
||||||
|
actions = {
|
||||||
|
['default'] = function(selected)
|
||||||
|
if not selected or #selected == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selected_name = selected[1]
|
||||||
|
local problem = nil
|
||||||
|
for _, p in ipairs(problems) do
|
||||||
|
if p.display_name == selected_name then
|
||||||
|
problem = p
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if problem then
|
||||||
|
picker_utils.setup_problem(platform, contest_id, problem.id)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function contest_picker(platform)
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
local fzf = require('fzf-lua')
|
||||||
|
local contests = picker_utils.get_contests_for_platform(platform)
|
||||||
|
|
||||||
|
if #contests == 0 then
|
||||||
|
vim.notify(
|
||||||
|
('No contests found for platform: %s'):format(platform_display_name),
|
||||||
|
vim.log.levels.WARN
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local entries = vim.tbl_map(function(contest)
|
||||||
|
return contest.display_name
|
||||||
|
end, contests)
|
||||||
|
|
||||||
|
return fzf.fzf_exec(entries, {
|
||||||
|
prompt = ('Select Contest (%s)> '):format(platform_display_name),
|
||||||
|
fzf_opts = {
|
||||||
|
['--header'] = 'ctrl-r: refresh',
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
['default'] = function(selected)
|
||||||
|
if not selected or #selected == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selected_name = selected[1]
|
||||||
|
local contest = nil
|
||||||
|
for _, c in ipairs(contests) do
|
||||||
|
if c.display_name == selected_name then
|
||||||
|
contest = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if contest then
|
||||||
|
problem_picker(platform, contest.id)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
['ctrl-r'] = function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
cache.clear_contest_list(platform)
|
||||||
|
contest_picker(platform)
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function platform_picker()
|
||||||
|
local fzf = require('fzf-lua')
|
||||||
|
local platforms = picker_utils.get_platforms()
|
||||||
|
local entries = vim.tbl_map(function(platform)
|
||||||
|
return platform.display_name
|
||||||
|
end, platforms)
|
||||||
|
|
||||||
|
return fzf.fzf_exec(entries, {
|
||||||
|
prompt = 'Select Platform> ',
|
||||||
|
actions = {
|
||||||
|
['default'] = function(selected)
|
||||||
|
if not selected or #selected == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local selected_name = selected[1]
|
||||||
|
local platform = nil
|
||||||
|
for _, p in ipairs(platforms) do
|
||||||
|
if p.display_name == selected_name then
|
||||||
|
platform = p
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if platform then
|
||||||
|
contest_picker(platform.id)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform_picker = platform_picker,
|
||||||
|
}
|
||||||
171
lua/cp/pickers/init.lua
Normal file
171
lua/cp/pickers/init.lua
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local logger = require('cp.log')
|
||||||
|
local scrape = require('cp.scrape')
|
||||||
|
local utils = require('cp.utils')
|
||||||
|
|
||||||
|
---@class cp.PlatformItem
|
||||||
|
---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses")
|
||||||
|
---@field display_name string Human-readable platform name (e.g. "Codeforces", "AtCoder", "CSES")
|
||||||
|
|
||||||
|
---@class cp.ContestItem
|
||||||
|
---@field id string Contest identifier (e.g. "1951", "abc324", "sorting")
|
||||||
|
---@field name string Full contest name (e.g. "Educational Codeforces Round 168")
|
||||||
|
---@field display_name string Formatted display name for picker
|
||||||
|
|
||||||
|
---@class cp.ProblemItem
|
||||||
|
---@field id string Problem identifier (e.g. "a", "b", "c")
|
||||||
|
---@field name string Problem name (e.g. "Two Permutations", "Painting Walls")
|
||||||
|
---@field display_name string Formatted display name for picker
|
||||||
|
|
||||||
|
---Get list of available competitive programming platforms
|
||||||
|
---@return cp.PlatformItem[]
|
||||||
|
local function get_platforms()
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
return vim.tbl_map(function(platform)
|
||||||
|
return {
|
||||||
|
id = platform,
|
||||||
|
display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
|
||||||
|
}
|
||||||
|
end, constants.PLATFORMS)
|
||||||
|
end
|
||||||
|
|
||||||
|
---Get list of contests for a specific platform
|
||||||
|
---@param platform string Platform identifier (e.g. "codeforces", "atcoder")
|
||||||
|
---@return cp.ContestItem[]
|
||||||
|
local function get_contests_for_platform(platform)
|
||||||
|
local contests = {}
|
||||||
|
|
||||||
|
cache.load()
|
||||||
|
local cached_contests = cache.get_contest_list(platform)
|
||||||
|
if cached_contests then
|
||||||
|
return cached_contests
|
||||||
|
end
|
||||||
|
|
||||||
|
if not utils.setup_python_env() then
|
||||||
|
return contests
|
||||||
|
end
|
||||||
|
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
logger.log(
|
||||||
|
('Scraping %s for contests, this may take a few seconds...'):format(platform_display_name),
|
||||||
|
vim.log.levels.INFO,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
local plugin_path = utils.get_plugin_path()
|
||||||
|
local cmd = {
|
||||||
|
'uv',
|
||||||
|
'run',
|
||||||
|
'--directory',
|
||||||
|
plugin_path,
|
||||||
|
'-m',
|
||||||
|
'scrapers.' .. platform,
|
||||||
|
'contests',
|
||||||
|
}
|
||||||
|
local result = vim
|
||||||
|
.system(cmd, {
|
||||||
|
cwd = plugin_path,
|
||||||
|
text = true,
|
||||||
|
timeout = 30000,
|
||||||
|
})
|
||||||
|
:wait()
|
||||||
|
|
||||||
|
if result.code ~= 0 then
|
||||||
|
logger.log(
|
||||||
|
('Failed to get contests for %s: %s'):format(platform, result.stderr or 'unknown error'),
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return contests
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, data = pcall(vim.json.decode, result.stdout)
|
||||||
|
if not ok or not data.success then
|
||||||
|
logger.log(('Failed to parse contest data for %s'):format(platform), vim.log.levels.ERROR)
|
||||||
|
return contests
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, contest in ipairs(data.contests or {}) do
|
||||||
|
table.insert(contests, {
|
||||||
|
id = contest.id,
|
||||||
|
name = contest.name,
|
||||||
|
display_name = contest.display_name,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
cache.set_contest_list(platform, contests)
|
||||||
|
return contests
|
||||||
|
end
|
||||||
|
|
||||||
|
---Get list of problems for a specific contest
|
||||||
|
---@param platform string Platform identifier
|
||||||
|
---@param contest_id string Contest identifier
|
||||||
|
---@return cp.ProblemItem[]
|
||||||
|
local function get_problems_for_contest(platform, contest_id)
|
||||||
|
local problems = {}
|
||||||
|
|
||||||
|
cache.load()
|
||||||
|
local contest_data = cache.get_contest_data(platform, contest_id)
|
||||||
|
if contest_data and contest_data.problems then
|
||||||
|
for _, problem in ipairs(contest_data.problems) do
|
||||||
|
table.insert(problems, {
|
||||||
|
id = problem.id,
|
||||||
|
name = problem.name,
|
||||||
|
display_name = problem.name,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return problems
|
||||||
|
end
|
||||||
|
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
logger.log(
|
||||||
|
('Scraping %s %s for problems, this may take a few seconds...'):format(
|
||||||
|
platform_display_name,
|
||||||
|
contest_id
|
||||||
|
),
|
||||||
|
vim.log.levels.INFO,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
local metadata_result = scrape.scrape_contest_metadata(platform, contest_id)
|
||||||
|
if not metadata_result.success then
|
||||||
|
logger.log(
|
||||||
|
('Failed to get problems for %s %s: %s'):format(
|
||||||
|
platform,
|
||||||
|
contest_id,
|
||||||
|
metadata_result.error or 'unknown error'
|
||||||
|
),
|
||||||
|
vim.log.levels.ERROR
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, problem in ipairs(metadata_result.problems or {}) do
|
||||||
|
table.insert(problems, {
|
||||||
|
id = problem.id,
|
||||||
|
name = problem.name,
|
||||||
|
display_name = problem.name,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return problems
|
||||||
|
end
|
||||||
|
|
||||||
|
---Set up a specific problem by calling the main CP handler
|
||||||
|
---@param platform string Platform identifier
|
||||||
|
---@param contest_id string Contest identifier
|
||||||
|
---@param problem_id string Problem identifier
|
||||||
|
local function setup_problem(platform, contest_id, problem_id)
|
||||||
|
local cp = require('cp')
|
||||||
|
cp.handle_command({ fargs = { platform, contest_id, problem_id } })
|
||||||
|
end
|
||||||
|
|
||||||
|
M.get_platforms = get_platforms
|
||||||
|
M.get_contests_for_platform = get_contests_for_platform
|
||||||
|
M.get_problems_for_contest = get_problems_for_contest
|
||||||
|
M.setup_problem = setup_problem
|
||||||
|
|
||||||
|
return M
|
||||||
138
lua/cp/pickers/telescope.lua
Normal file
138
lua/cp/pickers/telescope.lua
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
local finders = require('telescope.finders')
|
||||||
|
local pickers = require('telescope.pickers')
|
||||||
|
local conf = require('telescope.config').values
|
||||||
|
local action_state = require('telescope.actions.state')
|
||||||
|
local actions = require('telescope.actions')
|
||||||
|
|
||||||
|
local picker_utils = require('cp.pickers')
|
||||||
|
|
||||||
|
local function problem_picker(opts, platform, contest_id)
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
local problems = picker_utils.get_problems_for_contest(platform, contest_id)
|
||||||
|
|
||||||
|
if #problems == 0 then
|
||||||
|
vim.notify(
|
||||||
|
('No problems found for contest: %s %s'):format(platform_display_name, contest_id),
|
||||||
|
vim.log.levels.WARN
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
pickers
|
||||||
|
.new(opts, {
|
||||||
|
prompt_title = ('Select Problem (%s %s)'):format(platform_display_name, contest_id),
|
||||||
|
finder = finders.new_table({
|
||||||
|
results = problems,
|
||||||
|
entry_maker = function(entry)
|
||||||
|
return {
|
||||||
|
value = entry,
|
||||||
|
display = entry.display_name,
|
||||||
|
ordinal = entry.display_name,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
sorter = conf.generic_sorter(opts),
|
||||||
|
attach_mappings = function(prompt_bufnr)
|
||||||
|
actions.select_default:replace(function()
|
||||||
|
local selection = action_state.get_selected_entry()
|
||||||
|
actions.close(prompt_bufnr)
|
||||||
|
|
||||||
|
if selection then
|
||||||
|
picker_utils.setup_problem(platform, contest_id, selection.value.id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
:find()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function contest_picker(opts, platform)
|
||||||
|
local constants = require('cp.constants')
|
||||||
|
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
|
||||||
|
local contests = picker_utils.get_contests_for_platform(platform)
|
||||||
|
|
||||||
|
if #contests == 0 then
|
||||||
|
vim.notify(
|
||||||
|
('No contests found for platform: %s'):format(platform_display_name),
|
||||||
|
vim.log.levels.WARN
|
||||||
|
)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
pickers
|
||||||
|
.new(opts, {
|
||||||
|
prompt_title = ('Select Contest (%s)'):format(platform_display_name),
|
||||||
|
results_title = '<C-r> refresh',
|
||||||
|
finder = finders.new_table({
|
||||||
|
results = contests,
|
||||||
|
entry_maker = function(entry)
|
||||||
|
return {
|
||||||
|
value = entry,
|
||||||
|
display = entry.display_name,
|
||||||
|
ordinal = entry.display_name,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
sorter = conf.generic_sorter(opts),
|
||||||
|
attach_mappings = function(prompt_bufnr, map)
|
||||||
|
actions.select_default:replace(function()
|
||||||
|
local selection = action_state.get_selected_entry()
|
||||||
|
actions.close(prompt_bufnr)
|
||||||
|
|
||||||
|
if selection then
|
||||||
|
problem_picker(opts, platform, selection.value.id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
map('i', '<C-r>', function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
cache.clear_contest_list(platform)
|
||||||
|
actions.close(prompt_bufnr)
|
||||||
|
contest_picker(opts, platform)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
:find()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function platform_picker(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
|
||||||
|
local platforms = picker_utils.get_platforms()
|
||||||
|
|
||||||
|
pickers
|
||||||
|
.new(opts, {
|
||||||
|
prompt_title = 'Select Platform',
|
||||||
|
finder = finders.new_table({
|
||||||
|
results = platforms,
|
||||||
|
entry_maker = function(entry)
|
||||||
|
return {
|
||||||
|
value = entry,
|
||||||
|
display = entry.display_name,
|
||||||
|
ordinal = entry.display_name,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
sorter = conf.generic_sorter(opts),
|
||||||
|
attach_mappings = function(prompt_bufnr)
|
||||||
|
actions.select_default:replace(function()
|
||||||
|
local selection = action_state.get_selected_entry()
|
||||||
|
actions.close(prompt_bufnr)
|
||||||
|
|
||||||
|
if selection then
|
||||||
|
contest_picker(opts, selection.value.id)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
:find()
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform_picker = platform_picker,
|
||||||
|
}
|
||||||
|
|
@ -13,44 +13,14 @@
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
local cache = require('cp.cache')
|
local cache = require('cp.cache')
|
||||||
local logger = require('cp.log')
|
|
||||||
local problem = require('cp.problem')
|
local problem = require('cp.problem')
|
||||||
|
local utils = require('cp.utils')
|
||||||
local function get_plugin_path()
|
|
||||||
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
|
||||||
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
|
||||||
end
|
|
||||||
|
|
||||||
local function check_internet_connectivity()
|
local function check_internet_connectivity()
|
||||||
local result = vim.system({ 'ping', '-c', '1', '-W', '3', '8.8.8.8' }, { text = true }):wait()
|
local result = vim.system({ 'ping', '-c', '1', '-W', '3', '8.8.8.8' }, { text = true }):wait()
|
||||||
return result.code == 0
|
return result.code == 0
|
||||||
end
|
end
|
||||||
|
|
||||||
local function setup_python_env()
|
|
||||||
local plugin_path = get_plugin_path()
|
|
||||||
local venv_dir = plugin_path .. '/.venv'
|
|
||||||
|
|
||||||
if vim.fn.executable('uv') == 0 then
|
|
||||||
logger.log(
|
|
||||||
'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/',
|
|
||||||
vim.log.levels.WARN
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
if vim.fn.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 result.code ~= 0 then
|
|
||||||
logger.log('failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
logger.log('python environment setup complete')
|
|
||||||
end
|
|
||||||
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
---@param platform string
|
---@param platform string
|
||||||
---@param contest_id string
|
---@param contest_id string
|
||||||
---@return {success: boolean, problems?: table[], error?: string}
|
---@return {success: boolean, problems?: table[], error?: string}
|
||||||
|
|
@ -77,14 +47,14 @@ function M.scrape_contest_metadata(platform, contest_id)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
if not setup_python_env() then
|
if not utils.setup_python_env() then
|
||||||
return {
|
return {
|
||||||
success = false,
|
success = false,
|
||||||
error = 'Python environment setup failed',
|
error = 'Python environment setup failed',
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local plugin_path = get_plugin_path()
|
local plugin_path = utils.get_plugin_path()
|
||||||
|
|
||||||
local args = {
|
local args = {
|
||||||
'uv',
|
'uv',
|
||||||
|
|
@ -182,7 +152,7 @@ function M.scrape_problem(ctx)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
if not setup_python_env() then
|
if not utils.setup_python_env() then
|
||||||
return {
|
return {
|
||||||
success = false,
|
success = false,
|
||||||
problem_id = ctx.problem_name,
|
problem_id = ctx.problem_name,
|
||||||
|
|
@ -190,33 +160,19 @@ function M.scrape_problem(ctx)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local plugin_path = get_plugin_path()
|
local plugin_path = utils.get_plugin_path()
|
||||||
|
|
||||||
local args
|
local args = {
|
||||||
if ctx.contest == 'cses' then
|
'uv',
|
||||||
args = {
|
'run',
|
||||||
'uv',
|
'--directory',
|
||||||
'run',
|
plugin_path,
|
||||||
'--directory',
|
'-m',
|
||||||
plugin_path,
|
'scrapers.' .. ctx.contest,
|
||||||
'-m',
|
'tests',
|
||||||
'scrapers.' .. ctx.contest,
|
ctx.contest_id,
|
||||||
'tests',
|
ctx.problem_id,
|
||||||
ctx.problem_id,
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
args = {
|
|
||||||
'uv',
|
|
||||||
'run',
|
|
||||||
'--directory',
|
|
||||||
plugin_path,
|
|
||||||
'-m',
|
|
||||||
'scrapers.' .. ctx.contest,
|
|
||||||
'tests',
|
|
||||||
ctx.contest_id,
|
|
||||||
ctx.problem_id,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
local result = vim
|
local result = vim
|
||||||
.system(args, {
|
.system(args, {
|
||||||
|
|
@ -308,11 +264,11 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config)
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
||||||
if not setup_python_env() then
|
if not utils.setup_python_env() then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
||||||
local plugin_path = get_plugin_path()
|
local plugin_path = utils.get_plugin_path()
|
||||||
local jobs = {}
|
local jobs = {}
|
||||||
|
|
||||||
for _, prob in ipairs(problems) do
|
for _, prob in ipairs(problems) do
|
||||||
|
|
|
||||||
44
lua/cp/utils.lua
Normal file
44
lua/cp/utils.lua
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local logger = require('cp.log')
|
||||||
|
|
||||||
|
---@return string
|
||||||
|
function M.get_plugin_path()
|
||||||
|
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
||||||
|
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
||||||
|
end
|
||||||
|
|
||||||
|
local python_env_setup = false
|
||||||
|
|
||||||
|
---@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
|
||||||
|
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 result.code ~= 0 then
|
||||||
|
logger.log('failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
logger.log('Python environment setup complete')
|
||||||
|
end
|
||||||
|
|
||||||
|
python_env_setup = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
|
local utils = require('cp.utils')
|
||||||
|
|
||||||
local function get_git_version()
|
local function get_git_version()
|
||||||
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
local plugin_root = utils.get_plugin_path()
|
||||||
local plugin_root = vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
|
||||||
|
|
||||||
local result = vim
|
local result = vim
|
||||||
.system({ 'git', 'describe', '--tags', '--always', '--dirty' }, {
|
.system({ 'git', 'describe', '--tags', '--always', '--dirty' }, {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"backoff>=2.2.1",
|
||||||
"beautifulsoup4>=4.13.5",
|
"beautifulsoup4>=4.13.5",
|
||||||
"cloudscraper>=1.2.71",
|
"cloudscraper>=1.2.71",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
import backoff
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup, Tag
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
|
||||||
|
|
@ -99,7 +99,6 @@ def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]:
|
||||||
if problem:
|
if problem:
|
||||||
problems.append(problem)
|
problems.append(problem)
|
||||||
|
|
||||||
problems.sort(key=lambda x: x.id)
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -169,7 +168,6 @@ def scrape(url: str) -> list[TestCase]:
|
||||||
|
|
||||||
def scrape_contests() -> list[ContestSummary]:
|
def scrape_contests() -> list[ContestSummary]:
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import random
|
|
||||||
|
|
||||||
def get_max_pages() -> int:
|
def get_max_pages() -> int:
|
||||||
try:
|
try:
|
||||||
|
|
@ -197,168 +195,161 @@ def scrape_contests() -> list[ContestSummary]:
|
||||||
except Exception:
|
except Exception:
|
||||||
return 15
|
return 15
|
||||||
|
|
||||||
def scrape_page_with_retry(page: int, max_retries: int = 3) -> list[ContestSummary]:
|
def scrape_page(page: int) -> list[ContestSummary]:
|
||||||
for attempt in range(max_retries):
|
@backoff.on_exception(
|
||||||
|
backoff.expo,
|
||||||
|
(requests.exceptions.RequestException, requests.exceptions.HTTPError),
|
||||||
|
max_tries=4,
|
||||||
|
jitter=backoff.random_jitter,
|
||||||
|
on_backoff=lambda details: print(
|
||||||
|
f"Request failed on page {page} (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}",
|
||||||
|
file=sys.stderr,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@backoff.on_predicate(
|
||||||
|
backoff.expo,
|
||||||
|
lambda response: response.status_code == 429,
|
||||||
|
max_tries=4,
|
||||||
|
jitter=backoff.random_jitter,
|
||||||
|
on_backoff=lambda details: print(
|
||||||
|
f"Rate limited on page {page}, retrying in {details['wait']:.1f}s",
|
||||||
|
file=sys.stderr,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def make_request() -> requests.Response:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
url = f"https://atcoder.jp/contests/archive?page={page}"
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = make_request()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
table = soup.find("table", class_="table")
|
||||||
|
if not table:
|
||||||
|
return []
|
||||||
|
|
||||||
|
tbody = table.find("tbody")
|
||||||
|
if not tbody or not isinstance(tbody, Tag):
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = tbody.find_all("tr")
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
contests = []
|
||||||
|
for row in rows:
|
||||||
|
cells = row.find_all("td")
|
||||||
|
if len(cells) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
contest_cell = cells[1]
|
||||||
|
link = contest_cell.find("a")
|
||||||
|
if not link or not link.get("href"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
href = link.get("href")
|
||||||
|
contest_id = href.split("/")[-1]
|
||||||
|
name = link.get_text().strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
headers = {
|
name = name.encode().decode("unicode_escape")
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||||
}
|
pass
|
||||||
url = f"https://atcoder.jp/contests/archive?page={page}"
|
|
||||||
response = requests.get(url, headers=headers, timeout=10)
|
|
||||||
|
|
||||||
if response.status_code == 429:
|
name = (
|
||||||
backoff_time = (2**attempt) + random.uniform(0, 1)
|
name.replace("\uff08", "(")
|
||||||
print(
|
.replace("\uff09", ")")
|
||||||
f"Rate limited on page {page}, retrying in {backoff_time:.1f}s",
|
.replace("\u3000", " ")
|
||||||
file=sys.stderr,
|
)
|
||||||
)
|
name = re.sub(
|
||||||
time.sleep(backoff_time)
|
r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name
|
||||||
continue
|
)
|
||||||
|
|
||||||
response.raise_for_status()
|
def generate_display_name_from_id(contest_id: str) -> str:
|
||||||
|
parts = contest_id.replace("-", " ").replace("_", " ")
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
parts = re.sub(
|
||||||
table = soup.find("table", class_="table")
|
r"\b(jsc|JSC)\b",
|
||||||
if not table:
|
"Japanese Student Championship",
|
||||||
return []
|
parts,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
tbody = table.find("tbody")
|
)
|
||||||
if not tbody or not isinstance(tbody, Tag):
|
parts = re.sub(
|
||||||
return []
|
r"\b(wtf|WTF)\b",
|
||||||
|
"World Tour Finals",
|
||||||
rows = tbody.find_all("tr")
|
parts,
|
||||||
if not rows:
|
flags=re.IGNORECASE,
|
||||||
return []
|
)
|
||||||
|
parts = re.sub(
|
||||||
contests = []
|
r"\b(ahc)(\d+)\b",
|
||||||
for row in rows:
|
r"Heuristic Contest \2 (AHC)",
|
||||||
cells = row.find_all("td")
|
parts,
|
||||||
if len(cells) < 2:
|
flags=re.IGNORECASE,
|
||||||
continue
|
)
|
||||||
|
parts = re.sub(
|
||||||
contest_cell = cells[1]
|
r"\b(arc)(\d+)\b",
|
||||||
link = contest_cell.find("a")
|
r"Regular Contest \2 (ARC)",
|
||||||
if not link or not link.get("href"):
|
parts,
|
||||||
continue
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
href = link.get("href")
|
parts = re.sub(
|
||||||
contest_id = href.split("/")[-1]
|
r"\b(abc)(\d+)\b",
|
||||||
name = link.get_text().strip()
|
r"Beginner Contest \2 (ABC)",
|
||||||
|
parts,
|
||||||
try:
|
flags=re.IGNORECASE,
|
||||||
name = name.encode().decode("unicode_escape")
|
)
|
||||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
parts = re.sub(
|
||||||
pass
|
r"\b(agc)(\d+)\b",
|
||||||
|
r"Grand Contest \2 (AGC)",
|
||||||
name = (
|
parts,
|
||||||
name.replace("\uff08", "(")
|
flags=re.IGNORECASE,
|
||||||
.replace("\uff09", ")")
|
|
||||||
.replace("\u3000", " ")
|
|
||||||
)
|
|
||||||
name = re.sub(
|
|
||||||
r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_display_name_from_id(contest_id: str) -> str:
|
|
||||||
parts = contest_id.replace("-", " ").replace("_", " ")
|
|
||||||
|
|
||||||
parts = re.sub(
|
|
||||||
r"\b(jsc|JSC)\b",
|
|
||||||
"Japanese Student Championship",
|
|
||||||
parts,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
parts = re.sub(
|
|
||||||
r"\b(wtf|WTF)\b",
|
|
||||||
"World Tour Finals",
|
|
||||||
parts,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
parts = re.sub(
|
|
||||||
r"\b(ahc)(\d+)\b",
|
|
||||||
r"Heuristic Contest \2 (AHC)",
|
|
||||||
parts,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
parts = re.sub(
|
|
||||||
r"\b(arc)(\d+)\b",
|
|
||||||
r"Regular Contest \2 (ARC)",
|
|
||||||
parts,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
parts = re.sub(
|
|
||||||
r"\b(abc)(\d+)\b",
|
|
||||||
r"Beginner Contest \2 (ABC)",
|
|
||||||
parts,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
parts = re.sub(
|
|
||||||
r"\b(agc)(\d+)\b",
|
|
||||||
r"Grand Contest \2 (AGC)",
|
|
||||||
parts,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
return parts.title()
|
|
||||||
|
|
||||||
english_chars = sum(1 for c in name if c.isascii() and c.isalpha())
|
|
||||||
total_chars = len(re.sub(r"\s+", "", name))
|
|
||||||
|
|
||||||
if total_chars > 0 and english_chars / total_chars < 0.3:
|
|
||||||
display_name = generate_display_name_from_id(contest_id)
|
|
||||||
else:
|
|
||||||
display_name = name
|
|
||||||
if "AtCoder Beginner Contest" in name:
|
|
||||||
match = re.search(r"AtCoder Beginner Contest (\d+)", name)
|
|
||||||
if match:
|
|
||||||
display_name = (
|
|
||||||
f"Beginner Contest {match.group(1)} (ABC)"
|
|
||||||
)
|
|
||||||
elif "AtCoder Regular Contest" in name:
|
|
||||||
match = re.search(r"AtCoder Regular Contest (\d+)", name)
|
|
||||||
if match:
|
|
||||||
display_name = f"Regular Contest {match.group(1)} (ARC)"
|
|
||||||
elif "AtCoder Grand Contest" in name:
|
|
||||||
match = re.search(r"AtCoder Grand Contest (\d+)", name)
|
|
||||||
if match:
|
|
||||||
display_name = f"Grand Contest {match.group(1)} (AGC)"
|
|
||||||
elif "AtCoder Heuristic Contest" in name:
|
|
||||||
match = re.search(r"AtCoder Heuristic Contest (\d+)", name)
|
|
||||||
if match:
|
|
||||||
display_name = (
|
|
||||||
f"Heuristic Contest {match.group(1)} (AHC)"
|
|
||||||
)
|
|
||||||
|
|
||||||
contests.append(
|
|
||||||
ContestSummary(
|
|
||||||
id=contest_id, name=name, display_name=display_name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return contests
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
if response.status_code == 429:
|
|
||||||
continue
|
|
||||||
print(
|
|
||||||
f"Failed to scrape page {page} (attempt {attempt + 1}): {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
if attempt == max_retries - 1:
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Unexpected error on page {page}: {e}", file=sys.stderr)
|
|
||||||
return []
|
|
||||||
|
|
||||||
return []
|
return parts.title()
|
||||||
|
|
||||||
|
english_chars = sum(1 for c in name if c.isascii() and c.isalpha())
|
||||||
|
total_chars = len(re.sub(r"\s+", "", name))
|
||||||
|
|
||||||
|
if total_chars > 0 and english_chars / total_chars < 0.3:
|
||||||
|
display_name = generate_display_name_from_id(contest_id)
|
||||||
|
else:
|
||||||
|
display_name = name
|
||||||
|
if "AtCoder Beginner Contest" in name:
|
||||||
|
match = re.search(r"AtCoder Beginner Contest (\d+)", name)
|
||||||
|
if match:
|
||||||
|
display_name = f"Beginner Contest {match.group(1)} (ABC)"
|
||||||
|
elif "AtCoder Regular Contest" in name:
|
||||||
|
match = re.search(r"AtCoder Regular Contest (\d+)", name)
|
||||||
|
if match:
|
||||||
|
display_name = f"Regular Contest {match.group(1)} (ARC)"
|
||||||
|
elif "AtCoder Grand Contest" in name:
|
||||||
|
match = re.search(r"AtCoder Grand Contest (\d+)", name)
|
||||||
|
if match:
|
||||||
|
display_name = f"Grand Contest {match.group(1)} (AGC)"
|
||||||
|
elif "AtCoder Heuristic Contest" in name:
|
||||||
|
match = re.search(r"AtCoder Heuristic Contest (\d+)", name)
|
||||||
|
if match:
|
||||||
|
display_name = f"Heuristic Contest {match.group(1)} (AHC)"
|
||||||
|
|
||||||
|
contests.append(
|
||||||
|
ContestSummary(id=contest_id, name=name, display_name=display_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
return contests
|
||||||
|
|
||||||
max_pages = get_max_pages()
|
max_pages = get_max_pages()
|
||||||
page_results = {}
|
page_results = {}
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
future_to_page = {
|
future_to_page = {
|
||||||
executor.submit(scrape_page_with_retry, page): page
|
executor.submit(scrape_page, page): page for page in range(1, max_pages + 1)
|
||||||
for page in range(1, max_pages + 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for future in concurrent.futures.as_completed(future_to_page):
|
for future in concurrent.futures.as_completed(future_to_page):
|
||||||
|
|
|
||||||
|
|
@ -203,8 +203,6 @@ def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]:
|
||||||
ProblemSummary(id=problem_letter, name=problem_name)
|
ProblemSummary(id=problem_letter, name=problem_name)
|
||||||
)
|
)
|
||||||
|
|
||||||
problems.sort(key=lambda x: x.id)
|
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
unique_problems: list[ProblemSummary] = []
|
unique_problems: list[ProblemSummary] = []
|
||||||
for p in problems:
|
for p in problems:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
import backoff
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup, Tag
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
|
||||||
|
|
@ -41,36 +40,29 @@ def denormalize_category_name(category_id: str) -> str:
|
||||||
return category_map.get(category_id, category_id.replace("_", " ").title())
|
return category_map.get(category_id, category_id.replace("_", " ").title())
|
||||||
|
|
||||||
|
|
||||||
def request_with_retry(
|
@backoff.on_exception(
|
||||||
url: str, headers: dict, max_retries: int = 3
|
backoff.expo,
|
||||||
) -> requests.Response:
|
(requests.exceptions.RequestException, requests.exceptions.HTTPError),
|
||||||
for attempt in range(max_retries):
|
max_tries=4,
|
||||||
try:
|
jitter=backoff.random_jitter,
|
||||||
delay = 0.5 + random.uniform(0, 0.3)
|
on_backoff=lambda details: print(
|
||||||
time.sleep(delay)
|
f"Request failed (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}",
|
||||||
|
file=sys.stderr,
|
||||||
response = requests.get(url, headers=headers, timeout=10)
|
),
|
||||||
|
)
|
||||||
if response.status_code == 429:
|
@backoff.on_predicate(
|
||||||
backoff = (2**attempt) + random.uniform(0, 1)
|
backoff.expo,
|
||||||
print(f"Rate limited, retrying in {backoff:.1f}s", file=sys.stderr)
|
lambda response: response.status_code == 429,
|
||||||
time.sleep(backoff)
|
max_tries=4,
|
||||||
continue
|
jitter=backoff.random_jitter,
|
||||||
|
on_backoff=lambda details: print(
|
||||||
response.raise_for_status()
|
f"Rate limited, retrying in {details['wait']:.1f}s", file=sys.stderr
|
||||||
return response
|
),
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
def make_request(url: str, headers: dict) -> requests.Response:
|
||||||
if attempt == max_retries - 1:
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
raise
|
response.raise_for_status()
|
||||||
backoff = 2**attempt
|
return response
|
||||||
print(
|
|
||||||
f"Request failed (attempt {attempt + 1}), retrying in {backoff}s: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
time.sleep(backoff)
|
|
||||||
|
|
||||||
raise Exception("All retry attempts failed")
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_category_problems(category_id: str) -> list[ProblemSummary]:
|
def scrape_category_problems(category_id: str) -> list[ProblemSummary]:
|
||||||
|
|
@ -82,7 +74,7 @@ def scrape_category_problems(category_id: str) -> list[ProblemSummary]:
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
}
|
}
|
||||||
|
|
||||||
response = request_with_retry(problemset_url, headers)
|
response = make_request(problemset_url, headers)
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
|
||||||
|
|
@ -122,7 +114,6 @@ def scrape_category_problems(category_id: str) -> list[ProblemSummary]:
|
||||||
|
|
||||||
problems.append(ProblemSummary(id=problem_id, name=problem_name))
|
problems.append(ProblemSummary(id=problem_id, name=problem_name))
|
||||||
|
|
||||||
problems.sort(key=lambda x: int(x.id))
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -176,7 +167,7 @@ def scrape_categories() -> list[ContestSummary]:
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
}
|
}
|
||||||
response = request_with_retry("https://cses.fi/problemset/", headers)
|
response = make_request("https://cses.fi/problemset/", headers)
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
categories = []
|
categories = []
|
||||||
|
|
@ -188,12 +179,7 @@ def scrape_categories() -> list[ContestSummary]:
|
||||||
|
|
||||||
category_id = normalize_category_name(category_name)
|
category_id = normalize_category_name(category_name)
|
||||||
|
|
||||||
ul = h2.find_next_sibling("ul", class_="task-list")
|
display_name = category_name
|
||||||
problem_count = 0
|
|
||||||
if ul:
|
|
||||||
problem_count = len(ul.find_all("li", class_="task"))
|
|
||||||
|
|
||||||
display_name = f"{category_name} ({problem_count} problems)"
|
|
||||||
|
|
||||||
categories.append(
|
categories.append(
|
||||||
ContestSummary(
|
ContestSummary(
|
||||||
|
|
@ -276,9 +262,6 @@ def scrape_all_problems() -> dict[str, list[ProblemSummary]]:
|
||||||
problem = ProblemSummary(id=problem_id, name=problem_name)
|
problem = ProblemSummary(id=problem_id, name=problem_name)
|
||||||
all_categories[current_category].append(problem)
|
all_categories[current_category].append(problem)
|
||||||
|
|
||||||
for category in all_categories:
|
|
||||||
all_categories[category].sort(key=lambda x: int(x.id))
|
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"Found {len(all_categories)} categories with {sum(len(probs) for probs in all_categories.values())} problems",
|
f"Found {len(all_categories)} categories with {sum(len(probs) for probs in all_categories.values())} problems",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
|
|
@ -323,7 +306,7 @@ def scrape(url: str) -> list[TestCase]:
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
}
|
}
|
||||||
|
|
||||||
response = request_with_retry(url, headers)
|
response = make_request(url, headers)
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,39 @@ describe('cp.config', function()
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
describe('picker validation', function()
|
||||||
|
it('validates picker is valid value', function()
|
||||||
|
local invalid_config = {
|
||||||
|
picker = 'invalid_picker',
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.has_error(function()
|
||||||
|
config.setup(invalid_config)
|
||||||
|
end, "Invalid picker 'invalid_picker'. Must be 'telescope' or 'fzf-lua'")
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('allows nil picker', function()
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
local result = config.setup({ picker = nil })
|
||||||
|
assert.is_nil(result.picker)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('allows telescope picker without checking availability', function()
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
local result = config.setup({ picker = 'telescope' })
|
||||||
|
assert.equals('telescope', result.picker)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('allows fzf-lua picker without checking availability', function()
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
local result = config.setup({ picker = 'fzf-lua' })
|
||||||
|
assert.equals('fzf-lua', result.picker)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
describe('default_filename', function()
|
describe('default_filename', function()
|
||||||
|
|
|
||||||
31
spec/fzf_lua_spec.lua
Normal file
31
spec/fzf_lua_spec.lua
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
describe('cp.fzf_lua', function()
|
||||||
|
local spec_helper = require('spec.spec_helper')
|
||||||
|
|
||||||
|
before_each(function()
|
||||||
|
spec_helper.setup()
|
||||||
|
|
||||||
|
package.preload['fzf-lua'] = function()
|
||||||
|
return {
|
||||||
|
fzf_exec = function(_, _) end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_each(function()
|
||||||
|
spec_helper.teardown()
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('module loading', function()
|
||||||
|
it('loads fzf-lua integration without error', function()
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
require('cp.pickers.fzf_lua')
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns module with platform_picker function', function()
|
||||||
|
local fzf_lua_cp = require('cp.pickers.fzf_lua')
|
||||||
|
assert.is_table(fzf_lua_cp)
|
||||||
|
assert.is_function(fzf_lua_cp.platform_picker)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
204
spec/picker_spec.lua
Normal file
204
spec/picker_spec.lua
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
describe('cp.picker', function()
|
||||||
|
local picker
|
||||||
|
local spec_helper = require('spec.spec_helper')
|
||||||
|
|
||||||
|
before_each(function()
|
||||||
|
spec_helper.setup()
|
||||||
|
picker = require('cp.pickers')
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_each(function()
|
||||||
|
spec_helper.teardown()
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('get_platforms', function()
|
||||||
|
it('returns platform list with display names', function()
|
||||||
|
local platforms = picker.get_platforms()
|
||||||
|
|
||||||
|
assert.is_table(platforms)
|
||||||
|
assert.is_true(#platforms > 0)
|
||||||
|
|
||||||
|
for _, platform in ipairs(platforms) do
|
||||||
|
assert.is_string(platform.id)
|
||||||
|
assert.is_string(platform.display_name)
|
||||||
|
assert.is_not_nil(platform.display_name:match('^%u'))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('includes expected platforms with correct display names', function()
|
||||||
|
local platforms = picker.get_platforms()
|
||||||
|
local platform_map = {}
|
||||||
|
for _, p in ipairs(platforms) do
|
||||||
|
platform_map[p.id] = p.display_name
|
||||||
|
end
|
||||||
|
|
||||||
|
assert.equals('CodeForces', platform_map['codeforces'])
|
||||||
|
assert.equals('AtCoder', platform_map['atcoder'])
|
||||||
|
assert.equals('CSES', platform_map['cses'])
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('get_contests_for_platform', function()
|
||||||
|
it('returns empty list when scraper fails', function()
|
||||||
|
vim.system = function(_, _)
|
||||||
|
return {
|
||||||
|
wait = function()
|
||||||
|
return { code = 1, stderr = 'test error' }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local contests = picker.get_contests_for_platform('test_platform')
|
||||||
|
assert.is_table(contests)
|
||||||
|
assert.equals(0, #contests)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns empty list when JSON is invalid', function()
|
||||||
|
vim.system = function(_, _)
|
||||||
|
return {
|
||||||
|
wait = function()
|
||||||
|
return { code = 0, stdout = 'invalid json' }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local contests = picker.get_contests_for_platform('test_platform')
|
||||||
|
assert.is_table(contests)
|
||||||
|
assert.equals(0, #contests)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns contest list when scraper succeeds', function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local utils = require('cp.utils')
|
||||||
|
|
||||||
|
cache.load = function() end
|
||||||
|
cache.get_contest_list = function()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
cache.set_contest_list = function() end
|
||||||
|
|
||||||
|
utils.setup_python_env = function()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
utils.get_plugin_path = function()
|
||||||
|
return '/test/path'
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.system = function(_, _)
|
||||||
|
return {
|
||||||
|
wait = function()
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = vim.json.encode({
|
||||||
|
success = true,
|
||||||
|
contests = {
|
||||||
|
{
|
||||||
|
id = 'abc123',
|
||||||
|
name = 'AtCoder Beginner Contest 123',
|
||||||
|
display_name = 'Beginner Contest 123 (ABC)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id = '1951',
|
||||||
|
name = 'Educational Round 168',
|
||||||
|
display_name = 'Educational Round 168',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local contests = picker.get_contests_for_platform('test_platform')
|
||||||
|
assert.is_table(contests)
|
||||||
|
assert.equals(2, #contests)
|
||||||
|
assert.equals('abc123', contests[1].id)
|
||||||
|
assert.equals('AtCoder Beginner Contest 123', contests[1].name)
|
||||||
|
assert.equals('Beginner Contest 123 (ABC)', contests[1].display_name)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('get_problems_for_contest', function()
|
||||||
|
it('returns problems from cache when available', function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
cache.load = function() end
|
||||||
|
cache.get_contest_data = function(_, _)
|
||||||
|
return {
|
||||||
|
problems = {
|
||||||
|
{ id = 'a', name = 'Problem A' },
|
||||||
|
{ id = 'b', name = 'Problem B' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local problems = picker.get_problems_for_contest('test_platform', 'test_contest')
|
||||||
|
assert.is_table(problems)
|
||||||
|
assert.equals(2, #problems)
|
||||||
|
assert.equals('a', problems[1].id)
|
||||||
|
assert.equals('Problem A', problems[1].name)
|
||||||
|
assert.equals('Problem A', problems[1].display_name)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('falls back to scraping when cache miss', function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local scrape = require('cp.scrape')
|
||||||
|
|
||||||
|
cache.load = function() end
|
||||||
|
cache.get_contest_data = function(_, _)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
scrape.scrape_contest_metadata = function(_, _)
|
||||||
|
return {
|
||||||
|
success = true,
|
||||||
|
problems = {
|
||||||
|
{ id = 'x', name = 'Problem X' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local problems = picker.get_problems_for_contest('test_platform', 'test_contest')
|
||||||
|
assert.is_table(problems)
|
||||||
|
assert.equals(1, #problems)
|
||||||
|
assert.equals('x', problems[1].id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns empty list when scraping fails', function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local scrape = require('cp.scrape')
|
||||||
|
|
||||||
|
cache.load = function() end
|
||||||
|
cache.get_contest_data = function(_, _)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
scrape.scrape_contest_metadata = function(_, _)
|
||||||
|
return {
|
||||||
|
success = false,
|
||||||
|
error = 'test error',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local problems = picker.get_problems_for_contest('test_platform', 'test_contest')
|
||||||
|
assert.is_table(problems)
|
||||||
|
assert.equals(0, #problems)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('setup_problem', function()
|
||||||
|
it('calls cp.handle_command with correct arguments', function()
|
||||||
|
local cp = require('cp')
|
||||||
|
local called_with = nil
|
||||||
|
|
||||||
|
cp.handle_command = function(opts)
|
||||||
|
called_with = opts
|
||||||
|
end
|
||||||
|
|
||||||
|
picker.setup_problem('codeforces', '1951', 'a')
|
||||||
|
|
||||||
|
assert.is_table(called_with)
|
||||||
|
assert.is_table(called_with.fargs)
|
||||||
|
assert.equals('codeforces', called_with.fargs[1])
|
||||||
|
assert.equals('1951', called_with.fargs[2])
|
||||||
|
assert.equals('a', called_with.fargs[3])
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
describe('cp.scrape', function()
|
describe('cp.scrape', function()
|
||||||
local scrape
|
local scrape
|
||||||
local mock_cache
|
local mock_cache
|
||||||
|
local mock_utils
|
||||||
local mock_system_calls
|
local mock_system_calls
|
||||||
local temp_files
|
local temp_files
|
||||||
local spec_helper = require('spec.spec_helper')
|
local spec_helper = require('spec.spec_helper')
|
||||||
|
|
@ -8,6 +9,7 @@ describe('cp.scrape', function()
|
||||||
before_each(function()
|
before_each(function()
|
||||||
spec_helper.setup()
|
spec_helper.setup()
|
||||||
temp_files = {}
|
temp_files = {}
|
||||||
|
mock_system_calls = {}
|
||||||
|
|
||||||
mock_cache = {
|
mock_cache = {
|
||||||
load = function() end,
|
load = function() end,
|
||||||
|
|
@ -18,7 +20,14 @@ describe('cp.scrape', function()
|
||||||
set_test_cases = function() end,
|
set_test_cases = function() end,
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_system_calls = {}
|
mock_utils = {
|
||||||
|
setup_python_env = function()
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
get_plugin_path = function()
|
||||||
|
return '/test/plugin/path'
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
vim.system = function(cmd, opts)
|
vim.system = function(cmd, opts)
|
||||||
table.insert(mock_system_calls, { cmd = cmd, opts = opts })
|
table.insert(mock_system_calls, { cmd = cmd, opts = opts })
|
||||||
|
|
@ -46,6 +55,8 @@ describe('cp.scrape', function()
|
||||||
end
|
end
|
||||||
|
|
||||||
package.loaded['cp.cache'] = mock_cache
|
package.loaded['cp.cache'] = mock_cache
|
||||||
|
package.loaded['cp.utils'] = mock_utils
|
||||||
|
package.loaded['cp.scrape'] = nil
|
||||||
scrape = require('cp.scrape')
|
scrape = require('cp.scrape')
|
||||||
|
|
||||||
local original_fn = vim.fn
|
local original_fn = vim.fn
|
||||||
|
|
@ -129,17 +140,22 @@ describe('cp.scrape', function()
|
||||||
|
|
||||||
describe('system dependency checks', function()
|
describe('system dependency checks', function()
|
||||||
it('handles missing uv executable', function()
|
it('handles missing uv executable', function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
local utils = require('cp.utils')
|
||||||
|
|
||||||
|
cache.load = function() end
|
||||||
|
cache.get_contest_data = function()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
vim.fn.executable = function(cmd)
|
vim.fn.executable = function(cmd)
|
||||||
return cmd == 'uv' and 0 or 1
|
return cmd == 'uv' and 0 or 1
|
||||||
end
|
end
|
||||||
|
|
||||||
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
|
utils.setup_python_env = function()
|
||||||
|
return vim.fn.executable('uv') == 1
|
||||||
|
end
|
||||||
|
|
||||||
assert.is_false(result.success)
|
|
||||||
assert.is_not_nil(result.error:match('Python environment setup failed'))
|
|
||||||
end)
|
|
||||||
|
|
||||||
it('handles python environment setup failure', function()
|
|
||||||
vim.system = function(cmd)
|
vim.system = function(cmd)
|
||||||
if cmd[1] == 'ping' then
|
if cmd[1] == 'ping' then
|
||||||
return {
|
return {
|
||||||
|
|
@ -147,12 +163,6 @@ describe('cp.scrape', function()
|
||||||
return { code = 0 }
|
return { code = 0 }
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
elseif cmd[1] == 'uv' and cmd[2] == 'sync' then
|
|
||||||
return {
|
|
||||||
wait = function()
|
|
||||||
return { code = 1, stderr = 'setup failed' }
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
return {
|
return {
|
||||||
wait = function()
|
wait = function()
|
||||||
|
|
@ -161,14 +171,43 @@ describe('cp.scrape', function()
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
vim.fn.isdirectory = function()
|
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
|
||||||
return 0
|
|
||||||
|
assert.is_false(result.success)
|
||||||
|
assert.is_not_nil(result.error)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('handles python environment setup failure', function()
|
||||||
|
local cache = require('cp.cache')
|
||||||
|
|
||||||
|
cache.load = function() end
|
||||||
|
cache.get_contest_data = function()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
mock_utils.setup_python_env = function()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
vim.system = function(cmd)
|
||||||
|
if cmd[1] == 'ping' then
|
||||||
|
return {
|
||||||
|
wait = function()
|
||||||
|
return { code = 0 }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
wait = function()
|
||||||
|
return { code = 0 }
|
||||||
|
end,
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
|
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
|
||||||
|
|
||||||
assert.is_false(result.success)
|
assert.is_false(result.success)
|
||||||
assert.is_not_nil(result.error:match('Python environment setup failed'))
|
assert.equals('Python environment setup failed', result.error)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
it('handles network connectivity issues', function()
|
it('handles network connectivity issues', function()
|
||||||
|
|
@ -396,7 +435,7 @@ describe('cp.scrape', function()
|
||||||
assert.is_not_nil(tests_call)
|
assert.is_not_nil(tests_call)
|
||||||
assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests'))
|
assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests'))
|
||||||
assert.is_true(vim.tbl_contains(tests_call.cmd, '1001'))
|
assert.is_true(vim.tbl_contains(tests_call.cmd, '1001'))
|
||||||
assert.is_false(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching'))
|
assert.is_true(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching'))
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
78
spec/telescope_spec.lua
Normal file
78
spec/telescope_spec.lua
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
describe('cp.telescope', function()
|
||||||
|
local spec_helper = require('spec.spec_helper')
|
||||||
|
|
||||||
|
before_each(function()
|
||||||
|
spec_helper.setup()
|
||||||
|
|
||||||
|
package.preload['telescope'] = function()
|
||||||
|
return {
|
||||||
|
register_extension = function(ext_config)
|
||||||
|
return ext_config
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
package.preload['telescope.pickers'] = function()
|
||||||
|
return {
|
||||||
|
new = function(_, _)
|
||||||
|
return {
|
||||||
|
find = function() end,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
package.preload['telescope.finders'] = function()
|
||||||
|
return {
|
||||||
|
new_table = function(opts)
|
||||||
|
return opts
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
package.preload['telescope.config'] = function()
|
||||||
|
return {
|
||||||
|
values = {
|
||||||
|
generic_sorter = function()
|
||||||
|
return {}
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
package.preload['telescope.actions'] = function()
|
||||||
|
return {
|
||||||
|
select_default = {
|
||||||
|
replace = function() end,
|
||||||
|
},
|
||||||
|
close = function() end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
package.preload['telescope.actions.state'] = function()
|
||||||
|
return {
|
||||||
|
get_selected_entry = function()
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_each(function()
|
||||||
|
spec_helper.teardown()
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('module loading', function()
|
||||||
|
it('registers telescope extension without error', function()
|
||||||
|
assert.has_no.errors(function()
|
||||||
|
require('cp.pickers.telescope')
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns module with platform_picker function', function()
|
||||||
|
local telescope_cp = require('cp.pickers.telescope')
|
||||||
|
assert.is_table(telescope_cp)
|
||||||
|
assert.is_function(telescope_cp.platform_picker)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
@ -94,7 +94,6 @@ def test_scrape_contests_success(mocker):
|
||||||
return mock_response
|
return mock_response
|
||||||
|
|
||||||
mocker.patch("scrapers.atcoder.requests.get", side_effect=mock_get_side_effect)
|
mocker.patch("scrapers.atcoder.requests.get", side_effect=mock_get_side_effect)
|
||||||
mocker.patch("scrapers.atcoder.time.sleep")
|
|
||||||
|
|
||||||
result = scrape_contests()
|
result = scrape_contests()
|
||||||
|
|
||||||
|
|
@ -116,7 +115,6 @@ def test_scrape_contests_no_table(mocker):
|
||||||
mock_response.text = "<html><body>No table found</body></html>"
|
mock_response.text = "<html><body>No table found</body></html>"
|
||||||
|
|
||||||
mocker.patch("scrapers.atcoder.requests.get", return_value=mock_response)
|
mocker.patch("scrapers.atcoder.requests.get", return_value=mock_response)
|
||||||
mocker.patch("scrapers.atcoder.time.sleep")
|
|
||||||
|
|
||||||
result = scrape_contests()
|
result = scrape_contests()
|
||||||
|
|
||||||
|
|
@ -127,7 +125,6 @@ def test_scrape_contests_network_error(mocker):
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"scrapers.atcoder.requests.get", side_effect=Exception("Network error")
|
"scrapers.atcoder.requests.get", side_effect=Exception("Network error")
|
||||||
)
|
)
|
||||||
mocker.patch("scrapers.atcoder.time.sleep")
|
|
||||||
|
|
||||||
result = scrape_contests()
|
result = scrape_contests()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,12 +168,12 @@ def test_scrape_categories_success(mocker):
|
||||||
assert result[0] == ContestSummary(
|
assert result[0] == ContestSummary(
|
||||||
id="introductory_problems",
|
id="introductory_problems",
|
||||||
name="Introductory Problems",
|
name="Introductory Problems",
|
||||||
display_name="Introductory Problems (2 problems)",
|
display_name="Introductory Problems",
|
||||||
)
|
)
|
||||||
assert result[1] == ContestSummary(
|
assert result[1] == ContestSummary(
|
||||||
id="sorting_and_searching",
|
id="sorting_and_searching",
|
||||||
name="Sorting and Searching",
|
name="Sorting and Searching",
|
||||||
display_name="Sorting and Searching (3 problems)",
|
display_name="Sorting and Searching",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
11
uv.lock
generated
11
uv.lock
generated
|
|
@ -2,6 +2,15 @@ version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "backoff"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "beautifulsoup4"
|
name = "beautifulsoup4"
|
||||||
version = "4.13.5"
|
version = "4.13.5"
|
||||||
|
|
@ -375,6 +384,7 @@ name = "scrapers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "backoff" },
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "cloudscraper" },
|
{ name = "cloudscraper" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
|
|
@ -392,6 +402,7 @@ dev = [
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "backoff", specifier = ">=2.2.1" },
|
||||||
{ name = "beautifulsoup4", specifier = ">=4.13.5" },
|
{ name = "beautifulsoup4", specifier = ">=4.13.5" },
|
||||||
{ name = "cloudscraper", specifier = ">=1.2.71" },
|
{ name = "cloudscraper", specifier = ">=1.2.71" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue