Files
Questie-X/.agent/rules/lua-coding-style.md
T

23 KiB
Raw Blame History

paths
paths
**/*.lua

Lua Coding Style

This file extends common/coding-style.md with Lua specific content.

Version Compatibility Matrix

These rules target universal Lua coverage across 5.0, 5.1, 5.2, 5.3, and 5.4. When a feature differs between versions, use the portable pattern or guard with a version check.

Feature 5.0 5.1 5.2 5.3 5.4 Portable Pattern
Length operator # table.getn table.getn(t) or #t with guard
Varargs ... as expr arg table See Varargs section
select() Guard: if select then ... else arg[i]
math.mod / math.fmod math.mod math.fmod math.fmod math.fmod or math.mod
string.gmatch string.gfind string.gmatch or string.gfind
setfenv/getfenv _ENV Version-gated sandbox
unpack() global table.unpack unpack or table.unpack
xpcall extra args Wrap in closure for 5.0/5.1
goto statement Avoid; use early return
Integer subtype All numbers are doubles in 5.05.2
Bitwise operators bit32 lib native (bit32 deprecated) native bit32 lib on 5.2; native ops on 5.3+
__gc for tables Only for userdata in 5.0/5.1
__len for tables (userdata only) rawlen or custom function; 5.1 __len only for userdata
table.move Manual loop
table.foreach/foreachi (deprecated) removed Use pairs/ipairs (5.0+)
table.setn (deprecated) removed Track length manually
table.maxn (deprecated) removed Manual loop over keys
package table loadlib Guard package and package.path
pcall extra args Universal
coroutine.status Universal
table.pack {n = select("#", ...), ...}
rawlen() rawlen or function(t) return #t end
loadstring (deprecated→load) loadstring or load
string.pack/unpack External struct lib for older versions
utf8 library External lua-utf8 lib for older
package.loaders .searchers package.loaders or package.searchers
coroutine.isyieldable Guard: coroutine.isyieldable and ...
math.atan2 math.atan(y, x) — note argument order is (y, x), not (x, y)
math.log10 removed math.log(x, 10) or math.log(x) / math.log(10)
math.pow x ^ y (native operator) or math.pow(x, y)
math.log(x, base) math.log(x) / math.log(base) for portable base
Floor division // math.floor(a / b) for 5.05.2
math.cosh/sinh/tanh deprecated Implement manually or use external lib
math.frexp/math.ldexp deprecated x * 2.0^exp for ldexp; external lib for frexp
coroutine.close Guard: coroutine.close and coroutine.close(co)
warn() function Guard: if warn then warn(msg) end
<const>/<close> attrs Use local without attrs on 5.05.3
__le metamethod required (derived from __lt) (must define explicitly) Always define both __lt and __le
String→number coercion auto auto auto auto removed from core Use explicit tonumber() for portability
Long string nesting [[]] (nestable) (no nesting) Use [=[...]=] for nested long strings
%z pattern class deprecated Use literal \0 to match the null character (ASCII 0) in patterns (5.2+)
__ipairs metamethod deprecated Avoid; ipairs uses raw integer keys
Float→string .0 suffix 2.0"2" 2.0"2.0" Use string.format for consistent formatting
print calls tostring (hardwired) Use __tostring metamethod for custom output
math.random auto-seeded (new PRNG) Call math.randomseed explicitly on 5.05.3
math.log(x, base) math.log(x) / math.log(base) for base argument
Floor division // math.floor(a / b) for 5.05.2
io.lines return count 1 1 1 1 4 (line, extra, line number, error) Wrap in (io.lines(...)) to get 1 value
collectgarbage("count") N/A 2 values 2 values 1 value 1 value Use math.floor(collectgarbage("count"))

Cross-Version Compatibility Shim

Place at the top of your entry point file to normalize APIs across versions:

-- compat.lua: Universal Lua 5.05.4 shim
-- Place at top of your entry point; all other files use these locals.

-- Core builtins that moved between versions
local unpack       = unpack or table.unpack               -- 5.2 moved to table
local getn         = table.getn or function(t) return #t end  -- 5.0 has no #
local setn         = table.setn or function() end          -- 5.0 tracks length via setn
local maxn         = table.maxn or function(t)             -- removed in 5.3
    local n = 0
    for k in pairs(t) do
        if type(k) == "number" and k > n then n = k end
    end
    return n
end

-- String library renames
local gmatch       = string.gmatch or string.gfind        -- 5.0 uses gfind

-- Math library renames
local fmod         = math.fmod or math.mod                -- 5.0 uses math.mod

-- Module system: 5.0 has no package table
local loadlib      = package and package.loadlib or loadlib

-- Vararg helpers
local getVarargCount = select                              -- nil in 5.0
    and function(...) return select("#", ...) end          -- 5.1+: use select
    or  function() return arg and arg.n or 0 end           -- 5.0: use arg.n

-- Code loading: loadstring renamed to load in 5.2
local loadstring    = loadstring or load                   -- 5.2+ removed loadstring

-- Raw operations: rawlen added in 5.2 (bypasses __len)
local rawlen        = rawlen or function(v) return #v end  -- fallback uses #

-- table.pack: added in 5.2; polyfill for 5.0/5.1
local table_pack    = table.pack or function(...)
    return { n = select and select("#", ...) or (arg and arg.n or 0), ... }
end

Formatting

  • StyLua for code formatting — always run stylua . before committing
  • Luacheck for lints — luacheck . --no-color (treat warnings as CI failures)
  • 4-space indent (StyLua default)
  • Max line width: 120 characters
  • Trailing commas in multi-line table constructors (prevents noisy diffs)

Naming

Follow standard Lua conventions:

  • camelCase for local variables, functions, method names
  • PascalCase for modules, "classes" (tables used as classes via metatables)
  • UPPER_SNAKE_CASE for constants and enum-like values
  • _camelCase (leading underscore) for private/internal helpers
  • UPPER_SNAKE_CASE for WoW event strings ("PLAYER_LOGIN", "QUEST_ACCEPTED")
-- GOOD
local QuestieDB = {}           -- PascalCase module
local MAX_RETRIES = 3          -- UPPER_SNAKE constant
local questId = 10141          -- camelCase local
local function _countTable(t)  -- _camelCase private helper

Scoping: local Is Non-Negotiable

Every variable MUST be local unless explicitly required as a global. Globals leak into _G, pollute the namespace, introduce untraceable coupling between files, and cause ADDON_ACTION_BLOCKED taint in WoW sandboxes.

-- BAD: Implicit global — leaks into _G, causes taint
myValue = 10

-- GOOD: Explicit local
local myValue = 10

Global caching: Cache frequently-called global functions into module-level locals. This provides measurable performance improvement in tight loops (Lua resolves locals via stack slot, globals via hash lookup into _G).

-- Cache at file / module top — BEFORE any function definitions
local type       = type
local pairs      = pairs
local ipairs     = ipairs
local next       = next
local tostring   = tostring
local tinsert    = table.insert
local tremove    = table.remove
local tconcat    = table.concat
local format     = string.format
local floor      = math.floor
local max        = math.max
local min        = math.min
local coroutine  = coroutine
local setmetatable = setmetatable
local getmetatable = getmetatable

Immutability

Language note: This rule overrides common/coding-style.md's strict immutability. Lua tables are mutable by design and creating fresh copies on every update is prohibitively expensive for large data sets (e.g., 40,000+ NPC records). Use immutability where practical; use controlled mutation with clear ownership semantics where performance demands it.

When immutability is practical (configuration, options, small payloads):

-- GOOD — returns a new table with the field updated
local function withField(original, key, value)
    local copy = {}
    for k, v in pairs(original) do copy[k] = v end
    copy[key] = value
    return copy
end

When mutation is necessary (hot paths, large tables, WoW frame pools), document ownership:

-- ACCEPTABLE — mutation of owned table, clearly documented
--- Compiles NPC data into the binary cache. Mutates QuestieDB.npcData in place
--- because copying 40k records per compile is prohibitively expensive.
function QuestieDBCompiler:CompileNPCData(sourceData, targetTable)
    for id, data in pairs(sourceData) do
        targetTable[id] = self:EncodeRow(data)
    end
end

Use read-only proxies for tables that must not be modified after initialization:

local function readOnly(t)
    return setmetatable({}, {
        __index = t,
        __newindex = function(_, k, _)
            error(format("Attempt to modify read-only table at key: %s", tostring(k)), 2)
        end,
        -- table.getn for 5.0 compat; # for 5.1+
        __len = function() return (table.getn or rawlen or function(x) return #x end)(t) end,
    })
end

local QUEST_FLAGS = readOnly({ SHARABLE = 0x0008, DAILY = 0x1000 })

Tables

Construction

Prefer table literals over sequential assignment. The compiler generates fewer instructions and programmer intent is clearer:

-- GOOD: Single allocation, clear structure
local config = {
    maxRetries  = 3,
    timeout     = 5,
    version     = "1.4.7",
    pluginNames = { "WotLKDB", "TurtleDB" },
}

-- BAD: Multiple allocations, fragmented intent
local config = {}
config.maxRetries = 3
config.timeout = 5
config.version = "1.4.7"
config.pluginNames = {}
config.pluginNames[1] = "WotLKDB"
config.pluginNames[2] = "TurtleDB"

Array Building in Loops

Use t[#t + 1] (fastest in Lua 5.1+) or tinsert. In Lua 5.0, use tinsert or track an index manually since # does not exist:

-- GOOD: Fastest array append (Lua 5.1+)
local results = {}
for id, data in pairs(sourceData) do
    results[#results + 1] = data
end

-- GOOD: Works on ALL versions (5.05.4)
local results = {}
for id, data in pairs(sourceData) do
    tinsert(results, data)
end

-- GOOD: Manual index tracking (universal, fastest in 5.0)
local results = {}
local n = 0
for id, data in pairs(sourceData) do
    n = n + 1
    results[n] = data
end

Iteration Patterns

Use case Pattern Lua versions Notes
Sequential array (no holes) for i = 1, #t do 5.1+ Fastest; no function call overhead
Sequential array (universal) for i = 1, table.getn(t) do 5.0+ Use getn shim for 5.0 compat
Array with value for i, v in ipairs(t) do 5.0+ Stops at first nil hole
Dictionary / sparse table for k, v in pairs(t) do 5.0+ Unordered; processes every key
Universal / taint-free for k, v in next, t do 5.0+ Equivalent to pairs but avoids metamethod
Empty-check if next(t) == nil then 5.0+ Only reliable way
Counting elements Custom _countTable function 5.0+ #t/table.getn only counts array part
-- Empty-check: ONLY correct way
if next(myTable) == nil then
    -- Table is truly empty (no array or hash keys)
end

-- WRONG: Unreliable for dictionaries and sparse arrays
if #myTable == 0 then -- BROKEN on { a = 1, b = 2 }

Holes in Arrays

The # operator is undefined on sparse arrays (arrays with nil gaps). If holes are possible, track length with a counter field or use a dedicated array class.

-- WRONG: Undefined behavior with holes
local t = { 1, nil, 3 }
print(#t)  -- Could be 1 or 3 (implementation-dependent)

-- CORRECT: Track length explicitly
local t = { n = 3; 1, nil, 3 }
for i = 1, t.n do print(t[i]) end

Table Recycling and Wipe

Reuse tables to reduce GC pressure in hot paths. Use wipe() (WoW) or manual niling:

-- WoW environment: wipe() clears all keys
wipe(myTable)

-- Standard Lua: manual clear
for k in pairs(myTable) do myTable[k] = nil end

Functions

  • Keep functions under 50 lines. Extract helpers prefixed with _.
  • Return early to flatten nesting. Lua has no guard clauses, so explicit early returns are the idiomatic substitute.
  • Avoid deep nesting (> 4 levels). Refactor into helper functions.
-- BAD: Deep nesting
function processQuest(questId)
    if questId then
        local data = QuestieDB.questData[questId]
        if data then
            local name = data[1]
            if name then
                -- 4 levels deep...
            end
        end
    end
end

-- GOOD: Early returns flatten the code
function processQuest(questId)
    if not questId then return end
    local data = QuestieDB.questData[questId]
    if not data then return end
    local name = data[1]
    if not name then return end
    -- Proceed at 1 level of nesting
end

Default Arguments

Lua has no native defaults. Use the or idiom for simple cases, explicit nil checks for values where false or 0 are valid:

-- Simple default (WRONG if false/0 are valid values)
local function greet(name, greeting)
    name     = name     or "Adventurer"
    greeting = greeting or "Hello"
    return format("%s, %s!", greeting, name)
end

-- Precise default (handles false/0 correctly)
local function setEnabled(flag)
    if flag == nil then flag = true end  -- Default to true; false is a valid input
end

Varargs

Varargs behavior differs significantly across Lua versions:

Version Access pattern Length
5.0 arg table (auto-created) arg.n
5.1+ ... as expression select("#", ...)
-- Lua 5.1+: Use select for length (handles trailing nil)
local function logAll(...)
    local n = select("#", ...)
    for i = 1, n do
        local v = select(i, ...)
        print(format("arg[%d] = %s", i, tostring(v)))
    end
end

-- Lua 5.0: Use the implicit 'arg' table
local function logAll(...)  -- 5.0 creates 'arg' automatically
    for i = 1, arg.n do
        print(format("arg[%d] = %s", i, tostring(arg[i])))
    end
end

-- UNIVERSAL: Works on 5.05.4
local function logAll(...)
    local args = select and { n = select("#", ...), ... } or arg
    for i = 1, args.n do
        print(format("arg[%d] = %s", i, tostring(args[i])))
    end
end

Strings

  • Short concatenation: .. is fine for 23 pieces.
  • Loop-built strings: Use table.concat to avoid O(n²) intermediate string allocations.
  • Structured output: Prefer string.format over .. chains for readability and localization.
-- BAD: O(n²) string growth in a loop
local result = ""
for _, v in ipairs(data) do
    result = result .. tostring(v) .. ", "
end

-- GOOD: O(n) via table.concat
local parts = {}
for i, v in ipairs(data) do
    parts[i] = tostring(v)
end
local result = tconcat(parts, ", ")

Error Handling

Lua uses pcall / xpcall as its try/catch equivalent. Use them at system boundaries (event handlers, plugin entry points, data loading). NEVER silently swallow errors.

-- pcall: Returns ok, result_or_error
local ok, err = pcall(function()
    dangerousOperation()
end)
if not ok then
    Questie:Debug(Questie.DEBUG_CRITICAL,
        "[Module] Operation failed: " .. tostring(err))
end

-- xpcall: Adds a message handler for stack traces
local function errorHandler(msg)
    return tostring(msg) .. "\n" .. debugstack(2)
end

local ok, result = xpcall(dangerousOperation, errorHandler)

Version note: In Lua 5.0 and 5.1, xpcall does NOT support passing arguments to the called function. Wrap in a closure:

-- WRONG in 5.0/5.1: xpcall does not forward args
local ok, result = xpcall(dangerousOp, errorHandler, arg1, arg2)

-- CORRECT (universal): Wrap in closure
local ok, result = xpcall(function()
    return dangerousOp(arg1, arg2)
end, errorHandler)

Error Propagation

Lua uses the success, result multi-return pattern (no exceptions). Propagate errors by returning nil, errorMessage:

local function loadConfig(path)
    local f, err = io.open(path, "r")
    if not f then return nil, "Cannot open: " .. tostring(err) end

    local content = f:read("*a")
    f:close()

    if not content or content == "" then
        return nil, "Empty config file: " .. path
    end

    return content
end

-- Caller
local config, err = loadConfig("settings.ini")
if not config then
    error("Config load failed: " .. err)
end

Nil Guards and Defensive Access

Always check nil before indexing. Chain guards for deep access:

-- Safe deep access (short-circuit on nil)
local name = myData and myData.info and myData.info.name

-- Explicit nil check when 0, false, or "" are valid values
if myData.count ~= nil then
    processCount(myData.count)  -- count could be 0
end

Metatables

Use metatables for __index delegation, operator overloads, __tostring, and read-only enforcement. Never expose raw metatables of security-sensitive tables publicly; use __metatable to guard them.

-- Guard metatable from external manipulation
local Secret = {}
Secret.__index = Secret
Secret.__metatable = "Access denied"  -- getmetatable() returns this string

function Secret:New(value)
    return setmetatable({ _value = value }, Secret)
end

Common Metamethods

Metamethod Purpose Example
__index Delegation / inheritance Class systems, proxy tables
__newindex Intercept writes Read-only guards, validation
__tostring Custom tostring() output Debug printing
__call Make a table callable Functor pattern
__len Custom # operator Tables: 5.2+ only; userdata: 5.1+; ignored for tables in 5.0/5.1
__eq, __lt, __le Comparison operators 5.0+; 5.4: __le must be explicit (no longer derived from __lt)
__gc Garbage collection finalizer Tables: 5.2+ only; userdata: 5.0+
__close To-be-closed variable cleanup 5.4+ only; local x <close> = resource
__metatable Protect from getmetatable Security-sensitive objects (5.0+)
__concat Custom .. operator String-like objects (5.0+)

Coroutines

Use coroutines for cooperative multitasking — chunked database compilation, lazy iteration over large datasets, and frame-budgeted work in game engines.

Critical Lua 5.0/5.1 limitation: You cannot yield from inside a pcall/xpcall call. This throws "cannot resume dead coroutine". Structure your code so the yield happens outside the protected call. This was fixed in Lua 5.2+ (yield-across-pcall support).

Lua 5.0 note: The full coroutine library (create, resume, yield, status, wrap) is available in Lua 5.0. However, in 5.0, you cannot yield from inside a C function, a metamethod, or an iterator — only from the main coroutine body. This restriction was relaxed in 5.1 (yield from iterators became possible) and further in 5.2 (yield across pcall/xpcall).

local fmod = math.fmod or math.mod  -- 5.0 has math.mod, 5.1+ has math.fmod

local co = coroutine.create(function()
    for i = 1, 10000 do
        processRecord(i)
        if fmod(i, 100) == 0 then   -- 5.0: no % operator; use fmod shim
            coroutine.yield()  -- Pause every 100 records
        end
    end
end)

-- Resume from a frame ticker (WoW: C_Timer.After, OnUpdate)
local function tick()
    if coroutine.status(co) ~= "dead" then
        local ok, err = coroutine.resume(co)
        if not ok then
            error("Coroutine error: " .. tostring(err))
        end
    end
end

Numeric Precision

Lua 5.05.2 use 64-bit IEEE doubles for all numbers (no integer subtype). Lua 5.3+ introduced a separate integer subtype. Integers above 2^53 lose precision in double-only versions. Be aware of floating-point edge cases:

-- WRONG: Float comparison
if result == 0.3 then  -- May fail due to IEEE 754

-- CORRECT: Epsilon comparison
local EPSILON = 1e-9
if math.abs(result - 0.3) < EPSILON then

Debug Output

  • No print() in production code — it bypasses logging levels, cannot be filtered, and causes taint in WoW.
  • Gate debug output behind a severity level flag.
  • Use structured debug helpers.
-- BAD
print("Loading quest " .. questId)

-- GOOD
Questie:Debug(Questie.DEBUG_DEVELOP,
    format("[QuestieDB] Loading quest %d", questId))

File Organization

MANY SMALL FILES > FEW LARGE FILES:

  • 200400 lines typical, 800 lines absolute maximum
  • One module / class per file
  • Group by feature/domain, not by type
  • Data files (large lookup tables) may exceed 800 lines — exclude them from line-count rules
Database/
├── QuestieDB.lua          # Core query interface
├── compiler.lua           # Binary compilation
├── Corrections/
│   ├── QuestCorrections.lua
│   └── NPCCorrections.lua
├── Data/
│   ├── questData.lua      # Raw data (exempt from 800-line rule)
│   └── npcData.lua
└── Zones/
    └── zoneDB.lua

Code Quality Checklist

Before marking work complete on any Lua file:

  • All variables are local (zero undeclared globals)
  • Functions are under 50 lines
  • Files are under 800 lines (data files exempt)
  • No nesting deeper than 4 levels
  • Error paths return nil, errMsg or log explicitly — never silently swallowed
  • No hardcoded magic numbers (use named constants)
  • Table iteration uses the correct primitive (ipairs, pairs, next, numeric for)
  • String building in loops uses table.concat, not ..
  • luacheck passes with zero warnings
  • stylua --check passes