fix: synchronous problem fetch

This commit is contained in:
Barrett Ruth 2025-10-01 12:25:07 -04:00
parent 91ce43e529
commit b406c0ce4e
12 changed files with 140 additions and 352 deletions

View file

@ -11,13 +11,10 @@
---@class ContestListData
---@field contests table[]
---@field cached_at number
---@class ContestData
---@field problems Problem[]
---@field scraped_at string
---@field test_cases? CachedTestCase[]
---@field test_cases_cached_at? number
---@field timeout_ms? number
---@field memory_mb? number
---@field interactive? boolean
@ -121,7 +118,6 @@ function M.set_contest_data(platform, contest_id, problems)
cache_data[platform][contest_id] = {
problems = problems,
scraped_at = os.date('%Y-%m-%d'),
}
M.save()
@ -194,7 +190,6 @@ function M.set_test_cases(
end
cache_data[platform][problem_key].test_cases = test_cases
cache_data[platform][problem_key].test_cases_cached_at = os.time()
if timeout_ms then
cache_data[platform][problem_key].timeout_ms = timeout_ms
end
@ -273,7 +268,6 @@ function M.set_contest_list(platform, contests)
cache_data.contest_lists[platform] = {
contests = contests,
cached_at = os.time(),
}
M.save()

View file

@ -8,7 +8,7 @@ local platforms = constants.PLATFORMS
local actions = constants.ACTIONS
local function parse_command(args)
if #args == 0 then
if vim.tbl_isempty(args) then
return {
type = 'restore_from_file',
}

View file

@ -270,7 +270,7 @@ function M.setup(user_config)
end
end
if #available_langs == 0 then
if vim.tbl_isemtpy(available_langs) then
error('No language configurations found')
end

View file

@ -8,7 +8,7 @@ local function contest_picker(platform)
local fzf = require('fzf-lua')
local contests = picker_utils.get_contests_for_platform(platform)
if #contests == 0 then
if vim.tbl_isempty(contests) then
vim.notify(
('No contests found for platform: %s'):format(platform_display_name),
vim.log.levels.WARN
@ -27,7 +27,7 @@ local function contest_picker(platform)
},
actions = {
['default'] = function(selected)
if not selected or #selected == 0 then
if vim.tbl_isempty(selected) then
return
end
@ -65,7 +65,7 @@ function M.pick()
prompt = 'Select Platform> ',
actions = {
['default'] = function(selected)
if not selected or #selected == 0 then
if vim.tbl_isempty(selected) then
return
end

View file

@ -3,7 +3,7 @@ local M = {}
local cache = require('cp.cache')
local config = require('cp.config').get_config()
local logger = require('cp.log')
local utils = require('cp.utils')
local scraper = require('cp.scraper')
---@class cp.PlatformItem
---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses")
@ -40,157 +40,33 @@ end
---@param platform string Platform identifier (e.g. "codeforces", "atcoder")
---@return cp.ContestItem[]
function M.get_contests_for_platform(platform)
logger.log('loading contests...', vim.log.levels.INFO, true)
logger.log(('Loading %s contests..'):format(platform), vim.log.levels.INFO, true)
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 {}
end
local picker_contests = cache.get_contest_list(platform) or {}
local plugin_path = utils.get_plugin_path()
local cmd = {
'uv',
'run',
'--directory',
plugin_path,
'-m',
'scrapers.' .. platform,
'contests',
}
if vim.tbl_isempty(picker_contests) then
logger.log(('Cache miss on %s contests'):format(platform))
local contests = scraper.scrape_contest_list(platform)
local result = vim
.system(cmd, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
:wait()
cache.set_contest_list(platform, contests)
logger.log(('exit code: %d, stdout length: %d'):format(result.code, #(result.stdout or '')))
if result.stderr and #result.stderr > 0 then
logger.log(('stderr: %s'):format(result.stderr:sub(1, 200)))
end
if result.code ~= 0 then
logger.log(
('Failed to load contests: %s'):format(result.stderr or 'unknown error'),
vim.log.levels.ERROR
)
return {}
end
logger.log(('stdout preview: %s'):format(result.stdout:sub(1, 100)))
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
logger.log(('JSON parse error: %s'):format(tostring(data)), vim.log.levels.ERROR)
return {}
end
if not data.success then
logger.log(
('Scraper returned success=false: %s'):format(data.error or 'no error message'),
vim.log.levels.ERROR
)
return {}
end
local contests = {}
for _, contest in ipairs(data.contests or {}) do
table.insert(contests, {
id = contest.id,
name = contest.name,
display_name = contest.display_name,
})
end
logger.log(('loaded %d contests'):format(#contests))
return contests
end
---@param platform string Platform identifier
---@param contest_id string Contest identifier
---@return cp.ProblemItem[]
function M.get_problems_for_contest(platform, contest_id)
logger.log('loading contest problems...', vim.log.levels.INFO, true)
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,
for _, contest in ipairs(contests or {}) do
table.insert(picker_contests, {
id = contest.id,
name = contest.name,
display_name = contest.display_name,
})
end
return problems
end
if not utils.setup_python_env() then
return problems
end
local plugin_path = utils.get_plugin_path()
local cmd = {
'uv',
'run',
'--directory',
plugin_path,
'-m',
'scrapers.' .. platform,
'metadata',
contest_id,
}
local result = vim
.system(cmd, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
:wait()
if result.code ~= 0 then
logger.log(
('Failed to scrape contest: %s'):format(result.stderr or 'unknown error'),
vim.log.levels.ERROR
)
return problems
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
logger.log('Failed to parse contest data', vim.log.levels.ERROR)
return problems
end
if not data.success then
logger.log(data.error or 'Contest scraping failed', vim.log.levels.ERROR)
return problems
end
if not data.problems or #data.problems == 0 then
logger.log('Contest has no problems available', vim.log.levels.WARN)
return problems
end
cache.set_contest_data(platform, contest_id, data.problems)
for _, problem in ipairs(data.problems) do
table.insert(problems, {
id = problem.id,
name = problem.name,
display_name = problem.name,
})
end
return problems
logger.log(
('Loaded %d %s contests.'):format(#picker_contests, platform),
vim.log.levels.INFO,
true
)
return picker_contests
end
---@param platform string Platform identifier

View file

@ -13,7 +13,7 @@ local function contest_picker(opts, platform)
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform
local contests = picker_utils.get_contests_for_platform(platform)
if #contests == 0 then
if vim.tbl_isempty(contests) then
vim.notify(
('No contests found for platform: %s'):format(platform_display_name),
vim.log.levels.WARN

View file

@ -66,9 +66,9 @@ end
local function parse_test_cases_from_cache(platform, contest_id, problem_id)
local cache = require('cp.cache')
cache.load()
local cached_test_cases = cache.get_test_cases(platform, contest_id, problem_id)
local cached_test_cases = cache.get_test_cases(platform, contest_id, problem_id) or {}
if not cached_test_cases or #cached_test_cases == 0 then
if vim.tbl_isempty(cached_test_cases) then
return {}
end
@ -299,9 +299,9 @@ function M.load_test_cases(state)
state.get_platform() or '',
state.get_contest_id() or '',
state.get_problem_id()
)
) or {}
if #test_cases == 0 then
if vim.tbl_isempty(test_cases) then
local input_file = state.get_input_file()
local expected_file = state.get_expected_file()
test_cases = parse_test_cases_from_files(input_file, expected_file)

View file

@ -1,11 +1,42 @@
local M = {}
local cache = require('cp.cache')
local utils = require('cp.utils')
local function run_scraper(platform, subcommand, args, callback)
local logger = require('cp.log')
local function syshandle(result)
if result.code ~= 0 then
local msg = 'Scraper failed: ' .. (result.error or result.stderr or 'Unknown error')
logger.log(msg, vim.log.levels.ERROR)
return {
success = false,
error = msg,
}
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
local msg = 'Failed to parse scraper output: ' .. tostring(data)
logger.log(msg, vim.log.levels.ERROR)
return {
success = false,
error = msg,
}
end
return {
success = true,
data = data,
}
end
local function run_scraper(platform, subcommand, args, opts)
if not utils.setup_python_env() then
callback({ success = false, error = 'Python environment setup failed' })
return
local msg = 'Python environment setup failed'
logger.log(msg, vim.log.levels.ERROR)
return {
success = false,
message = msg,
}
end
local plugin_path = utils.get_plugin_path()
@ -18,114 +49,96 @@ local function run_scraper(platform, subcommand, args, callback)
'scrapers.' .. platform,
subcommand,
}
vim.list_extend(cmd, args)
for _, arg in ipairs(args or {}) do
table.insert(cmd, arg)
end
vim.system(cmd, {
cwd = plugin_path,
local sysopts = {
text = true,
timeout = 30000,
}, function(result)
if result.code ~= 0 then
callback({
success = false,
error = 'Scraper failed: ' .. (result.stderr or 'Unknown error'),
})
return
end
}
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
callback({
success = false,
error = 'Failed to parse scraper output: ' .. tostring(data),
})
return
end
callback(data)
end)
if opts.sync then
local result = vim.system(cmd, sysopts):wait()
return syshandle(result)
else
vim.system(cmd, sysopts, function(result)
return opts.on_exit(syshandle(result))
end)
end
end
function M.scrape_contest_metadata(platform, contest_id, callback)
cache.load()
local cached = cache.get_contest_data(platform, contest_id)
if cached then
callback({ success = true, problems = cached.problems })
return
end
run_scraper(platform, 'metadata', { contest_id }, function(result)
if result.success and result.problems then
cache.set_contest_data(platform, contest_id, result.problems)
end
callback(result)
end)
run_scraper(platform, 'metadata', { contest_id }, {
on_exit = function(result)
if result.success and result.data.problems then
callback(result.data.problems)
end
end,
})
end
function M.scrape_contest_list(platform, callback)
cache.load()
local cached = cache.get_contest_list(platform)
if cached then
callback({ success = true, contests = cached })
return
function M.scrape_contest_list(platform)
local result = run_scraper(platform, 'contests', {}, { sync = true })
if not result.success or not result.data.contests then
logger.log(('Could not scrape contests list for platform %s: %s'):format(platform, result.msg))
return {}
end
run_scraper(platform, 'contests', {}, function(result)
if result.success and result.contests then
cache.set_contest_list(platform, result.contests)
end
callback(result)
end)
return result.data.contests
end
function M.scrape_problem_tests(platform, contest_id, problem_id, callback)
run_scraper(platform, 'tests', { contest_id, problem_id }, function(result)
if result.success and result.tests then
vim.schedule(function()
local mkdir_ok = pcall(vim.fn.mkdir, 'io', 'p')
if mkdir_ok then
local config = require('cp.config')
local base_name = config.default_filename(contest_id, problem_id)
run_scraper(platform, 'tests', { contest_id, problem_id }, {
on_exit = function(result)
if result.success and result.data.tests then
vim.schedule(function()
local mkdir_ok = pcall(vim.fn.mkdir, 'io', 'p')
if mkdir_ok then
local config = require('cp.config')
local base_name = config.default_filename(contest_id, problem_id)
for i, test_case in ipairs(result.tests) do
local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin'
local expected_file = 'io/' .. base_name .. '.' .. i .. '.cpout'
for i, test_case in ipairs(result.tests) do
local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin'
local expected_file = 'io/' .. base_name .. '.' .. i .. '.cpout'
local input_content = test_case.input:gsub('\r', '')
local expected_content = test_case.expected:gsub('\r', '')
local input_content = test_case.input:gsub('\r', '')
local expected_content = test_case.expected:gsub('\r', '')
pcall(vim.fn.writefile, vim.split(input_content, '\n', true), input_file)
pcall(vim.fn.writefile, vim.split(expected_content, '\n', true), expected_file)
pcall(
vim.fn.writefile,
vim.split(input_content, '\n', { trimempty = true }),
input_file
)
pcall(
vim.fn.writefile,
vim.split(expected_content, '\n', { trimempty = true }),
expected_file
)
end
end
end
end)
end)
local cached_tests = {}
for i, test_case in ipairs(result.tests) do
table.insert(cached_tests, {
index = i,
input = test_case.input,
expected = test_case.expected,
})
local cached_tests = {}
for i, test_case in ipairs(result.tests) do
table.insert(cached_tests, {
index = i,
input = test_case.input,
expected = test_case.expected,
})
end
cache.set_test_cases(
platform,
contest_id,
problem_id,
cached_tests,
result.timeout_ms,
result.memory_mb
)
end
cache.set_test_cases(
platform,
contest_id,
problem_id,
cached_tests,
result.timeout_ms,
result.memory_mb
)
end
callback(result)
end)
callback(result)
end,
})
end
return M

View file

@ -28,10 +28,10 @@ function M.set_platform(platform)
return true
end
-- NOTE: this is backwards
function M.setup_contest(platform, contest_id, problem_id, language)
if not state.get_platform() then
logger.log('No platform configured. Use :CP <platform> <contest> [...] first.')
return
end
@ -42,6 +42,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
return
end
state.set_contest_id(contest_id)
logger.log('fetching contests problems...', vim.log.levels.INFO, true)
scraper.scrape_contest_metadata(platform, contest_id, function(result)
@ -54,14 +55,13 @@ function M.setup_contest(platform, contest_id, problem_id, language)
end
local problems = result.problems
if not problems or #problems == 0 then
if vim.tbl_isempty(problems) then
logger.log('no problems found in contest', vim.log.levels.ERROR)
return
end
logger.log(('found %d problems'):format(#problems))
state.set_contest_id(contest_id)
local target_problem = problem_id or problems[1].id
if problem_id then
@ -81,6 +81,7 @@ function M.setup_contest(platform, contest_id, problem_id, language)
end
end
-- NOTE: should setup buffer without a name, then save it with proper name later for immediate editing
M.setup_problem(contest_id, target_problem, language)
M.scrape_remaining_problems(platform, contest_id, problems)
@ -161,6 +162,7 @@ function M.setup_problem(contest_id, problem_id, language)
elseif vim.tbl_contains(config.scrapers, platform) then
logger.log('loading test cases...')
-- TODO: caching should be here, not in scrpaer.lua
scraper.scrape_problem_tests(platform, contest_id, problem_id, function(result)
if result.success then
logger.log(('loaded %d test cases for %s'):format(#(result.tests or {}), problem_id))
@ -194,7 +196,7 @@ function M.scrape_remaining_problems(platform, contest_id, problems)
end
end
if #missing_problems == 0 then
if vim.tbl_isempty(missing_problems) then
logger.log('all problems already cached')
return
end

View file

@ -235,7 +235,7 @@ function M.toggle_run_panel(is_debug)
local function navigate_test_case(delta)
local test_state = run.get_run_panel_state()
if #test_state.test_cases == 0 then
if vim.tbl_isempty(test_state.test_cases) then
return
end
test_state.current_index = (test_state.current_index + delta) % #test_state.test_cases