Skip to content

Why wowlua-ls ​

WoW addon development has a tooling problem. Lua is dynamically typed, WoW's API is enormous, and the patterns the community uses (factory functions, builder chains, mixins, multi-return APIs) are exactly the patterns that generic Lua tooling struggles with.

LuaLS is a good general-purpose Lua language server. But it wasn't built for WoW, and it shows. wowlua-ls was built from scratch specifically for WoW addon development, and it's opinionated about what matters.

The short version

Most of what follows works out of the box, no annotations required: WoW API stubs, event payloads, XML frames, .toc files, mixins, and multi-flavor guards. It reads the same ---@ syntax as LuaLS, so migrating costs you nothing. Skip straight to the full comparison.

WoW-native, not bolted on ​

9,000+ WoW API stubs, zero setup ​

wowlua-ls ships with complete WoW API stubs: retail, classic, classic era, and WoW Forever. Every function, frame type, enum, and global variable is typed. Hover over CreateFrame and you get the full signature. Access a field on a Frame and get completion for every method.

The stubs are precomputed and compressed into the binary. They load instantly at startup and are shared across all files - never re-parsed per file.

Event handlers typed automatically ​

Every WoW addon writes event handlers. wowlua-ls types them end-to-end (self, the event name, and the per-event payload) with no manual annotations needed in your code:

