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..33c2453 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,18 +156,37 @@ 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`. 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`) - [ ] 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/doc/code-practice.txt b/doc/code-practice.txt index 9375ffe..04b64cc 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 = "", @@ -289,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 ~ @@ -316,7 +325,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 new file mode 100644 index 0000000..ca02db5 --- /dev/null +++ b/lua/code-practice/ai_hints.lua @@ -0,0 +1,104 @@ +-- Code Practice - AI-Assisted Hints +local config = require("code-practice.config") + +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, " + .. "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 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] + + if not token or token == "" then + callback(nil, "HF token not found in $" .. token_env) + return + end + + 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 = "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 ai_hints diff --git a/lua/code-practice/browser.lua b/lua/code-practice/browser.lua index 4a3cfaf..2585a37 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") @@ -56,7 +55,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) @@ -74,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( @@ -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/config.lua b/lua/code-practice/config.lua index 8b22ca6..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", @@ -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 = "", @@ -57,27 +63,27 @@ 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) +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] if value == nil then - return nil + return default end end return value end -return M +return config diff --git a/lua/code-practice/db.lua b/lua/code-practice/db.lua index 758801d..e15d7c6 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,28 +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 return db_connection @@ -82,88 +59,42 @@ 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 --- 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 +104,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) + require("code-practice.utils").notify("Failed to record attempt: " .. (tostring(err) or "unknown"), "warn") end return ok @@ -253,7 +185,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)) + conn:eval("SELECT * FROM theory_options WHERE exercise_id = ? ORDER BY option_number", exercise_id) ) end diff --git a/lua/code-practice/engines.lua b/lua/code-practice/engines.lua index 004d21d..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", @@ -72,56 +72,61 @@ 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(engines.registry) + table.sort(names, function(a, b) + local ta, tb = engines.registry[a].type, engines.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] +function engines.get(name) + return engines.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 +function engines.list() + return sorted_names() end -function M.coding_engines() +function engines.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 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 6a78278..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 @@ -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 @@ -66,4 +74,4 @@ function M.check() end end -return M +return health 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 2302fb1..6abe594 100644 --- a/lua/code-practice/importer.lua +++ b/lua/code-practice/importer.lua @@ -2,22 +2,9 @@ local db = require("code-practice.db") local utils = require("code-practice.utils") -local M = {} +local importer = {} -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) +function importer.import(json_path, opts) opts = opts or {} if not json_path or json_path == "" then @@ -58,58 +45,78 @@ 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 = 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 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 = 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 @@ -126,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 2ef9d44..1f80df0 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 @@ -42,9 +40,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") @@ -89,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 }) @@ -240,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 @@ -251,6 +251,26 @@ function code_practice.get_current_exercise_id() return vim.b[bufnr].code_practice_exercise_id 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") + return + end + + local lines = { "" } + for i, hint in ipairs(hints) do + table.insert(lines, string.format(" %d. %s", i, hint)) + table.insert(lines, "") + end + + local bufnr, winid = 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) +end + function code_practice.show_hints() local exercise_id = code_practice.get_current_exercise_id() if not exercise_id then @@ -263,24 +283,37 @@ function code_practice.show_hints() return end - local hints = exercise.hints - if not hints or #hints == 0 then - utils.notify("No hints available for this exercise", "info") + if not config.get("ai_hints.enabled") then + show_static_hints(exercise) return end - local lines = { "" } - for i, hint in ipairs(hints) do - table.insert(lines, string.format(" %d. %s", i, hint)) - table.insert(lines, "") - 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() + utils.close_win(hint_winid) + end) - 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) + 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") + utils.close_win(hint_winid) + 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 @@ -299,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("") @@ -334,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.api.nvim_command("rightbelow vsplit") - vim.api.nvim_command("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) @@ -358,68 +366,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 6885b8e..89f0181 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.run_tests", "") local theory_options = nil if exercise.engine == "theory" then @@ -95,9 +163,10 @@ 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 run tests.") + 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("") @@ -137,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 @@ -176,7 +247,7 @@ function manager.open_exercise(id) end end - vim.api.nvim_command("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 ddd2a14..c4a59c6 100644 --- a/lua/code-practice/popup.lua +++ b/lua/code-practice/popup.lua @@ -1,7 +1,13 @@ -- Code Practice - Shared Popup / Scratch-Buffer Utilities -local M = {} +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 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" @@ -13,36 +19,45 @@ 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") + 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) +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) @@ -50,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 diff --git a/lua/code-practice/results.lua b/lua/code-practice/results.lua index 706a6ca..f503172 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 @@ -85,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 @@ -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/runner.lua b/lua/code-practice/runner.lua index bfd9630..4667fe0 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) @@ -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 diff --git a/lua/code-practice/utils.lua b/lua/code-practice/utils.lua index 138048a..0447271 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 _, err = file:write(content) file:close() - return true + return err == 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) @@ -52,16 +52,27 @@ function utils.delete_temp_files() end end -function utils.json_decode(str) - local ok, result = pcall(vim.json.decode, str) - return ok and result or nil +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.escape_sql(s) - if type(s) ~= "string" then - return s +function utils.close_win(winid) + if winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_close(winid, true) end - return s:gsub("'", "''") +end + +function utils.json_decode(str) + local ok, result = pcall(vim.json.decode, str) + return ok and result or nil end return utils 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/test/test_flow.lua b/test/test_flow.lua index 8b7155b..8ebffbd 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") @@ -798,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)) 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):