Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM alpine:3.20
FROM alpine:3.21

RUN apk add --no-cache neovim python3 git sqlite-dev

Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
--------
Expand All @@ -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
------------
Expand Down Expand Up @@ -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 <path>`, or use `:CP! import <path>` 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
Expand Down
18 changes: 14 additions & 4 deletions doc/code-practice.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

==============================================================================
Expand Down Expand Up @@ -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 = "<CR>",
Expand Down Expand Up @@ -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 ~

Expand All @@ -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

==============================================================================
Expand Down
104 changes: 104 additions & 0 deletions lua/code-practice/ai_hints.lua
Original file line number Diff line number Diff line change
@@ -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
118 changes: 44 additions & 74 deletions lua/code-practice/browser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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 <C-t> to run tests")
local lines = manager.format_exercise_preview(enriched, {
description_header = true,
footer = "Press Enter to open, then <C-t> to run tests",
})

state.preview_cache[exercise.id] = lines
return lines
Expand Down Expand Up @@ -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", "<cmd>lua require('code-practice.browser').move_selection(1)<CR>")
map("k", "<cmd>lua require('code-practice.browser').move_selection(-1)<CR>")
map("<down>", "<cmd>lua require('code-practice.browser').move_selection(1)<CR>")
map("<up>", "<cmd>lua require('code-practice.browser').move_selection(-1)<CR>")
map("gg", "<cmd>lua require('code-practice.browser').go_top()<CR>")
map("G", "<cmd>lua require('code-practice.browser').go_bottom()<CR>")
map("j", function()
browser.move_selection(1)
end)
map("k", function()
browser.move_selection(-1)
end)
map("<down>", function()
browser.move_selection(1)
end)
map("<up>", 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 "<CR>"
map(open_key, "<cmd>lua require('code-practice.browser').open_selected()<CR>")
map(open_key, browser.open_selected)
if open_key ~= "<CR>" then
map("<CR>", "<cmd>lua require('code-practice.browser').open_selected()<CR>")
map("<CR>", browser.open_selected)
end
map("o", "<cmd>lua require('code-practice.browser').open_selected()<CR>")
map(keymaps.filter_easy or "e", "<cmd>lua require('code-practice.browser').filter_by_difficulty('easy')<CR>")
map(keymaps.filter_medium or "m", "<cmd>lua require('code-practice.browser').filter_by_difficulty('medium')<CR>")
map(keymaps.filter_hard or "h", "<cmd>lua require('code-practice.browser').filter_by_difficulty('hard')<CR>")
map(keymaps.filter_all or "a", "<cmd>lua require('code-practice.browser').clear_filters()<CR>")
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, "<cmd>lua require('code-practice.browser').filter_by_engine('" .. name .. "')<CR>")
map(eng.filter_key, function()
browser.filter_by_engine(name)
end)
end
end

local close_key = keymaps.close or "q"
map(close_key, "<cmd>lua require('code-practice.browser').close()<CR>")
map(close_key, browser.close)
if close_key ~= "<esc>" then
map("<esc>", "<cmd>lua require('code-practice.browser').close()<CR>")
map("<esc>", browser.close)
end
map("?", "<cmd>lua require('code-practice.help').show()<CR>")
map("?", function()
require("code-practice.help").show()
end)
end

local function update_display()
Expand Down
Loading