lua
local f = CreateFrame("Frame")
f:SetScript("OnEvent", function(self, event, ...)
    -- self: Frame (receiver's actual type)
    -- event: string (FrameEvent)

    if event == "ENCOUNTER_END" then
        local encounterID, encounterName, difficultyID, groupSize, success = ...
        -- encounterID: number, encounterName: string, etc.
        -- All typed from the event payload declaration
    end
end)

This works for all 1,000+ WoW events. The "OnEvent" literal selects the overload that types the handler callback. Inside the handler, narrowing event to a specific string activates per-event payload typing on ....

The same system works for "OnUpdate" (typed elapsed: number), "OnClick" (typed button: string, down: boolean), and all other script types, each with the correct parameters.

For custom event systems, the @event annotation + ...params<EventType> projection gives you the same experience. See the Events guide.

XML frame scanning ​

wowlua-ls automatically scans your .xml files and understands WoW's XML frame system:

  • Virtual templates become @class declarations. Use them in annotations and get completions on their fields
  • Named frames (name="MyAddonFrame") become typed globals - no more false undefined-global warnings
  • parentKey children become typed fields on the parent frame
  • inherits and mixin attributes populate the class hierarchy
  • $parent name resolution in frame names works correctly
  • Intrinsic elements (intrinsic="true") define custom element types
  • KeyValue elements with type declarations (string, number, boolean) are typed

No annotations needed. wowlua-ls reads the XML and infers the types.

TOC file support ​

Full language server features for .toc files:

  • Hover documentation on all standard TOC directives
  • Completions for directive names and context-aware values
  • Go-to-definition on file paths - jump to the .lua or .xml file
  • Diagnostics for missing Interface version, duplicate headers, and nonexistent files
  • SavedVariables auto-detection: variables declared in .toc are automatically registered as allowed globals

Flavor filtering ​

If your addon targets multiple WoW versions, declare your targets and get warnings on APIs that don't exist in all of them:

json
{ "flavors": ["retail", "classic"] }
lua
-- warning: C_WeeklyRewards.HasAvailableRewards is not available in classic
C_WeeklyRewards.HasAvailableRewards()

if WOW_PROJECT_ID == WOW_PROJECT_MAINLINE then
    C_WeeklyRewards.HasAvailableRewards() -- OK, narrowed to retail
end

Flavor guards are understood automatically: WOW_PROJECT_ID checks, boolean flag patterns, and the @flavor-narrows annotation all suppress false warnings in guarded code.

Secret values ​

In retail 12.x, APIs like UnitHealth return secret values during combat and other restrictions, and comparing or doing math on one is a Lua error that only shows up in those situations. wowlua-ls knows which API results may be secret and flags the operations that would error:

lua
local hp, maxHp = UnitHealth("target"), UnitHealthMax("target")
local pct = hp / maxHp -- warning: secret-arithmetic

if canaccessallvalues(hp, maxHp) then
    pct = hp / maxHp -- OK, narrowed to number
end

See the Secret Values guide.

Mixin and template support ​

CreateFrame, Mixin, CreateFromMixins, and CreateAndInitFromMixin return intersection types automatically:

lua
local f = CreateFrame("Button", nil, nil, "BackdropTemplate")
-- f: Button & BackdropTemplate
-- All methods from both Button and BackdropTemplate are available

Smart type inference ​

Metatable inference ​

wowlua-ls understands setmetatable + __index chains without any annotations:

lua
local MyClass = {}
MyClass.__index = MyClass

function MyClass.new()
    return setmetatable({}, MyClass)
end

function MyClass:greet()
    return "hello"
end

local obj = MyClass.new()
obj:greet() -- works, greet() resolved through __index chain

This extends to chained metatables (grandparent resolution), self-referential metatables (mt.__index = mt), __call metamethods (callable tables), and operator metamethods (__add, __sub, etc.) with correct return types.

Correlated narrowing ​

Check one return value, and the LS narrows the rest. This is automatic - no annotations needed in most cases:

lua
---@return (string name, number level) | (nil, nil)
function getPlayer(id) ... end

local name, level = getPlayer(id)
-- name: string | nil, level: number | nil

if name then
    -- Both narrowed: name is string, level is number
    -- No false warnings on level even though you only checked name
    print(name .. " is level " .. level)
end

wowlua-ls also infers correlated returns from your function bodies. If every branch either returns all values or all nils, the LS detects the pattern and narrows them together - no @return tuple-union annotation needed.

Nil safety ​

Nil tracking works with every narrowing pattern:

  • if x then / if x ~= nil then
  • Early exits: if not x then return end
  • assert(x) and custom assertion functions
  • type(x) == "string" guards (on symbols and field chains)
  • while post-conditions
  • x = x or default coalescing
  • Compound and/or expressions
  • Field-presence narrowing: if obj.title then narrows a union to members where title is defined
  • Custom type guard functions via @type-narrows

Backward parameter inference ​

wowlua-ls can infer parameter types from how they're used in the function body:

lua
local function double(x)
    return x * 2  -- x inferred as number (used in arithmetic)
end

This also works through typed function calls. If a parameter is passed to a function that expects number, the parameter is inferred as number. Explicit @param annotations always take precedence.

List element inference ​

A list built by inserting into an empty table takes its element type from the inserted values, so its elements keep hover, completion, and diagnostics:

lua
---@param item Item
local function collect(item)
    local list = {}
    table.insert(list, item) -- or tinsert(list, item), or list[#list + 1] = item
    for _, v in ipairs(list) do
        print(v.name) -- v: Item
    end
    return table.remove(list) -- Item
end

Inserting values of different types gives a union element type. A list annotated with ---@type Item[], or created with initial elements ({ a, b }), keeps that element type, and inserts are checked against it.

A list held in a field (self.items = {}, ns.queue = {}) is typed the same way by what's inserted or appended (t[#t + 1] = v) through the field, including one created where it's first added to (self.items = self.items or {}). A table that's also written with other keys through the field is used as a map rather than a list, and keeps no element type. Other files see these element types too: through a function returning the list, or through the field itself (holder.items[1]).

Generics that actually work ​

LuaLS supports basic @generic but struggles with more advanced patterns. wowlua-ls's generic system handles parameterized classes, constrained type parameters, backtick factory annotations, and function-type projections:

lua
---@class Pool<T>
---@field _items T[]
local Pool = {}

---@return T
function Pool:Get()
    return table.remove(self._items)
end

---@type Pool<Frame>
local framePool = setmetatable({ _items = {} }, { __index = Pool })
local frame = framePool:Get() -- frame: Frame (T resolved from the class)

Class-level generics propagate through method calls automatically - no need to redeclare @generic T on every method. Constrained generics (@generic T: Frame) ensure the type parameter satisfies a base type. And backtick annotations (`T`) let factory functions resolve a class from a string literal argument:

lua
---@class Dog
---@field name string

---@generic T
---@param name `T`
---@return T
local function CreateClass(name) return {} end

local Dog = CreateClass("Dog") -- T resolves to the Dog class

See the Generics guide for the full story.

Builder patterns and factory functions ​

If your addon uses a schema builder, a class factory, or any pattern where methods progressively build a type, wowlua-ls can track it. The @builds-field, @built-name, and @defclass annotations give the LS enough information to provide full completion and type checking on the result:

lua
local schema = Schema.Create("PlayerState")
    :AddString("name")
    :AddNumber("level")
    :AddDeferred("guild", Guild)
    :Build()

schema.name   -- string
schema.level  -- number
schema.guild  -- Guild!

Every field is typed. Every access is checked. The type is named PlayerState and can be referenced in annotations elsewhere.

80+ diagnostics ​

wowlua-ls ships with 80+ diagnostics organized across several categories:

  • Type safety: type-mismatch, return-mismatch, field-type-mismatch, assign-type-mismatch, generic-constraint-mismatch, invalid-op
  • Nil checking: need-check-nil, nil-index, nil-table-key, missing-return-value, implicit-nil-return
  • Function calls: missing-parameter, redundant-parameter, cannot-call, discard-returns
  • Globals and fields: undefined-global, undefined-field, inject-field, create-global, missing-fields
  • Annotation correctness: undefined-doc-class, undefined-doc-name, malformed-annotation, circle-doc-class, and more
  • Code quality: unused-local, unused-function, shadowed-local, unreachable-code, deprecated, empty-block, trailing-space
  • WoW-specific: wrong-flavor-api, secret-comparison, secret-arithmetic, secret-table-key, secret-access, access-private, access-protected

Each diagnostic is individually configurable: silence it per-line or per-file with @diagnostic, or enable, disable, or change its severity project-wide in .wowluarc.json. Several stricter checks are off by default and opt-in.

See the full diagnostic reference.

Diagnostic plugins ​

Write custom diagnostics as Lua scripts to enforce your project's conventions:

lua
-- .wowluarc.json: { "plugins": ["checks/no-direct-db-access.lua"] }

-- checks/no-direct-db-access.lua
for _, access in ipairs(ctx:find_field_reads("db")) do
    if access.receiver == "MyAddonDB" then
        ctx:emit({
            message = "Use GetSetting() instead of direct DB access",
            severity = "warning",
            range = access.range
        })
    end
end

Plugins run in a sandboxed environment with access to local variables, field reads/writes, method calls, and event declarations. They're instruction-limited and fault-tolerant: a crashing plugin is automatically disabled after repeated failures.

CI-ready CLI ​

Lint your addon from the command line:

bash
wowlua_ls check path/to/addon
wowlua_ls check path/to/addon --severity hint

Exit code is 1 if any diagnostics are found. Drop it straight into your CI pipeline.

It's fast ​

wowlua-ls is written in Rust. Workspace scanning is parallel (via rayon). The WoW API stubs are precomputed and compressed, loaded once at startup, shared across all files, never re-parsed. Per-file analysis runs in three phases with a fixpoint resolution loop, so even complex cross-file type chains converge quickly.

Coming from LuaLS ​

wowlua-ls uses the same ---@ annotation syntax as LuaLS. Your existing @param, @return, @class, @field, @type, @alias, @generic, @overload, @deprecated, @nodiscard, and @diagnostic annotations all work. You don't need to rewrite anything.

What you gain:

FeatureLuaLSwowlua-ls
WoW API stubs (retail + classic + classic era + Forever)Via addon9,000+ built in
Event handler payload typingNo1,000+ events with typed ... payloads
XML frame/template scanningNoAutomatic: templates, named frames, parentKey fields
TOC file editingNoHover, completions, go-to-def, diagnostics
Flavor-specific API warningsNowrong-flavor-api with WOW_PROJECT_ID guards
Secret-value checks (retail 12.x)Nosecret-* diagnostics with issecretvalue / canaccessvalue narrowing
Mixin/template intersection typesNoCreateFrame + Mixin return A & B
Parameterized classesPartial@class Foo<T> with method propagation
Generic constraintsPartial@generic T: Base, enforced at call sites
Function-type projectionsNoparams<F> / returns<F>
Metatable __index inferencePartialFull chain, __call, operator metamethods
Multi-return correlated narrowingNoAutomatic + inferred from function bodies
Builder pattern typingNo@builds-field + @return built
Class factory patternsNo@defclass with parameterized parents
Custom type guardsNo@type-narrows annotation
Diagnostic pluginsNoCustom Lua scripts for project conventions
CLI linting for CI--check writes a report filewowlua_ls check prints diagnostics, exits 1 on findings
Callable tablesAnnotate with @overload / @operator callInferred from setmetatable + __call
Operator metamethodsAnnotate with @operatorInferred from __add, __sub, etc.
Correlated nil fieldsNo@correlated annotation
Correlated local inferenceNoAutomatic from branch patterns
Lateinit fields (T!)NoNon-nil assertion with nil assignment
Tuple-union returnsNo(A, B) | (C, D) syntax
Opaque type aliasesNo@alias (opaque) ID number
Backward param inferenceNoInfers types from function body usage
Inlay hints (6 categories)PartialParam names, variable/return/param types, for-loop vars, chained returns
Code lensPartialUsages, implementations, overrides

Philosophy ​

wowlua-ls is opinionated. It believes:

  • The LS should carry its weight. You can always add annotations, but you shouldn't have to annotate things the LS can figure out on its own. Backward parameter inference, metatable resolution, and correlated return detection all exist so you can focus your annotations where they matter most.
  • Nil is the most important type to track. Most WoW addon bugs at runtime are nil errors. The narrowing system is deliberately thorough because catching nil bugs at edit time is the single highest-value thing a language server can do.
  • WoW patterns deserve first-class support. Mixins and templates, class factories, addon namespaces, metatable OOP, XML frame definitions, flavor guards - these aren't edge cases, they're how addons are built. The LS should understand them without workarounds.
  • Diagnostics should be actionable. Every warning should either point to a real bug or a real improvement. Noisy diagnostics get disabled. That's why several checks are off by default and the severity system is fully configurable.