feat: :CP test refactor

This commit is contained in:
Barrett Ruth 2025-09-18 10:20:20 -04:00
parent b2e1ea2c58
commit fb240fd501
10 changed files with 97 additions and 200 deletions

View file

@ -49,17 +49,10 @@ Setup Commands ~
Action Commands ~ Action Commands ~
:CP run Compile and run current problem with test input. :CP test [--debug] Toggle test panel for individual test case
Shows execution time and output comparison.
Requires contest setup first.
:CP debug Compile with debug flags and run current problem.
Includes sanitizers and debug symbols.
Requires contest setup first.
:CP test Toggle test panel for individual test case
debugging. Shows per-test results with three-pane debugging. Shows per-test results with three-pane
layout for easy Expected/Actual comparison. layout for easy Expected/Actual comparison.
Use --debug flag to compile with debug flags
Requires contest setup first. Requires contest setup first.
Navigation Commands ~ Navigation Commands ~
@ -123,7 +116,6 @@ Optional configuration with lazy.nvim: >
end, end,
}, },
snippets = { ... }, -- LuaSnip snippets snippets = { ... }, -- LuaSnip snippets
tile = function(source_buf, input_buf, output_buf) ... end,
filename = function(contest, contest_id, problem_id, config, language) ... end, filename = function(contest, contest_id, problem_id, config, language) ... end,
} }
} }
@ -139,8 +131,6 @@ Optional configuration with lazy.nvim: >
during operation. during operation.
• {scrapers} (`table<string,boolean>`) Per-platform scraper control. • {scrapers} (`table<string,boolean>`) Per-platform scraper control.
Default enables all platforms. Default enables all platforms.
• {tile}? (`function`) Custom window arrangement function.
`function(source_buf, input_buf, output_buf)`
• {filename}? (`function`) Custom filename generation function. • {filename}? (`function`) Custom filename generation function.
`function(contest, contest_id, problem_id, config, language)` `function(contest, contest_id, problem_id, config, language)`
Should return full filename with extension. Should return full filename with extension.
@ -170,9 +160,7 @@ Optional configuration with lazy.nvim: >
*cp.Hooks* *cp.Hooks*
Fields: ~ Fields: ~
• {before_run}? (`function`) Called before `:CP run`. • {before_debug}? (`function`) Called before debug compilation.
`function(ctx: ProblemContext)`
• {before_debug}? (`function`) Called before `:CP debug`.
`function(ctx: ProblemContext)` `function(ctx: ProblemContext)`
• {setup_code}? (`function`) Called after source file is opened. • {setup_code}? (`function`) Called after source file is opened.
Used to configure buffer settings. Used to configure buffer settings.
@ -247,44 +235,38 @@ Example: Setting up and solving AtCoder contest ABC324
< This creates a.cc and scrapes test cases < This creates a.cc and scrapes test cases
4. Code your solution, then test: > 4. Code your solution, then test: >
:CP run
<
5. If test fails, debug individual test cases: >
:CP test :CP test
< Navigate with j/k, run specific tests with <enter> < Navigate with j/k, run specific tests with <enter>
Exit test panel with q or :CP test when done Exit test panel with q or :CP test when done
6. If needed, compile with debug flags: > 5. If needed, debug with sanitizers: >
:CP debug :CP test --debug
< <
7. Move to next problem: > 6. Move to next problem: >
:CP next :CP next
< This automatically sets up problem B < This automatically sets up problem B
7. Continue solving problems with :CP next/:CP prev navigation 6. Continue solving problems with :CP next/:CP prev navigation
8. Submit solutions on AtCoder website 7. Submit solutions on AtCoder website
Example: Quick setup for single Codeforces problem > Example: Quick setup for single Codeforces problem >
:CP codeforces 1933 a " One command setup :CP codeforces 1933 a " One command setup
:CP run " Test immediately :CP test " Test immediately
< <
TEST PANEL *cp-test* TEST PANEL *cp-test*
The test panel provides individual test case debugging with a three-pane The test panel provides individual test case debugging with a three-pane
layout showing test list, expected output, and actual output side-by-side. layout showing test list, expected output, and actual output side-by-side.
Currently supported for AtCoder and CSES.
Note: Codeforces is not supported due to the ambiguity of identifying
individual test case output. See https://codeforces.com/blog/entry/138406
for ongoing efforts to resolve this.
Activation ~ Activation ~
*:CP-test* *:CP-test*
:CP test Toggle test panel on/off. When activated, :CP test [--debug] Toggle test panel on/off. When activated,
replaces current layout with test interface. replaces current layout with test interface.
Automatically compiles and runs all tests. Automatically compiles and runs all tests.
Toggle again to restore original layout. Use --debug flag to compile with debug symbols
and sanitizers. Toggle again to restore original
layout.
Interface ~ Interface ~
@ -293,8 +275,6 @@ The test panel uses a three-pane layout for easy comparison: >
┌─ Test List ─────────────────────────────────────────────────┐ ┌─ Test List ─────────────────────────────────────────────────┐
│ 1. PASS 12ms │ │ 1. PASS 12ms │
│> 2. FAIL 45ms │ │> 2. FAIL 45ms │
│ 3. 8ms │
│ 4. │
│ │ │ │
│ ── Input ── │ │ ── Input ── │
│ 5 3 │ │ 5 3 │
@ -317,7 +297,7 @@ q Exit test panel (restore layout)
Execution Details ~ Execution Details ~
Test cases are executed individually using the same compilation and Test cases are executed individually using the same compilation and
execution pipeline as |:CP-run|, but with isolated input/output for execution pipeline, but with isolated input/output for
precise failure analysis. All tests are automatically run when the precise failure analysis. All tests are automatically run when the
panel opens. panel opens.

