From 48f84221cd22945f9c616c781e34394979802ca3 Mon Sep 17 00:00:00 2001 From: davidefiocco <4547987+davidefiocco@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:11:30 +0100 Subject: [PATCH 01/10] Add consistent instructions for code and theory exercises --- lua/code-practice/manager.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/code-practice/manager.lua b/lua/code-practice/manager.lua index 6885b8e..ab899fe 100644 --- a/lua/code-practice/manager.lua +++ b/lua/code-practice/manager.lua @@ -96,8 +96,11 @@ function manager.open_exercise(id) end add_meta("") local run_key = (config.get("keymaps.exercise") or {}).run_tests or "" - add_meta("Press 1-" .. #theory_options .. " to select your answer, then " .. run_key .. " to run tests.") + add_meta("Press 1-" .. #theory_options .. " to select your answer, then " .. run_key .. " to check.") end + else + local run_key = (config.get("keymaps.exercise") or {}).run_tests or "" + add_meta("Modify the code below, then " .. run_key .. " to run tests.") end add_meta("") From 846ebc77354d5fb1f6544e69fecf1761ea971d83 Mon Sep 17 00:00:00 2001 From: davidefiocco <4547987+davidefiocco@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:16:33 +0100 Subject: [PATCH 02/10] Add tests for exercise buffer instructions --- test/test_flow.lua | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/test_flow.lua b/test/test_flow.lua index 8b7155b..225bcb7 100644 --- a/test/test_flow.lua +++ b/test/test_flow.lua @@ -90,6 +90,30 @@ test("Retrieve exercise by ID", function() assert_truthy(ex.test_cases and #ex.test_cases > 0, "no test cases") end) +-- 4b. Exercise buffer instructions +test("Exercise buffer shows correct instructions for code exercises", function() + local mgr = require("code-practice.manager") + local bufnr = mgr.open_exercise(1) + assert_truthy(bufnr, "buffer nil") + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") + assert_truthy(content:match("Modify the code below"), "missing instruction for code exercises") +end) + +test("Exercise buffer shows correct instructions for theory exercises", function() + local db = require("code-practice.db") + local exercises = db.get_all_exercises({ engine = "theory" }) + if #exercises == 0 then + skip("no theory exercises") + end + local mgr = require("code-practice.manager") + local bufnr = mgr.open_exercise(exercises[1].id) + assert_truthy(bufnr, "buffer nil") + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") + assert_truthy(content:match("Press %d+%-%d+ to select your answer"), "missing instruction for theory exercises") +end) + -- 5. Test cases test("Test cases load for exercise 1", function() local db = require("code-practice.db") From 6977cf166326bcbe0d3838f9d528c72d44ccc576 Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 22:05:20 +0100 Subject: [PATCH 03/10] Introduced LLM-generated hints --- Dockerfile | 2 +- README.md | 22 ++++++++-- lua/code-practice/ai_hints.lua | 75 ++++++++++++++++++++++++++++++++++ lua/code-practice/config.lua | 6 +++ lua/code-practice/init.lua | 63 ++++++++++++++++++++++------ test/test_flow.lua | 53 ++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 lua/code-practice/ai_hints.lua diff --git a/Dockerfile b/Dockerfile index 776cf4b..a5f8723 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.20 +FROM alpine:3.21 RUN apk add --no-cache neovim python3 git sqlite-dev diff --git a/README.md b/README.md index 759375e..a72bab9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ Code Practice (Neovim) ====================== -A Neovim plugin for browsing coding exercises, solving them, and running tests -— all without leaving the editor. +A Neovim plugin for browsing coding exercises, solving them (potentially getting some help from AI 🤖), and running tests — all without leaving the editor. Features -------- @@ -12,6 +11,7 @@ Features - Theory questions with answer checking - Results window and solution viewer - LLM-powered exercise generation (see Tools below) +- LLM-powered context-aware hints (opt-in, via Hugging Face Inference API) Installation ------------ @@ -156,6 +156,23 @@ uv run tools/generate_exercises.py tools/syllabus.toml --engines my_engines.toml 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 +instead of showing static hints. The hint is based on your current buffer and the +reference solution. + +Requires `curl` and a HF token (`HF_TOKEN` env var). + +```lua +require("code-practice").setup({ + ai_hints = { + enabled = true, + model = "Qwen/Qwen3-Coder-Next", -- default + }, +}) +``` + Data ---- Exercises are stored in an SQLite database at `stdpath("data")/code-practice/exercises.db`. @@ -167,7 +184,6 @@ Roadmap - [ ] Random exercise (`:CP random`) - [ ] Search widget in browser - [ ] Bug-finding exercise type -- [ ] Context-aware LLM hint based on current buffer code - [ ] Live timer with opt-out config - [ ] Git theory questions - [ ] Haskell engine diff --git a/lua/code-practice/ai_hints.lua b/lua/code-practice/ai_hints.lua new file mode 100644 index 0000000..a37bc4a --- /dev/null +++ b/lua/code-practice/ai_hints.lua @@ -0,0 +1,75 @@ +-- Code Practice - AI-Assisted Hints +local config = require("code-practice.config") +local utils = require("code-practice.utils") + +local M = {} + +local SYSTEM_PROMPT = "You are a tutor. A student is working on an exercise. " + .. "Based on the exercise description, their current attempt, and the reference solution, " + .. "give one short, non-revealing hint (if really warranted, two) that nudges them " + .. "in the right direction. Do NOT give the answer." + +local API_URL = "https://router.huggingface.co/v1/chat/completions" + +function M.generate(exercise, buffer_content, callback) + local model = config.get("ai_hints.model") + local token_env = config.get("ai_hints.hf_token_env") or "HF_TOKEN" + local token = vim.env[token_env] + + if not token or token == "" then + callback(nil, "HF token not found in $" .. token_env) + return + end + + local user_msg = string.format( + "## Exercise\n%s\n\n## Current attempt\n%s\n\n## Reference solution\n%s", + exercise.description or "", + buffer_content, + exercise.solution or "" + ) + + local payload = vim.json.encode({ + model = model, + messages = { + { role = "system", content = SYSTEM_PROMPT }, + { role = "user", content = user_msg }, + }, + max_tokens = 256, + }) + + vim.system({ + "curl", "-s", + API_URL, + "-H", "Content-Type: application/json", + "-H", "Authorization: Bearer " .. token, + "-d", payload, + }, { text = true }, function(result) + vim.schedule(function() + if result.code ~= 0 then + callback(nil, "curl failed (exit " .. tostring(result.code) .. ")") + return + end + + local ok, body = pcall(vim.json.decode, result.stdout) + if not ok or not body then + callback(nil, "Failed to parse API response") + return + end + + if body.error then + callback(nil, body.error.message or vim.inspect(body.error)) + return + end + + local choice = body.choices and body.choices[1] + if not choice or not choice.message then + callback(nil, "No response from model") + return + end + + callback(choice.message.content) + end) + end) +end + +return M diff --git a/lua/code-practice/config.lua b/lua/code-practice/config.lua index 8b22ca6..4e0a8cc 100644 --- a/lua/code-practice/config.lua +++ b/lua/code-practice/config.lua @@ -35,6 +35,12 @@ M.defaults = { auto_save = true, }, + ai_hints = { + enabled = false, + model = "Qwen/Qwen3-Coder-Next", + hf_token_env = "HF_TOKEN", + }, + keymaps = { browser = { open_item = "", diff --git a/lua/code-practice/init.lua b/lua/code-practice/init.lua index 2ef9d44..60cc8d7 100644 --- a/lua/code-practice/init.lua +++ b/lua/code-practice/init.lua @@ -251,18 +251,7 @@ function code_practice.get_current_exercise_id() return vim.b[bufnr].code_practice_exercise_id end -function code_practice.show_hints() - local exercise_id = code_practice.get_current_exercise_id() - if not exercise_id then - utils.notify("No exercise associated with this buffer", "error") - return - end - - local exercise = manager.get_exercise(exercise_id) - if not exercise then - return - end - +local function show_static_hints(exercise) local hints = exercise.hints if not hints or #hints == 0 then utils.notify("No hints available for this exercise", "info") @@ -284,6 +273,56 @@ function code_practice.show_hints() end) end +function code_practice.show_hints() + local exercise_id = code_practice.get_current_exercise_id() + if not exercise_id then + utils.notify("No exercise associated with this buffer", "error") + return + end + + local exercise = manager.get_exercise(exercise_id) + if not exercise then + return + end + + if not config.get("ai_hints.enabled") then + show_static_hints(exercise) + return + 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 " }) + popup.set_lines(hint_bufnr, { "", " Generating hint..." }) + popup.map_close(hint_bufnr, function() + if hint_winid and vim.api.nvim_win_is_valid(hint_winid) then + vim.api.nvim_win_close(hint_winid, true) + end + end) + + local ai_hints = require("code-practice.ai_hints") + ai_hints.generate(exercise, buffer_content, function(hint_text, err) + if not hint_bufnr or not vim.api.nvim_buf_is_valid(hint_bufnr) then + return + end + + if err then + utils.notify("AI hint failed: " .. err, "error") + if hint_winid and vim.api.nvim_win_is_valid(hint_winid) then + vim.api.nvim_win_close(hint_winid, true) + end + show_static_hints(exercise) + return + end + + local lines = { "" } + for _, line in ipairs(utils.split_lines(hint_text)) do + table.insert(lines, " " .. line) + end + table.insert(lines, "") + popup.set_lines(hint_bufnr, lines) + end) +end + function code_practice.show_solution() local exercise_id = code_practice.get_current_exercise_id() if not exercise_id then diff --git a/test/test_flow.lua b/test/test_flow.lua index 225bcb7..8ebffbd 100644 --- a/test/test_flow.lua +++ b/test/test_flow.lua @@ -822,6 +822,59 @@ test("Reopen unloaded exercise: buffer content is restored", function() assert_truthy(content:find("Exercise:"), "unloaded buffer should be repopulated with content") end) +-- 40. AI hints config defaults +test("AI hints: config defaults are present", function() + local config = require("code-practice.config") + assert_eq(config.get("ai_hints.enabled"), false, "ai_hints.enabled default") + assert_eq(config.get("ai_hints.model"), "Qwen/Qwen3-Coder-Next", "ai_hints.model default") + assert_eq(config.get("ai_hints.hf_token_env"), "HF_TOKEN", "ai_hints.hf_token_env default") +end) + +-- 41. AI hints: missing token returns error via callback +test("AI hints: missing HF token produces error callback", function() + local ai_hints = require("code-practice.ai_hints") + local saved = vim.env.HF_TOKEN + vim.env.HF_TOKEN = nil + + local cb_err + ai_hints.generate({ description = "test", solution = "test" }, "buffer", function(_, err) + cb_err = err + end) + + vim.env.HF_TOKEN = saved + assert_truthy(cb_err, "expected error callback when token is missing") + assert_contains(cb_err, "HF_TOKEN", "error should mention the env var name") +end) + +-- 42. AI hints: show_hints falls back to static hints when disabled +test("AI hints: show_hints uses static path when disabled", function() + local config = require("code-practice.config") + assert_eq(config.get("ai_hints.enabled"), false, "ai_hints should be disabled") + + local cp = require("code-practice.init") + cp.open_exercise(1) + + local notifications = {} + local original_notify = vim.notify + vim.notify = function(msg, ...) + table.insert(notifications, msg) + original_notify(msg, ...) + end + + cp.show_hints() + + vim.notify = original_notify + + -- Should not have triggered AI path (no "Generating hint..." notification) + local found_generating = false + for _, msg in ipairs(notifications) do + if msg:find("Generating") then + found_generating = true + end + end + assert_eq(found_generating, false, "should not trigger AI hints when disabled") +end) + -- Summary io.write("\n" .. string.rep("=", 44) .. "\n") io.write(string.format(" Results: %d passed, %d failed, %d skipped\n", passed, failed, skipped)) From 82a080bd21b9af75ca416ec12de4435fdc1adb25 Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 22:11:11 +0100 Subject: [PATCH 04/10] Small fixes --- lua/code-practice/browser.lua | 2 +- lua/code-practice/health.lua | 8 ++++++++ lua/code-practice/init.lua | 10 +++++++--- lua/code-practice/results.lua | 5 +++-- lua/code-practice/utils.lua | 6 +++--- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lua/code-practice/browser.lua b/lua/code-practice/browser.lua index 4a3cfaf..4841bfc 100644 --- a/lua/code-practice/browser.lua +++ b/lua/code-practice/browser.lua @@ -56,7 +56,7 @@ function browser.render_exercise_list() elseif ex.difficulty == "medium" then diff_icon = "◐" elseif ex.difficulty == "hard" then - diff_icon = "○" + diff_icon = "◇" end local engine_icon = engines.icon(ex.engine) diff --git a/lua/code-practice/health.lua b/lua/code-practice/health.lua index 6a78278..e11c685 100644 --- a/lua/code-practice/health.lua +++ b/lua/code-practice/health.lua @@ -49,6 +49,14 @@ function M.check() vim.health.warn("uv not found", { "Install uv (https://github.com/astral-sh/uv) to use :CP generate" }) end + if config.get("ai_hints.enabled") then + if vim.fn.executable("curl") == 1 then + vim.health.ok("curl found (needed for AI hints)") + else + vim.health.warn("curl not found", { "Install curl to use AI hints" }) + end + end + if vim.env.HF_TOKEN and vim.env.HF_TOKEN ~= "" then vim.health.ok("HF_TOKEN is set") else diff --git a/lua/code-practice/init.lua b/lua/code-practice/init.lua index 60cc8d7..b451e96 100644 --- a/lua/code-practice/init.lua +++ b/lua/code-practice/init.lua @@ -42,9 +42,13 @@ end function code_practice.setup(opts) config.setup(opts or {}) - local conn = db.connect() - local row = conn:eval("SELECT COUNT(*) as count FROM exercises") - local count = row and (row.count or (row[1] and row[1].count)) or 0 + local ok, conn = pcall(db.connect) + if not ok or not conn then + utils.notify("Database error: " .. tostring(conn) .. ". Try :CP import to re-create.", "error") + return + end + local row_ok, row = pcall(conn.eval, conn, "SELECT COUNT(*) as count FROM exercises") + local count = row_ok and row and (row.count or (row[1] and row[1].count)) or 0 if count == 0 then local json_path = config.get("storage.exercises_json") diff --git a/lua/code-practice/results.lua b/lua/code-practice/results.lua index 706a6ca..7cc4f59 100644 --- a/lua/code-practice/results.lua +++ b/lua/code-practice/results.lua @@ -5,6 +5,7 @@ local popup = require("code-practice.popup") local results = {} results._winid = 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 @@ -105,9 +106,9 @@ function results.show(result, on_next) end if result.passed then - vim.api.nvim_buf_add_highlight(bufnr, -1, "DiagnosticOk", 0, 0, -1) + vim.api.nvim_buf_add_highlight(bufnr, ns, "DiagnosticOk", 0, 0, -1) else - vim.api.nvim_buf_add_highlight(bufnr, -1, "DiagnosticError", 0, 0, -1) + vim.api.nvim_buf_add_highlight(bufnr, ns, "DiagnosticError", 0, 0, -1) end end diff --git a/lua/code-practice/utils.lua b/lua/code-practice/utils.lua index 138048a..a56cf5a 100644 --- a/lua/code-practice/utils.lua +++ b/lua/code-practice/utils.lua @@ -11,9 +11,9 @@ function utils.write_file(path, content) if not file then return false end - file:write(content) + local ok, err = file:write(content) file:close() - return true + return ok ~= nil end function utils.get_buffer_content(bufnr) @@ -35,7 +35,7 @@ function utils.trim(s) end function utils.split_lines(str) - return vim.split(str, "\n", { plain = true }) + return vim.split(str or "", "\n", { plain = true }) end function utils.create_temp_file(prefix, extension) From 59f1a0bf7fd772a47d3515c2487e2d626979dedb Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 22:20:10 +0100 Subject: [PATCH 05/10] Better support for multiple choice and vimdoc update --- doc/code-practice.txt | 10 +++++++- lua/code-practice/ai_hints.lua | 43 +++++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/doc/code-practice.txt b/doc/code-practice.txt index 9375ffe..d1d91d9 100644 --- a/doc/code-practice.txt +++ b/doc/code-practice.txt @@ -32,6 +32,7 @@ Features: - Results window with per-test pass/fail detail - Reference solution viewer - Solved/unsolved status indicators in the browser +- AI-powered contextual hints (open-ended and multiple-choice aware) - LLM-powered exercise generation from a configurable syllabus ============================================================================== @@ -184,6 +185,12 @@ Options are merged with |vim.tbl_deep_extend()|. auto_save = true, }, + ai_hints = { + enabled = false, -- set true to use AI hints instead of static + model = "Qwen/Qwen3-Coder-Next", -- HF Inference API model + hf_token_env = "HF_TOKEN", -- env var holding the HF token + }, + keymaps = { browser = { open_item = "", @@ -316,7 +323,8 @@ Run `:checkhealth code-practice` to verify: - nui.nvim and sqlite.lua plugins - Engine executables based on enabled config (iterates the registry) - uv executable (needed for |:CP| `generate`) -- HF_TOKEN environment variable (needed for |:CP| `generate`) +- curl executable (needed when `ai_hints.enabled` is true) +- HF_TOKEN environment variable (needed for |:CP| `generate` and AI hints) - Database file existence ============================================================================== diff --git a/lua/code-practice/ai_hints.lua b/lua/code-practice/ai_hints.lua index a37bc4a..0ff2a6d 100644 --- a/lua/code-practice/ai_hints.lua +++ b/lua/code-practice/ai_hints.lua @@ -4,13 +4,27 @@ local utils = require("code-practice.utils") local M = {} -local SYSTEM_PROMPT = "You are a tutor. A student is working on an exercise. " +local OPEN_SYSTEM_PROMPT = "You are a tutor. A student is working on an exercise. " .. "Based on the exercise description, their current attempt, and the reference solution, " .. "give one short, non-revealing hint (if really warranted, two) that nudges them " .. "in the right direction. Do NOT give the answer." +local STRUCTURED_SYSTEM_PROMPT = "You are a tutor. A student is answering a multiple-choice question. " + .. "You know the correct answer, but you must NOT reveal it or eliminate options. " + .. "Instead, give a brief conceptual hint that helps the student reason about the " + .. "underlying topic. Focus on clarifying the key concept, not on the options themselves." + local API_URL = "https://router.huggingface.co/v1/chat/completions" +local function format_options(options) + if not options or #options == 0 then return "" end + local parts = {} + for _, opt in ipairs(options) do + parts[#parts + 1] = string.format("%d. %s", opt.option_number, opt.option_text) + end + return table.concat(parts, "\n") +end + function M.generate(exercise, buffer_content, callback) local model = config.get("ai_hints.model") local token_env = config.get("ai_hints.hf_token_env") or "HF_TOKEN" @@ -21,17 +35,30 @@ function M.generate(exercise, buffer_content, callback) return end - local user_msg = string.format( - "## Exercise\n%s\n\n## Current attempt\n%s\n\n## Reference solution\n%s", - exercise.description or "", - buffer_content, - exercise.solution or "" - ) + local has_options = exercise.options and #exercise.options > 0 + local system_prompt, user_msg + + if has_options then + system_prompt = STRUCTURED_SYSTEM_PROMPT + user_msg = string.format( + "## Question\n%s\n\n## Options\n%s", + exercise.description or "", + format_options(exercise.options) + ) + else + system_prompt = OPEN_SYSTEM_PROMPT + user_msg = string.format( + "## Exercise\n%s\n\n## Current attempt\n%s\n\n## Reference solution\n%s", + exercise.description or "", + buffer_content, + exercise.solution or "" + ) + end local payload = vim.json.encode({ model = model, messages = { - { role = "system", content = SYSTEM_PROMPT }, + { role = "system", content = system_prompt }, { role = "user", content = user_msg }, }, max_tokens = 256, From cb1e5197e360216b7a98f455826a0570be85ff9b Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 22:24:24 +0100 Subject: [PATCH 06/10] Simplify handling of SQL queries --- lua/code-practice/db.lua | 62 ++++++++++++---------------------------- 1 file changed, 18 insertions(+), 44 deletions(-) diff --git a/lua/code-practice/db.lua b/lua/code-practice/db.lua index 758801d..3f0ada6 100644 --- a/lua/code-practice/db.lua +++ b/lua/code-practice/db.lua @@ -1,6 +1,5 @@ -- Code Practice - Database Module local config = require("code-practice.config") -local utils = require("code-practice.utils") local ok_sqlite, sqlite = pcall(require, "sqlite") if not ok_sqlite then @@ -40,27 +39,6 @@ local function normalize_single(results) return nil end -local function safe_insert(conn, table_name, columns, values) - local col_list = table.concat(columns, ", ") - local val_list = {} - for _, v in ipairs(values) do - if type(v) == "string" then - table.insert(val_list, "'" .. utils.escape_sql(v) .. "'") - elseif type(v) == "boolean" then - table.insert(val_list, v and 1 or 0) - elseif v == nil then - table.insert(val_list, "NULL") - else - table.insert(val_list, tostring(v)) - end - end - local sql = string.format("INSERT INTO %s (%s) VALUES (%s)", table_name, col_list, table.concat(val_list, ", ")) - local ok, err = pcall(conn.eval, conn, sql) - if not ok then - return false, tostring(err) - end - return true -end function db.connect() if db_connection then @@ -143,27 +121,24 @@ function db.create_tables() conn:eval("CREATE INDEX IF NOT EXISTS idx_attempts_exercise ON attempts(exercise_id)") end --- Filters are only populated by the browser UI (hardcoded difficulty/engine --- strings), never from raw user input. utils.escape_sql is a defence-in-depth --- measure, not a substitute for parameterised queries. function db.get_all_exercises(filters) local conn = db.connect() local query = "SELECT * FROM exercises" local conditions = {} + local params = {} if filters then if filters.difficulty then - table.insert(conditions, string.format("difficulty = '%s'", utils.escape_sql(filters.difficulty))) + table.insert(conditions, "difficulty = :difficulty") + params.difficulty = filters.difficulty end if filters.engine then - table.insert(conditions, string.format("engine = '%s'", utils.escape_sql(filters.engine))) + table.insert(conditions, "engine = :engine") + params.engine = filters.engine end if filters.search and filters.search ~= "" then - local search_term = utils.escape_sql(filters.search) - table.insert( - conditions, - string.format("(title LIKE '%%%s%%' OR description LIKE '%%%s%%')", search_term, search_term) - ) + table.insert(conditions, "(title LIKE :search OR description LIKE :search)") + params.search = "%" .. filters.search .. "%" end end @@ -173,33 +148,34 @@ function db.get_all_exercises(filters) query = query .. " ORDER BY difficulty, title" + if next(params) then + return normalize_rows(conn:eval(query, params)) + end return normalize_rows(conn:eval(query)) end function db.get_exercise_by_id(id) local conn = db.connect() - return normalize_single(conn:eval(string.format("SELECT * FROM exercises WHERE id = %d", id))) + return normalize_single(conn:eval("SELECT * FROM exercises WHERE id = ?", id)) end function db.get_test_cases(exercise_id) local conn = db.connect() - return normalize_rows( - conn:eval(string.format("SELECT * FROM test_cases WHERE exercise_id = %d ORDER BY id", exercise_id)) - ) + return normalize_rows(conn:eval("SELECT * FROM test_cases WHERE exercise_id = ? ORDER BY id", exercise_id)) end function db.record_attempt(exercise_id, code, passed, output, duration_ms) local conn = db.connect() - local ok, err = safe_insert( + local ok, err = pcall( + conn.eval, conn, - "attempts", - { "exercise_id", "code", "passed", "output", "duration_ms" }, - { exercise_id, code, passed and 1 or 0, output, duration_ms } + "INSERT INTO attempts (exercise_id, code, passed, output, duration_ms) VALUES (:eid, :code, :passed, :output, :dur)", + { eid = exercise_id, code = code, passed = passed and 1 or 0, output = output, dur = duration_ms } ) if not ok then - vim.notify("[code-practice] Failed to record attempt: " .. (err or "unknown"), vim.log.levels.WARN) + vim.notify("[code-practice] Failed to record attempt: " .. (tostring(err) or "unknown"), vim.log.levels.WARN) end return ok @@ -252,9 +228,7 @@ end function db.get_theory_options(exercise_id) local conn = db.connect() - return normalize_rows( - conn:eval(string.format("SELECT * FROM theory_options WHERE exercise_id = %d ORDER BY option_number", exercise_id)) - ) + return normalize_rows(conn:eval("SELECT * FROM theory_options WHERE exercise_id = ? ORDER BY option_number", exercise_id)) end return db From 5f36f9452b1e841fa7533cbfb8c053c11c588016 Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 22:52:03 +0100 Subject: [PATCH 07/10] Streamline SQL handing and more polish --- README.md | 3 + doc/code-practice.txt | 8 ++- lua/code-practice/ai_hints.lua | 24 ++++--- lua/code-practice/browser.lua | 114 ++++++++++++------------------- lua/code-practice/db.lua | 78 +++++---------------- lua/code-practice/importer.lua | 14 +++- lua/code-practice/init.lua | 110 ++++-------------------------- lua/code-practice/manager.lua | 120 +++++++++++++++++++++++++-------- lua/code-practice/utils.lua | 22 +++++- plugin/code-practice.lua | 20 +++--- schema.sql | 48 +++++++++++++ test/seed_db.py | 49 +------------- tools/generate_exercises.py | 36 ++-------- 13 files changed, 283 insertions(+), 363 deletions(-) create mode 100644 schema.sql diff --git a/README.md b/README.md index a72bab9..33c2453 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,9 @@ Exercises are stored in an SQLite database at `stdpath("data")/code-practice/exe Import exercises from a JSON file with `:CP import `, or use `:CP! import ` to replace existing data. The database path is configurable via `storage.db_path`. +The database schema is defined in [`schema.sql`](schema.sql) at the repository root and +shared by the Lua plugin, the test seeder, and the exercise generator. + Roadmap ------- - [ ] Random exercise (`:CP random`) diff --git a/doc/code-practice.txt b/doc/code-practice.txt index d1d91d9..04b64cc 100644 --- a/doc/code-practice.txt +++ b/doc/code-practice.txt @@ -296,9 +296,11 @@ CONFIGURATION FILES ~ Exercises are stored in an SQLite database at the path configured by `storage.db_path` (default: `stdpath("data")/code-practice/exercises.db`). -The database contains four tables: exercises, test_cases, attempts, and -theory_options. Foreign keys are enabled; deleting an exercise cascades to -its test cases, attempts, and options. +The database schema is defined in `schema.sql` at the repository root -- +the single source of truth shared by the Lua plugin, the test seeder, and +the exercise generator. It contains four tables: exercises, test_cases, +attempts, and theory_options. Foreign keys are enabled; deleting an +exercise cascades to its test cases, attempts, and options. IMPORTING ~ diff --git a/lua/code-practice/ai_hints.lua b/lua/code-practice/ai_hints.lua index 0ff2a6d..b0e9637 100644 --- a/lua/code-practice/ai_hints.lua +++ b/lua/code-practice/ai_hints.lua @@ -1,6 +1,5 @@ -- Code Practice - AI-Assisted Hints local config = require("code-practice.config") -local utils = require("code-practice.utils") local M = {} @@ -17,7 +16,9 @@ local STRUCTURED_SYSTEM_PROMPT = "You are a tutor. A student is answering a mult local API_URL = "https://router.huggingface.co/v1/chat/completions" local function format_options(options) - if not options or #options == 0 then return "" end + if not options or #options == 0 then + return "" + end local parts = {} for _, opt in ipairs(options) do parts[#parts + 1] = string.format("%d. %s", opt.option_number, opt.option_text) @@ -40,11 +41,8 @@ function M.generate(exercise, buffer_content, callback) if has_options then system_prompt = STRUCTURED_SYSTEM_PROMPT - user_msg = string.format( - "## Question\n%s\n\n## Options\n%s", - exercise.description or "", - format_options(exercise.options) - ) + user_msg = + string.format("## Question\n%s\n\n## Options\n%s", exercise.description or "", format_options(exercise.options)) else system_prompt = OPEN_SYSTEM_PROMPT user_msg = string.format( @@ -65,11 +63,15 @@ function M.generate(exercise, buffer_content, callback) }) vim.system({ - "curl", "-s", + "curl", + "-s", API_URL, - "-H", "Content-Type: application/json", - "-H", "Authorization: Bearer " .. token, - "-d", payload, + "-H", + "Content-Type: application/json", + "-H", + "Authorization: Bearer " .. token, + "-d", + payload, }, { text = true }, function(result) vim.schedule(function() if result.code ~= 0 then diff --git a/lua/code-practice/browser.lua b/lua/code-practice/browser.lua index 4841bfc..5276128 100644 --- a/lua/code-practice/browser.lua +++ b/lua/code-practice/browser.lua @@ -3,7 +3,6 @@ local db = require("code-practice.db") local engines = require("code-practice.engines") local manager = require("code-practice.manager") local config = require("code-practice.config") -local utils = require("code-practice.utils") local ok_popup, Popup = pcall(require, "nui.popup") local ok_layout, Layout = pcall(require, "nui.layout") @@ -99,62 +98,15 @@ function browser.render_preview() return state.preview_cache[exercise.id] end - local lines = {} - - table.insert(lines, string.format("# %s", exercise.title)) - table.insert(lines, "") - - table.insert(lines, string.format("Difficulty: %s | Engine: %s", exercise.difficulty, exercise.engine)) - table.insert(lines, "") - - table.insert(lines, "## Description") - table.insert(lines, "") - for _, line in ipairs(utils.split_lines(exercise.description)) do - table.insert(lines, line) - end - table.insert(lines, "") - - local test_cases = db.get_test_cases(exercise.id) - if #test_cases > 0 then - table.insert(lines, "## Test Cases") - table.insert(lines, "") - for i, tc in ipairs(test_cases) do - if not tc.is_hidden or tc.is_hidden == 0 then - table.insert(lines, string.format("Test %d:", i)) - if tc.description then - table.insert(lines, string.format(" %s", tc.description)) - end - if tc.input and tc.input ~= "" then - table.insert(lines, string.format(" Input: %s", tc.input)) - end - table.insert(lines, string.format(" Expected: %s", tc.expected_output)) - table.insert(lines, "") - end - end - end - - local eng = engines.get(exercise.engine) - if eng and eng.type == "theory" then - local options = db.get_theory_options(exercise.id) - if #options > 0 then - table.insert(lines, "## Options") - table.insert(lines, "") - for _, opt in ipairs(options) do - table.insert(lines, string.format("%d. %s", opt.option_number, opt.option_text)) - end - table.insert(lines, "") - end - end - - local tags = utils.json_decode(exercise.tags) - if tags and #tags > 0 then - table.insert(lines, "## Tags") - table.insert(lines, table.concat(tags, ", ")) - table.insert(lines, "") + local enriched = manager.get_exercise(exercise.id) + if not enriched then + return { "Exercise not found" } end - table.insert(lines, "") - table.insert(lines, "Press Enter to open, then to run tests") + local lines = manager.format_exercise_preview(enriched, { + description_header = true, + footer = "Press Enter to open, then to run tests", + }) state.preview_cache[exercise.id] = lines return lines @@ -240,36 +192,54 @@ function browser.setup_keymaps() vim.keymap.set("n", key, action, vim.tbl_extend("force", opts, { buffer = preview_buf })) end - map("j", "lua require('code-practice.browser').move_selection(1)") - map("k", "lua require('code-practice.browser').move_selection(-1)") - map("", "lua require('code-practice.browser').move_selection(1)") - map("", "lua require('code-practice.browser').move_selection(-1)") - map("gg", "lua require('code-practice.browser').go_top()") - map("G", "lua require('code-practice.browser').go_bottom()") + map("j", function() + browser.move_selection(1) + end) + map("k", function() + browser.move_selection(-1) + end) + map("", function() + browser.move_selection(1) + end) + map("", function() + browser.move_selection(-1) + end) + map("gg", browser.go_top) + map("G", browser.go_bottom) local open_key = keymaps.open_item or keymaps.open or "" - map(open_key, "lua require('code-practice.browser').open_selected()") + map(open_key, browser.open_selected) if open_key ~= "" then - map("", "lua require('code-practice.browser').open_selected()") + map("", browser.open_selected) end - map("o", "lua require('code-practice.browser').open_selected()") - map(keymaps.filter_easy or "e", "lua require('code-practice.browser').filter_by_difficulty('easy')") - map(keymaps.filter_medium or "m", "lua require('code-practice.browser').filter_by_difficulty('medium')") - map(keymaps.filter_hard or "h", "lua require('code-practice.browser').filter_by_difficulty('hard')") - map(keymaps.filter_all or "a", "lua require('code-practice.browser').clear_filters()") + map("o", browser.open_selected) + map(keymaps.filter_easy or "e", function() + browser.filter_by_difficulty("easy") + end) + map(keymaps.filter_medium or "m", function() + browser.filter_by_difficulty("medium") + end) + map(keymaps.filter_hard or "h", function() + browser.filter_by_difficulty("hard") + end) + map(keymaps.filter_all or "a", browser.clear_filters) for _, name in ipairs(engines.list()) do local eng = engines.get(name) if eng.filter_key then - map(eng.filter_key, "lua require('code-practice.browser').filter_by_engine('" .. name .. "')") + map(eng.filter_key, function() + browser.filter_by_engine(name) + end) end end local close_key = keymaps.close or "q" - map(close_key, "lua require('code-practice.browser').close()") + map(close_key, browser.close) if close_key ~= "" then - map("", "lua require('code-practice.browser').close()") + map("", browser.close) end - map("?", "lua require('code-practice.help').show()") + map("?", function() + require("code-practice.help").show() + end) end local function update_display() diff --git a/lua/code-practice/db.lua b/lua/code-practice/db.lua index 3f0ada6..e15d7c6 100644 --- a/lua/code-practice/db.lua +++ b/lua/code-practice/db.lua @@ -39,7 +39,6 @@ local function normalize_single(results) return nil end - function db.connect() if db_connection then return db_connection @@ -60,65 +59,22 @@ function db.connect() return db_connection end +local function read_schema() + local src = debug.getinfo(1, "S").source:sub(2) + local plugin_root = vim.fn.fnamemodify(src, ":h:h:h") + local schema_path = plugin_root .. "/schema.sql" + return table.concat(vim.fn.readfile(schema_path), "\n") +end + function db.create_tables() local conn = db_connection - - conn:eval([[ - CREATE TABLE IF NOT EXISTS exercises ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT NOT NULL, - difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')), - engine TEXT NOT NULL, - tags TEXT DEFAULT '[]', - hints TEXT DEFAULT '[]', - solution TEXT, - starter_code TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ]]) - - conn:eval([[ - CREATE TABLE IF NOT EXISTS test_cases ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - input TEXT, - expected_output TEXT NOT NULL, - is_hidden INTEGER DEFAULT 0, - description TEXT, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ) - ]]) - - conn:eval([[ - CREATE TABLE IF NOT EXISTS attempts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - code TEXT, - passed INTEGER NOT NULL, - output TEXT, - duration_ms INTEGER, - attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ) - ]]) - - conn:eval([[ - CREATE TABLE IF NOT EXISTS theory_options ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - option_number INTEGER NOT NULL, - option_text TEXT NOT NULL, - is_correct INTEGER DEFAULT 0, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ) - ]]) - - conn:eval("CREATE INDEX IF NOT EXISTS idx_exercises_engine ON exercises(engine)") - conn:eval("CREATE INDEX IF NOT EXISTS idx_exercises_difficulty ON exercises(difficulty)") - conn:eval("CREATE INDEX IF NOT EXISTS idx_test_cases_exercise ON test_cases(exercise_id)") - conn:eval("CREATE INDEX IF NOT EXISTS idx_attempts_exercise ON attempts(exercise_id)") + local schema = read_schema() + for stmt in schema:gmatch("[^;]+") do + stmt = stmt:match("^%s*(.-)%s*$") + if stmt ~= "" then + conn:eval(stmt) + end + end end function db.get_all_exercises(filters) @@ -175,7 +131,7 @@ function db.record_attempt(exercise_id, code, passed, output, duration_ms) ) if not ok then - vim.notify("[code-practice] Failed to record attempt: " .. (tostring(err) or "unknown"), vim.log.levels.WARN) + require("code-practice.utils").notify("Failed to record attempt: " .. (tostring(err) or "unknown"), "warn") end return ok @@ -228,7 +184,9 @@ end function db.get_theory_options(exercise_id) local conn = db.connect() - return normalize_rows(conn:eval("SELECT * FROM theory_options WHERE exercise_id = ? ORDER BY option_number", exercise_id)) + return normalize_rows( + conn:eval("SELECT * FROM theory_options WHERE exercise_id = ? ORDER BY option_number", exercise_id) + ) end return db diff --git a/lua/code-practice/importer.lua b/lua/code-practice/importer.lua index 2302fb1..4acaeec 100644 --- a/lua/code-practice/importer.lua +++ b/lua/code-practice/importer.lua @@ -92,9 +92,14 @@ function M.import(json_path, opts) sql_val(tc.is_hidden == true or tc.is_hidden == 1), sql_val(tc.description or "") ) - local tc_ok = pcall(conn.eval, conn, tc_sql) + local tc_ok, tc_err = pcall(conn.eval, conn, tc_sql) if tc_ok then counts.test_cases = counts.test_cases + 1 + else + utils.notify( + "Failed to insert test case for exercise " .. tostring(ex.id) .. ": " .. tostring(tc_err), + "warn" + ) end end @@ -107,9 +112,14 @@ function M.import(json_path, opts) sql_val(opt.option_text), sql_val(opt.is_correct == 1) ) - local opt_ok = pcall(conn.eval, conn, opt_sql) + local opt_ok, opt_err = pcall(conn.eval, conn, opt_sql) if opt_ok then counts.theory_options = counts.theory_options + 1 + else + utils.notify( + "Failed to insert theory option for exercise " .. tostring(ex.id) .. ": " .. tostring(opt_err), + "warn" + ) end end end diff --git a/lua/code-practice/init.lua b/lua/code-practice/init.lua index b451e96..94e1836 100644 --- a/lua/code-practice/init.lua +++ b/lua/code-practice/init.lua @@ -16,9 +16,7 @@ local solution_window = { } local function close_solution_window() - if solution_window.winid and vim.api.nvim_win_is_valid(solution_window.winid) then - vim.api.nvim_win_close(solution_window.winid, true) - end + utils.close_win(solution_window.winid) solution_window.winid = nil solution_window.bufnr = nil end @@ -244,9 +242,7 @@ function code_practice.show_stats() local bufnr, winid = popup.open_float({ width = 0.3, height = 0.3, title = " Statistics " }) popup.set_lines(bufnr, lines) popup.map_close(bufnr, function() - if winid and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_close(winid, true) - end + utils.close_win(winid) end) end @@ -271,9 +267,7 @@ local function show_static_hints(exercise) local bufnr, winid = popup.open_float({ width = 0.5, height = 0.4, title = " Hints " }) popup.set_lines(bufnr, lines) popup.map_close(bufnr, function() - if winid and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_close(winid, true) - end + utils.close_win(winid) end) end @@ -298,9 +292,7 @@ function code_practice.show_hints() local hint_bufnr, hint_winid = 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() - if hint_winid and vim.api.nvim_win_is_valid(hint_winid) then - vim.api.nvim_win_close(hint_winid, true) - end + utils.close_win(hint_winid) end) local ai_hints = require("code-practice.ai_hints") @@ -311,9 +303,7 @@ function code_practice.show_hints() if err then utils.notify("AI hint failed: " .. err, "error") - if hint_winid and vim.api.nvim_win_is_valid(hint_winid) then - vim.api.nvim_win_close(hint_winid, true) - end + utils.close_win(hint_winid) show_static_hints(exercise) return end @@ -342,30 +332,8 @@ function code_practice.show_solution() close_solution_window() - local comment_prefix = engines.comment_prefix(exercise.engine) - - local lines = {} - local function add_meta(line) - if comment_prefix == "" then - table.insert(lines, line) - else - if line == "" then - table.insert(lines, comment_prefix) - else - table.insert(lines, comment_prefix .. " " .. line) - end - end - end - - add_meta("Solution: " .. exercise.title) - add_meta("Difficulty: " .. exercise.difficulty .. " | Engine: " .. exercise.engine) + local lines, add_meta = manager.build_header_lines(exercise, "Solution") add_meta("") - if exercise.description and exercise.description ~= "" then - for _, desc_line in ipairs(utils.split_lines(exercise.description)) do - add_meta(desc_line) - end - add_meta("") - end add_meta("") add_meta(string.rep("-", 40)) add_meta("") @@ -377,8 +345,8 @@ function code_practice.show_solution() local bufnr = popup.create_scratch_buf({ filetype = engines.filetype(exercise.engine) }) popup.set_lines(bufnr, lines) - vim.api.nvim_command("rightbelow vsplit") - vim.api.nvim_command("buffer " .. bufnr) + vim.cmd("rightbelow vsplit") + vim.cmd.buffer(bufnr) solution_window.winid = vim.api.nvim_get_current_win() solution_window.bufnr = bufnr @@ -401,68 +369,14 @@ function code_practice.show_description() return end - local lines = {} - table.insert(lines, "# " .. exercise.title) - table.insert(lines, "") - table.insert(lines, string.format("Difficulty: %s | Engine: %s", exercise.difficulty, exercise.engine)) - table.insert(lines, "") - - for _, line in ipairs(utils.split_lines(exercise.description)) do - table.insert(lines, line) - end - table.insert(lines, "") - - if exercise.engine == "theory" then - local options = exercise.options or {} - if #options > 0 then - table.insert(lines, "## Options") - table.insert(lines, "") - for _, opt in ipairs(options) do - table.insert(lines, string.format("%d. %s", opt.option_number, opt.option_text)) - end - table.insert(lines, "") - end - else - local test_cases = exercise.test_cases or {} - local visible = {} - for _, tc in ipairs(test_cases) do - if not tc.is_hidden or tc.is_hidden == 0 then - table.insert(visible, tc) - end - end - if #visible > 0 then - table.insert(lines, "## Test Cases") - table.insert(lines, "") - for i, tc in ipairs(visible) do - table.insert(lines, string.format("Test %d:", i)) - if tc.description and tc.description ~= "" then - table.insert(lines, string.format(" %s", tc.description)) - end - if tc.input and tc.input ~= "" then - table.insert(lines, string.format(" Input: %s", tc.input)) - end - table.insert(lines, string.format(" Expected: %s", tc.expected_output)) - table.insert(lines, "") - end - end - end - - local tags = exercise.tags or {} - if #tags > 0 then - table.insert(lines, "## Tags") - table.insert(lines, table.concat(tags, ", ")) - table.insert(lines, "") - end - - table.insert(lines, "") - table.insert(lines, "Press q, , or to close") + local lines = manager.format_exercise_preview(exercise, { + footer = "Press q, , or to close", + }) local bufnr, winid = popup.open_float({ filetype = "markdown", title = " Description " }) popup.set_lines(bufnr, lines) popup.map_close(bufnr, function() - if winid and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_close(winid, true) - end + utils.close_win(winid) end) end diff --git a/lua/code-practice/manager.lua b/lua/code-practice/manager.lua index ab899fe..374ceda 100644 --- a/lua/code-practice/manager.lua +++ b/lua/code-practice/manager.lua @@ -6,6 +6,24 @@ local utils = require("code-practice.utils") local manager = {} +function manager.build_header_lines(exercise, label) + local comment_prefix = engines.comment_prefix(exercise.engine) + local lines = {} + local add_meta = utils.meta_writer(lines, comment_prefix) + + add_meta(label .. ": " .. exercise.title) + add_meta("Difficulty: " .. exercise.difficulty .. " | Engine: " .. exercise.engine) + add_meta("") + if exercise.description and exercise.description ~= "" then + for _, desc_line in ipairs(utils.split_lines(exercise.description)) do + add_meta(desc_line) + end + add_meta("") + end + + return lines, add_meta +end + function manager.get_exercise(id) local exercise = db.get_exercise_by_id(id) if not exercise then @@ -28,6 +46,78 @@ function manager.list_exercises(filters) return db.get_all_exercises(filters) end +function manager.format_exercise_preview(exercise, opts) + opts = opts or {} + local lines = {} + + table.insert(lines, string.format("# %s", exercise.title)) + table.insert(lines, "") + table.insert(lines, string.format("Difficulty: %s | Engine: %s", exercise.difficulty, exercise.engine)) + table.insert(lines, "") + + if opts.description_header then + table.insert(lines, "## Description") + table.insert(lines, "") + end + for _, line in ipairs(utils.split_lines(exercise.description)) do + table.insert(lines, line) + end + table.insert(lines, "") + + local test_cases = exercise.test_cases or {} + local visible = {} + for _, tc in ipairs(test_cases) do + if not tc.is_hidden or tc.is_hidden == 0 then + table.insert(visible, tc) + end + end + if #visible > 0 then + table.insert(lines, "## Test Cases") + table.insert(lines, "") + for i, tc in ipairs(visible) do + table.insert(lines, string.format("Test %d:", i)) + if tc.description and tc.description ~= "" then + table.insert(lines, string.format(" %s", tc.description)) + end + if tc.input and tc.input ~= "" then + table.insert(lines, string.format(" Input: %s", tc.input)) + end + table.insert(lines, string.format(" Expected: %s", tc.expected_output)) + table.insert(lines, "") + end + end + + local eng = engines.get(exercise.engine) + if eng and eng.type == "theory" then + local options = exercise.options or {} + if #options > 0 then + table.insert(lines, "## Options") + table.insert(lines, "") + for _, opt in ipairs(options) do + table.insert(lines, string.format("%d. %s", opt.option_number, opt.option_text)) + end + table.insert(lines, "") + end + end + + local tags = exercise.tags or {} + if type(tags) == "string" then + tags = utils.json_decode(tags) or {} + end + if #tags > 0 then + table.insert(lines, "## Tags") + table.insert(lines, table.concat(tags, ", ")) + table.insert(lines, "") + end + + if opts.footer then + table.insert(lines, "") + table.insert(lines, opts.footer) + end + + return lines +end + function manager.get_stats() return db.get_stats() end @@ -61,30 +151,8 @@ function manager.open_exercise(id) vim.bo[bufnr].modifiable = true vim.bo[bufnr].readonly = false - local comment_prefix = engines.comment_prefix(exercise.engine) - - local lines = {} - local function add_meta(line) - if comment_prefix == "" then - table.insert(lines, line) - else - if line == "" then - table.insert(lines, comment_prefix) - else - table.insert(lines, comment_prefix .. " " .. line) - end - end - end - - add_meta("Exercise: " .. exercise.title) - add_meta("Difficulty: " .. exercise.difficulty .. " | Engine: " .. exercise.engine) - add_meta("") - if exercise.description and exercise.description ~= "" then - for _, desc_line in ipairs(utils.split_lines(exercise.description)) do - add_meta(desc_line) - end - add_meta("") - end + local lines, add_meta = manager.build_header_lines(exercise, "Exercise") + local run_key = (config.get("keymaps.exercise") or {}).run_tests or "" local theory_options = nil if exercise.engine == "theory" then @@ -95,11 +163,9 @@ function manager.open_exercise(id) add_meta(string.format("%d. %s", opt.option_number, opt.option_text)) end add_meta("") - local run_key = (config.get("keymaps.exercise") or {}).run_tests or "" add_meta("Press 1-" .. #theory_options .. " to select your answer, then " .. run_key .. " to check.") end else - local run_key = (config.get("keymaps.exercise") or {}).run_tests or "" add_meta("Modify the code below, then " .. run_key .. " to run tests.") end @@ -179,7 +245,7 @@ function manager.open_exercise(id) end end - vim.api.nvim_command("buffer " .. bufnr) + vim.cmd.buffer(bufnr) return bufnr end diff --git a/lua/code-practice/utils.lua b/lua/code-practice/utils.lua index a56cf5a..8036aed 100644 --- a/lua/code-practice/utils.lua +++ b/lua/code-practice/utils.lua @@ -11,9 +11,9 @@ function utils.write_file(path, content) if not file then return false end - local ok, err = file:write(content) + local _, err = file:write(content) file:close() - return ok ~= nil + return err == nil end function utils.get_buffer_content(bufnr) @@ -52,6 +52,24 @@ function utils.delete_temp_files() end end +function utils.meta_writer(lines, comment_prefix) + return function(line) + if comment_prefix == "" then + table.insert(lines, line) + elseif line == "" then + table.insert(lines, comment_prefix) + else + table.insert(lines, comment_prefix .. " " .. line) + end + end +end + +function utils.close_win(winid) + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_close(winid, true) + end +end + function utils.json_decode(str) local ok, result = pcall(vim.json.decode, str) return ok and result or nil diff --git a/plugin/code-practice.lua b/plugin/code-practice.lua index 706e1ca..05ad381 100644 --- a/plugin/code-practice.lua +++ b/plugin/code-practice.lua @@ -1,5 +1,6 @@ -- Code Practice - Plugin Commands local code_practice = require("code-practice.init") +local utils = require("code-practice.utils") vim.api.nvim_create_user_command("CP", function(opts) local args = opts.fargs @@ -18,29 +19,28 @@ vim.api.nvim_create_user_command("CP", function(opts) elseif sub == "import" then local path = args[2] or require("code-practice.config").get("storage.exercises_json") or "" if path == "" then - vim.notify("[code-practice] Usage: :CP import ", vim.log.levels.WARN) + utils.notify("Usage: :CP import ", "warn") return end local importer = require("code-practice.importer") local counts, err = importer.import(path, { replace = opts.bang }) if counts then local mode = opts.bang and "Replaced with" or "Imported" - vim.notify( + utils.notify( string.format( - "[code-practice] %s %d exercises, %d test cases, %d theory options", + "%s %d exercises, %d test cases, %d theory options", mode, counts.exercises, counts.test_cases, counts.theory_options - ), - vim.log.levels.INFO + ) ) local browser = require("code-practice.browser") if browser.refresh then browser.refresh() end else - vim.notify("[code-practice] Import failed: " .. (err or "unknown"), vim.log.levels.ERROR) + utils.notify("Import failed: " .. (err or "unknown"), "error") end elseif sub == "generate" then local topic = vim.fn.input("Topic: ") @@ -71,7 +71,7 @@ vim.api.nvim_create_user_command("CP", function(opts) local cmd = { "uv", "run", script, tmp, "--db-path", db_path } - vim.notify("[code-practice] Generating exercises...", vim.log.levels.INFO) + utils.notify("Generating exercises...") local output_lines = {} vim.fn.jobstart(cmd, { @@ -92,19 +92,19 @@ vim.api.nvim_create_user_command("CP", function(opts) vim.schedule(function() local msg = table.concat(output_lines, "\n") if exit_code == 0 then - vim.notify("[code-practice] " .. msg, vim.log.levels.INFO) + utils.notify(msg) local browser = require("code-practice.browser") if browser.refresh then browser.refresh() end else - vim.notify("[code-practice] Generation failed:\n" .. msg, vim.log.levels.ERROR) + utils.notify("Generation failed:\n" .. msg, "error") end end) end, }) else - vim.notify("[code-practice] Unknown subcommand: " .. sub, vim.log.levels.WARN) + utils.notify("Unknown subcommand: " .. sub, "warn") end end, { nargs = "*", diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..15bfe98 --- /dev/null +++ b/schema.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS exercises ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL, + difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')), + engine TEXT NOT NULL, + tags TEXT DEFAULT '[]', + hints TEXT DEFAULT '[]', + solution TEXT, + starter_code TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS test_cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exercise_id INTEGER NOT NULL, + input TEXT, + expected_output TEXT NOT NULL, + is_hidden INTEGER DEFAULT 0, + description TEXT, + FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exercise_id INTEGER NOT NULL, + code TEXT, + passed INTEGER NOT NULL, + output TEXT, + duration_ms INTEGER, + attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS theory_options ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exercise_id INTEGER NOT NULL, + option_number INTEGER NOT NULL, + option_text TEXT NOT NULL, + is_correct INTEGER DEFAULT 0, + FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_exercises_engine ON exercises(engine); +CREATE INDEX IF NOT EXISTS idx_exercises_difficulty ON exercises(difficulty); +CREATE INDEX IF NOT EXISTS idx_test_cases_exercise ON test_cases(exercise_id); +CREATE INDEX IF NOT EXISTS idx_attempts_exercise ON attempts(exercise_id); diff --git a/test/seed_db.py b/test/seed_db.py index 1bf0b56..786f433 100644 --- a/test/seed_db.py +++ b/test/seed_db.py @@ -5,6 +5,7 @@ import os import sqlite3 import sys +from pathlib import Path def main(): @@ -21,52 +22,8 @@ def main(): conn = sqlite3.connect(db_path) conn.execute("PRAGMA foreign_keys = ON") - conn.executescript(""" - CREATE TABLE IF NOT EXISTS exercises ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT NOT NULL, - difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')), - engine TEXT NOT NULL, - tags TEXT DEFAULT '[]', - hints TEXT DEFAULT '[]', - solution TEXT, - starter_code TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS test_cases ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - input TEXT, - expected_output TEXT NOT NULL, - is_hidden INTEGER DEFAULT 0, - description TEXT, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS attempts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - code TEXT, - passed INTEGER NOT NULL, - output TEXT, - duration_ms INTEGER, - attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS theory_options ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - option_number INTEGER NOT NULL, - option_text TEXT NOT NULL, - is_correct INTEGER DEFAULT 0, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_exercises_engine ON exercises(engine); - CREATE INDEX IF NOT EXISTS idx_exercises_difficulty ON exercises(difficulty); - CREATE INDEX IF NOT EXISTS idx_test_cases_exercise ON test_cases(exercise_id); - CREATE INDEX IF NOT EXISTS idx_attempts_exercise ON attempts(exercise_id); - """) + schema_path = Path(__file__).resolve().parent.parent / "schema.sql" + conn.executescript(schema_path.read_text()) for ex in exercises: tags = ex.get("tags", []) diff --git a/tools/generate_exercises.py b/tools/generate_exercises.py index 548b2a3..25428cb 100755 --- a/tools/generate_exercises.py +++ b/tools/generate_exercises.py @@ -346,39 +346,11 @@ def parse_titles(raw: str) -> list[str]: # Database # --------------------------------------------------------------------------- +SCHEMA_PATH = Path(__file__).resolve().parent.parent / "schema.sql" + + def ensure_tables(conn: sqlite3.Connection): - conn.executescript(""" - CREATE TABLE IF NOT EXISTS exercises ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - description TEXT NOT NULL, - difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')), - engine TEXT NOT NULL, - tags TEXT DEFAULT '[]', - hints TEXT DEFAULT '[]', - solution TEXT, - starter_code TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS test_cases ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - input TEXT, - expected_output TEXT NOT NULL, - is_hidden INTEGER DEFAULT 0, - description TEXT, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS theory_options ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - exercise_id INTEGER NOT NULL, - option_number INTEGER NOT NULL, - option_text TEXT NOT NULL, - is_correct INTEGER DEFAULT 0, - FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE - ); - """) + conn.executescript(SCHEMA_PATH.read_text()) def wipe_exercise_tables(conn: sqlite3.Connection): From 8f883d6af14e02c4a6500682f24f528ce2056984 Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 23:00:01 +0100 Subject: [PATCH 08/10] Harden SQL and token handling, add config.get default param --- lua/code-practice/ai_hints.lua | 8 +++- lua/code-practice/browser.lua | 2 +- lua/code-practice/config.lua | 4 +- lua/code-practice/engines.lua | 29 +++++++----- lua/code-practice/help.lua | 2 +- lua/code-practice/importer.lua | 83 ++++++++++++++++------------------ lua/code-practice/init.lua | 2 +- lua/code-practice/manager.lua | 2 +- lua/code-practice/results.lua | 2 +- lua/code-practice/runner.lua | 8 ++-- lua/code-practice/utils.lua | 7 --- 11 files changed, 74 insertions(+), 75 deletions(-) diff --git a/lua/code-practice/ai_hints.lua b/lua/code-practice/ai_hints.lua index b0e9637..c5b80c0 100644 --- a/lua/code-practice/ai_hints.lua +++ b/lua/code-practice/ai_hints.lua @@ -28,7 +28,7 @@ end function M.generate(exercise, buffer_content, callback) local model = config.get("ai_hints.model") - local token_env = config.get("ai_hints.hf_token_env") or "HF_TOKEN" + local token_env = config.get("ai_hints.hf_token_env", "HF_TOKEN") local token = vim.env[token_env] if not token or token == "" then @@ -62,6 +62,9 @@ function M.generate(exercise, buffer_content, callback) max_tokens = 256, }) + local header_file = vim.fn.tempname() + vim.fn.writefile({ "Authorization: Bearer " .. token }, header_file) + vim.system({ "curl", "-s", @@ -69,10 +72,11 @@ function M.generate(exercise, buffer_content, callback) "-H", "Content-Type: application/json", "-H", - "Authorization: Bearer " .. token, + "@" .. header_file, "-d", payload, }, { text = true }, function(result) + vim.fn.delete(header_file) vim.schedule(function() if result.code ~= 0 then callback(nil, "curl failed (exit " .. tostring(result.code) .. ")") diff --git a/lua/code-practice/browser.lua b/lua/code-practice/browser.lua index 5276128..2585a37 100644 --- a/lua/code-practice/browser.lua +++ b/lua/code-practice/browser.lua @@ -73,7 +73,7 @@ function browser.render_exercise_list() table.insert(lines, " No exercises found.") end - local km = config.get("keymaps.browser") or {} + local km = config.get("keymaps.browser", {}) table.insert(lines, "") table.insert(lines, " " .. string.rep("─", 30)) table.insert( diff --git a/lua/code-practice/config.lua b/lua/code-practice/config.lua index 4e0a8cc..6746130 100644 --- a/lua/code-practice/config.lua +++ b/lua/code-practice/config.lua @@ -72,14 +72,14 @@ function M.setup(user_config) vim.fn.mkdir(M.config.storage.home, "p") end -function M.get(key) +function M.get(key, default) local keys = vim.split(key, ".", { plain = true }) local value = M.config for _, k in ipairs(keys) do value = value[k] if value == nil then - return nil + return default end end diff --git a/lua/code-practice/engines.lua b/lua/code-practice/engines.lua index 004d21d..99c51e2 100644 --- a/lua/code-practice/engines.lua +++ b/lua/code-practice/engines.lua @@ -72,28 +72,33 @@ M.registry = { }, } --- Stable iteration order (alphabetical, but theory last for UI consistency). -M._order = { "python", "rust", "theory" } +local function sorted_names() + local names = vim.tbl_keys(M.registry) + table.sort(names, function(a, b) + local ta, tb = M.registry[a].type, M.registry[b].type + if ta == "theory" and tb ~= "theory" then + return false + end + if ta ~= "theory" and tb == "theory" then + return true + end + return a < b + end) + return names +end function M.get(name) return M.registry[name] end function M.list() - local result = {} - for _, name in ipairs(M._order) do - if M.registry[name] then - table.insert(result, name) - end - end - return result + return sorted_names() end function M.coding_engines() local result = {} - for _, name in ipairs(M._order) do - local eng = M.registry[name] - if eng and eng.type == "coding" then + for _, name in ipairs(sorted_names()) do + if M.registry[name].type == "coding" then table.insert(result, name) end end diff --git a/lua/code-practice/help.lua b/lua/code-practice/help.lua index 852def7..be3cb0c 100644 --- a/lua/code-practice/help.lua +++ b/lua/code-practice/help.lua @@ -63,7 +63,7 @@ function help.show() end vim.cmd("stopinsert") - local km = config.get("keymaps.exercise") or {} + local km = config.get("keymaps.exercise", {}) local filter_lines = {} for _, name in ipairs(engines.list()) do diff --git a/lua/code-practice/importer.lua b/lua/code-practice/importer.lua index 4acaeec..762ae96 100644 --- a/lua/code-practice/importer.lua +++ b/lua/code-practice/importer.lua @@ -4,19 +4,6 @@ local utils = require("code-practice.utils") local M = {} -local function sql_val(v) - if v == nil then - return "NULL" - end - if type(v) == "boolean" then - return v and "1" or "0" - end - if type(v) == "number" then - return tostring(v) - end - return "'" .. utils.escape_sql(tostring(v)) .. "'" -end - function M.import(json_path, opts) opts = opts or {} @@ -58,41 +45,48 @@ function M.import(json_path, opts) hints = vim.json.encode(hints) end - local sql = string.format( + local insert_ok, err = pcall( + conn.eval, + conn, [[INSERT OR REPLACE INTO exercises (id, title, description, difficulty, engine, tags, hints, solution, starter_code, created_at, updated_at) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)]], - sql_val(ex.id), - sql_val(ex.title), - sql_val(ex.description), - sql_val(ex.difficulty), - sql_val(ex.engine), - sql_val(tags or "[]"), - sql_val(hints or "[]"), - sql_val(ex.solution or ""), - sql_val(ex.starter_code or ""), - sql_val(ex.created_at or ""), - sql_val(ex.updated_at or "") + VALUES (:id, :title, :description, :difficulty, :engine, + :tags, :hints, :solution, :starter_code, + :created_at, :updated_at)]], + { + id = ex.id, + title = ex.title, + description = ex.description, + difficulty = ex.difficulty, + engine = ex.engine, + tags = tags or "[]", + hints = hints or "[]", + solution = ex.solution or "", + starter_code = ex.starter_code or "", + created_at = ex.created_at or "", + updated_at = ex.updated_at or "", + } ) - - local insert_ok, err = pcall(conn.eval, conn, sql) if not insert_ok then error("Failed to insert exercise " .. tostring(ex.id) .. ": " .. tostring(err)) end counts.exercises = counts.exercises + 1 for _, tc in ipairs(ex.test_cases or {}) do - local tc_sql = string.format( + local tc_ok, tc_err = pcall( + conn.eval, + conn, [[INSERT INTO test_cases (exercise_id, input, expected_output, is_hidden, description) - VALUES (%s, %s, %s, %s, %s)]], - sql_val(ex.id), - sql_val(tc.input or ""), - sql_val(tc.expected_output), - sql_val(tc.is_hidden == true or tc.is_hidden == 1), - sql_val(tc.description or "") + VALUES (:eid, :input, :expected, :hidden, :desc)]], + { + eid = ex.id, + input = tc.input or "", + expected = tc.expected_output, + hidden = (tc.is_hidden == true or tc.is_hidden == 1) and 1 or 0, + desc = tc.description or "", + } ) - local tc_ok, tc_err = pcall(conn.eval, conn, tc_sql) if tc_ok then counts.test_cases = counts.test_cases + 1 else @@ -104,15 +98,18 @@ function M.import(json_path, opts) end for _, opt in ipairs(ex.theory_options or {}) do - local opt_sql = string.format( + local opt_ok, opt_err = pcall( + conn.eval, + conn, [[INSERT INTO theory_options (exercise_id, option_number, option_text, is_correct) - VALUES (%s, %s, %s, %s)]], - sql_val(ex.id), - sql_val(opt.option_number), - sql_val(opt.option_text), - sql_val(opt.is_correct == 1) + 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, + } ) - local opt_ok, opt_err = pcall(conn.eval, conn, opt_sql) if opt_ok then counts.theory_options = counts.theory_options + 1 else diff --git a/lua/code-practice/init.lua b/lua/code-practice/init.lua index 94e1836..c526324 100644 --- a/lua/code-practice/init.lua +++ b/lua/code-practice/init.lua @@ -91,7 +91,7 @@ local function setup_exercise_keymaps(bufnr) end vim.b[bufnr].code_practice_keymaps_set = true - local km = config.get("keymaps.exercise") or {} + local km = config.get("keymaps.exercise", {}) local function bmap(key, fn, desc) if key then vim.keymap.set("n", key, fn, { buffer = bufnr, silent = true, desc = desc }) diff --git a/lua/code-practice/manager.lua b/lua/code-practice/manager.lua index 374ceda..1e25870 100644 --- a/lua/code-practice/manager.lua +++ b/lua/code-practice/manager.lua @@ -152,7 +152,7 @@ function manager.open_exercise(id) vim.bo[bufnr].readonly = false local lines, add_meta = manager.build_header_lines(exercise, "Exercise") - local run_key = (config.get("keymaps.exercise") or {}).run_tests or "" + local run_key = config.get("keymaps.exercise.run_tests", "") local theory_options = nil if exercise.engine == "theory" then diff --git a/lua/code-practice/results.lua b/lua/code-practice/results.lua index 7cc4f59..f503172 100644 --- a/lua/code-practice/results.lua +++ b/lua/code-practice/results.lua @@ -86,7 +86,7 @@ function results.show(result, on_next) push("No detailed results available.") end - local next_key = (config.get("keymaps.exercise") or {}).next_exercise or "" + local next_key = config.get("keymaps.exercise.next_exercise", "") push("") if on_next then diff --git a/lua/code-practice/runner.lua b/lua/code-practice/runner.lua index bfd9630..6cca76a 100644 --- a/lua/code-practice/runner.lua +++ b/lua/code-practice/runner.lua @@ -105,8 +105,8 @@ local function run_interpreted_async(eng, eng_name, exercise_id, code, callback) local temp_file = utils.create_temp_file("solution", eng.ext) local results = {} local all_passed = true - local timeout_ms = (config.get("runner.timeout") or 5) * 1000 - local cmd = eng.run_cmd(config.get("engines." .. eng_name) or {}) + local timeout_ms = config.get("runner.timeout", 5) * 1000 + local cmd = eng.run_cmd(config.get("engines." .. eng_name, {})) local function run_case(i) if i > #test_cases then @@ -148,10 +148,10 @@ local function run_compiled_async(eng, eng_name, exercise_id, code, callback) local src_file = utils.create_temp_file("solution", eng.ext) local bin_file = src_file:gsub("%." .. eng.ext .. "$", "") - local cfg = config.get("engines." .. eng_name) or {} + local cfg = config.get("engines." .. eng_name, {}) local results = {} local all_passed = true - local timeout_ms = (config.get("runner.timeout") or 5) * 1000 + local timeout_ms = config.get("runner.timeout", 5) * 1000 local function cleanup() vim.fn.delete(src_file) diff --git a/lua/code-practice/utils.lua b/lua/code-practice/utils.lua index 8036aed..0447271 100644 --- a/lua/code-practice/utils.lua +++ b/lua/code-practice/utils.lua @@ -75,11 +75,4 @@ function utils.json_decode(str) return ok and result or nil end -function utils.escape_sql(s) - if type(s) ~= "string" then - return s - end - return s:gsub("'", "''") -end - return utils From 7eb362ce768b18f4380d9e4b11fbdd5e660881fe Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 23:06:47 +0100 Subject: [PATCH 09/10] More polish --- lua/code-practice/ai_hints.lua | 6 +---- lua/code-practice/popup.lua | 47 ++++++++++++++++++++++------------ lua/code-practice/runner.lua | 4 +++ 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/lua/code-practice/ai_hints.lua b/lua/code-practice/ai_hints.lua index c5b80c0..6366e6c 100644 --- a/lua/code-practice/ai_hints.lua +++ b/lua/code-practice/ai_hints.lua @@ -62,9 +62,6 @@ function M.generate(exercise, buffer_content, callback) max_tokens = 256, }) - local header_file = vim.fn.tempname() - vim.fn.writefile({ "Authorization: Bearer " .. token }, header_file) - vim.system({ "curl", "-s", @@ -72,11 +69,10 @@ function M.generate(exercise, buffer_content, callback) "-H", "Content-Type: application/json", "-H", - "@" .. header_file, + "Authorization: Bearer " .. token, "-d", payload, }, { text = true }, function(result) - vim.fn.delete(header_file) vim.schedule(function() if result.code ~= 0 then callback(nil, "curl failed (exit " .. tostring(result.code) .. ")") diff --git a/lua/code-practice/popup.lua b/lua/code-practice/popup.lua index ddd2a14..502c23f 100644 --- a/lua/code-practice/popup.lua +++ b/lua/code-practice/popup.lua @@ -1,4 +1,10 @@ -- Code Practice - Shared Popup / Scratch-Buffer Utilities +local ok_nui, NuiPopup = pcall(require, "nui.popup") +if not ok_nui then + vim.notify("[code-practice] nui.nvim not found. Install MunifTanjim/nui.nvim", vim.log.levels.ERROR) + return {} +end + local M = {} function M.create_scratch_buf(opts) @@ -15,31 +21,40 @@ end function M.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 row = math.floor((vim.o.lines - height) / 2) - local col = math.floor((vim.o.columns - width) / 2) - local bufnr = M.create_scratch_buf({ filetype = opts.filetype }) + local border = { style = opts.border or ui_border } + if opts.title then + border.text = { top = opts.title, top_align = "center" } + end - local win_opts = { - relative = "editor", - row = row, - col = col, - width = width, - height = height, - border = opts.border or "rounded", - style = "minimal", + local buf_options = { + buftype = "nofile", + bufhidden = "wipe", + swapfile = false, + modifiable = false, + readonly = true, } - if opts.title then - win_opts.title = opts.title - win_opts.title_pos = "center" + if opts.filetype then + buf_options.filetype = opts.filetype end - local winid = vim.api.nvim_open_win(bufnr, true, win_opts) - return bufnr, winid + local popup = NuiPopup({ + relative = "editor", + position = "50%", + size = { width = width, height = height }, + border = border, + buf_options = buf_options, + }) + + popup:mount() + + return popup.bufnr, popup.winid end function M.set_lines(bufnr, lines) diff --git a/lua/code-practice/runner.lua b/lua/code-practice/runner.lua index 6cca76a..4667fe0 100644 --- a/lua/code-practice/runner.lua +++ b/lua/code-practice/runner.lua @@ -260,6 +260,10 @@ function runner.run_test_async(exercise_id, code, engine_name, callback) return callback(nil, "Unsupported engine: " .. engine_name) end + if config.get("engines." .. engine_name .. ".enabled") == false then + return callback(nil, engine_name .. " engine is disabled. Enable it in your config to run exercises.") + end + if eng.type == "theory" then run_theory_async(exercise_id, code, finish) elseif eng.compile_cmd then From f176f927b0ca1a711e3735c984ab87801ba610a3 Mon Sep 17 00:00:00 2001 From: "davide.fiocco" Date: Fri, 6 Mar 2026 23:24:04 +0100 Subject: [PATCH 10/10] Use neovim and consistent var patterns --- lua/code-practice/ai_hints.lua | 6 +++--- lua/code-practice/config.lua | 18 +++++++++--------- lua/code-practice/engines.lua | 32 ++++++++++++++++---------------- lua/code-practice/health.lua | 6 +++--- lua/code-practice/importer.lua | 6 +++--- lua/code-practice/init.lua | 5 +---- lua/code-practice/manager.lua | 6 ++++-- lua/code-practice/popup.lua | 12 ++++++------ 8 files changed, 45 insertions(+), 46 deletions(-) diff --git a/lua/code-practice/ai_hints.lua b/lua/code-practice/ai_hints.lua index 6366e6c..ca02db5 100644 --- a/lua/code-practice/ai_hints.lua +++ b/lua/code-practice/ai_hints.lua @@ -1,7 +1,7 @@ -- Code Practice - AI-Assisted Hints local config = require("code-practice.config") -local M = {} +local ai_hints = {} local OPEN_SYSTEM_PROMPT = "You are a tutor. A student is working on an exercise. " .. "Based on the exercise description, their current attempt, and the reference solution, " @@ -26,7 +26,7 @@ local function format_options(options) return table.concat(parts, "\n") end -function M.generate(exercise, buffer_content, callback) +function ai_hints.generate(exercise, buffer_content, callback) local model = config.get("ai_hints.model") local token_env = config.get("ai_hints.hf_token_env", "HF_TOKEN") local token = vim.env[token_env] @@ -101,4 +101,4 @@ function M.generate(exercise, buffer_content, callback) end) end -return M +return ai_hints diff --git a/lua/code-practice/config.lua b/lua/code-practice/config.lua index 6746130..3fe4915 100644 --- a/lua/code-practice/config.lua +++ b/lua/code-practice/config.lua @@ -1,5 +1,5 @@ -- Code Practice - Configuration Module -local M = {} +local config = {} local function build_engine_defaults() local engines = require("code-practice.engines") @@ -13,7 +13,7 @@ local function build_engine_defaults() return defaults end -M.defaults = { +config.defaults = { storage = { home = vim.fn.stdpath("data") .. "/code-practice", db_path = vim.fn.stdpath("data") .. "/code-practice/exercises.db", @@ -63,18 +63,18 @@ M.defaults = { }, } -M.config = vim.deepcopy(M.defaults) +config.config = vim.deepcopy(config.defaults) -function M.setup(user_config) +function config.setup(user_config) user_config = user_config or {} - M.config = vim.tbl_deep_extend("force", M.defaults, user_config) + config.config = vim.tbl_deep_extend("force", config.defaults, user_config) - vim.fn.mkdir(M.config.storage.home, "p") + vim.fn.mkdir(config.config.storage.home, "p") end -function M.get(key, default) +function config.get(key, default) local keys = vim.split(key, ".", { plain = true }) - local value = M.config + local value = config.config for _, k in ipairs(keys) do value = value[k] @@ -86,4 +86,4 @@ function M.get(key, default) return value end -return M +return config diff --git a/lua/code-practice/engines.lua b/lua/code-practice/engines.lua index 99c51e2..f253da4 100644 --- a/lua/code-practice/engines.lua +++ b/lua/code-practice/engines.lua @@ -6,9 +6,9 @@ -- To add a new engine, add an entry here and (for generation) a matching -- section in tools/engines.toml. No other files need to change. -local M = {} +local engines = {} -M.registry = { +engines.registry = { python = { type = "coding", filetype = "python", @@ -73,9 +73,9 @@ M.registry = { } local function sorted_names() - local names = vim.tbl_keys(M.registry) + local names = vim.tbl_keys(engines.registry) table.sort(names, function(a, b) - local ta, tb = M.registry[a].type, M.registry[b].type + local ta, tb = engines.registry[a].type, engines.registry[b].type if ta == "theory" and tb ~= "theory" then return false end @@ -87,46 +87,46 @@ local function sorted_names() return names end -function M.get(name) - return M.registry[name] +function engines.get(name) + return engines.registry[name] end -function M.list() +function engines.list() return sorted_names() end -function M.coding_engines() +function engines.coding_engines() local result = {} for _, name in ipairs(sorted_names()) do - if M.registry[name].type == "coding" then + if engines.registry[name].type == "coding" then table.insert(result, name) end end return result end -function M.comment_prefix(name) - local eng = M.registry[name] +function engines.comment_prefix(name) + local eng = engines.registry[name] if eng then return eng.comment_prefix end return "#" end -function M.filetype(name) - local eng = M.registry[name] +function engines.filetype(name) + local eng = engines.registry[name] if eng then return eng.filetype end return "text" end -function M.icon(name) - local eng = M.registry[name] +function engines.icon(name) + local eng = engines.registry[name] if eng then return eng.icon end return "📝" end -return M +return engines diff --git a/lua/code-practice/health.lua b/lua/code-practice/health.lua index e11c685..310b576 100644 --- a/lua/code-practice/health.lua +++ b/lua/code-practice/health.lua @@ -1,8 +1,8 @@ local engines = require("code-practice.engines") -local M = {} +local health = {} -function M.check() +function health.check() vim.health.start("code-practice") if vim.fn.has("nvim-0.10") == 1 then @@ -74,4 +74,4 @@ function M.check() end end -return M +return health diff --git a/lua/code-practice/importer.lua b/lua/code-practice/importer.lua index 762ae96..6abe594 100644 --- a/lua/code-practice/importer.lua +++ b/lua/code-practice/importer.lua @@ -2,9 +2,9 @@ local db = require("code-practice.db") local utils = require("code-practice.utils") -local M = {} +local importer = {} -function M.import(json_path, opts) +function importer.import(json_path, opts) opts = opts or {} if not json_path or json_path == "" then @@ -133,4 +133,4 @@ function M.import(json_path, opts) return tx_result, nil end -return M +return importer diff --git a/lua/code-practice/init.lua b/lua/code-practice/init.lua index c526324..1f80df0 100644 --- a/lua/code-practice/init.lua +++ b/lua/code-practice/init.lua @@ -345,10 +345,7 @@ function code_practice.show_solution() local bufnr = popup.create_scratch_buf({ filetype = engines.filetype(exercise.engine) }) popup.set_lines(bufnr, lines) - vim.cmd("rightbelow vsplit") - vim.cmd.buffer(bufnr) - - solution_window.winid = vim.api.nvim_get_current_win() + solution_window.winid = vim.api.nvim_open_win(bufnr, true, { split = "right" }) solution_window.bufnr = bufnr popup.map_close(bufnr, close_solution_window) diff --git a/lua/code-practice/manager.lua b/lua/code-practice/manager.lua index 1e25870..89f0181 100644 --- a/lua/code-practice/manager.lua +++ b/lua/code-practice/manager.lua @@ -206,7 +206,9 @@ function manager.open_exercise(id) vim.b[bufnr].code_practice_exercise_id = id vim.b[bufnr].code_practice_engine = exercise.engine - if exercise.engine == "theory" then + 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 @@ -245,7 +247,7 @@ function manager.open_exercise(id) end end - vim.cmd.buffer(bufnr) + vim.api.nvim_set_current_buf(bufnr) return bufnr end diff --git a/lua/code-practice/popup.lua b/lua/code-practice/popup.lua index 502c23f..c4a59c6 100644 --- a/lua/code-practice/popup.lua +++ b/lua/code-practice/popup.lua @@ -5,9 +5,9 @@ if not ok_nui then return {} end -local M = {} +local popup = {} -function M.create_scratch_buf(opts) +function popup.create_scratch_buf(opts) opts = opts or {} local bufnr = vim.api.nvim_create_buf(false, true) vim.bo[bufnr].buftype = "nofile" @@ -19,7 +19,7 @@ function M.create_scratch_buf(opts) return bufnr end -function M.open_float(opts) +function popup.open_float(opts) opts = opts or {} local ui_border = require("code-practice.config").get("ui.border", "rounded") @@ -57,7 +57,7 @@ function M.open_float(opts) return popup.bufnr, popup.winid end -function M.set_lines(bufnr, lines) +function popup.set_lines(bufnr, lines) vim.bo[bufnr].modifiable = true vim.bo[bufnr].readonly = false vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) @@ -65,10 +65,10 @@ function M.set_lines(bufnr, lines) vim.bo[bufnr].readonly = true end -function M.map_close(bufnr, close_fn) +function popup.map_close(bufnr, close_fn) for _, key in ipairs({ "q", "", "" }) do vim.keymap.set({ "n", "i" }, key, close_fn, { buffer = bufnr, silent = true, nowait = true }) end end -return M +return popup