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/**'
|
||||
- '*.lua'
|
||||
- '.luarc.json'
|
||||
- 'stylua.toml'
|
||||
- 'selene.toml'
|
||||
- '*.toml'
|
||||
python:
|
||||
- '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,
|
||||
compilation, and testing workflow for online judges.
|
||||
|
||||
Supported platforms: AtCoder, Codeforces, CSES
|
||||
Supported languages: C++, Python
|
||||
Supported platforms (for now!): AtCoder, Codeforces, CSES
|
||||
|
||||
==============================================================================
|
||||
REQUIREMENTS *cp-requirements*
|
||||
|
|
@ -72,6 +71,9 @@ COMMANDS *cp-commands*
|
|||
Use --debug flag to compile with debug flags.
|
||||
Requires contest setup first.
|
||||
|
||||
:CP pick Launch configured picker for interactive
|
||||
platform/contest/problem selection.
|
||||
|
||||
Navigation Commands ~
|
||||
:CP next Navigate to next problem in current contest.
|
||||
Stops at last problem (no wrapping).
|
||||
|
|
@ -131,10 +133,11 @@ Here's an example configuration with lazy.nvim: >lua
|
|||
default = {
|
||||
cpp = {
|
||||
compile = { 'g++', '{source}', '-o', '{binary}',
|
||||
'-std=c++17' },
|
||||
'-std=c++17', '-fdiagnostic-colors=always' },
|
||||
test = { '{binary}' },
|
||||
debug = { 'g++', '{source}', '-o', '{binary}',
|
||||
'-std=c++17', '-g',
|
||||
'-fdiagnostic-colors=always'
|
||||
'-fsanitize=address,undefined' },
|
||||
},
|
||||
python = {
|
||||
|
|
@ -143,14 +146,8 @@ Here's an example configuration with lazy.nvim: >lua
|
|||
},
|
||||
},
|
||||
snippets = {},
|
||||
hooks = {
|
||||
before_run = nil,
|
||||
before_debug = nil,
|
||||
setup_code = nil,
|
||||
},
|
||||
debug = false,
|
||||
scrapers = { 'atcoder', 'codeforces', 'cses' },
|
||||
filename = default_filename, -- <contest id> + <problem id>
|
||||
run_panel = {
|
||||
ansi = true,
|
||||
diff_mode = 'vim',
|
||||
|
|
@ -165,6 +162,7 @@ Here's an example configuration with lazy.nvim: >lua
|
|||
'--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
|
||||
{run_panel} (|RunPanelConfig|) Test panel behavior configuration.
|
||||
{diff} (|DiffConfig|) Diff backend configuration.
|
||||
{picker} (string, optional) Picker integration: "telescope",
|
||||
"fzf-lua", or nil to disable. When enabled, provides
|
||||
:CP pick for interactive platform/contest/problem selection.
|
||||
{filename} (function, optional) Custom filename generation.
|
||||
function(contest, contest_id, problem_id, config, language)
|
||||
Should return full filename with extension.
|
||||
|
|
@ -279,21 +280,6 @@ AtCoder ~
|
|||
*cp-atcoder*
|
||||
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: >
|
||||
:CP atcoder abc324 a " Full setup: problem A from contest ABC324
|
||||
:CP atcoder abc324 " Contest setup: load contest metadata only
|
||||
|
|
@ -308,21 +294,6 @@ Codeforces ~
|
|||
*cp-codeforces*
|
||||
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: >
|
||||
:CP codeforces 1934 a " Full setup: problem A from contest 1934
|
||||
:CP codeforces 1934 " Contest setup: load contest metadata only
|
||||
|
|
@ -336,21 +307,6 @@ CSES ~
|
|||
*cp-cses*
|
||||
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: >
|
||||
:CP cses dynamic_programming 1633 " Set up problem 1633 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
|
||||
|
||||
|
|
@ -398,6 +354,39 @@ Example: Setting up and solving AtCoder contest ABC324
|
|||
|
||||
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*
|
||||
|
||||
|
|
@ -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
|
||||
compiler output, program stderr, and diff visualization.
|
||||
|
||||
==============================================================================
|
||||
HIGHLIGHT GROUPS *cp-highlights*
|
||||
HIGHLIGHT GROUPS *cp-highlights*
|
||||
|
||||
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.
|
||||
|
||||
==============================================================================
|
||||
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
|
||||
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
|
||||
|
||||
ANSI colors automatically use your terminal's color palette through Neovim's
|
||||
vim.g.terminal_color_* variables. This ensures compiler colors match your
|
||||
colorscheme without manual configuration.
|
||||
|
||||
If your colorscheme doesn't set terminal colors, cp.nvim falls back to
|
||||
sensible defaults. You can override terminal colors in your configuration: >vim
|
||||
let g:terminal_color_1 = '#ff6b6b' " Custom red
|
||||
let g:terminal_color_2 = '#51cf66' " Custom green
|
||||
<
|
||||
If your colorscheme doesn't set terminal colors, cp.nvim will warn you and
|
||||
ANSI colors won't display properly - set them like so: >vim
|
||||
let g:terminal_color_1 = '#ff6b6b'
|
||||
...
|
||||
|
||||
==============================================================================
|
||||
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
|
||||
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
|
||||
|
||||
You can customize any highlight group by linking to existing groups or
|
||||
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
|
||||
run_panel.next_test_key)
|
||||
<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}.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*
|
||||
|
||||
|
|
@ -690,7 +587,15 @@ snippets include basic C++ and Python templates for each contest type.
|
|||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@
|
|||
---@class CacheData
|
||||
---@field [string] table<string, ContestData>
|
||||
---@field file_states? table<string, FileState>
|
||||
---@field contest_lists? table<string, ContestListData>
|
||||
|
||||
---@class ContestListData
|
||||
---@field contests table[]
|
||||
---@field cached_at number
|
||||
---@field expires_at number
|
||||
|
||||
---@class ContestData
|
||||
---@field problems Problem[]
|
||||
|
|
@ -33,6 +39,12 @@ local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
|
|||
local cache_data = {}
|
||||
local loaded = false
|
||||
|
||||
local CONTEST_LIST_TTL = {
|
||||
cses = 7 * 24 * 60 * 60, -- 1 week
|
||||
codeforces = 24 * 60 * 60, -- 1 day
|
||||
atcoder = 24 * 60 * 60, -- 1 day
|
||||
}
|
||||
|
||||
---@param platform string
|
||||
---@return number?
|
||||
local function get_expiry_date(platform)
|
||||
|
|
@ -277,4 +289,57 @@ function M.set_file_state(file_path, platform, contest_id, problem_id, language)
|
|||
M.save()
|
||||
end
|
||||
|
||||
---@param platform string
|
||||
---@return table[]?
|
||||
function M.get_contest_list(platform)
|
||||
vim.validate({
|
||||
platform = { platform, 'string' },
|
||||
})
|
||||
|
||||
if not cache_data.contest_lists or not cache_data.contest_lists[platform] then
|
||||
return nil
|
||||
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
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
|
||||
---@field run_panel RunPanelConfig
|
||||
---@field diff DiffConfig
|
||||
---@field picker "telescope"|"fzf-lua"|nil
|
||||
|
||||
---@class cp.UserConfig
|
||||
---@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 run_panel? RunPanelConfig
|
||||
---@field diff? DiffConfig
|
||||
---@field picker? "telescope"|"fzf-lua"|nil
|
||||
|
||||
local M = {}
|
||||
local constants = require('cp.constants')
|
||||
|
|
@ -110,6 +112,7 @@ M.defaults = {
|
|||
args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' },
|
||||
},
|
||||
},
|
||||
picker = nil,
|
||||
}
|
||||
|
||||
---@param user_config cp.UserConfig|nil
|
||||
|
|
@ -129,6 +132,7 @@ function M.setup(user_config)
|
|||
filename = { user_config.filename, { 'function', 'nil' }, true },
|
||||
run_panel = { user_config.run_panel, { 'table', 'nil' }, true },
|
||||
diff = { user_config.diff, { 'table', 'nil' }, true },
|
||||
picker = { user_config.picker, { 'string', 'nil' }, true },
|
||||
})
|
||||
|
||||
if user_config.contests then
|
||||
|
|
@ -164,6 +168,12 @@ function M.setup(user_config)
|
|||
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
|
||||
|
||||
local config = vim.tbl_deep_extend('force', M.defaults, user_config or {})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
local M = {}
|
||||
|
||||
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.PYTHON = 'python'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
local M = {}
|
||||
|
||||
local utils = require('cp.utils')
|
||||
|
||||
local function check_nvim_version()
|
||||
if vim.fn.has('nvim-0.10.0') == 1 then
|
||||
vim.health.ok('Neovim 0.10.0+ detected')
|
||||
|
|
@ -22,8 +24,7 @@ local function check_uv()
|
|||
end
|
||||
|
||||
local function check_python_env()
|
||||
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
||||
plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
||||
local plugin_path = utils.get_plugin_path()
|
||||
local venv_dir = plugin_path .. '/.venv'
|
||||
|
||||
if vim.fn.isdirectory(venv_dir) == 1 then
|
||||
|
|
@ -34,8 +35,7 @@ local function check_python_env()
|
|||
end
|
||||
|
||||
local function check_scrapers()
|
||||
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
||||
plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
||||
local plugin_path = utils.get_plugin_path()
|
||||
|
||||
local scrapers = { 'atcoder.py', 'codeforces.py', 'cses.py' }
|
||||
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
|
||||
logger.log(('using cached test cases (%d)'):format(#cached_test_cases))
|
||||
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)
|
||||
|
||||
if not scrape_result.success then
|
||||
|
|
@ -562,9 +573,9 @@ local function toggle_run_panel(is_debug)
|
|||
config.hooks.before_debug(ctx)
|
||||
end
|
||||
|
||||
local execute_module = require('cp.execute')
|
||||
local execute = require('cp.execute')
|
||||
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
|
||||
run.run_all_test_cases(ctx, contest_config, config)
|
||||
else
|
||||
|
|
@ -698,6 +709,48 @@ local function navigate_problem(delta, language)
|
|||
setup_problem(state.contest_id, new_problem.id, language)
|
||||
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 current_file = vim.fn.expand('%:p')
|
||||
if current_file == '' then
|
||||
|
|
@ -837,6 +890,8 @@ function M.handle_command(opts)
|
|||
navigate_problem(1, cmd.language)
|
||||
elseif cmd.action == 'prev' then
|
||||
navigate_problem(-1, cmd.language)
|
||||
elseif cmd.action == 'pick' then
|
||||
handle_pick_action()
|
||||
end
|
||||
return
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ function M.set_config(user_config)
|
|||
config = user_config
|
||||
end
|
||||
|
||||
function M.log(msg, level)
|
||||
function M.log(msg, level, override)
|
||||
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)
|
||||
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 cache = require('cp.cache')
|
||||
local logger = require('cp.log')
|
||||
local problem = require('cp.problem')
|
||||
|
||||
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 utils = require('cp.utils')
|
||||
|
||||
local function check_internet_connectivity()
|
||||
local result = vim.system({ 'ping', '-c', '1', '-W', '3', '8.8.8.8' }, { text = true }):wait()
|
||||
return result.code == 0
|
||||
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 contest_id string
|
||||
---@return {success: boolean, problems?: table[], error?: string}
|
||||
|
|
@ -77,14 +47,14 @@ function M.scrape_contest_metadata(platform, contest_id)
|
|||
}
|
||||
end
|
||||
|
||||
if not setup_python_env() then
|
||||
if not utils.setup_python_env() then
|
||||
return {
|
||||
success = false,
|
||||
error = 'Python environment setup failed',
|
||||
}
|
||||
end
|
||||
|
||||
local plugin_path = get_plugin_path()
|
||||
local plugin_path = utils.get_plugin_path()
|
||||
|
||||
local args = {
|
||||
'uv',
|
||||
|
|
@ -182,7 +152,7 @@ function M.scrape_problem(ctx)
|
|||
}
|
||||
end
|
||||
|
||||
if not setup_python_env() then
|
||||
if not utils.setup_python_env() then
|
||||
return {
|
||||
success = false,
|
||||
problem_id = ctx.problem_name,
|
||||
|
|
@ -190,33 +160,19 @@ function M.scrape_problem(ctx)
|
|||
}
|
||||
end
|
||||
|
||||
local plugin_path = get_plugin_path()
|
||||
local plugin_path = utils.get_plugin_path()
|
||||
|
||||
local args
|
||||
if ctx.contest == 'cses' then
|
||||
args = {
|
||||
'uv',
|
||||
'run',
|
||||
'--directory',
|
||||
plugin_path,
|
||||
'-m',
|
||||
'scrapers.' .. ctx.contest,
|
||||
'tests',
|
||||
ctx.problem_id,
|
||||
}
|
||||
else
|
||||
args = {
|
||||
'uv',
|
||||
'run',
|
||||
'--directory',
|
||||
plugin_path,
|
||||
'-m',
|
||||
'scrapers.' .. ctx.contest,
|
||||
'tests',
|
||||
ctx.contest_id,
|
||||
ctx.problem_id,
|
||||
}
|
||||
end
|
||||
local args = {
|
||||
'uv',
|
||||
'run',
|
||||
'--directory',
|
||||
plugin_path,
|
||||
'-m',
|
||||
'scrapers.' .. ctx.contest,
|
||||
'tests',
|
||||
ctx.contest_id,
|
||||
ctx.problem_id,
|
||||
}
|
||||
|
||||
local result = vim
|
||||
.system(args, {
|
||||
|
|
@ -308,11 +264,11 @@ function M.scrape_problems_parallel(platform, contest_id, problems, config)
|
|||
return {}
|
||||
end
|
||||
|
||||
if not setup_python_env() then
|
||||
if not utils.setup_python_env() then
|
||||
return {}
|
||||
end
|
||||
|
||||
local plugin_path = get_plugin_path()
|
||||
local plugin_path = utils.get_plugin_path()
|
||||
local jobs = {}
|
||||
|
||||
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 utils = require('cp.utils')
|
||||
|
||||
local function get_git_version()
|
||||
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
|
||||
local plugin_root = vim.fn.fnamemodify(plugin_path, ':h:h:h')
|
||||
local plugin_root = utils.get_plugin_path()
|
||||
|
||||
local result = vim
|
||||
.system({ 'git', 'describe', '--tags', '--always', '--dirty' }, {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ description = "Add your description here"
|
|||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"backoff>=2.2.1",
|
||||
"beautifulsoup4>=4.13.5",
|
||||
"cloudscraper>=1.2.71",
|
||||
"requests>=2.32.5",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
|
||||
import backoff
|
||||
import requests
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
|
|
@ -99,7 +99,6 @@ def scrape_contest_problems(contest_id: str) -> list[ProblemSummary]:
|
|||
if problem:
|
||||
problems.append(problem)
|
||||
|
||||
problems.sort(key=lambda x: x.id)
|
||||
return problems
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -169,7 +168,6 @@ def scrape(url: str) -> list[TestCase]:
|
|||
|
||||
def scrape_contests() -> list[ContestSummary]:
|
||||
import concurrent.futures
|
||||
import random
|
||||
|
||||
def get_max_pages() -> int:
|
||||
try:
|
||||
|
|
@ -197,168 +195,161 @@ def scrape_contests() -> list[ContestSummary]:
|
|||
except Exception:
|
||||
return 15
|
||||
|
||||
def scrape_page_with_retry(page: int, max_retries: int = 3) -> list[ContestSummary]:
|
||||
for attempt in range(max_retries):
|
||||
def scrape_page(page: int) -> list[ContestSummary]:
|
||||
@backoff.on_exception(
|
||||
backoff.expo,
|
||||
(requests.exceptions.RequestException, requests.exceptions.HTTPError),
|
||||
max_tries=4,
|
||||
jitter=backoff.random_jitter,
|
||||
on_backoff=lambda details: print(
|
||||
f"Request failed on page {page} (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}",
|
||||
file=sys.stderr,
|
||||
),
|
||||
)
|
||||
@backoff.on_predicate(
|
||||
backoff.expo,
|
||||
lambda response: response.status_code == 429,
|
||||
max_tries=4,
|
||||
jitter=backoff.random_jitter,
|
||||
on_backoff=lambda details: print(
|
||||
f"Rate limited on page {page}, retrying in {details['wait']:.1f}s",
|
||||
file=sys.stderr,
|
||||
),
|
||||
)
|
||||
def make_request() -> requests.Response:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
url = f"https://atcoder.jp/contests/archive?page={page}"
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
try:
|
||||
response = make_request()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
table = soup.find("table", class_="table")
|
||||
if not table:
|
||||
return []
|
||||
|
||||
tbody = table.find("tbody")
|
||||
if not tbody or not isinstance(tbody, Tag):
|
||||
return []
|
||||
|
||||
rows = tbody.find_all("tr")
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
contests = []
|
||||
for row in rows:
|
||||
cells = row.find_all("td")
|
||||
if len(cells) < 2:
|
||||
continue
|
||||
|
||||
contest_cell = cells[1]
|
||||
link = contest_cell.find("a")
|
||||
if not link or not link.get("href"):
|
||||
continue
|
||||
|
||||
href = link.get("href")
|
||||
contest_id = href.split("/")[-1]
|
||||
name = link.get_text().strip()
|
||||
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
url = f"https://atcoder.jp/contests/archive?page={page}"
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
name = name.encode().decode("unicode_escape")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
|
||||
if response.status_code == 429:
|
||||
backoff_time = (2**attempt) + random.uniform(0, 1)
|
||||
print(
|
||||
f"Rate limited on page {page}, retrying in {backoff_time:.1f}s",
|
||||
file=sys.stderr,
|
||||
)
|
||||
time.sleep(backoff_time)
|
||||
continue
|
||||
name = (
|
||||
name.replace("\uff08", "(")
|
||||
.replace("\uff09", ")")
|
||||
.replace("\u3000", " ")
|
||||
)
|
||||
name = re.sub(
|
||||
r"[\uff01-\uff5e]", lambda m: chr(ord(m.group()) - 0xFEE0), name
|
||||
)
|
||||
|
||||
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")
|
||||
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:
|
||||
name = name.encode().decode("unicode_escape")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
pass
|
||||
|
||||
name = (
|
||||
name.replace("\uff08", "(")
|
||||
.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,
|
||||
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,
|
||||
)
|
||||
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()
|
||||
page_results = {}
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
future_to_page = {
|
||||
executor.submit(scrape_page_with_retry, page): page
|
||||
for page in range(1, max_pages + 1)
|
||||
executor.submit(scrape_page, page): page for page in range(1, max_pages + 1)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
problems.sort(key=lambda x: x.id)
|
||||
|
||||
seen: set[str] = set()
|
||||
unique_problems: list[ProblemSummary] = []
|
||||
for p in problems:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
|
||||
import backoff
|
||||
import requests
|
||||
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())
|
||||
|
||||
|
||||
def request_with_retry(
|
||||
url: str, headers: dict, max_retries: int = 3
|
||||
) -> requests.Response:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
delay = 0.5 + random.uniform(0, 0.3)
|
||||
time.sleep(delay)
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 429:
|
||||
backoff = (2**attempt) + random.uniform(0, 1)
|
||||
print(f"Rate limited, retrying in {backoff:.1f}s", file=sys.stderr)
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
backoff = 2**attempt
|
||||
print(
|
||||
f"Request failed (attempt {attempt + 1}), retrying in {backoff}s: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
time.sleep(backoff)
|
||||
|
||||
raise Exception("All retry attempts failed")
|
||||
@backoff.on_exception(
|
||||
backoff.expo,
|
||||
(requests.exceptions.RequestException, requests.exceptions.HTTPError),
|
||||
max_tries=4,
|
||||
jitter=backoff.random_jitter,
|
||||
on_backoff=lambda details: print(
|
||||
f"Request failed (attempt {details['tries']}), retrying in {details['wait']:.1f}s: {details['exception']}",
|
||||
file=sys.stderr,
|
||||
),
|
||||
)
|
||||
@backoff.on_predicate(
|
||||
backoff.expo,
|
||||
lambda response: response.status_code == 429,
|
||||
max_tries=4,
|
||||
jitter=backoff.random_jitter,
|
||||
on_backoff=lambda details: print(
|
||||
f"Rate limited, retrying in {details['wait']:.1f}s", file=sys.stderr
|
||||
),
|
||||
)
|
||||
def make_request(url: str, headers: dict) -> requests.Response:
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
response = request_with_retry(problemset_url, headers)
|
||||
response = make_request(problemset_url, headers)
|
||||
|
||||
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.sort(key=lambda x: int(x.id))
|
||||
return problems
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -176,7 +167,7 @@ def scrape_categories() -> list[ContestSummary]:
|
|||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
response = request_with_retry("https://cses.fi/problemset/", headers)
|
||||
response = make_request("https://cses.fi/problemset/", headers)
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
categories = []
|
||||
|
|
@ -188,12 +179,7 @@ def scrape_categories() -> list[ContestSummary]:
|
|||
|
||||
category_id = normalize_category_name(category_name)
|
||||
|
||||
ul = h2.find_next_sibling("ul", class_="task-list")
|
||||
problem_count = 0
|
||||
if ul:
|
||||
problem_count = len(ul.find_all("li", class_="task"))
|
||||
|
||||
display_name = f"{category_name} ({problem_count} problems)"
|
||||
display_name = category_name
|
||||
|
||||
categories.append(
|
||||
ContestSummary(
|
||||
|
|
@ -276,9 +262,6 @@ def scrape_all_problems() -> dict[str, list[ProblemSummary]]:
|
|||
problem = ProblemSummary(id=problem_id, name=problem_name)
|
||||
all_categories[current_category].append(problem)
|
||||
|
||||
for category in all_categories:
|
||||
all_categories[category].sort(key=lambda x: int(x.id))
|
||||
|
||||
print(
|
||||
f"Found {len(all_categories)} categories with {sum(len(probs) for probs in all_categories.values())} problems",
|
||||
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"
|
||||
}
|
||||
|
||||
response = request_with_retry(url, headers)
|
||||
response = make_request(url, headers)
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
|
|
|
|||
|
|
@ -231,6 +231,39 @@ describe('cp.config', function()
|
|||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('picker validation', function()
|
||||
it('validates picker is valid value', function()
|
||||
local invalid_config = {
|
||||
picker = 'invalid_picker',
|
||||
}
|
||||
|
||||
assert.has_error(function()
|
||||
config.setup(invalid_config)
|
||||
end, "Invalid picker 'invalid_picker'. Must be 'telescope' or 'fzf-lua'")
|
||||
end)
|
||||
|
||||
it('allows nil picker', function()
|
||||
assert.has_no.errors(function()
|
||||
local result = config.setup({ picker = nil })
|
||||
assert.is_nil(result.picker)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('allows telescope picker without checking availability', function()
|
||||
assert.has_no.errors(function()
|
||||
local result = config.setup({ picker = 'telescope' })
|
||||
assert.equals('telescope', result.picker)
|
||||
end)
|
||||
end)
|
||||
|
||||
it('allows fzf-lua picker without checking availability', function()
|
||||
assert.has_no.errors(function()
|
||||
local result = config.setup({ picker = 'fzf-lua' })
|
||||
assert.equals('fzf-lua', result.picker)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('default_filename', function()
|
||||
|
|
|
|||
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()
|
||||
local scrape
|
||||
local mock_cache
|
||||
local mock_utils
|
||||
local mock_system_calls
|
||||
local temp_files
|
||||
local spec_helper = require('spec.spec_helper')
|
||||
|
|
@ -8,6 +9,7 @@ describe('cp.scrape', function()
|
|||
before_each(function()
|
||||
spec_helper.setup()
|
||||
temp_files = {}
|
||||
mock_system_calls = {}
|
||||
|
||||
mock_cache = {
|
||||
load = function() end,
|
||||
|
|
@ -18,7 +20,14 @@ describe('cp.scrape', function()
|
|||
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)
|
||||
table.insert(mock_system_calls, { cmd = cmd, opts = opts })
|
||||
|
|
@ -46,6 +55,8 @@ describe('cp.scrape', function()
|
|||
end
|
||||
|
||||
package.loaded['cp.cache'] = mock_cache
|
||||
package.loaded['cp.utils'] = mock_utils
|
||||
package.loaded['cp.scrape'] = nil
|
||||
scrape = require('cp.scrape')
|
||||
|
||||
local original_fn = vim.fn
|
||||
|
|
@ -129,17 +140,22 @@ describe('cp.scrape', function()
|
|||
|
||||
describe('system dependency checks', function()
|
||||
it('handles missing uv executable', function()
|
||||
local cache = require('cp.cache')
|
||||
local utils = require('cp.utils')
|
||||
|
||||
cache.load = function() end
|
||||
cache.get_contest_data = function()
|
||||
return nil
|
||||
end
|
||||
|
||||
vim.fn.executable = function(cmd)
|
||||
return cmd == 'uv' and 0 or 1
|
||||
end
|
||||
|
||||
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)
|
||||
if cmd[1] == 'ping' then
|
||||
return {
|
||||
|
|
@ -147,12 +163,6 @@ describe('cp.scrape', function()
|
|||
return { code = 0 }
|
||||
end,
|
||||
}
|
||||
elseif cmd[1] == 'uv' and cmd[2] == 'sync' then
|
||||
return {
|
||||
wait = function()
|
||||
return { code = 1, stderr = 'setup failed' }
|
||||
end,
|
||||
}
|
||||
end
|
||||
return {
|
||||
wait = function()
|
||||
|
|
@ -161,14 +171,43 @@ describe('cp.scrape', function()
|
|||
}
|
||||
end
|
||||
|
||||
vim.fn.isdirectory = function()
|
||||
return 0
|
||||
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
|
||||
|
||||
assert.is_false(result.success)
|
||||
assert.is_not_nil(result.error)
|
||||
end)
|
||||
|
||||
it('handles python environment setup failure', function()
|
||||
local cache = require('cp.cache')
|
||||
|
||||
cache.load = function() end
|
||||
cache.get_contest_data = function()
|
||||
return nil
|
||||
end
|
||||
|
||||
mock_utils.setup_python_env = function()
|
||||
return false
|
||||
end
|
||||
|
||||
vim.system = function(cmd)
|
||||
if cmd[1] == 'ping' then
|
||||
return {
|
||||
wait = function()
|
||||
return { code = 0 }
|
||||
end,
|
||||
}
|
||||
end
|
||||
return {
|
||||
wait = function()
|
||||
return { code = 0 }
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local result = scrape.scrape_contest_metadata('atcoder', 'abc123')
|
||||
|
||||
assert.is_false(result.success)
|
||||
assert.is_not_nil(result.error:match('Python environment setup failed'))
|
||||
assert.equals('Python environment setup failed', result.error)
|
||||
end)
|
||||
|
||||
it('handles network connectivity issues', function()
|
||||
|
|
@ -396,7 +435,7 @@ describe('cp.scrape', function()
|
|||
assert.is_not_nil(tests_call)
|
||||
assert.is_true(vim.tbl_contains(tests_call.cmd, 'tests'))
|
||||
assert.is_true(vim.tbl_contains(tests_call.cmd, '1001'))
|
||||
assert.is_false(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching'))
|
||||
assert.is_true(vim.tbl_contains(tests_call.cmd, 'sorting_and_searching'))
|
||||
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
|
||||
|
||||
mocker.patch("scrapers.atcoder.requests.get", side_effect=mock_get_side_effect)
|
||||
mocker.patch("scrapers.atcoder.time.sleep")
|
||||
|
||||
result = scrape_contests()
|
||||
|
||||
|
|
@ -116,7 +115,6 @@ def test_scrape_contests_no_table(mocker):
|
|||
mock_response.text = "<html><body>No table found</body></html>"
|
||||
|
||||
mocker.patch("scrapers.atcoder.requests.get", return_value=mock_response)
|
||||
mocker.patch("scrapers.atcoder.time.sleep")
|
||||
|
||||
result = scrape_contests()
|
||||
|
||||
|
|
@ -127,7 +125,6 @@ def test_scrape_contests_network_error(mocker):
|
|||
mocker.patch(
|
||||
"scrapers.atcoder.requests.get", side_effect=Exception("Network error")
|
||||
)
|
||||
mocker.patch("scrapers.atcoder.time.sleep")
|
||||
|
||||
result = scrape_contests()
|
||||
|
||||
|
|
|
|||
|
|
@ -168,12 +168,12 @@ def test_scrape_categories_success(mocker):
|
|||
assert result[0] == ContestSummary(
|
||||
id="introductory_problems",
|
||||
name="Introductory Problems",
|
||||
display_name="Introductory Problems (2 problems)",
|
||||
display_name="Introductory Problems",
|
||||
)
|
||||
assert result[1] == ContestSummary(
|
||||
id="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
|
||||
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]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.13.5"
|
||||
|
|
@ -375,6 +384,7 @@ name = "scrapers"
|
|||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "cloudscraper" },
|
||||
{ name = "requests" },
|
||||
|
|
@ -392,6 +402,7 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "backoff", specifier = ">=2.2.1" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.13.5" },
|
||||
{ name = "cloudscraper", specifier = ">=1.2.71" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue