AgentStack
SKILL verified MIT Self-run

Lua Vibe

skill-bpavlo-agent-skills-lua-vibe · by bpavlo

Lua best practices, idioms, and ecosystem tooling. Use when writing or refactoring Lua code; designing modules; choosing between locals, tables, and metatables; handling errors with pcall/xpcall/assert; building OOP via __index; tuning performance for LuaJIT; setting up LuaRocks, busted tests, lua-language-server, stylua, luacheck, or selene; or writing LuaCATS type annotations.

No reviews yet
0 installs
3 views
0.0% view→install

Install

$ agentstack add skill-bpavlo-agent-skills-lua-vibe

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

Are you the author of Lua Vibe? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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 local and a (rarely correct) global, deciding when to use pcall, 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, or selene.
  • 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 Manualhttps://www.lua.org/manual/5.1/. Authoritative for the language LuaJIT runs.
  • lua-users wikihttp://lua-users.org/wiki/ — large body of idiomatic snippets.
  • LuaJIT documentationhttps://luajit.org/ — JIT semantics, NYI list, performance guide.
  • LuaCATS specificationhttps://luals.github.io/wiki/annotations/ — annotation grammar consumed by lua_ls.
  • busted documentationhttps://lunarmodules.github.io/busted/.
  • LuaRocks documentationhttps://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_case for variables and functions.
  • PascalCase for tables intended to be instantiated via metatables (i.e. "classes").
  • SCREAMING_SNAKE_CASE for module-level constants.
  • _name underscore 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.
  • self for 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 with ipairs or for i = 1, #t do.
  • A record (string keys): { name = "ada", year = 1815 }. Iterate with pairs.
  • A set (keys are members, value is true): { x = true, y = true }. Test with s[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:

  1. Failure is expected (parsing user input, optional dependency, network).
  2. 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.

Versions

  • v0.1.0 Imported from the upstream source.