View file

@ -15,7 +15,8 @@
---@class CachedTestCase ---@class CachedTestCase
---@field index? number ---@field index? number
---@field input string ---@field input string
---@field output string ---@field expected? string
---@field output? string
local M = {} local M = {}

View file

@ -37,7 +37,6 @@
---@field hooks Hooks ---@field hooks Hooks
---@field debug boolean ---@field debug boolean
---@field scrapers table<string, boolean> ---@field scrapers table<string, boolean>
---@field tile? fun(source_buf: number, input_buf: number, output_buf: number)
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
---@class cp.UserConfig ---@class cp.UserConfig
@ -46,7 +45,6 @@
---@field hooks? Hooks ---@field hooks? Hooks
---@field debug? boolean ---@field debug? boolean
---@field scrapers? table<string, boolean> ---@field scrapers? table<string, boolean>
---@field tile? fun(source_buf: number, input_buf: number, output_buf: number)
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
local M = {} local M = {}
@ -67,7 +65,6 @@ M.defaults = {
return platform, true return platform, true
end) end)
:totable(), :totable(),
tile = nil,
filename = nil, filename = nil,
} }
@ -85,7 +82,6 @@ function M.setup(user_config)
hooks = { user_config.hooks, { "table", "nil" }, true }, hooks = { user_config.hooks, { "table", "nil" }, true },
debug = { user_config.debug, { "boolean", "nil" }, true }, debug = { user_config.debug, { "boolean", "nil" }, true },
scrapers = { user_config.scrapers, { "table", "nil" }, true }, scrapers = { user_config.scrapers, { "table", "nil" }, true },
tile = { user_config.tile, { "function", "nil" }, true },
filename = { user_config.filename, { "function", "nil" }, true }, filename = { user_config.filename, { "function", "nil" }, true },
}) })

View file

@ -1,7 +1,7 @@
local M = {} local M = {}
M.PLATFORMS = { "atcoder", "codeforces", "cses" } M.PLATFORMS = { "atcoder", "codeforces", "cses" }
M.ACTIONS = { "run", "debug", "test", "next", "prev" } M.ACTIONS = { "test", "next", "prev" }
M.CPP = "cpp" M.CPP = "cpp"
M.PYTHON = "python" M.PYTHON = "python"

View file

