Files
Questie-X/.agent/rules/lua-patterns.md
T

13 KiB
Raw Blame History

paths
paths
**/*.lua

Lua Patterns

This file extends common/patterns.md with Lua specific content.

Module Pattern

The standard Lua module returns a table as its public API. All internal state and helpers are file-local. This is the foundation of all Lua architecture.

-- mymodule.lua
local M = {}

-- Private state — invisible outside this file
local _cache = {}
local _initialized = false

-- Private helper — underscore prefix signals internal use
local function _buildCacheKey(id)
    return "key_" .. tostring(id)
end

-- Public API — the only things callers can access
function M.get(id)
    return _cache[_buildCacheKey(id)]
end

function M.init()
    if _initialized then return end
    _initialized = true
    -- one-time setup
end

return M

Legacy Module Styles (5.0 and 5.1)

In Lua 5.0, setfenv was used directly to create module environments (there was no module() function). In Lua 5.1, the module() built-in was introduced. Both are deprecated/removed in 5.2+:

-- LEGACY 5.0 style — setfenv only (no module() function exists in 5.0)
local M = {}
setfenv(1, M)  -- Sets the current function's environment to M

function get(id)    -- Automatically scoped to M
    return cache[id]
end
return M

-- LEGACY 5.1 style — module() function (introduced in 5.1, deprecated in 5.2)
module("mymodule")  -- Creates a global table, sets the function env

function get(id)    -- Automatically added to the module table
    return cache[id]
end

-- MODERN style (5.0+) — return-table pattern works on ALL versions
local M = {}
function M.get(id) return cache[id] end
return M

Rule: Always use the return-table pattern. It works on Lua 5.05.4, does not depend on setfenv or module(), and does not pollute the global namespace.

Loader / ImportModule Pattern

In large addon systems, a central loader avoids circular require() chains by acting as a module registry with lazy resolution:

-- QuestieLoader.lua
local Loader = {}
local _modules = {}

function Loader:CreateModule(name)
    local mod = {}
    _modules[name] = mod
    return mod
end

function Loader:ImportModule(name)
    return _modules[name]
end

-- Usage in a module file:
local QuestieDB = QuestieLoader:CreateModule("QuestieDB")

-- Usage as a consumer:
local QuestieDB = QuestieLoader:ImportModule("QuestieDB")

This pattern breaks circular dependencies because modules register themselves at parse time but only call ImportModule at runtime (inside function bodies).

OOP / "Class" Pattern

Simulate classes and inheritance with metatables. This is the most common OOP approach in Lua.

Basic Class

local Animal = {}
Animal.__index = Animal

function Animal:New(name, sound)
    return setmetatable({
        name  = name,
        sound = sound,
    }, self)  -- 'self' is Animal here (or a subclass)
end

function Animal:Speak()
    return self.name .. " says " .. self.sound
end

Inheritance

Chain __index through the class hierarchy. Call parent constructors with dot-notation (not colon) to avoid double-wrapping self:

-- Dog extends Animal
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog

function Dog:New(name)
    -- CORRECT: dot-notation passes 'self' (Dog) explicitly
    local instance = Animal.New(self, name, "Woof")
    return setmetatable(instance, Dog)
end

function Dog:Fetch(item)
    return self.name .. " fetches the " .. item
end

-- BAD: colon-notation would pass Dog as 'self' twice
-- local instance = Animal:New(name, "Woof")  -- WRONG

Mixins

Add behavior from multiple sources without full multiple inheritance:

local Serializable = {}
function Serializable:Serialize()
    local parts = {}
    for k, v in pairs(self) do
        table.insert(parts, tostring(k) .. "=" .. tostring(v))
    end
    return table.concat(parts, ";")
end

-- Mixin application
local function applyMixin(class, mixin)
    for k, v in pairs(mixin) do
        if class[k] == nil then  -- Don't overwrite existing methods
            class[k] = v
        end
    end
end

applyMixin(Dog, Serializable)
-- Now Dog instances have :Serialize()

Singleton Pattern

Wrap initialization in a one-time guard. Common for managers, registries, and services:

local ConfigManager = {}
local _config = nil

function ConfigManager:Get(key)
    if not _config then
        _config = self:_load()
    end
    return _config[key]
end

function ConfigManager:_load()
    -- expensive one-time load
    return { debug = false, maxLevel = 80 }
end

Registry / Plugin Architecture

Use a central registry for runtime plugin discovery without hard coupling. This is the pattern used by QuestiePluginAPI:

local PluginRegistry = { _plugins = {} }

function PluginRegistry:Register(name, pluginData)
    assert(type(name) == "string", "Plugin name must be a string")
    assert(not self._plugins[name], "Plugin already registered: " .. name)

    local plugin = {
        name  = name,
        data  = pluginData or {},
        stats = { QUEST = 0, NPC = 0, OBJECT = 0, ITEM = 0 },
    }
    self._plugins[name] = plugin
    return plugin
end

function PluginRegistry:Get(name)
    return self._plugins[name]
end

function PluginRegistry:IsAnyLoaded()
    return next(self._plugins) ~= nil
end

Plugin-Side Registration

Plugins register at parse time (top of file, outside any event handler) so the core can discover them immediately:

-- In plugin Loader.lua (top level, not inside PLAYER_LOGIN)
local plugin = PluginRegistry:Register("WotLKDB", addonTable)

Observer / Event Bus Pattern

Decouple producers from consumers. Essential for addon systems where modules load in unpredictable order:

local EventBus = { _listeners = {} }

function EventBus:On(event, fn)
    self._listeners[event] = self._listeners[event] or {}
    -- Use table.insert for 5.0 compat (no # operator)
    table.insert(self._listeners[event], fn)
end

function EventBus:Off(event, fn)
    local listeners = self._listeners[event]
    if not listeners then return end
    -- Reverse iterate; table.getn for 5.0, # for 5.1+
    local n = table.getn and table.getn(listeners) or #listeners
    for i = n, 1, -1 do
        if listeners[i] == fn then
            table.remove(listeners, i)
            return
        end
    end
end

function EventBus:Emit(event, ...)
    local listeners = self._listeners[event]
    if not listeners then return end
    -- ipairs works on all versions (5.0+)
    for _, fn in ipairs(listeners) do
        fn(...)  -- 5.1+: ... is an expression; 5.0: use unpack(arg) instead
    end
end

Coroutine-Based Lazy Iterator

Turn expensive database scans into resumable, lazy iterations without building the full result set in memory:

local function filteredNPCs(npcData, predicate)
    return coroutine.wrap(function()
        for id, data in pairs(npcData) do
            if data and predicate(id, data) then
                coroutine.yield(id, data)
            end
        end
    end)
end

-- Usage: processes NPCs one at a time, no intermediate table
for npcId, npcData in filteredNPCs(QuestieDB.npcData, function(id, d)
    return d[2] and d[2] >= 70  -- level >= 70
end) do
    processNPC(npcId, npcData)
end

Version note: coroutine.wrap is available in Lua 5.0+. However, in 5.0 you cannot yield from inside an iterator used in a generic for loop as part of a C boundary. The wrap-based pattern above works because the for loop drives resume directly.

Chunked Processing (Frame-Budgeted Work)

For operations that must not freeze the game client, split work across frames:

local function createChunkedProcessor(items, processFunc, chunkSize)
    chunkSize = chunkSize or 100
    local keys = {}
    -- Use table.insert for 5.0 compat
    for k in pairs(items) do table.insert(keys, k) end

    local index = 1
    local total = table.getn and table.getn(keys) or #keys

    return function()  -- Call this each frame/tick
        local budget = math.min(index + chunkSize - 1, total)
        for i = index, budget do
            processFunc(keys[i], items[keys[i]])
        end
        index = budget + 1
        return index > total  -- Returns true when done
    end
end

-- Usage with WoW C_Timer
local processor = createChunkedProcessor(rawData, compileRecord, 200)
local ticker
ticker = C_Timer.NewTicker(0.01, function()
    if processor() then
        ticker:Cancel()
        print("Compilation complete!")
    end
end)

Memoization / Caching

Cache expensive computations. Support cache invalidation:

local _zoneCache = {}
local _zoneCacheDirty = false

function ZoneDB:GetZoneAreaId(uiMapId)
    if not _zoneCacheDirty and _zoneCache[uiMapId] ~= nil then
        return _zoneCache[uiMapId]
    end
    local result = self:_computeZoneAreaId(uiMapId)
    _zoneCache[uiMapId] = result
    return result
end

function ZoneDB:InvalidateCache()
    wipe(_zoneCache)
    _zoneCacheDirty = false
end

Caution: Use ~= nil for the cache check, not truthiness. Cached false or 0 values are valid and must not trigger recomputation.

AddonTable Pattern (WoW-Specific)

The WoW client passes a private shared table to every file listed in the addon's .toc:

-- File 1: data.lua
local addonName, addonTable = ...
addonTable.npcData = { [1] = { "Ragnaros", 63, 1 } }

-- File 2: init.lua — same addonTable reference
local addonName, addonTable = ...
local npc = addonTable.npcData[1]
print(npc[1])  -- "Ragnaros"

Critical rule: Use addonTable as the sole inter-file communication channel. Never use _G for data sharing between files — it pollutes the namespace, causes taint, and is visible to every addon in the client.

Lua 5.0 note: The vararg ... syntax to capture addonName, addonTable works differently in 5.0. In 5.0, you must use the implicit arg table:

-- Lua 5.1+:
local addonName, addonTable = ...

-- Lua 5.0:
local addonName = arg[1]
local addonTable = arg[2]

-- Universal (works on 5.05.4):
local addonName  = select and select(1, ...) or (arg and arg[1])
local addonTable = select and select(2, ...) or (arg and arg[2])

Proxy / Facade Pattern

Wrap a complex subsystem behind a simplified interface:

local QuestieAPI = {}

function QuestieAPI:GetQuestName(questId)
    local QuestieDB = QuestieLoader:ImportModule("QuestieDB")
    local data = QuestieDB and QuestieDB:GetQuest(questId)
    return data and data.name or "Unknown Quest"
end

Functional Patterns

Lua supports higher-order functions. Use them for data transformations:

-- map: Transform each element (uses ipairs for 5.0 compat)
local function map(t, fn)
    local result = {}
    for i, v in ipairs(t) do result[i] = fn(v, i) end
    return result
end

-- filter: Keep elements matching predicate
local function filter(t, predicate)
    local result = {}
    for i, v in ipairs(t) do
        if predicate(v, i) then table.insert(result, v) end
    end
    return result
end

-- reduce: Fold elements into a single value
local function reduce(t, fn, initial)
    local acc = initial
    for i, v in ipairs(t) do acc = fn(acc, v, i) end
    return acc
end

-- Compose: Right-to-left function composition
-- Note: Uses arg.n in 5.0 since select() doesn't exist
local function compose(...)
    local fns = select and { ... } or arg
    local n = select and select("#", ...) or fns.n
    return function(x)
        for i = n, 1, -1 do x = fns[i](x) end
        return x
    end
end

Enumerations

Lua has no native enums. Simulate with constant tables. Optionally freeze with readOnly() (see coding-style.md):

local DebugLevel = {
    CRITICAL = 1,
    INFO     = 2,
    DEVELOP  = 3,
}

local QuestFlags = {
    SHARABLE    = 0x0008,
    DAILY       = 0x1000,
    WEEKLY      = 0x8000,
    AUTO_ACCEPT = 0x80000,
}

Weak Tables

Use weak references for caches that should not prevent garbage collection:

-- Values are weak: GC can collect them when no other references exist
local textureCache = setmetatable({}, { __mode = "v" })

function getTexture(path)
    local cached = textureCache[path]
    if cached then return cached end
    local tex = loadTexture(path)
    textureCache[path] = tex
    return tex
end
Mode Meaning
__mode = "v" Weak values — GC collects values with no other refs
__mode = "k" Weak keys — GC collects keys with no other refs
__mode = "kv" Both weak — GC collects either direction

Ephemeron tables (5.2+): In Lua 5.2, weak tables with weak keys behave as ephemeron tables. In an ephemeron table, a value is considered reachable only if its key is reachable. If the only reference to a key comes through its value (e.g., a table used as both key and value), the entry is removed. This prevents reference cycles from keeping entries alive and is the correct behavior for cache patterns. In 5.0/5.1, a strong value could keep a weak-keyed entry alive even if the key was unreachable.

Mode reference:

Mode When entry is removed
"v" Value is garbage-collectable and no other references exist
"k" Key is garbage-collectable and no other references exist
"kv" Either key or value is independently collectable