feat(picker): picker support

This commit is contained in:
Barrett Ruth 2025-09-21 11:10:54 -04:00
parent ea9883895f
commit a33e66680b
11 changed files with 829 additions and 2 deletions

View file

@ -72,6 +72,12 @@ 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. Requires
picker = 'telescope' or picker = 'fzf-lua'
in configuration and corresponding plugin
to be installed.
Navigation Commands ~
:CP next Navigate to next problem in current contest.
Stops at last problem (no wrapping).
@ -117,6 +123,32 @@ Template Variables ~
g++ abc324a.cpp -o build/abc324a.run -std=c++17
<
==============================================================================
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.
Flow: Platform → Contest → Problem → Setup
Requires picker = 'telescope' or picker = 'fzf-lua' in configuration.
Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed.
Picker availability is checked at runtime when command is executed.
Picker Flow ~
1. Platform Selection: Choose from AtCoder, Codeforces, CSES
2. Contest Selection: Choose from available contests for selected platform
3. Problem Selection: Choose from problems in selected contest
4. Problem Setup: Automatically runs equivalent of :CP platform contest problem
Notes ~
• Contest lists are fetched dynamically using scrapers
• Large contest lists may take time to load
• Runtime picker validation - shows clear error if picker plugin not available
• Picker configuration can be changed without plugin restart
==============================================================================
CONFIGURATION *cp-config*
@ -131,10 +163,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 = {
@ -165,6 +198,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 +214,11 @@ 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
:Telescope cp or :FzfLua cp commands for interactive
platform/contest/problem selection. Requires the
corresponding picker plugin to be installed.
{filename} (function, optional) Custom filename generation.
function(contest, contest_id, problem_id, config, language)
Should return full filename with extension.

View file

@ -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 {})

View file

@ -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'

View file

@ -698,6 +698,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, telescope = 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 +879,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

123
lua/cp/pickers/fzf_lua.lua Normal file
View file

@ -0,0 +1,123 @@
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),
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,
},
})
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,
}

145
lua/cp/pickers/init.lua Normal file
View file

@ -0,0 +1,145 @@
local M = {}
local cache = require('cp.cache')
local logger = require('cp.log')
local scrape = require('cp.scrape')
---@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 = {}
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 plugin_path = 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
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 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

View file

@ -0,0 +1,130 @@
local finders = require('telescope.finders')
local pickers = require('telescope.pickers')
local telescope = require('telescope')
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 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, map)
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
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),
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)
return true
end,
})
:find()
end
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, map)
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
return {
platform_picker = platform_picker,
}

View file

@ -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
View 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(entries, opts) 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)

188
spec/picker_spec.lua Normal file
View file

@ -0,0 +1,188 @@
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_true(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(cmd, opts)
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(cmd, opts)
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()
vim.system = function(cmd, opts)
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(platform, contest_id)
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('a - 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(platform, contest_id)
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(platform, contest_id)
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)

78
spec/telescope_spec.lua Normal file
View 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(opts, picker_opts)
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)