@ -193,8 +193,9 @@ end
---@param ctx ProblemContext ---@param ctx ProblemContext
---@param contest_config ContestConfig ---@param contest_config ContestConfig
---@param is_debug? boolean
---@return boolean success ---@return boolean success
function M.compile_problem(ctx, contest_config) function M.compile_problem(ctx, contest_config, is_debug)
vim.validate({ vim.validate({
ctx = { ctx, "table" }, ctx = { ctx, "table" },
contest_config = { contest_config, "table" }, contest_config = { contest_config, "table" },
@ -214,13 +215,15 @@ function M.compile_problem(ctx, contest_config)
version = tostring(language_config.version), version = tostring(language_config.version),
} }
if language_config.compile then local compile_cmd = (is_debug and language_config.debug) and language_config.debug or language_config.compile
if compile_cmd then
language_config.compile = compile_cmd
local compile_result = M.compile_generic(language_config, substitutions) local compile_result = M.compile_generic(language_config, substitutions)
if compile_result.code ~= 0 then if compile_result.code ~= 0 then
logger.log("compilation failed: " .. (compile_result.stderr or "unknown error"), vim.log.levels.ERROR) logger.log("compilation failed: " .. (compile_result.stderr or "unknown error"), vim.log.levels.ERROR)
return false return false
end end
logger.log("compilation successful") logger.log(("compilation successful (%s)"):format(is_debug and "debug mode" or "test mode"))
end end
return true return true

View file

@ -4,7 +4,6 @@ local config_module = require("cp.config")
local snippets = require("cp.snippets") local snippets = require("cp.snippets")
local execute = require("cp.execute") local execute = require("cp.execute")
local scrape = require("cp.scrape") local scrape = require("cp.scrape")
local window = require("cp.window")
local logger = require("cp.log") local logger = require("cp.log")
local problem = require("cp.problem") local problem = require("cp.problem")
local cache = require("cp.cache") local cache = require("cp.cache")
@ -133,13 +132,6 @@ local function setup_problem(contest_id, problem_id, language)
config.hooks.setup_code(ctx) config.hooks.setup_code(ctx)
end end
local src_buf = vim.api.nvim_get_current_buf()
local input_buf = vim.fn.bufnr(ctx.input_file, true)
local output_buf = vim.fn.bufnr(ctx.output_file, true)
local tile_fn = config.tile or window.default_tile
tile_fn(src_buf, input_buf, output_buf)
logger.log(("switched to problem %s"):format(ctx.problem_name)) logger.log(("switched to problem %s"):format(ctx.problem_name))
end end
@ -152,60 +144,7 @@ local function get_current_problem()
return filename return filename
end end
local function run_problem() local function toggle_test_panel(is_debug)
local problem_id = get_current_problem()
if not problem_id then
return
end
logger.log(("running problem: %s"):format(problem_id))
if not state.platform then
logger.log(
"No contest configured. Use :CP <platform> <contest> <problem> to set up first.",
vim.log.levels.ERROR
)
return
end
local contest_config = config.contests[state.platform]
local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config)
if config.hooks and config.hooks.before_run then
config.hooks.before_run(ctx)
end
vim.schedule(function()
execute.run_problem(ctx, contest_config, false)
vim.cmd.checktime()
end)
end
local function debug_problem()
local problem_id = get_current_problem()
if not problem_id then
return
end
if not state.platform then
logger.log("no platform set", vim.log.levels.ERROR)
return
end
local contest_config = config.contests[state.platform]
local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config)
if config.hooks and config.hooks.before_debug then
config.hooks.before_debug(ctx)
end
vim.schedule(function()
execute.run_problem(ctx, contest_config, true)
vim.cmd.checktime()
end)
end
local function toggle_test_panel()
if state.test_panel_active then if state.test_panel_active then
if state.saved_session then if state.saved_session then
vim.cmd(("source %s"):format(state.saved_session)) vim.cmd(("source %s"):format(state.saved_session))
@ -225,11 +164,6 @@ local function toggle_test_panel()
return return
end end
if state.platform == "codeforces" then
logger.log("test panel not yet supported for codeforces", vim.log.levels.ERROR)
return
end
local problem_id = get_current_problem() local problem_id = get_current_problem()
if not problem_id then if not problem_id then
return return
@ -347,14 +281,14 @@ local function toggle_test_panel()
return return
end end
local expected_lines = {}
local expected_text = current_test.expected local expected_text = current_test.expected
for _, line in ipairs(vim.split(expected_text, "\n", { plain = true, trimempty = true })) do local expected_lines = vim.split(expected_text, "\n", { plain = true, trimempty = true })
table.insert(expected_lines, line)
end
vim.api.nvim_buf_set_lines(test_buffers.expected_buf, 0, -1, false, expected_lines) vim.api.nvim_buf_set_lines(test_buffers.expected_buf, 0, -1, false, expected_lines)
if vim.fn.has("nvim-0.8.0") == 1 then
vim.api.nvim_set_option_value("winbar", "Expected", { win = test_windows.expected_win })
end
end end
local function update_actual_pane() local function update_actual_pane()
@ -366,27 +300,32 @@ local function toggle_test_panel()
end end
local actual_lines = {} local actual_lines = {}
local enable_diff = false
if current_test.actual then if current_test.actual then
for _, line in ipairs(vim.split(current_test.actual, "\n", { plain = true, trimempty = true })) do actual_lines = vim.split(current_test.actual, "\n", { plain = true, trimempty = true })
table.insert(actual_lines, line) enable_diff = current_test.status == "fail"
end
if current_test.status == "fail" then
vim.api.nvim_set_option_value("diff", true, { win = test_windows.expected_win })
vim.api.nvim_set_option_value("diff", true, { win = test_windows.actual_win })
else
vim.api.nvim_set_option_value("diff", false, { win = test_windows.expected_win })
vim.api.nvim_set_option_value("diff", false, { win = test_windows.actual_win })
end
else else
table.insert(actual_lines, "(not run yet)") actual_lines = { "(not run yet)" }
vim.api.nvim_set_option_value("diff", false, { win = test_windows.expected_win })
vim.api.nvim_set_option_value("diff", false, { win = test_windows.actual_win })
end end
vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines) vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines)
if vim.fn.has("nvim-0.8.0") == 1 then
vim.api.nvim_set_option_value("winbar", "Actual", { win = test_windows.actual_win })
end
vim.api.nvim_set_option_value("diff", enable_diff, { win = test_windows.expected_win })
vim.api.nvim_set_option_value("diff", enable_diff, { win = test_windows.actual_win })
if enable_diff then
vim.api.nvim_win_call(test_windows.expected_win, function()
vim.cmd("diffthis")
end)
vim.api.nvim_win_call(test_windows.actual_win, function()
vim.cmd("diffthis")
end)
end
end end
local function refresh_test_panel() local function refresh_test_panel()
@ -430,9 +369,13 @@ local function toggle_test_panel()
end, { buffer = buf, silent = true }) end, { buffer = buf, silent = true })
end end
if is_debug and config.hooks and config.hooks.before_debug then
config.hooks.before_debug(ctx)
end
local execute_module = require("cp.execute") local execute_module = require("cp.execute")
local contest_config = config.contests[state.platform] local contest_config = config.contests[state.platform]
if execute_module.compile_problem(ctx, contest_config) then if execute_module.compile_problem(ctx, contest_config, is_debug) then
test_module.run_all_test_cases(ctx, contest_config) test_module.run_all_test_cases(ctx, contest_config)
end end
@ -515,6 +458,7 @@ local function parse_command(args)
end end
local language = nil local language = nil
local debug = false
for i, arg in ipairs(args) do for i, arg in ipairs(args) do
local lang_match = arg:match("^--lang=(.+)$") local lang_match = arg:match("^--lang=(.+)$")
@ -526,17 +470,19 @@ local function parse_command(args)
else else
return { type = "error", message = "--lang requires a value" } return { type = "error", message = "--lang requires a value" }
end end
elseif arg == "--debug" then
debug = true
end end
end end
local filtered_args = vim.tbl_filter(function(arg) local filtered_args = vim.tbl_filter(function(arg)
return not (arg:match("^--lang") or arg == language) return not (arg:match("^--lang") or arg == language or arg == "--debug")
end, args) end, args)
local first = filtered_args[1] local first = filtered_args[1]
if vim.tbl_contains(actions, first) then if vim.tbl_contains(actions, first) then
return { type = "action", action = first, language = language } return { type = "action", action = first, language = language, debug = debug }
end end
if vim.tbl_contains(platforms, first) then if vim.tbl_contains(platforms, first) then
@ -604,12 +550,8 @@ function M.handle_command(opts)
end end
if cmd.type == "action" then if cmd.type == "action" then
if cmd.action == "run" then if cmd.action == "test" then
run_problem() toggle_test_panel(cmd.debug)
elseif cmd.action == "debug" then
debug_problem()
elseif cmd.action == "test" then
toggle_test_panel()
elseif cmd.action == "next" then elseif cmd.action == "next" then
navigate_problem(1, cmd.language) navigate_problem(1, cmd.language)
elseif cmd.action == "prev" then elseif cmd.action == "prev" then

