A terminal-based, interactive programming exercise runner. Inspired by Rustlings and 100 Exercises to Learn Rust, LangQuest extends the concept to multiple languages - work through hands-on exercises in Rust, Go, C++, Python, RISC-V assembly, and Markdown with real-time feedback, progress tracking, and a built-in hint system.
- Features
- Installation
- Getting Started
- Creating Your Own Exercises
- CLI Reference
- Dependencies
- License
- Multi-language support - Rust, Go, C++, Python, RISC-V assembly, and Markdown/conceptual exercises
- Live verification - file saves trigger immediate re-runs; results stream into the TUI without leaving the editor
- Paged exercise view - Theory → Task → Output → Solution, navigated with arrow keys
- Progressive hints - reveal hints one at a time; after all hints, optionally unlock the full solution
- Syntax-highlighted solutions - reference code and prose explanations, gated until pass or explicit unlock
- Overview with tree panel - scrollable exercise table and module/exercise tree with live progress
- Persistent progress - encrypted, GitHub-account-bound
.lq.progressfile tracks scores, pass status, and solution visibility (tamper-resistant; not shareable between students)
Prerequisites:
- Rust toolchain (edition 2024, Rust ≥ 1.88)
- Build tools for your language exercises (see Exercise Toolchains)
rustcfor rust exericsesgofor Go exercisesg++and Catch2 for C/C++ exercisespython3andpytestfor Python exercises- Oracle Java JDK 21 and plantuml.jar for PlantUML exercises
# Clone and install
git clone https://github.com/tschinz/langquest.git
cd langquest
cargo install --path .
# Or run directly without installing
cargo run -- --repo /path/to/exercises
# of via crates.io
cargo install langquest
lq --repo /path/to/exercisesLangQuest embeds encryption/sealing keys at build time. For classroom/student consistency you usually want the exact CI-produced binary from GitHub Releases, not a local source build that may embed different keys.
The scripts/install_latest_release.xx scripts download the latest release from https://github.com/tschinz/langquest/releases which contain custom keys used within the HEI courses.
They auto-detect the platform/architecture, fetch the matching asset, install the binary, and print the resulting version.
Run:
curl -fsSL https://raw.githubusercontent.com/tschinz/langquest/refs/heads/main/scripts/install_latest_release.sh | shDefault install path:
~/.local/bin/lq
Override install path:
INSTALL_DIR=/usr/local/bin curl -fsSL https://raw.githubusercontent.com/tschinz/langquest/refs/heads/main/scripts/install_latest_release.sh | shScript file:
scripts/install_latest_release.sh
Run:
irm https://raw.githubusercontent.com/tschinz/langquest/refs/heads/main/scripts/install_latest_release.ps1 | iexDefault install path:
%LOCALAPPDATA%\Programs\lq\bin\lq.exe
Override install path:
$env:INSTALL_DIR = 'C:\\Tools\\lq\\bin'
irm https://raw.githubusercontent.com/tschinz/langquest/refs/heads/main/scripts/install_latest_release.ps1 | iexScript file:
scripts/install_latest_release.ps1
- The installer scripts use GitHub's
releases/latestmetadata and do not require you to specify a tag manually. - If the install directory is not already on your
PATH, the script adds it.
If you installed with the release scripts, uninstall by removing the installed binary from the install directory.
Default uninstall:
rm -f ~/.local/bin/lqIf you installed with a custom path, remove it from that directory instead:
rm -f /your/custom/install/dir/lqOptional PATH cleanup in startup files (only if you no longer want the path):
sed -i.bak '/# Added by lq installer/,+1d' ~/.bashrc ~/.bash_profile ~/.zshrc ~/.zprofile ~/.profile 2>/dev/null || trueDefault uninstall:
Remove-Item "$env:LOCALAPPDATA\Programs\lq\bin\lq.exe" -Force -ErrorAction SilentlyContinueIf you installed with a custom path, remove that file instead:
Remove-Item "C:\\your\\custom\\install\\dir\\lq.exe" -ForceOptional PATH cleanup (remove the installer path from user PATH):
$dir = Join-Path $env:LOCALAPPDATA "Programs\lq\bin"
$paths = ([Environment]::GetEnvironmentVariable("Path", "User") -split ';') | Where-Object { $_ -and $_ -ne $dir }
[Environment]::SetEnvironmentVariable("Path", ($paths -join ';'), "User")command -v lq || echo "lq not found"Get-Command lq -ErrorAction SilentlyContinueDepending on which languages your exercises use, install the corresponding toolchains:
| Language | Installation |
|---|---|
| Rust | Install via rustup |
| Python | Install Python 3.x and pytest: pip install pytest |
| Go | Install from go.dev or via package manager |
| C++ | g++ (Xcode CLT / apt install g++) and Catch2 (brew install catch2 / apt install catch2) |
| RISC-V | GNU toolchain (apt install gcc-riscv64-linux-gnu) or Ripes simulator |
| PlantUML | Oracle Java JDK 21 (java on PATH) and the PLANTUML_JAR environment variable pointing to plantuml.jar (or set plantuml.bin in lq.toml) |
| Markdown | No additional tools required - verification is keyword/regex-based |
Quick setup: Run
just setupto install all toolchains automatically (macOS, Linux, and Windows supported).
Create a new directory for your exercises. The structure follows a simple modules → exercises hierarchy:
my-exercises/
├── lq.toml ← toolchain commands (plaintext, auto-created)
├── .lq.progress ← encrypted progress (auto-created; commit to sync)
├── .lq.attest ← machine-bound identity cache (git-ignore this)
├── 01-basics/ ← module (prefixed with NN-)
│ ├── 01-hello-world/ ← exercise (prefixed with NN-)
│ │ ├── 01-theory.md
│ │ ├── 02-task.md
│ │ ├── main.rs
│ │ └── solution/
│ │ ├── main.rs
│ │ └── solution.md
│ └── 02-variables/
│ └── ...
└── 02-control-flow/
└── ...
Naming conventions:
- Module and exercise directories must be prefixed with a two-digit number (
01-,02-, …) - Use lowercase kebab-case:
01-hello-world,02-variables - Directories without the numeric prefix are ignored
# Point lq at your exercise repository
lq --repo /path/to/my-exercises
# Or cd into the repo first
cd /path/to/my-exercises
lq| Key | Action |
|---|---|
a |
Open about page |
m |
Toggle menubar |
q |
Quit LangQuest |
| Key | Action |
|---|---|
↑ / ↓ |
Navigate the exercise list |
Enter |
Open the selected exercise |
z |
Collapse / expand all exercise folders |
| Key | Action |
|---|---|
← / → |
Jump between pages (Theory, Task, Debug, Output, Solution) |
↑ / ↓ |
Scroll text within the current page |
j / k |
Jump to the previous / next exercise |
e |
Open the current exercise file in your editor |
E |
Open the current exercise directory in your editor |
h |
Reveal the next hint |
o |
Go back to the Overview page |
- Arrow keys: scroll up/down, switch tabs
j/k: switch to previous / next exercisee: open exercise file in editorE: open exercise directory in editor
lq manages three files at the root of your exercise repository, split by purpose:
| File | Contents | Format | Commit? |
|---|---|---|---|
lq.toml |
Toolchain commands (rust.cmd, python.cmd, …) |
Plaintext TOML | Yes - shareable |
.lq.progress |
Scores, pass state, hints, current exercise | Encrypted, identity-bound | Yes - see syncing below |
.lq.attest |
Machine-bound proof of your last online identity check | Encrypted | No - never commit |
lq.toml is the only hand-editable file; it holds no progress and is safe to
customise:
[rust]
cmd = "rustc --edition 2024 --test <file> -o <out>"
cmd_cargo = "cargo test --no-fail-fast --no-run --message-format=json"
[python]
cmd = "python3 -m pytest <file> --tb=short -q"
[plantuml]
bin = "~/.local/bin/lq/plantuml.jar"
cmd = "java -jar <plantuml> -tpng <file>"
[ripes]
bin = "/Applications/Ripes.app/Contents/MacOS/Ripes"
cmd = "ripes --mode cli -t asm --proc RV32_SS --json --src <file>"
[ide]
bin = "/usr/local/bin/zed" # your editor; auto-detected on first run
cmd = "<ide> <file>"The e shortcut opens the current exercise's source file in the editor configured under [ide] (and PlantUML previews open there too). ide.bin, ripes.bin, and plantuml.bin are auto-populated with a platform-appropriate default the first time lq runs - Zed or VS Code for the IDE, the discovered Ripes app for RISC-V, and the PLANTUML_JAR environment variable for PlantUML - and can be edited to point elsewhere. When no IDE is found, e falls back to the OS default handler.
lq uses file system events (e.g. inotify) to detect saves and automatically re-verify the exercises. Some environments like Docker containers on Windows do not send events to the langquest process.
Set the POLLING_MS environment variable to enable a polling watcher instead:
POLLING_MS=1000 lq {args}Don't set it or set it to 0 to use the default, non-polling watcher instead.
best_scoreonly increases - lower scores never overwrite higher onespassedbecomestruewhenscore >= thresholdand never resetssolution_seenbecomestrueon first Solution page visit and never resets
Progress lives in the encrypted .lq.progress file, not in lq.toml, so it cannot be edited by hand - tampering fails an integrity check and lq refuses to start. Progress is also bound to your GitHub account (via the gh CLI): it will not open under a different account, so a solved file cannot be shared between students.
- First launch requires internet (and
gh auth login) once, to bind your progress to your GitHub identity. - Offline afterwards works via a machine-bound attestation cached on each machine's first online launch (valid for 30 days).
For teachers - reading progress. The read-only commands lq -s (stats) and lq status are not identity-gated: you can run them against any student's repository to inspect their progress. Both print the bound owner (Owner: <login> (GitHub #<id>)) decrypted from the tamper-proof file, so you can confirm the progress belongs to the expected student. Because the owner is sealed inside the encrypted blob, a student who copies a classmate's solved .lq.progress into their own repo will still show the classmate's owner - the swap is immediately visible. Doing exercises (the interactive TUI) remains bound to the student's own GitHub account, so a copied file cannot be continued as one's own.
Running lq -s also writes a machine-readable results.toml at the repo root - the full evaluation in a form that is easy to script grading against. It contains the bound student identity, overall and per-module summaries, and a per-exercise record (passed, best_score, solution_seen, hint counts, …):
[meta]
generated_at = 1786454786
lq_version = "0.2.3"
[student]
verified = true
login = "alice"
github_id = 4242
[summary]
total_exercises = 9
completed = 7
tests_passed = 38 # unit tests passed across all exercises …
tests_total = 45 # … out of this many - enables partial-credit grading
solutions_seen = 0
hints_shown = 0
hints_explored = 0
hints_total = 171
average_best_score = 0.82
times_saved = 21
[modules.01-intro]
total = 3
completed = 2
tests_passed = 11
tests_total = 13
solutions_seen = 0
hints_shown = 2
times_saved = 21
average_best_score = 0.85
[[exercises]]
path = "01-rust/01-hello-world"
name = "Hello world!"
language = "rust"
difficulty = 1
passed = true
best_score = 1.0
tests_passed = 3 # per-exercise test counts (from the last/best run)
tests_total = 3
solution_seen = false
hints_shown = 0
times_saved = 10
hints_revealed = 0
hints_total = 3The tests_passed / tests_total counts (also shown in the terminal -s output and per module) let you award partial credit for an "almost finished" exercise - a student who passes 3 of 4 unit tests still gets most of the points.
tests_total is counted statically from the test source, so the total is known before an exercise is ever verified (it shows 0/N, not 0/0):
| Language | Tests counted from |
|---|---|
| Rust | #[test] attributes in main.rs |
| Go | func Test… in main_test.go |
| C++ | TEST_CASE( in main_test.cpp (Catch2) |
| Python | def test… in main.py |
| RISC-V | # EXPECT_REG: / ; EXPECT_REG: directives in main.asm |
tests_passed is the number satisfied at the student's best verification.
results.toml is a regenerable export (not read back by lq); the trust anchor remains the encrypted .lq.progress, so generate it yourself from each student's repo rather than trusting a committed copy.
Using multiple machines (e.g. home PC + school laptop): because progress is bound to your GitHub account - not the machine - you can work on any machine signed into the same account. Since each student works in their own fork, the clean way to sync is to commit .lq.progress:
# End of a session
git add .lq.progress && git commit -m "progress" && git push
# Start of the next session, on the other machine
git pullNotes:
- Add
.lq.attestto your exercise repo's.gitignore- it is machine-specific and each machine regenerates its own; never commit it. .lq.progressis an encrypted binary blob, so git cannot merge two versions. Always pull before a session and push after to avoid conflicts from working on both machines at once.- Committing
.lq.progressis safe even in a public fork: it is encrypted and account-bound, so nobody can read your scores or reuse the file.
See docs/exercise-repo.gitignore for a ready .gitignore to drop into your exercise repository.
Exercise repositories can be published in two tiers:
- Teacher repo - the source of truth.
solution/solution.mdandsolution/main.*are readable plaintext. - Student repo - a published copy where the contents of every
solution/directory are encrypted, so students cannot read solutions by opening the files, browsing the repo on GitHub, orgrep-ing the tree. They can still reveal a solution inside LangQuest (which is tracked assolution_seen).
LangQuest reads either form transparently - a sealed file is detected by its magic header and decrypted at load time - so the same binary works against both repos with no configuration.
Seal a repository in place with:
lq seal-solutions --repo /path/to/repo # encrypt every solution/ file (idempotent)Only files under a solution/ directory are affected; student working files, 02-task.md, and 01-theory.md are left untouched.
There is deliberately no unseal command in lq - otherwise a student could bulk-decrypt every solution from their sealed repo. The teacher's private repo holds the plaintext solutions and is the source of truth.
Automating it. Keep the teacher repo private and let CI publish the sealed student repo on every push. A ready-to-adapt GitHub Actions workflow is provided in docs/publish-student-repo.yml: it installs lq, runs lq seal-solutions, and pushes the sealed tree to a separate student repository.
As with progress encryption, the sealing key is embedded in the
lqbinary, so this prevents casual reading of solutions rather than defeating a determined reverse-engineer.
Each exercise lives in its own directory within a module:
<NN>-<module>/
└── <NN>-<exercise>/
├── 01-theory.md ← optional background reading
├── 02-task.md ← required task description with frontmatter
├── main.<ext> ← student source file (rs, go, cpp, py, md, asm, puml)
│ (name overridable via the `source` task field)
├── opt: Cargo.toml ← *optional* rust cargo project file
└── solution/
├── main.<ext> ← reference solution
└── solution.md ← hints and explanation
Background reading rendered on the Theory page. Plain Markdown, no special requirements.
The task description with required TOML frontmatter:
---
id = "hello_world"
name = "Hello, World!"
language = "rust"
difficulty = 2
description = "Implement a function that returns a greeting string."
topics = ["functions", "strings", "return_values"]
---
# Hello, World!
Your task is to implement the `greeting()` function so that it returns
the string `"Hello, World!"` exactly.| Field | Type | Description |
|---|---|---|
id |
string | Unique snake_case identifier (key in lq.toml) |
name |
string | Display name in the exercise table |
language |
string (rust, go, cpp, python, riscv, plantuml, text) |
language type of exercise |
difficulty |
integer (1-5) | Shown as stars in the Overview |
description |
string | One-line summary |
topics |
array | Tags shown in the Topics column |
source |
string (optional) | Student source file name; overrides the default main.<ext> |
The file extension determines the language and verification method:
Rust (main.rs) - Uses #[test] functions with // TODO markers:
// TODO: implement the add function
fn add(a: i32, b: i32) -> i32 {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_positive() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, 1), 0);
}
}Optional Rust Cargo project file (Cargo.toml):
[package]
name = "exercise"
version = "0.1.0"
edition = "2024"
[dependencies]
sha2 = "0.11.0"
[[bin]]
name = "exercise"
path = "main.rs"Go (main.go + main_test.go) - Uses testing.T functions:
// TODO: implement the Add function
func Add(a, b int) int {
return 0
}C++ (main.cpp + main_test.cpp) - Uses Catch2 test cases:
// main.cpp - student source
#include <string>
// TODO: implement the greeting function
std::string greeting() {
return "";
}// main_test.cpp - test file (provided, not edited by student)
#include <catch2/catch_test_macros.hpp>
#include <string>
std::string greeting();
TEST_CASE("greeting returns correct string", "[greeting]") {
REQUIRE(greeting() == "Hello, World!");
}Python (main.py) - Uses unittest or pytest:
# TODO: implement the add function
def add(a: int, b: int) -> int:
pass
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, 1) == 0RISC-V Assembly (main.asm) - Uses EXPECT_* directives:
# EXPECT_REG: x7 42
# TODO: implement the program
# your code here| Directive | Description |
|---|---|
EXPECT_REG: <name> <value> |
register name and expected 32-bit value |
Markdown (main.md) - Free-text answers matched against keywords:
# Question: What keyword makes a Rust variable mutable?
<!-- Write your answer below -->
Note: The
<!-- Write your answer below -->marker line is mandatory for text exercises. Keywords are only searched in the content that appears after this marker, so students can read the question without accidentally matching keywords in it. Each keyword selects its matching mode by its wrapper (see Keyword matching below).
PlantUML (main.puml) - Diagrams graded by keyword/regex matching against the keywords in solution/solution.md. The reference diagram in solution/main.puml is also required as it's used as the reference solution
@startuml
' TODO: draw the diagram described in 02-task.md
@endumlOn save the diagram is rendered to main.png. It is opened once in the configured [ide] (or the OS default image viewer if none is found); later saves re-render the file in place, which the editor auto-reloads, so no duplicate tabs are opened. Rendering requires Oracle Java JDK 21 on PATH and the PLANTUML_JAR environment variable pointing to plantuml.jar; set plantuml.bin in lq.toml to override the jar path. Grading uses the same keyword matching as Markdown, searched over the whole diagram source (PlantUML comments are ignored).
Get the latest PlantUML jar from https://plantuml.com/download and set the environment variable or the plantuml.bin path in lq.toml:
export PLANTUML_JAR=/path/to/plantuml.jarsolution/main.* - The complete reference solution:
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_positive() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, 1), 0);
}
}solution/solution.md - Hints, keywords, and explanation with frontmatter:
---
title = "Adding Numbers"
hints = [
"The function should return the sum of both parameters.",
"Use the + operator to add two integers.",
"Rust returns the last expression without a semicolon.",
]
keywords = ["mut", "let", "i32"]
---
## Explanation
To add two numbers in Rust, simply use the `+` operator. The function returns the last expression automatically when there's no semicolon.| Field | Description |
|---|---|
title |
Display name for the solution |
hints |
Ordered list revealed one at a time with h |
keywords |
Patterns for scoring Markdown and PlantUML exercises (see below) |
| body | Prose explanation shown on the Solution page |
The keywords array scores Markdown and PlantUML submissions. Matching is always case-insensitive, and each keyword picks its mode from its wrapper:
| Keyword form | Mode | Matches |
|---|---|---|
s/PATTERN/ |
regex | PATTERN as a regular expression |
w/TEXT/ |
whitespace-insensitive literal | TEXT as a substring, with spaces and tabs ignored on both sides |
"TEXT" (anything else) |
literal | TEXT as a plain substring, verbatim |
"welcome"→ matches the literal textwelcome.s/[^-]->/→ regex: a request arrow->but not a reply arrow-->.w/login(user, pass)/→ matcheslogin(user, pass),login ( user,pass )andlogin(user,pass)alike - whitespace differences don't matter.
TOML tip: write regex keywords as single-quoted TOML strings (
's/\d+/'), not double-quoted. Single quotes are literal strings, so backslashes pass through unchanged; in double-quoted strings a\dor\(is an invalid escape and the exercise silently fails to load.
To catch anything that makes an exercise fail to load, run lq verify: it lists every faulty exercise with an appropriate error message.
LangQuest - interactive programming exercises
Usage: lq [OPTIONS] [COMMAND]
Commands:
status Print current exercise and overall progress
verify Check if every exercise in the repo parses correctly
seal-solutions Encrypt every `solution/` file in place (teacher → student repo, for CI)
help Print this message or the help of the given subcommand(s)
Options:
-r --repo <REPO> Path to exercise repository root
--reset Wipe all progress in lq.toml and start fresh
-s, --stats Display detailed statistics about exercise progress
-k, --keys Print version and hashes of embedded crypto keys, then exit
-t, --toolchain Print the toolchain report (lq.toml location + tool status), then exit, then exit
-h, --help Print help
-V, --version Print version
Examples:
# Launch TUI with exercise repository
lq --repo /path/to/exercises
# Check progress without launching TUI
lq status
# Detailed progress statistics + write machine-readable results.toml
# (teachers can run this on any student repo)
lq -s --repo /path/to/student-repo
# Print lq version + SHA-256 fingerprints of embedded crypto keys
lq --keys
# Reset all progress (prompts for confirmation)
lq --reset
# Check if every exercise in a repository parses correctly
lq verify --repo /path/to/repo
# Seal all solution/ files for the student distribution (see CI workflow in docs/)
lq seal-solutions --repo /path/to/repolq --grade launches the TUI in read-only mode for grading a student's repository without having to sign in as the student.
The flag is locked behind the grading cargo feature (cargo build --release --features grading). By default, student binaries without the --grade flag are built.
Note: If one would like to script the grading process, use the lq -s --repo command, as it exports the results as TOML.
| Crate | Purpose |
|---|---|
ratatui |
TUI rendering framework |
crossterm |
Cross-platform terminal backend |
clap |
CLI argument parsing |
notify |
File-system watcher for live verification |
syntect |
Syntax highlighting on the Solution page |
pulldown-cmark |
Markdown rendering |
toml + serde |
Configuration and frontmatter parsing |
serde_json |
JSON serialization |
regex |
Keyword/regex matching for Markdown and PlantUML exercises |
anyhow |
Error propagation |
thiserror |
Typed domain errors |
chacha20poly1305 |
Encryption for progress and sealed solutions |
sha2 |
SHA-256 hashing for key derivation and integrity checks |
| Crate | Purpose |
|---|---|
dotenvy |
Load .env file for secret keys |
sha2 |
Hash embedded secret keys |
hex |
Hex-encode key fingerprints |
| Project name | Purpose |
|---|---|
catch2 |
For C++ tests |
This project is licensed under the MIT License - see the LICENSE file for details.

