16 — Capstone: Projects & Exercises
These projects integrate the concepts from all previous chapters into complete, working systems. Each is production-shaped: error handling, edge cases, and real-world patterns — not toy snippets.
Project 1: JSON Parser (Patterns + Recursion)
projects/json_parser.lua
-- Minimal JSON parser using Lua patterns + recursive descent
-- Supports: objects, arrays, strings, numbers, booleans, null
-- Does NOT support: comments, trailing commas (strict JSON)
local json = {}
-- Skip whitespace
local function skip_ws(s, i)
while i <= #s and s:sub(i, i):match("%s") do i = i + 1 end
return i
end
-- Parse a value: dispatch by first character
local function parse_value(s, i) -- forward declared, filled below
error("parse_value not initialized", 2)
end
-- Parse string: handle escape sequences
local function parse_string(s, i)
assert(s:sub(i, i) == '"', "expected string")
i = i + 1
local parts = {}
while i <= #s do
local c = s:sub(i, i)
if c == '"' then
return table.concat(parts), i + 1 -- end of string, new position
elseif c == "\\" then
i = i + 1
local esc = s:sub(i, i)
local escapes = {['"']='"', ['\\']='\\', ['/']='/', b='\b', f='\f', n='\n', r='\r', t='\t'}
if escapes[esc] then
parts[#parts + 1] = escapes[esc]
elseif esc == "u" then
-- Unicode escape: \uXXXX → UTF-8 bytes
local hex = s:sub(i + 1, i + 4)
local cp = tonumber(hex, 16)
if not cp then error("invalid unicode escape: " .. hex) end
parts[#parts + 1] = utf8.char(cp)
i = i + 4
else
error("invalid escape: \\" .. esc)
end
i = i + 1
else
parts[#parts + 1] = c
i = i + 1
end
end
error("unterminated string")
end
-- Parse number: integer, float, negative, scientific
local function parse_number(s, i)
local start = i
if s:sub(i, i) == "-" then i = i + 1 end -- negative sign
while i <= #s and s:sub(i, i):match("[%d%.eE%+%-]") do
-- careful: don't consume commas or brackets that look like signs
if s:sub(i, i):match("[eE]") and i + 1 <= #s and s:sub(i + 1, i + 1):match("[%d%+%-]") then
i = i + 1 -- skip exponent sign
end
i = i + 1
end
local num_str = s:sub(start, i - 1)
local n = tonumber(num_str)
if not n then error("invalid number: " .. num_str) end
return n, i
end
-- Parse object: { "key": value, ... }
local function parse_object(s, i)
assert(s:sub(i, i) == "{", "expected object")
i = skip_ws(s, i + 1)
local obj = {}
if s:sub(i, i) == "}" then return obj, i + 1 end -- empty object
while true do
i = skip_ws(s, i)
assert(s:sub(i, i) == '"', "expected key string")
local key, i = parse_string(s, i)
i = skip_ws(s, i)
assert(s:sub(i, i) == ":", "expected colon")
i = skip_ws(s, i + 1)
local value
value, i = parse_value(s, i)
obj[key] = value
i = skip_ws(s, i)
local c = s:sub(i, i)
if c == "}" then return obj, i + 1
elseif c == "," then i = skip_ws(s, i + 1)
else error("expected ',' or '}' in object at " .. i) end
end
end
-- Parse array: [ value, value, ... ]
local function parse_array(s, i)
assert(s:sub(i, i) == "[", "expected array")
i = skip_ws(s, i + 1)
local arr = {}
if s:sub(i, i) == "]" then return arr, i + 1 end -- empty array
local idx = 1
while true do
local value
value, i = parse_value(s, i)
arr[idx] = value
idx = idx + 1
i = skip_ws(s, i)
local c = s:sub(i, i)
if c == "]" then return arr, i + 1
elseif c == "," then i = skip_ws(s, i + 1)
else error("expected ',' or ']' at " .. i) end
end
end
-- Dispatch
local parsers = {
['"'] = parse_string,
['{'] = parse_object,
['['] = parse_array,
['t'] = function(s, i) -- true
assert(s:sub(i, i + 3) == "true", "expected true")
return true, i + 4
end,
['f'] = function(s, i) -- false
assert(s:sub(i, i + 4) == "false", "expected false")
return false, i + 5
end,
['n'] = function(s, i) -- null → nil
assert(s:sub(i, i + 3) == "null", "expected null")
return nil, i + 4 -- NOTE: JSON null → Lua nil (cannot distinguish from missing)
end,
}
-- parse_value: dispatch table for clean O(1) selection
parse_value = function(s, i)
i = skip_ws(s, i)
local c = s:sub(i, i)
local parser = parsers[c]
if parser then return parser(s, i) end
if c:match("[%-%d]") then return parse_number(s, i) end
error("unexpected character at " .. i .. ": " .. c)
end
-- Public API: decode JSON string to Lua table
function json.decode(s)
local value, i = parse_value(s, 1)
i = skip_ws(s, i)
if i <= #s then error("trailing data at " .. i) end
return value
end
-- Public API: encode Lua table to JSON string
function json.encode(val, pretty)
local indent = pretty and " " or ""
local function encode(v, level)
local t = type(v)
if t == "nil" then return "null"
elseif t == "boolean" then return v and "true" or "false"
elseif t == "number" then
if math.type(v) == "integer" or v == math.floor(v) then
return tostring(v)
end
return string.format("%.17g", v) -- full float precision
elseif t == "string" then
return string.format("%q", v):gsub("\\\n", "\\n") -- %q handles most escaping
elseif t == "table" then
local parts = {}
-- detect array vs object
local is_array = true
local max_idx = 0
for k in pairs(v) do
if type(k) == "number" and k == math.floor(k) and k >= 1 then
if k > max_idx then max_idx = k end
else is_array = false; break end
end
if is_array and max_idx == #v then
for i = 1, #v do
parts[i] = string.rep(indent, level + 1) .. encode(v[i], level + 1)
end
if #parts == 0 then return "[]" end
return "[\n" .. table.concat(parts, ",\n") .. "\n" .. string.rep(indent, level) .. "]"
else
local keys = {}
for k in pairs(v) do keys[#keys + 1] = k end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, k in ipairs(keys) do
local key_str = string.format("%q", tostring(k))
local val_str = encode(v[k], level + 1)
parts[#parts + 1] = string.rep(indent, level + 1) .. key_str .. ": " .. val_str
end
if #parts == 0 then return "{}" end
return "{\n" .. table.concat(parts, ",\n") .. "\n" .. string.rep(indent, level) .. "}"
end
end
error("cannot encode " .. t, 2)
end
return encode(val, 0)
end
-- Test:
local data = json.decode([[
{"name": "Alice", "age": 30, "tags": ["admin", "user"], "active": true}
]])
print(data.name, data.age, data.tags[1], data.active) -- Alice 30 admin true
print(json.encode(data, true))
Project 2: Coroutine-Based Pipeline Processor
projects/pipeline.lua
-- Data processing pipeline using coroutines (see ch11)
-- Each stage is a coroutine that pulls from upstream and yields downstream
-- Backpressure: consumer-driven (lazy, only processes when pulled)
local function stage(name, input, transform)
return coroutine.wrap(function()
for item in input do -- pull from upstream (may block until upstream yields)
local result = transform(item)
if result ~= nil then
coroutine.yield(result) -- push downstream
end
end
end), name
end
local function source(items)
return coroutine.wrap(function()
for _, item in ipairs(items) do
coroutine.yield(item)
end
end)
end
local function sink(collect)
return function(input)
local results = {}
for item in input do
if collect then results[#results + 1] = item
else print(item) end
end
return results
end
end
-- Build a multi-stage pipeline
local data = {
"2024-01-15 INFO server started",
"2024-01-15 ERROR connection timeout to db",
"2024-01-15 WARN retry 1/3",
"2024-01-15 ERROR auth failed for user admin",
"2024-01-15 INFO request completed in 45ms",
}
local pipe = source(data)
pipe = stage("parse", pipe, function(line)
local date, level, msg = line:match("(%S+) (%S+) (.*)")
return {date = date, level = level, msg = msg}
end)
pipe = stage("filter_errors", pipe, function(entry)
if entry.level == "ERROR" then return entry end -- only errors pass through
return nil -- filtered out (nil not yielded)
end)
pipe = stage("format", pipe, function(entry)
return string.format("[%s] %s: %s", entry.date, entry.level, entry.msg)
end)
local results = sink(true)(pipe)
for _, r in ipairs(results) do print(r) end
-- [2024-01-15] ERROR: connection timeout to db
-- [2024-01-15] ERROR: auth failed for user admin
Project 3: ORM Query Builder (Metatables + Chaining)
projects/query_builder.lua
-- Fluent query builder using metatables for method chaining
-- Builds SQL strings with parameter binding (injection-safe)
local QueryBuilder = {}
QueryBuilder.__index = QueryBuilder
function QueryBuilder.new(table_name)
return setmetatable({
table = table_name,
select = {"*"},
where_clauses = {},
params = {}, -- parameter values (for prepared statements)
order_by = nil,
limit_n = nil,
offset_n = nil,
}, QueryBuilder)
end
-- SELECT clause: list of columns
function QueryBuilder:select_cols(...)
self.select = {...}
return self -- fluent: return self for chaining
end
-- WHERE clause with parameter binding (prevents SQL injection)
function QueryBuilder:where(condition, ...)
local args = {...}
-- Replace ? in condition with positional parameter placeholders
local clause = condition
local param_idx = #self.params
for _, arg in ipairs(args) do
param_idx = param_idx + 1
self.params[param_idx] = arg
end
self.where_clauses[#self.where_clauses + 1] = clause
return self
end
-- ORDER BY
function QueryBuilder:order(column, direction)
-- Validate column name (prevent injection — only allow alphanumeric + underscore)
assert(column:match("^[%a_][%w_]*$"), "invalid column name: " .. column)
self.order_by = column .. " " .. (direction or "ASC")
return self
end
-- LIMIT / OFFSET
function QueryBuilder:limit(n) self.limit_n = tonumber(n); return self end
function QueryBuilder:offset(n) self.offset_n = tonumber(n); return self end
-- Build the final SQL string + params array
function QueryBuilder:build()
local sql = "SELECT " .. table.concat(self.select, ", ") .. " FROM " .. self.table
if #self.where_clauses > 0 then
sql = sql .. " WHERE " .. table.concat(self.where_clauses, " AND ")
end
if self.order_by then
sql = sql .. " ORDER BY " .. self.order_by
end
if self.limit_n then
sql = sql .. " LIMIT " .. self.limit_n
end
if self.offset_n then
sql = sql .. " OFFSET " .. self.offset_n
end
return sql, self.params
end
-- __tostring for easy logging
QueryBuilder.__tostring = function(self)
local sql, params = self:build()
if #params > 0 then
return sql .. " -- params: " .. table.concat(params, ", ")
end
return sql
end
-- Usage:
local q = QueryBuilder.new("users")
:select_cols("id", "name", "email")
:where("age > ?", 18)
:where("status = ?", "active")
:order("created_at", "DESC")
:limit(10)
print(tostring(q))
-- SELECT id, name, email FROM users WHERE age > ? AND status = ? ORDER BY created_at DESC LIMIT 10
-- params: 18, active
-- Execute (with a hypothetical db module):
-- local sql, params = q:build()
-- local results = db:execute(sql, unpack(params))
Project 4: Plugin System (Sandboxed Module Loading)
projects/plugin_system.lua
-- Plugin system: load untrusted plugins in a sandbox, provide a controlled API
-- Plugins can only access whitelisted functions (no io, os.execute, require)
local PluginSystem = {}
PluginSystem.__index = PluginSystem
-- The sandbox API: only what plugins are allowed to use
local function create_plugin_api(host)
return {
-- Logging (through host's logger, not io.write)
log = function(level, msg) host.logger:log(level, msg) end,
-- Register hooks (callback registration)
on_event = function(event, fn)
host.hooks[event] = host.hooks[event] or {}
table.insert(host.hooks[event], fn)
end,
-- Read-only config (copy, not reference)
get_config = function(key) return host.config[key] end,
-- Safe math/string functions (no file/network access)
math = math, -- safe: pure functions
string = string,
table = table,
pairs = pairs, ipairs = ipairs, type = type,
tostring = tostring, tonumber = tonumber,
assert = assert, error = error,
select = select, setmetatable = setmetatable,
-- NO: io, os, require, load, loadfile, dofile, debug, _G
}
end
function PluginSystem.new(config)
return setmetatable({
config = config or {},
hooks = {},
logger = {log = function(lvl, msg) print("[" .. lvl .. "] " .. msg) end},
loaded = {},
}, PluginSystem)
end
-- Load a plugin from source code (string) — sandboxed
function PluginSystem:load(name, source)
local api = create_plugin_api(self)
-- Compile with the sandbox environment (4th arg to load)
local fn, err = load(source, name, "t", api) -- "t" = text only (no bytecode injection)
if not fn then return false, "compile error: " .. err end
local ok, result = pcall(fn) -- run the plugin's main chunk
if not ok then return false, "runtime error: " .. result end
self.loaded[name] = result or true
return true
end
-- Trigger an event: call all registered hooks for that event
function PluginSystem:trigger(event, ...)
local hooks = self.hooks[event]
if not hooks then return end
for _, fn in ipairs(hooks) do
local ok, err = pcall(fn, ...) -- protect: one bad plugin doesn't crash others
if not ok then
self.logger:log("error", "plugin hook '" .. event .. "' failed: " .. tostring(err))
end
end
end
-- Usage:
local ps = PluginSystem.new({server_name = "MyApp"})
-- Load a plugin (from source string — in production, read from plugins/ dir)
ps:load("greeter", [[
-- This code runs in a sandbox: cannot access io, os, _G, require
log("info", "greeter plugin loaded")
on_event("user_join", function(username)
log("info", "welcome " .. username .. " to " .. get_config("server_name"))
end)
on_event("user_leave", function(username)
log("info", "goodbye " .. username)
end)
]])
-- Trigger events (plugins respond)
ps:trigger("user_join", "Alice") -- [info] welcome Alice to MyApp
ps:trigger("user_leave", "Alice") -- [info] goodbye Alice
-- Security test: a malicious plugin
local ok, err = ps:load("malicious", [[
os.execute("rm -rf /") -- attempt to escape sandbox
]])
print(ok, err) -- false runtime error: attempt to index nil value (global 'os')
-- The sandbox holds: 'os' is not in the env table → nil → indexing nil → error
Exercise Set
Exercise 1: Implement a binary search on a sorted table
lua
-- Write a function that performs binary search on a sorted array
-- Return the index of the target, or nil if not found
-- Handle: empty array, single element, target not found, duplicates (return any match)
local function binary_search(arr, target)
-- Your code here
local lo, hi = 1, #arr
while lo <= hi do
local mid = math.floor((lo + hi) / 2)
if arr[mid] == target then return mid
elseif arr[mid] < target then lo = mid + 1
else hi = mid - 1 end
end
return nil
end
assert(binary_search({1, 3, 5, 7, 9}, 5) == 3)
assert(binary_search({1, 3, 5, 7, 9}, 4) == nil)
assert(binary_search({}, 1) == nil)
assert(binary_search({42}, 42) == 1)
assert(binary_search({42}, 41) == nil)
Exercise 2: Implement an LRU cache
lua
-- LRU (Least Recently Used) cache: fixed capacity, evicts oldest on insert
-- O(1) get and put using a hash table + doubly-linked list
-- Hint: use two tables: a hash map (key → node) and manual prev/next links
local LRUCache = {}
LRUCache.__index = LRUCache
function LRUCache.new(capacity)
return setmetatable({
capacity = capacity,
cache = {}, -- key → {value = v, prev = key, next = key}
head = nil, -- most recently used
tail = nil, -- least recently used
}, LRUCache)
end
function LRUCache:get(key)
local node = self.cache[key]
if not node then return nil end
self:move_to_front(key, node)
return node.value
end
function LRUCache:put(key, value)
if self.cache[key] then
self.cache[key].value = value
self:move_to_front(key, self.cache[key])
return
end
if #self.cache >= self.capacity then
self:evict_tail()
end
self.cache[key] = {value = value}
self:insert_front(key, self.cache[key])
end
-- Implement: move_to_front, insert_front, evict_tail (linked list manipulation)
-- (left as exercise for the reader — test with the assertions below)
local cache = LRUCache.new(3)
cache:put("a", 1)
cache:put("b", 2)
cache:put("c", 3)
assert(cache:get("a") == 1) -- a is now most recently used
cache:put("d", 4) -- evicts "b" (least recently used after a was accessed)
assert(cache:get("b") == nil)
assert(cache:get("a") == 1)
assert(cache:get("c") == 3)
assert(cache:get("d") == 4)
Exercise 3: Write a coroutine-based timer
lua
-- Implement a "sleep" using coroutines + a scheduler
-- The scheduler should track when each coroutine should resume
-- Hint: use os.clock() for timing, a list of (coroutine, wake_time) pairs
-- Your code: a simple event loop that:
-- 1. Spawns coroutines that yield "sleep N" requests
-- 2. The scheduler tracks wake times
-- 3. Resumes coroutines when their sleep expires
-- 4. Handles coroutine completion and errors
-- Example expected behavior:
-- local function task1()
-- print("task1 start")
-- sleep(0.1)
-- print("task1 end")
-- end
-- local function task2()
-- print("task2 start")
-- sleep(0.05)
-- print("task2 end")
-- end
-- run({task1, task2})
-- Output (interleaved by time):
-- task1 start
-- task2 start
-- task2 end (after 0.05s)
-- task1 end (after 0.1s)
Exercise 4: Metatable-based type system
lua
-- Implement a simple type checker using metatables
-- Create a Type class that can:
-- 1. Define a type with a name and validation function
-- 2. Check if a value matches the type
-- 3. Support union types (T1 | T2) and array types (Array<T>)
-- Example:
-- local Int = Type.new("Int", function(v) return math.type(v) == "integer" end)
-- local Str = Type.new("Str", function(v) return type(v) == "string" end)
-- local StrOrInt = Type.union(Str, Int)
-- local StrArray = Type.array(Str)
--
-- assert(Int:check(42) == true)
-- assert(Int:check(3.14) == false)
-- assert(StrOrInt:check("hello") == true)
-- assert(StrOrInt:check(42) == true)
-- assert(StrArray:check({"a", "b"}) == true)
-- assert(StrArray:check({1, 2}) == false)
🧠 Final Challenge: Build a Mini-REPL
lua
-- Build an interactive REPL that:
-- 1. Reads expressions from stdin
-- 2. Compiles them with load() (sandboxed)
-- 3. Prints the result (handling multiple return values)
-- 4. Maintains state between inputs (a persistent environment)
-- 5. Handles errors gracefully (compile + runtime)
-- 6. Supports special commands: :quit, :env, :help
-- Hints:
-- - Use load(line, "repl", "t", env) where env is a persistent table
-- - For expressions (not statements), wrap in "return ..."
-- - Use pcall to catch errors
-- - The persistent env allows variables to survive between inputs
Summary
These projects exercise the full range of Lua's capabilities:
| Project | Concepts Used |
|---|---|
| JSON Parser | Patterns, recursion, string manipulation, error handling |
| Pipeline | Coroutines, generators, lazy evaluation, functional composition |
| Query Builder | Metatables, fluent API, __tostring, string building |
| Plugin System | Sandboxing, load() with custom env, pcall, hook pattern |
| LRU Cache | Tables as linked structures, metatables, O(1) operations |
| Timer | Coroutines, scheduler, cooperative multitasking |
| Type system | Metatables, function composition, recursive validation |
| REPL | load(), pcall, persistent environments, string eval |
Each project can be extended: add error recovery, add more types, add persistence. The skills transfer directly to real Lua applications: game mods (Roblox, Garry's Mod), Neovim plugins, Redis scripting, and embedded Lua hosts.