diff --git a/README.md b/README.md index 33c2453..ae9cf19 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ single-chord access (configurable via `keymaps.exercise`): | `Ctrl-n` | Next exercise | | `Ctrl-p` | Previous exercise | | `Ctrl-k` | Skip exercise | -| `Ctrl-i` | Show hints | +| `Ctrl-g` | Show hints | | `Ctrl-l` | View solution (split) | | `Ctrl-d` | Show description | | `Ctrl-b` | Open browser | @@ -158,7 +158,7 @@ Or from Neovim: `:CP generate` (prompts for topic, count, difficulty, and engine ### AI Hints -When enabled, `Ctrl-i` generates a context-aware hint using a Hugging Face model +When enabled, `Ctrl-g` generates a context-aware hint using a Hugging Face model instead of showing static hints. The hint is based on your current buffer and the reference solution. diff --git a/doc/code-practice.txt b/doc/code-practice.txt index 04b64cc..6e5b4f4 100644 --- a/doc/code-practice.txt +++ b/doc/code-practice.txt @@ -98,7 +98,7 @@ Tab completion is supported: type `:CP ` to explore. schema matches `test/example_exercises.json` in the plugin source. -:CP import! {path} Replace all existing exercises, test cases, attempts, +:CP! import {path} Replace all existing exercises, test cases, attempts, and theory options with the contents of {path}. :CP generate Interactively generate exercises via Hugging Face @@ -146,7 +146,7 @@ via `keymaps.exercise` in config. `Ctrl-n` Next exercise `Ctrl-p` Previous exercise `Ctrl-k` Skip exercise - `Ctrl-i` Show hints + `Ctrl-g` Show hints `Ctrl-l` View solution `Ctrl-d` Show description `Ctrl-b` Open browser @@ -181,8 +181,6 @@ Options are merged with |vim.tbl_deep_extend()|. runner = { timeout = 5, -- seconds per test case - show_time = true, - auto_save = true, }, ai_hints = { @@ -202,7 +200,7 @@ Options are merged with |vim.tbl_deep_extend()|. }, exercise = { run_tests = "", - show_hint = "", + show_hint = "", view_solution = "", show_description = "", next_exercise = "", diff --git a/lua/code-practice/browser.lua b/lua/code-practice/browser.lua index 2585a37..5008d54 100644 --- a/lua/code-practice/browser.lua +++ b/lua/code-practice/browser.lua @@ -275,6 +275,16 @@ local function update_display() vim.bo[preview_buf].modifiable = false vim.bo[list_buf].readonly = true vim.bo[preview_buf].readonly = true + + local list_win = state.popup.list.winid + if + list_win + and vim.api.nvim_win_is_valid(list_win) + and state.selected_index > 0 + and state.selected_index <= #list_lines + then + vim.api.nvim_win_set_cursor(list_win, { state.selected_index, 0 }) + end end function browser.refresh() diff --git a/lua/code-practice/config.lua b/lua/code-practice/config.lua index 3fe4915..14b49a5 100644 --- a/lua/code-practice/config.lua +++ b/lua/code-practice/config.lua @@ -31,8 +31,6 @@ config.defaults = { runner = { timeout = 5, - show_time = true, - auto_save = true, }, ai_hints = { @@ -52,7 +50,7 @@ config.defaults = { }, exercise = { run_tests = "", - show_hint = "", + show_hint = "", view_solution = "", show_description = "", next_exercise = "", @@ -67,6 +65,7 @@ config.config = vim.deepcopy(config.defaults) function config.setup(user_config) user_config = user_config or {} + config.defaults.engines = build_engine_defaults() config.config = vim.tbl_deep_extend("force", config.defaults, user_config) vim.fn.mkdir(config.config.storage.home, "p") diff --git a/lua/code-practice/engines.lua b/lua/code-practice/engines.lua index f253da4..a8591f0 100644 --- a/lua/code-practice/engines.lua +++ b/lua/code-practice/engines.lua @@ -23,8 +23,8 @@ engines.registry = { enabled = true, cmd = "python3", }, - run_cmd = function(cfg) - return cfg.cmd or "python3" + run_cmd = function(cfg, file) + return { cfg.cmd or "python3", file } end, wrap_test = function(code, input) local call = (input or ""):match("^%s*$") and "solution()" or ("solution(" .. input .. ")") diff --git a/lua/code-practice/help.lua b/lua/code-practice/help.lua index be3cb0c..c36239d 100644 --- a/lua/code-practice/help.lua +++ b/lua/code-practice/help.lua @@ -1,13 +1,7 @@ -- Code Practice - Keymap Cheat-Sheet -local ok, Popup = pcall(require, "nui.popup") -if not ok then - vim.notify("[code-practice] nui.nvim not found. Install MunifTanjim/nui.nvim", vim.log.levels.ERROR) - return {} -end - local config = require("code-practice.config") local engines = require("code-practice.engines") -local popup_util = require("code-practice.popup") +local popup = require("code-practice.popup") local help = {} @@ -28,42 +22,20 @@ end function help.show() local width = math.min(90, vim.o.columns - 4) local height = math.min(30, vim.o.lines - 4) - local row = math.max(1, math.floor((vim.o.lines - height) / 2)) - local col = math.max(1, math.floor((vim.o.columns - width) / 2)) - - local popup = Popup({ - relative = "editor", - position = { - row = row, - col = col, - }, - size = { - width = width, - height = height, - }, - border = { - style = "rounded", - text = { - top = " Keymaps ", - top_align = "center", - }, - }, - buf_options = { - modifiable = true, - readonly = false, - }, - win_options = { - winhighlight = "Normal:Normal,FloatBorder:FloatBorder", - }, - }) - - popup:mount() - if popup.winid then - vim.api.nvim_set_current_win(popup.winid) - end - vim.cmd("stopinsert") + + local bufnr, _, close_fn = popup.open_float({ width = width, height = height, title = " Keymaps " }) local km = config.get("keymaps.exercise", {}) + local bkm = config.get("keymaps.browser", {}) + + -- Two-column row with the right column aligned to a fixed offset. + local function row(lkey, ldesc, rkey, rdesc) + local left = " " .. pad(fmt_key(lkey), 19) .. ldesc + if rkey == nil and rdesc == nil then + return left + end + return pad(left, 54) .. pad(fmt_key(rkey), 17) .. (rdesc or "") + end local filter_lines = {} for _, name in ipairs(engines.list()) do @@ -73,21 +45,22 @@ function help.show() end end + local open_key = bkm.open_item or bkm.open or "" + local lines = { "", " BROWSER", " " .. string.rep("─", width - 4), - " j / k Move up / down Enter / o Open exercise", - " e Filter by Easy difficulty m Filter by Medium", - " h Filter by Hard difficulty a Clear all filters", + row("j / k", "Move up / down", open_key .. " / o", "Open exercise"), + row(bkm.filter_easy or "e", "Filter by Easy difficulty", bkm.filter_medium or "m", "Filter by Medium"), + row(bkm.filter_hard or "h", "Filter by Hard difficulty", bkm.filter_all or "a", "Clear all filters"), } for _, fl in ipairs(filter_lines) do table.insert(lines, fl) end - table.insert(lines, " q / Esc Close browser") - table.insert(lines, " ? Show this cheat-sheet") + table.insert(lines, row(bkm.close or "q", "Close browser", "?", "Show this cheat-sheet")) local exercise_lines = { "", @@ -122,22 +95,16 @@ function help.show() table.insert(lines, el) end - vim.bo[popup.bufnr].modifiable = true - vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, lines) - vim.bo[popup.bufnr].modifiable = false + popup.set_lines(bufnr, lines) local ns_help = vim.api.nvim_create_namespace("code_practice_help") for i, line in ipairs(lines) do if line:match("^ [A-Z]") and not line:match("^ See") and not line:match("^ Press") then - vim.api.nvim_buf_add_highlight(popup.bufnr, ns_help, "Underlined", i - 1, 0, -1) + vim.api.nvim_buf_add_highlight(bufnr, ns_help, "Underlined", i - 1, 0, -1) end end - popup_util.map_close(popup.bufnr, function() - if popup and popup.winid and vim.api.nvim_win_is_valid(popup.winid) then - popup:unmount() - end - end) + popup.map_close(bufnr, close_fn) end return help diff --git a/lua/code-practice/importer.lua b/lua/code-practice/importer.lua index 6abe594..b09a70d 100644 --- a/lua/code-practice/importer.lua +++ b/lua/code-practice/importer.lua @@ -4,6 +4,20 @@ local utils = require("code-practice.utils") local importer = {} +-- sqlite.lua's stmt:bind treats strings matching "^[%S]+%(.*%)$" as SQL +-- functions and skips binding them (see kkharji/sqlite.lua#87). Values like +-- "O(n)" or "O(n log n)" trigger this and silently become NULL. We work +-- around it by formatting values directly into the SQL string. +local function sql_escape(val) + if val == nil then + return "NULL" + elseif type(val) == "number" then + return tostring(val) + else + return "'" .. tostring(val):gsub("'", "''") .. "'" + end +end + function importer.import(json_path, opts) opts = opts or {} @@ -101,14 +115,13 @@ function importer.import(json_path, opts) local opt_ok, opt_err = pcall( conn.eval, conn, - [[INSERT INTO theory_options (exercise_id, option_number, option_text, is_correct) - VALUES (:eid, :num, :text, :correct)]], - { - eid = ex.id, - num = opt.option_number, - text = opt.option_text, - correct = opt.is_correct == 1 and 1 or 0, - } + string.format( + "INSERT INTO theory_options (exercise_id, option_number, option_text, is_correct) VALUES (%s, %s, %s, %s)", + sql_escape(ex.id), + sql_escape(opt.option_number), + sql_escape(opt.option_text), + sql_escape((opt.is_correct == 1 or opt.is_correct == true) and 1 or 0) + ) ) if opt_ok then counts.theory_options = counts.theory_options + 1 diff --git a/lua/code-practice/init.lua b/lua/code-practice/init.lua index 1f80df0..fb4f15a 100644 --- a/lua/code-practice/init.lua +++ b/lua/code-practice/init.lua @@ -239,11 +239,9 @@ function code_practice.show_stats() "", } - local bufnr, winid = popup.open_float({ width = 0.3, height = 0.3, title = " Statistics " }) + local bufnr, _, close_fn = popup.open_float({ width = 0.3, height = 0.3, title = " Statistics " }) popup.set_lines(bufnr, lines) - popup.map_close(bufnr, function() - utils.close_win(winid) - end) + popup.map_close(bufnr, close_fn) end function code_practice.get_current_exercise_id() @@ -264,11 +262,9 @@ local function show_static_hints(exercise) table.insert(lines, "") end - local bufnr, winid = popup.open_float({ width = 0.5, height = 0.4, title = " Hints " }) + local bufnr, _, close_fn = popup.open_float({ width = 0.5, height = 0.4, title = " Hints " }) popup.set_lines(bufnr, lines) - popup.map_close(bufnr, function() - utils.close_win(winid) - end) + popup.map_close(bufnr, close_fn) end function code_practice.show_hints() @@ -289,11 +285,9 @@ function code_practice.show_hints() end local buffer_content = utils.get_buffer_content(vim.api.nvim_get_current_buf()) - local hint_bufnr, hint_winid = popup.open_float({ width = 0.5, height = 0.4, title = " AI Hint " }) + local hint_bufnr, _, hint_close = popup.open_float({ width = 0.5, height = 0.4, title = " AI Hint " }) popup.set_lines(hint_bufnr, { "", " Generating hint..." }) - popup.map_close(hint_bufnr, function() - utils.close_win(hint_winid) - end) + popup.map_close(hint_bufnr, hint_close) local ai_hints = require("code-practice.ai_hints") ai_hints.generate(exercise, buffer_content, function(hint_text, err) @@ -303,7 +297,7 @@ function code_practice.show_hints() if err then utils.notify("AI hint failed: " .. err, "error") - utils.close_win(hint_winid) + hint_close() show_static_hints(exercise) return end @@ -370,11 +364,9 @@ function code_practice.show_description() footer = "Press q, , or to close", }) - local bufnr, winid = popup.open_float({ filetype = "markdown", title = " Description " }) + local bufnr, _, close_fn = popup.open_float({ filetype = "markdown", title = " Description " }) popup.set_lines(bufnr, lines) - popup.map_close(bufnr, function() - utils.close_win(winid) - end) + popup.map_close(bufnr, close_fn) end function code_practice.show_help() diff --git a/lua/code-practice/manager.lua b/lua/code-practice/manager.lua index 89f0181..da49c74 100644 --- a/lua/code-practice/manager.lua +++ b/lua/code-practice/manager.lua @@ -122,6 +122,104 @@ function manager.get_stats() return db.get_stats() end +local function build_exercise_content(exercise) + local lines, add_meta = manager.build_header_lines(exercise, "Exercise") + local run_key = config.get("keymaps.exercise.run_tests", "") + + local theory_options = nil + if exercise.engine == "theory" then + theory_options = exercise.options or db.get_theory_options(exercise.id) + if theory_options and #theory_options > 0 then + add_meta("Options:") + for _, opt in ipairs(theory_options) do + add_meta(string.format("%d. %s", opt.option_number, opt.option_text)) + end + add_meta("") + add_meta("Press 1-" .. #theory_options .. " to select your answer, then " .. run_key .. " to check.") + end + else + add_meta("Modify the code below, then " .. run_key .. " to run tests.") + end + + add_meta("") + add_meta(string.rep("-", 40)) + add_meta("") + + local starter = exercise.starter_code or "" + if exercise.engine == "theory" and theory_options and #theory_options > 0 then + starter = "" + end + if exercise.engine == "theory" then + if starter ~= "" then + for _, line in ipairs(utils.split_lines(starter)) do + table.insert(lines, line) + end + end + local has_answer_line = false + for _, line in ipairs(lines) do + if line:match("^[Aa]nswer:") then + has_answer_line = true + break + end + end + if not has_answer_line then + table.insert(lines, "Answer: ") + end + else + for _, line in ipairs(utils.split_lines(starter)) do + table.insert(lines, line) + end + end + + return table.concat(lines, "\n") +end + +local function setup_theory_keymaps(bufnr, exercise) + if vim.b[bufnr].code_practice_theory_keymaps then + return + end + vim.b[bufnr].code_practice_theory_keymaps = true + + local opts_by_num = {} + for _, opt in ipairs(exercise.options or {}) do + opts_by_num[opt.option_number] = opt.option_text + end + + for num, text in pairs(opts_by_num) do + vim.keymap.set("n", tostring(num), function() + local line_count = vim.api.nvim_buf_line_count(bufnr) + for i = 0, line_count - 1 do + local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] + if line and line:match("^Answer:") then + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, i, i + 1, false, { + string.format("Answer: %d [%s]", num, text), + }) + utils.notify(string.format("Selected option %d: %s", num, text), "info") + return + end + end + end, { buffer = bufnr, noremap = true, nowait = true }) + end +end + +local function is_floating(win) + local ok, cfg = pcall(vim.api.nvim_win_get_config, win) + return ok and cfg and cfg.relative and cfg.relative ~= "" +end + +local function focus_non_floating_window() + if not is_floating(vim.api.nvim_get_current_win()) then + return + end + for _, win in ipairs(vim.api.nvim_list_wins()) do + if not is_floating(win) then + vim.api.nvim_set_current_win(win) + return + end + end +end + function manager.open_exercise(id) local exercise = manager.get_exercise(id) if not exercise then @@ -150,103 +248,17 @@ function manager.open_exercise(id) if needs_content then vim.bo[bufnr].modifiable = true vim.bo[bufnr].readonly = false - - local lines, add_meta = manager.build_header_lines(exercise, "Exercise") - local run_key = config.get("keymaps.exercise.run_tests", "") - - local theory_options = nil - if exercise.engine == "theory" then - theory_options = exercise.options or db.get_theory_options(exercise.id) - if theory_options and #theory_options > 0 then - add_meta("Options:") - for _, opt in ipairs(theory_options) do - add_meta(string.format("%d. %s", opt.option_number, opt.option_text)) - end - add_meta("") - add_meta("Press 1-" .. #theory_options .. " to select your answer, then " .. run_key .. " to check.") - end - else - add_meta("Modify the code below, then " .. run_key .. " to run tests.") - end - - add_meta("") - add_meta(string.rep("-", 40)) - add_meta("") - - local starter = exercise.starter_code or "" - if exercise.engine == "theory" and theory_options and #theory_options > 0 then - starter = "" - end - if exercise.engine == "theory" then - if starter ~= "" then - for _, line in ipairs(utils.split_lines(starter)) do - table.insert(lines, line) - end - end - local has_answer_line = false - for _, line in ipairs(lines) do - if line:match("^[Aa]nswer:") then - has_answer_line = true - break - end - end - if not has_answer_line then - table.insert(lines, "Answer: ") - end - else - for _, line in ipairs(utils.split_lines(starter)) do - table.insert(lines, line) - end - end - - local content = table.concat(lines, "\n") - utils.set_buffer_content(bufnr, content) + utils.set_buffer_content(bufnr, build_exercise_content(exercise)) end vim.b[bufnr].code_practice_exercise_id = id vim.b[bufnr].code_practice_engine = exercise.engine - if exercise.engine == "theory" and not vim.b[bufnr].code_practice_theory_keymaps then - vim.b[bufnr].code_practice_theory_keymaps = true - - local opts_by_num = {} - for _, opt in ipairs(exercise.options or {}) do - opts_by_num[opt.option_number] = opt.option_text - end - - for num, text in pairs(opts_by_num) do - vim.keymap.set("n", tostring(num), function() - local line_count = vim.api.nvim_buf_line_count(bufnr) - for i = 0, line_count - 1 do - local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] - if line and line:match("^Answer:") then - vim.bo[bufnr].modifiable = true - vim.api.nvim_buf_set_lines(bufnr, i, i + 1, false, { - string.format("Answer: %d [%s]", num, text), - }) - utils.notify(string.format("Selected option %d: %s", num, text), "info") - return - end - end - end, { buffer = bufnr, noremap = true, nowait = true }) - end - end - - local current_win = vim.api.nvim_get_current_win() - local function is_floating(win) - local ok, cfg = pcall(vim.api.nvim_win_get_config, win) - return ok and cfg and cfg.relative and cfg.relative ~= "" - end - - if is_floating(current_win) then - for _, win in ipairs(vim.api.nvim_list_wins()) do - if not is_floating(win) then - vim.api.nvim_set_current_win(win) - break - end - end + if exercise.engine == "theory" then + setup_theory_keymaps(bufnr, exercise) end + focus_non_floating_window() vim.api.nvim_set_current_buf(bufnr) return bufnr diff --git a/lua/code-practice/popup.lua b/lua/code-practice/popup.lua index c4a59c6..1194816 100644 --- a/lua/code-practice/popup.lua +++ b/lua/code-practice/popup.lua @@ -23,10 +23,10 @@ function popup.open_float(opts) opts = opts or {} local ui_border = require("code-practice.config").get("ui.border", "rounded") - local width_ratio = opts.width or 0.6 - local height_ratio = opts.height or 0.6 - local width = math.floor(vim.o.columns * width_ratio) - local height = math.floor(vim.o.lines * height_ratio) + local w = opts.width or 0.6 + local h = opts.height or 0.6 + local width = w >= 1 and math.floor(w) or math.floor(vim.o.columns * w) + local height = h >= 1 and math.floor(h) or math.floor(vim.o.lines * h) local border = { style = opts.border or ui_border } if opts.title then @@ -44,7 +44,7 @@ function popup.open_float(opts) buf_options.filetype = opts.filetype end - local popup = NuiPopup({ + local win = NuiPopup({ relative = "editor", position = "50%", size = { width = width, height = height }, @@ -52,9 +52,20 @@ function popup.open_float(opts) buf_options = buf_options, }) - popup:mount() + win:mount() - return popup.bufnr, popup.winid + if win.winid then + vim.api.nvim_set_current_win(win.winid) + end + vim.cmd("stopinsert") + + local function close_fn() + if win.winid and vim.api.nvim_win_is_valid(win.winid) then + vim.api.nvim_win_close(win.winid, true) + end + end + + return win.bufnr, win.winid, close_fn end function popup.set_lines(bufnr, lines) diff --git a/lua/code-practice/results.lua b/lua/code-practice/results.lua index f503172..f68405d 100644 --- a/lua/code-practice/results.lua +++ b/lua/code-practice/results.lua @@ -3,15 +3,15 @@ local config = require("code-practice.config") local popup = require("code-practice.popup") local results = {} -results._winid = nil +results._close_fn = nil results._bufnr = nil local ns = vim.api.nvim_create_namespace("code_practice_results") function results.close() - if results._winid and vim.api.nvim_win_is_valid(results._winid) then - vim.api.nvim_win_close(results._winid, true) + if results._close_fn then + results._close_fn() end - results._winid = nil + results._close_fn = nil results._bufnr = nil end @@ -21,13 +21,9 @@ function results.show(result, on_next) return end - local bufnr, winid = popup.open_float() + local bufnr, _, close_fn = popup.open_float() results._bufnr = bufnr - results._winid = winid - if winid then - vim.api.nvim_set_current_win(winid) - end - vim.cmd("stopinsert") + results._close_fn = close_fn local lines = {} local function push(line) @@ -61,18 +57,24 @@ function results.show(result, on_next) for i, r in ipairs(result.results) do local status = r.passed and "✓ PASS" or "✗ FAIL" local duration = r.duration and string.format(" (%.0fms)", r.duration) or "" - push(string.format("Test %d: %s%s", i, status, duration)) + local hidden_tag = r.hidden and " (hidden)" or "" + push(string.format("Test %d: %s%s%s", i, status, hidden_tag, duration)) if r.error then append_block(" Error: ", r.error) push("") elseif not r.passed then - if r.input then - append_block(" Input: ", r.input) + if r.hidden then + push(" (hidden test — input and expected output withheld)") + push("") + else + if r.input then + append_block(" Input: ", r.input) + end + append_block(" Expected: ", r.expected) + append_block(" Got: ", r.actual) + push("") end - append_block(" Expected: ", r.expected) - append_block(" Got: ", r.actual) - push("") end end elseif result.correct_option then diff --git a/lua/code-practice/runner.lua b/lua/code-practice/runner.lua index 4667fe0..7ecb5dd 100644 --- a/lua/code-practice/runner.lua +++ b/lua/code-practice/runner.lua @@ -95,7 +95,7 @@ local function build_test_result(i, test, jr, timeout_ms) end -- Generic interpreted-engine runner. Works for any engine that provides --- `wrap_test(code, input)` and `run_cmd(cfg)` in the registry. +-- `wrap_test(code, input)` and `run_cmd(cfg, file)` in the registry. local function run_interpreted_async(eng, eng_name, exercise_id, code, callback) local test_cases = db.get_test_cases(exercise_id) if #test_cases == 0 then @@ -106,7 +106,7 @@ local function run_interpreted_async(eng, eng_name, exercise_id, code, callback) local results = {} local all_passed = true local timeout_ms = config.get("runner.timeout", 5) * 1000 - local cmd = eng.run_cmd(config.get("engines." .. eng_name, {})) + local cfg = config.get("engines." .. eng_name, {}) local function run_case(i) if i > #test_cases then @@ -118,7 +118,7 @@ local function run_interpreted_async(eng, eng_name, exercise_id, code, callback) local test = test_cases[i] utils.write_file(temp_file, eng.wrap_test(code, test.input or "")) - local job_id = run_job({ cmd, temp_file }, { timeout_ms = timeout_ms }, function(jr) + local job_id = run_job(eng.run_cmd(cfg, temp_file), { timeout_ms = timeout_ms }, function(jr) local entry, passed = build_test_result(i, test, jr, timeout_ms) if not passed then all_passed = false diff --git a/plugin/code-practice.lua b/plugin/code-practice.lua index 05ad381..d0b4623 100644 --- a/plugin/code-practice.lua +++ b/plugin/code-practice.lua @@ -43,66 +43,70 @@ vim.api.nvim_create_user_command("CP", function(opts) utils.notify("Import failed: " .. (err or "unknown"), "error") end elseif sub == "generate" then - local topic = vim.fn.input("Topic: ") - if not topic or topic == "" then - return - end - local count = vim.fn.input("Count [5]: ") - count = (count and count ~= "") and count or "5" - local difficulty = vim.fn.input("Difficulty (easy/medium/hard) [medium]: ") - difficulty = (difficulty and difficulty ~= "") and difficulty or "medium" - local engine_names = table.concat(require("code-practice.engines").list(), "/") - local engine = vim.fn.input("Engine (" .. engine_names .. ") [python]: ") - engine = (engine and engine ~= "") and engine or "python" + vim.ui.input({ prompt = "Topic: " }, function(topic) + if not topic or topic == "" then + return + end + vim.ui.input({ prompt = "Count (default 5): " }, function(count) + count = (count and count ~= "") and count or "5" + vim.ui.select({ "easy", "medium", "hard" }, { prompt = "Difficulty:" }, function(difficulty) + difficulty = difficulty or "medium" + local engine_list = require("code-practice.engines").list() + vim.ui.select(engine_list, { prompt = "Engine:" }, function(engine) + engine = engine or "python" - local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") - local script = plugin_dir .. "/tools/generate_exercises.py" - local db_path = require("code-practice.config").get("storage.db_path") + local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") + local script = plugin_dir .. "/tools/generate_exercises.py" + local db_path = require("code-practice.config").get("storage.db_path") - local tmp = vim.fn.tempname() .. ".toml" - local toml = string.format( - '[[exercises]]\ntopic = "%s"\nengine = "%s"\ndifficulty = "%s"\ncount = %s\n', - topic:gsub('"', '\\"'), - engine, - difficulty, - count - ) - vim.fn.writefile(vim.split(toml, "\n"), tmp) + local tmp = vim.fn.tempname() .. ".toml" + local toml = string.format( + '[[exercises]]\ntopic = "%s"\nengine = "%s"\ndifficulty = "%s"\ncount = %s\n', + topic:gsub('"', '\\"'), + engine, + difficulty, + count + ) + vim.fn.writefile(vim.split(toml, "\n"), tmp) - local cmd = { "uv", "run", script, tmp, "--db-path", db_path } + local cmd = { "uv", "run", script, tmp, "--db-path", db_path } - utils.notify("Generating exercises...") + utils.notify("Generating exercises...") - local output_lines = {} - vim.fn.jobstart(cmd, { - stdout_buffered = true, - stderr_buffered = true, - on_stdout = function(_, data) - if data then - vim.list_extend(output_lines, data) - end - end, - on_stderr = function(_, data) - if data then - vim.list_extend(output_lines, data) - end - end, - on_exit = function(_, exit_code) - vim.fn.delete(tmp) - vim.schedule(function() - local msg = table.concat(output_lines, "\n") - if exit_code == 0 then - utils.notify(msg) - local browser = require("code-practice.browser") - if browser.refresh then - browser.refresh() - end - else - utils.notify("Generation failed:\n" .. msg, "error") - end + local output_lines = {} + vim.fn.jobstart(cmd, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + if data then + vim.list_extend(output_lines, data) + end + end, + on_stderr = function(_, data) + if data then + vim.list_extend(output_lines, data) + end + end, + on_exit = function(_, exit_code) + vim.fn.delete(tmp) + vim.schedule(function() + local msg = table.concat(output_lines, "\n") + if exit_code == 0 then + utils.notify(msg) + local browser = require("code-practice.browser") + if browser.refresh then + browser.refresh() + end + else + utils.notify("Generation failed:\n" .. msg, "error") + end + end) + end, + }) + end) end) - end, - }) + end) + end) else utils.notify("Unknown subcommand: " .. sub, "warn") end diff --git a/test/test_flow.lua b/test/test_flow.lua index 8ebffbd..19b55f1 100644 --- a/test/test_flow.lua +++ b/test/test_flow.lua @@ -875,6 +875,251 @@ test("AI hints: show_hints uses static path when disabled", function() assert_eq(found_generating, false, "should not trigger AI hints when disabled") end) +-- 43. Popup: open_float focuses window in normal mode, Esc closes it +test("Popup: open_float focuses in normal mode and Esc closes", function() + local popup_mod = require("code-practice.popup") + local utils = require("code-practice.utils") + + local bufnr, winid = popup_mod.open_float({ width = 0.4, height = 0.3, title = " Test Popup " }) + assert_truthy(bufnr, "bufnr nil") + assert_truthy(winid, "winid nil") + + assert_eq(vim.api.nvim_get_current_win(), winid, "current window should be the popup") + assert_eq(vim.api.nvim_get_mode().mode, "n", "should be in normal mode") + + popup_mod.map_close(bufnr, function() + utils.close_win(winid) + end) + + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "x", false) + + assert_truthy(not vim.api.nvim_win_is_valid(winid), "popup window should be closed after Esc") +end) + +-- 44. Importer: theory options with function-like text survive import +test("Importer: theory options with parenthesised text survive replace import", function() + local importer = require("code-practice.importer") + local db_mod = require("code-practice.db") + + local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") + local fixture = plugin_root .. "/test/example_exercises.json" + + local counts, err = importer.import(fixture, { replace = true }) + assert_truthy(counts, "import returned nil: " .. tostring(err)) + assert_gt(counts.theory_options, 0, "theory_options imported") + + local conn = db_mod.connect() + local rows = conn:eval("SELECT COUNT(*) as count FROM theory_options") + local total = rows and (rows.count or (rows[1] and rows[1].count)) or 0 + assert_gt(total, 0, "theory_options should exist after import, got " .. total) + + local problematic = conn:eval( + "SELECT * FROM theory_options WHERE option_text LIKE '%(%' ORDER BY exercise_id, option_number" + ) + if type(problematic) == "table" and problematic[1] then + for _, row in ipairs(problematic) do + assert_truthy( + row.option_text and row.option_text ~= "", + "option_text should not be empty for exercise " .. row.exercise_id .. " option " .. row.option_number + ) + end + end +end) + +-- 45. Theory buffer: options and Answer line are present +test("Theory buffer: options and Answer line displayed", function() + local db_mod = require("code-practice.db") + local mgr = require("code-practice.manager") + + local theory = db_mod.get_all_exercises({ engine = "theory" }) + if #theory == 0 then + skip("no theory exercises in seed data") + end + + local ex_id = theory[1].id + local opts = db_mod.get_theory_options(ex_id) + if #opts == 0 then + skip("no theory options for exercise " .. ex_id) + end + + local bufnr = mgr.open_exercise(ex_id) + assert_truthy(bufnr, "buffer nil") + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") + + for _, opt in ipairs(opts) do + assert_truthy( + content:find(opt.option_text, 1, true), + "buffer should contain option " .. opt.option_number .. ": " .. opt.option_text + ) + end + + assert_truthy(content:find("Answer:", 1, true), "buffer should contain Answer: line") +end) + +-- 46. Theory buffer: all options visible after replace import +test("Theory buffer: options visible after replace import", function() + local importer = require("code-practice.importer") + local db_mod = require("code-practice.db") + local mgr = require("code-practice.manager") + + local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") + local fixture = plugin_root .. "/test/example_exercises.json" + local counts, err = importer.import(fixture, { replace = true }) + assert_truthy(counts, "import returned nil: " .. tostring(err)) + + local theory = db_mod.get_all_exercises({ engine = "theory" }) + if #theory == 0 then + skip("no theory exercises after import") + end + + local failures = {} + for _, ex_row in ipairs(theory) do + local opts = db_mod.get_theory_options(ex_row.id) + if #opts == 0 then + table.insert(failures, string.format("exercise %d (%s): 0 options", ex_row.id, ex_row.title)) + else + local bufnr = mgr.open_exercise(ex_row.id) + if bufnr then + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") + for _, opt in ipairs(opts) do + if not content:find(opt.option_text, 1, true) then + table.insert( + failures, + string.format("exercise %d option %d: %q not in buffer", ex_row.id, opt.option_number, opt.option_text) + ) + end + end + end + end + end + + if #failures > 0 then + error("Theory display failures:\n " .. table.concat(failures, "\n ")) + end +end) + +-- 47. manager.open_exercise content parity after bunload +test("Manager: open_exercise content identical after bunload", function() + local mgr = require("code-practice.manager") + + local buf1 = mgr.open_exercise(1) + assert_truthy(buf1, "first open nil") + local lines_before = vim.api.nvim_buf_get_lines(buf1, 0, -1, false) + + mgr.open_exercise(2) + vim.cmd("bunload " .. buf1) + + local buf1_again = mgr.open_exercise(1) + assert_truthy(buf1_again, "reopen nil") + local lines_after = vim.api.nvim_buf_get_lines(buf1_again, 0, -1, false) + + assert_eq(#lines_before, #lines_after, "line count mismatch") + for i, line in ipairs(lines_before) do + assert_eq(line, lines_after[i], "line " .. i .. " differs") + end +end) + +-- 48. manager.open_exercise focuses non-floating window +test("Manager: open_exercise lands in non-floating window", function() + local mgr = require("code-practice.manager") + + local buf = mgr.open_exercise(1) + assert_truthy(buf, "open nil") + + local win = vim.api.nvim_get_current_win() + local ok_cfg, cfg = pcall(vim.api.nvim_win_get_config, win) + local is_floating = ok_cfg and cfg and cfg.relative and cfg.relative ~= "" + assert_eq(is_floating, false, "current window should not be floating after open_exercise") +end) + +-- 49. popup.open_float with absolute sizes +test("Popup: open_float respects absolute width/height", function() + local popup_mod = require("code-practice.popup") + local utils_mod = require("code-practice.utils") + + local bufnr, winid = popup_mod.open_float({ width = 50, height = 20, title = " Abs Test " }) + assert_truthy(bufnr, "bufnr nil") + assert_truthy(winid, "winid nil") + + local win_width = vim.api.nvim_win_get_width(winid) + local win_height = vim.api.nvim_win_get_height(winid) + assert_eq(win_width, 50, "width") + assert_eq(win_height, 20, "height") + + utils_mod.close_win(winid) +end) + +-- 50. Results: hidden failing test must not leak its input/expected/actual +test("Results: hidden failing test does not leak details", function() + local results = require("code-practice.results") + + local result = { + passed = false, + results = { + { test_num = 1, passed = true, hidden = false, duration = 1, input = "[1]", expected = "1", actual = "1" }, + { + test_num = 2, + passed = false, + hidden = true, + duration = 1, + input = "SECRET_INPUT", + expected = "SECRET_EXPECTED", + actual = "WRONG_ACTUAL", + }, + }, + } + + results.show(result, nil) + local bufnr = results._bufnr + assert_truthy(bufnr, "results buffer nil") + local content = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") + results.close() + + -- The failed hidden test should still be listed as a failure... + assert_truthy(content:find("Test 2", 1, true), "hidden test should still appear as a result row") + -- ...but none of its internals may be revealed. + assert_truthy(not content:find("SECRET_INPUT", 1, true), "hidden test input leaked") + assert_truthy(not content:find("SECRET_EXPECTED", 1, true), "hidden test expected output leaked") + assert_truthy(not content:find("WRONG_ACTUAL", 1, true), "hidden test actual output leaked") +end) + +-- 51. Help: browser cheat-sheet reflects configured keymaps +test("Help: browser cheat-sheet reflects configured keymaps", function() + local config = require("code-practice.config") + local help = require("code-practice.help") + local utils = require("code-practice.utils") + + local original = config.config.keymaps.browser + config.config.keymaps.browser = { + open_item = "", + filter_easy = "", + filter_medium = "", + filter_hard = "", + filter_all = "", + close = "", + } + + local ok, err = pcall(function() + help.show() + local bufnr = vim.api.nvim_get_current_buf() + local winid = vim.api.nvim_get_current_win() + local content = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") + utils.close_win(winid) + + for _, key in ipairs({ "", "", "", "", "", "" }) do + assert_truthy(content:find(key, 1, true), "cheat-sheet missing configured browser key " .. key) + end + end) + + config.config.keymaps.browser = original + if not ok then + error(err) + end +end) + -- Summary io.write("\n" .. string.rep("=", 44) .. "\n") io.write(string.format(" Results: %d passed, %d failed, %d skipped\n", passed, failed, skipped))