View file

@ -1,3 +1,14 @@
---@class ScraperTestCase
---@field input string
---@field expected string
---@class ScraperResult
---@field success boolean
---@field problem_id string
---@field url? string
---@field tests? ScraperTestCase[]
---@field error? string
local M = {} local M = {}
local logger = require("cp.log") local logger = require("cp.log")
local cache = require("cp.cache") local cache = require("cp.cache")
@ -139,7 +150,7 @@ function M.scrape_contest_metadata(platform, contest_id)
end end
---@param ctx ProblemContext ---@param ctx ProblemContext
---@return {success: boolean, problem_id: string, test_count?: number, test_cases?: table[], url?: string, error?: string} ---@return {success: boolean, problem_id: string, test_count?: number, test_cases?: ScraperTestCase[], url?: string, error?: string}
function M.scrape_problem(ctx) function M.scrape_problem(ctx)
vim.validate({ vim.validate({
ctx = { ctx, "table" }, ctx = { ctx, "table" },
@ -249,40 +260,32 @@ function M.scrape_problem(ctx)
return data return data
end end
if data.test_cases and #data.test_cases > 0 then if data.tests and #data.tests > 0 then
local base_name = vim.fn.fnamemodify(ctx.input_file, ":r") local base_name = vim.fn.fnamemodify(ctx.input_file, ":r")
for i, test_case in ipairs(data.test_cases) do for i, test_case in ipairs(data.tests) do
local input_file = base_name .. "." .. i .. ".cpin" local input_file = base_name .. "." .. i .. ".cpin"
local expected_file = base_name .. "." .. i .. ".cpout" local expected_file = base_name .. "." .. i .. ".cpout"
local input_content = test_case.input:gsub("\r", "") local input_content = test_case.input:gsub("\r", "")
local expected_content = test_case.output:gsub("\r", "") local expected_content = test_case.expected:gsub("\r", "")
vim.fn.writefile(vim.split(input_content, "\n", true), input_file) vim.fn.writefile(vim.split(input_content, "\n", true), input_file)
vim.fn.writefile(vim.split(expected_content, "\n", true), expected_file) vim.fn.writefile(vim.split(expected_content, "\n", true), expected_file)
end end
local combined_input = data.combined and data.combined.input:gsub("\r", "") local combined_input = table.concat(
or table.concat( vim.tbl_map(function(tc)
vim.tbl_map(function(tc) return tc.input
return tc.input end, data.tests),
end, data.test_cases), "\n"
"\n" )
) local combined_output = table.concat(
local combined_output = data.combined and data.combined.output:gsub("\r", "") vim.tbl_map(function(tc)
or table.concat( return tc.expected
vim.tbl_map(function(tc) end, data.tests),
return tc.output "\n"
end, data.test_cases), )
"\n"
)
-- with atcoder, we combine together multiple test cases
-- TODO: per-platform settings to do this (i.e. do we stitch?)
if ctx.contest == "atcoder" then
combined_input = tostring(#data.test_cases) .. "\n" .. combined_input
end
vim.fn.writefile(vim.split(combined_input, "\n", true), ctx.input_file) vim.fn.writefile(vim.split(combined_input, "\n", true), ctx.input_file)
vim.fn.writefile(vim.split(combined_output, "\n", true), ctx.expected_file) vim.fn.writefile(vim.split(combined_output, "\n", true), ctx.expected_file)
@ -291,8 +294,8 @@ function M.scrape_problem(ctx)
return { return {
success = true, success = true,
problem_id = ctx.problem_name, problem_id = ctx.problem_name,
test_count = data.test_cases and #data.test_cases or 0, test_count = data.tests and #data.tests or 0,
test_cases = data.test_cases, test_cases = data.tests,
url = data.url, url = data.url,
} }
end end

View file

@ -68,7 +68,8 @@ local function parse_test_cases_from_cache(platform, contest_id, problem_id)
for i, test_case in ipairs(cached_test_cases) do for i, test_case in ipairs(cached_test_cases) do
local index = test_case.index or i local index = test_case.index or i
table.insert(test_cases, create_test_case(index, test_case.input, test_case.output)) local expected = test_case.expected or test_case.output
table.insert(test_cases, create_test_case(index, test_case.input, expected))
end end
return test_cases return test_cases
@ -171,9 +172,6 @@ local function run_single_test_case(ctx, contest_config, test_case)
local run_cmd = build_command(language_config.run, language_config.executable, substitutions) local run_cmd = build_command(language_config.run, language_config.executable, substitutions)
local stdin_content = test_case.input .. "\n" local stdin_content = test_case.input .. "\n"
if ctx.contest == "atcoder" then
stdin_content = "1\n" .. stdin_content
end
local start_time = vim.uv.hrtime() local start_time = vim.uv.hrtime()
local result = vim.system(run_cmd, { local result = vim.system(run_cmd, {

View file

@ -14,37 +14,14 @@ end, {
nargs = "*", nargs = "*",
desc = "Competitive programming helper", desc = "Competitive programming helper",
complete = function(ArgLead, CmdLine, _) complete = function(ArgLead, CmdLine, _)
local languages = vim.tbl_keys(constants.canonical_filetypes)
if ArgLead:match("^--lang=") then
local lang_completions = {}
for _, lang in ipairs(languages) do
table.insert(lang_completions, "--lang=" .. lang)
end
return vim.tbl_filter(function(completion)
return completion:find(ArgLead, 1, true) == 1
end, lang_completions)
end
if ArgLead:match("^%-") and not ArgLead:match("^--lang") then
return vim.tbl_filter(function(completion)
return completion:find(ArgLead, 1, true) == 1
end, { "--lang" })
end
local args = vim.split(vim.trim(CmdLine), "%s+") local args = vim.split(vim.trim(CmdLine), "%s+")
local num_args = #args local num_args = #args
if CmdLine:sub(-1) == " " then if CmdLine:sub(-1) == " " then
num_args = num_args + 1 num_args = num_args + 1
end end
local lang_flag_present = vim.tbl_contains(args, "--lang")
or vim.iter(args):any(function(arg)
return arg:match("^--lang=")
end)
if num_args == 2 then if num_args == 2 then
local candidates = { "--lang" } local candidates = {}
local cp = require("cp") local cp = require("cp")
local context = cp.get_current_context() local context = cp.get_current_context()
if context.platform and context.contest_id then if context.platform and context.contest_id then
@ -63,17 +40,13 @@ end, {
return vim.tbl_filter(function(cmd) return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1 return cmd:find(ArgLead, 1, true) == 1
end, candidates) end, candidates)
elseif args[#args - 1] == "--lang" then elseif num_args == 4 then
return vim.tbl_filter(function(lang)
return lang:find(ArgLead, 1, true) == 1
end, languages)
elseif num_args == 4 and not lang_flag_present then
if vim.tbl_contains(platforms, args[2]) then if vim.tbl_contains(platforms, args[2]) then
local cache = require("cp.cache") local cache = require("cp.cache")
cache.load() cache.load()
local contest_data = cache.get_contest_data(args[2], args[3]) local contest_data = cache.get_contest_data(args[2], args[3])
if contest_data and contest_data.problems then if contest_data and contest_data.problems then
local candidates = { "--lang" } local candidates = {}
for _, problem in ipairs(contest_data.problems) do for _, problem in ipairs(contest_data.problems) do
table.insert(candidates, problem.id) table.insert(candidates, problem.id)
end end

View file

@ -66,6 +66,7 @@ follows:
## TODO ## TODO
- fzf/telescope integration (whichever available)
- autocomplete with --lang and --debug
- finer-tuned problem limits (i.e. per-problem codeforces time, memory) - finer-tuned problem limits (i.e. per-problem codeforces time, memory)
- better highlighting
- notify discord members - notify discord members