Install
$ agentstack add skill-bpavlo-agent-skills-lua-vibe ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ● Filesystem access Used
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
lua-vibe — Lua Best Practices and Ecosystem
When to Use This Skill
Load this skill whenever the task involves:
- Writing new Lua code or refactoring existing modules.
- Choosing between
localand a (rarely correct) global, deciding when to usepcall, designing a metatable-based "class," or picking between../string.format/table.concat. - Resolving
nil-handling, multiple-return, or truthiness pitfalls. - Setting up a new project:
rockspec,package.path, directory layout. - Running tests with
busted(or compatible runners). - Configuring
lua-language-server(lua_ls),stylua,luacheck, orselene. - Adding LuaCATS (
---@) annotations so editors can type-check call sites. - Diagnosing performance: hot loops, allocation in tight code, JIT abort patterns, weak tables for caches.
- Reviewing someone's Lua for the standard set of anti-patterns (globals, over-
pcall, string concatenation in loops, ipairs vs numeric for).
The skill targets Lua 5.1 / LuaJIT as the lowest common denominator — what most embedders ship. Differences in 5.2/5.3/5.4 are flagged inline.
Source Material
- Programming in Lua (Roberto Ierusalimschy) — canonical book, multiple editions.
- Lua 5.1 Reference Manual —
https://www.lua.org/manual/5.1/. Authoritative for the language LuaJIT runs. - lua-users wiki —
http://lua-users.org/wiki/— large body of idiomatic snippets. - LuaJIT documentation —
https://luajit.org/— JIT semantics, NYI list, performance guide. - LuaCATS specification —
https://luals.github.io/wiki/annotations/— annotation grammar consumed bylua_ls. - busted documentation —
https://lunarmodules.github.io/busted/. - LuaRocks documentation —
https://github.com/luarocks/luarocks/wiki/.
Core Philosophy
These principles are evaluated in order. Each later principle assumes the earlier ones.
1. local everywhere
Every name that doesn't need to escape its file is local. Modules expose their public surface via a single returned table. Globals exist for two reasons only: legacy interop with the embedder, and deliberate convenience symbols (which should be documented and namespaced).
2. Tables are the only collection
Lua has one container. Pick a shape per variable — array, record, or set — and don't switch shapes mid-pipeline. A function should not return an array sometimes and a record other times.
3. Errors are values; pcall is for recoverable failure
pcall is for expected failures (optional dependency, network call, parsing user input). Wrapping every operation in pcall produces silent half-failures. Use assert for programmer errors and error(msg, level) to attribute to the caller.
4. Metatables are a tool, not a paradigm
__index, __newindex, __call, and __tostring cover 95% of useful metatable work. Inheritance via __index is fine; deeper-than-two-level hierarchies are usually wrong. Composition is almost always clearer.
5. String building scales with O(n), not O(n²)
.. is fine for two or three operands. Beyond that, string.format for clarity, table.concat for loops. Repeated .. in a loop is O(n²) on the GC.
6. Hot paths localize, cold paths read
In a hot inner loop, cache hash lookups (local fmt = string.format) and prefer numeric for i = 1, #t do over ipairs. Outside hot paths, write whichever is clearer. Profile before optimizing — most "slow Lua" is one bad table allocation, not idiomatic style.
7. Annotate the public surface, not the private
Add ---@param, ---@return, ---@class, and ---@type to anything in M.*. Internal helpers can stay untyped. Annotations are the contract; types unconsumed by tooling are noise.
8. Tooling pays for itself
Run stylua (or lua-format) on save, luacheck or selene in CI, lua-language-server in your editor. Each catches a different class of bug. The combined runtime cost is sub-second; the bug-prevention rate is high.
9. Pin LuaRocks dependencies
A rockspec without a version constraint is a time bomb. Pin to a specific tag or commit; bump deliberately. The same applies to vendored copies in vendor/ or git submodules.
10. Lua 5.1 unless you've checked
Every embedded host has its own Lua version. Default to writing Lua 5.1-compatible code (LuaJIT also runs this) unless you've verified the host supports later. The 5.1 → 5.2/5.3 differences (integer division, bitwise ops, goto, _ENV) are easy to invoke accidentally.
Language
Naming
snake_casefor variables and functions.PascalCasefor tables intended to be instantiated via metatables (i.e. "classes").SCREAMING_SNAKE_CASEfor module-level constants._nameunderscore prefix for module-private values that are exposed only because Lua has no real privacy.M(single letter) for the module's returned table at the top of a file. This convention is universal.selffor the implicit first argument of methods declared with:.
local M = {}
local PRIVATE_DEFAULT = 64
local function _internal_helper() end
function M.public_thing() end
return M
Variables and Scope
local is mandatory in practice. The omission produces a global, which:
- Costs a hash lookup on every access.
- Pollutes a shared namespace where other code can clobber it.
- Defeats
lua_ls"undefined global" diagnostics.
-- Wrong: implicit globals
function helper(x) return x * 2 end -- creates _G.helper
my_state = {} -- creates _G.my_state
-- Right
local function helper(x) return x * 2 end
local my_state = {}
In hot paths, also localize stdlib functions:
local fmt = string.format
local concat = table.concat
local insert = table.insert
The optimization is real (LuaJIT registers vs hash table), but the larger benefit is diagnostic clarity.
Truthiness
Only false and nil are falsy. Everything else is truthy, including 0, "", and {}.
if x then ... end -- x is not nil and not false
if x == nil then ... end -- explicit nil
if not x then ... end -- x is nil or false (sometimes ambiguous)
When nil and false mean different things, write the explicit comparison.
and / or for Defaults and Ternaries
Lua has no ternary. The idiom is cond and a or b, valid as long as a is never falsy:
local label = ok and "yes" or "no"
If a can legitimately be false or nil, switch to if:
local val
if ok then val = first_thing else val = fallback end
For default arguments:
function M.greet(name)
name = name or "world" -- works as long as name == false isn't valid
...
end
function M.toggle(verbose)
if verbose == nil then verbose = true end -- distinguishes nil from false
...
end
Tables
Lua has one collection. The same table can be:
- An array (numeric keys 1..n, no holes):
{1, 2, 3}. Iterate withipairsorfor i = 1, #t do. - A record (string keys):
{ name = "ada", year = 1815 }. Iterate withpairs. - A set (keys are members, value is
true):{ x = true, y = true }. Test withs[k]. - A hybrid:
{ "first", "second", name = "list" }. Allowed but read carefully.
Pick a shape per variable. A function that returns an array sometimes and a record other times has one too many responsibilities.
-- Array
for i, v in ipairs(items) do ... end
for i = 1, #items do local v = items[i]; ... end -- faster
-- Record
for k, v in pairs(record) do ... end
-- Set
local seen = {}
for _, x in ipairs(items) do
if not seen[x] then
seen[x] = true
process(x)
end
end
Length and Holes
#t returns some boundary in t. It is well-defined only for sequences (no nil holes). If your array can be sparse, store n explicitly:
local t = { n = 0 }
local function push(v) t.n = t.n + 1; t[t.n] = v end
table.pack(...) (5.2+, polyfilled in 5.1) and select("#", ...) give vararg counts.
Iteration Order
pairs(t) iteration order is unspecified for hash entries. If you need stable output, collect keys into an array and table.sort them.
local keys = {}
for k in pairs(t) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do print(k, t[k]) end
Multiple Return Values
Lua functions return any number of values. Two pitfalls:
- Trailing returns are discarded when the call is not in the last position:
``lua local a, b = f(), g() -- only first value of f(); both values of g() print(f(), g()) -- only first value of f(); both values of g() ``
- Wrapping a call in parentheses truncates to one:
``lua local a, b = (f()) -- b is always nil ``
Use this deliberately when discarding:
local n = select("#", ...) -- count varargs
local _, _, third = string.find(...) -- skip first two captures
Strings
Building
.. allocates and copies both operands. Fine for two or three:
local greeting = "hello, " .. name .. "!"
In loops, use table.concat:
local parts = {}
for i, line in ipairs(lines) do
parts[i] = line:upper()
end
local joined = table.concat(parts, "\n")
For format-style output, use string.format:
local msg = string.format("[%s] %d errors in %s", level, n, file)
%q quotes a string for round-tripping through load/loadstring. %s calls tostring on its argument.
Patterns
Lua patterns are not regex. They're simpler and faster, with a different syntax. The metacharacters:
. any character
%a letter %A not letter
%d digit %D not digit
%s whitespace %S not whitespace
%w alphanumeric %W not alphanumeric
%p punctuation %P not punctuation
%l lowercase %L not lowercase
%u uppercase %U not uppercase
%c control %C not control
%x hex %X not hex
[] character class [^] negated class
? 0 or 1 * 0 or more (greedy)
+ 1 or more (greedy) - 0 or more (non-greedy)
^ start of subject $ end of subject
% escapes magic chars: ( ) . % + - * ? [ ] ^ $
() capture group
-- Find
local i, j = string.find(s, "%d+") -- positions of first digit run
-- Match (returns captures or full match)
local year = s:match("(%d%d%d%d)")
-- Iterate matches
for word in s:gmatch("%S+") do print(word) end
-- Replace
local out = s:gsub("%s+", " ") -- collapse whitespace
For real regex, use a library (lpeg, lrexlib).
Common Idioms
-- Trim
local trimmed = s:match("^%s*(.-)%s*$")
-- Split on a single char
local function split(s, sep)
local out = {}
for part in s:gmatch("([^" .. sep .. "]+)") do out[#out + 1] = part end
return out
end
-- Starts with / ends with
local function starts_with(s, prefix) return s:sub(1, #prefix) == prefix end
local function ends_with(s, suffix) return suffix == "" or s:sub(-#suffix) == suffix end
5.3+ adds string.pack / string.unpack for binary serialization.
Control Flow
if a then ... elseif b then ... else ... end
while cond do ... end
repeat ... until cond -- post-test; cond is true to STOP
for i = 1, 10 do ... end
for i = 10, 1, -1 do ... end
for k, v in pairs(t) do ... end
-- 5.2+: break and goto
for i = 1, n do
if skip(i) then goto continue end
...
::continue::
end
Lua 5.1 has break but no continue. Use goto continue or refactor.
Functions
Lua functions are first-class values. Common forms:
local function f(x) return x + 1 end -- declarative, recommended
local f = function(x) return x + 1 end -- expression form
-- Method declaration: : injects implicit self
function Animal:speak() print(self.name) end
-- equivalent to:
Animal.speak = function(self) print(self.name) end
-- Method call: : passes receiver as first arg
animal:speak()
-- equivalent to:
animal.speak(animal)
local function is sugar for local f; f = function() ... end. Order matters for self-reference:
-- Doesn't work: a's body captures b before b exists
local function a() return b() end
local function b() return 1 end
-- Works: declare both, then assign
local a, b
function a() return b() end
function b() return 1 end
Varargs are accessed as ... and counted with select("#", ...):
local function variadic(...)
local n = select("#", ...)
for i = 1, n do
local arg = select(i, ...)
print(i, arg)
end
end
Closures
Inner functions capture outer locals by reference (upvalues). Useful for state hiding:
local function counter()
local n = 0
return function() n = n + 1; return n end
end
local next_id = counter()
print(next_id(), next_id(), next_id()) -- 1, 2, 3
Each call to counter() produces an independent closure with its own n.
Error Handling
error and assert
error("bad input") -- raises with message; level 1 (this function)
error("bad input", 2) -- attribute error to caller (preferred for libraries)
error({ code = "EBAD" }) -- non-string errors are allowed
assert(cond, "must be true") -- if cond is falsy, raises "must be true"
local h = assert(io.open(path)) -- idiom: raise on nil return
error(msg, level) controls which stack frame the error attributes to. level = 0 suppresses the position. level = 2 blames the caller — use it in library code.
assert(v, msg) is shorthand for if not v then error(msg or "assertion failed", 2) end. Use it for preconditions and to unwrap nilable returns.
pcall / xpcall
pcall runs a function in protected mode. Returns (true, results...) on success, (false, err) on failure.
local ok, result = pcall(do_thing, arg)
if not ok then
-- result is the error message (or value passed to error())
log_error(result)
return
end
-- use result
xpcall is pcall plus a handler that runs inside the failing frame, so debug.traceback produces a useful trace:
local ok, err = xpcall(do_thing, debug.traceback, arg)
if not ok then print(err) end -- err is "msg\nstack traceback: ..."
When to pcall
Use pcall only where:
- Failure is expected (parsing user input, optional dependency, network).
- You can recover meaningfully (fall back to a default, skip a row, retry).
Don't use pcall to "be safe." Wrapping every call hides bugs and produces silent half-failures. Prefer letting errors propagate; the embedder usually has a top-level error handler.
-- Good: optional dependency
local ok, lib = pcall(require, "optional-thing")
if ok then lib.setup() end
-- Bad: shotgun pcall
local ok1, x = pcall(get_x)
if not ok1 then return end
local ok2, y = pcall(get_y)
if not ok2 then return end
local ok3 = pcall(do_thing, x, y) -- silently fails, no stack trace
Error Objects
error(value) accepts any value, not just strings. Returning a structured error from a library is common:
local function load_config(path)
local f, err = io.open(path)
if not f then
error({ code = "EOPEN", path = path, cause = err }, 2)
end
...
end
local ok, err = pcall(load_config, "/missing")
if not ok and type(err) == "table" and err.code == "EOPEN" then
-- handle structured
end
For interop, also stringify on the way out so tostring(err) is human-readable:
setmetatable(err, { __tostring = function(self) return self.code .. ": " .. self.path end })
pcall and xpcall Differences
| Function | Handler runs in failing frame? | Use when | |---|---|---| | pcall(fn, ...) | No | You need only the message | | xpcall(fn, handler, ...) | Yes | You want a stack trace; you want a custom transformation of the error |
In Lua 5.1, xpcall doesn't accept arguments after the handler:
-- 5.1: pass via closure
xpcall(function() return do_thing(arg) end
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [bpavlo](https://github.com/bpavlo)
- **Source:** [bpavlo/agent-skills](https://github.com/bpavlo/agent-skills)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.