Skip to content

Diagnostic Plugins ​

wowlua-ls supports custom diagnostic plugins written in Lua. Plugins can query analysis results (local variables, field accesses, method calls) and emit their own diagnostics. This lets you enforce project-specific conventions that the built-in diagnostics don't cover.

Quick start ​

  1. Create a plugin file in your project:
lua
-- .wowlua-ls/no-global-assign.lua
return { ---@type wowlua.plugin.Plugin
    code = "no-global-assign",
    run = function(ctx)
        -- plugin logic here
    end,
}
  1. Register it in .wowluarc.json:
json
{
    "plugins": [".wowlua-ls/no-global-assign.lua"]
}
  1. Restart the language server. The plugin then runs on every file whose nearest .wowluarc.json lists it (see File placement). Diagnostics appear inline and in the problems panel, just like built-in diagnostics.

Plugin structure ​

A plugin file must return a table with two fields:

FieldTypeDescription
codestringUnique diagnostic code. Used in @diagnostic disable: my-code.
runfun(ctx: FileContext)Called once per file. Use ctx to query analysis and emit diagnostics.

The return table can be annotated with ---@type wowlua.plugin.Plugin for IDE support: completions, hover, and type checking on the plugin's own keys.

FileContext API ​

The ctx parameter provides methods to query the current file and emit diagnostics.

Properties ​

PropertyTypeDescription
ctx.uristringFull file URI (e.g. file:///path/to/file.lua)
ctx.file_namestringFile basename (e.g. Module.lua)

ctx:find_locals(opts?) ​

Find variables declared at file scope: file-level locals, plus every global the file assigns (including ones assigned inside functions).

lua
---@param opts? {name?: string, init?: "table"|"call"|"function"}
---@return LocalVar[]
  • name: only return variables with this exact name
  • init: only return variables whose initializer is a table constructor, function call, or function definition

ctx:find_event_declarations(type_name?) ​

Find @event declarations from the workspace. Returns all events aggregated across scanned files and stubs.

lua
---@param type_name? string  -- e.g. "WowEvent"
---@return EventDecl[]
  • type_name: only return events of this type (e.g. "WowEvent", "FrameEvent")

Emitting diagnostics ​

Four methods, one per severity level:

lua
ctx:error(range, message)    -- Error
ctx:warn(range, message)     -- Warning
ctx:hint(range, message)     -- Hint
ctx:info(range, message)     -- Information

Each takes a {start: integer, end: integer} byte range and a message string.

LocalVar API ​

A variable returned by find_locals.

Properties ​

PropertyTypeDescription
var.namestringVariable name
var.rangeRangeByte range of the definition site
var.initInitializer?The initializer expression, or nil

Methods ​

MethodReturnsDescription
var:field_reads()FieldAccess[]All var.field read accesses
var:field_writes()FieldAccess[]All var.field = value assignments
var:method_calls()MethodCall[]All var:method(args) and var.func(args) calls
var:method_defs()MethodDef[]All function var:method() end and function var.func() end definitions

Initializer API ​

The right-hand side of the variable's declaration or assignment.

Properties ​

PropertyTypeDescription
init.kindstringOne of "table", "call", "function", "literal", "other"

Methods ​

MethodReturnsDescription
init:fields()FieldInfo[]Fields of a table constructor, including fields assigned to the table later in the file (empty if not a table)
init:receiver()string?Receiver name in Foo:Bar() calls
init:method()string?Method/function name in Foo:Bar() or Foo.Bar() calls
init:args()ArgInfo[]Arguments of a call expression

Supporting types ​

FieldInfo ​

PropertyTypeDescription
namestringThe field name
rangeRangeByte range of the field definition
value_kindstringOne of "nil", "function", "table", "number", "string", "boolean", "expr" (any other expression)

FieldAccess ​

PropertyTypeDescription
field_namestringThe accessed field name
rangeRangeByte range of the access

MethodCall ​

PropertyTypeDescription
method_namestringThe called method name
rangeRangeByte range of the call
MethodReturnsDescription
call:args()ArgInfo[]Arguments passed to this call

MethodDef ​

PropertyTypeDescription
method_namestringThe defined method name
rangeRangeByte range of the definition
MethodReturnsDescription
def:params()Param[]Parameters in declaration order. For a colon method (function Obj:Do(action)), the implicit self is index 1 and action is index 2

Param ​

PropertyTypeDescription
namestringParameter name
indexinteger1-based parameter index
type_namestring?Annotation type name from @param (e.g. "ActionType"), or nil if untyped. Always nil for self
nilablebooleantrue if declared as @param name? or the type contains nil (e.g. `string
MethodReturnsDescription
param:comparisons()ComparisonInfo[]Equality comparisons (==/~=) involving this parameter

ArgInfo ​

PropertyTypeDescription
rangeRangeByte range of the argument
kindstringOne of "string", "number", "boolean", "nil", "table", "function", "other"
literalstring|number|boolean?Literal value if a constant, otherwise nil

ComparisonInfo ​

PropertyTypeDescription
rangeRangeByte range of the comparison
literalstring|number|boolean?Literal being compared against

EventDecl ​

PropertyTypeDescription
type_namestringEvent type (e.g. "WowEvent", "FrameEvent")
event_namestringEvent name (e.g. "ENCOUNTER_END", "OnLoad")
paramsEventParam[]Payload parameters
rangeRange?Byte range of the declaration in its source file (nil for built-in stubs)
source_uristring?File URI where declared (nil for built-in stubs)

EventParam ​

PropertyTypeDescription
namestringParameter name
type_namestringType name (e.g. "number", "string")
nilablebooleanWhether the parameter is optional
descriptionstring?Human-readable description

Range ​

PropertyTypeDescription
startintegerByte offset of range start (0-based)
endintegerByte offset of range end (exclusive)

Example: enforce method parameter conventions ​

This plugin flags a method whose first parameter is compared against string literals but doesn't have a comparison for every known action:

lua
return { ---@type wowlua.plugin.Plugin
    code = "unchecked-action",
    run = function(ctx)
        local expected = {"buy", "sell", "cancel"}
        for _, var in ipairs(ctx:find_locals({init = "table"})) do
            for _, def in ipairs(var:method_defs()) do
                -- For `function Obj:Method(action)`, params[1] is `self`
                local params = def:params()
                local action_param = params[1]
                if action_param and action_param.name == "self" then
                    action_param = params[2]
                end
                local comps = action_param and action_param:comparisons() or {}
                if #comps >= 2 then
                    -- Collect all compared literals
                    local checked = {}
                    for _, c in ipairs(comps) do
                        if c.literal then
                            checked[c.literal] = true
                        end
                    end
                    -- Check for completeness against the known set
                    for _, name in ipairs(expected) do
                        if not checked[name] then
                            ctx:hint(def.range,
                                "method '" .. def.method_name ..
                                "' doesn't handle action '" .. name .. "'")
                        end
                    end
                end
            end
        end
    end,
}

Example: warn on missing table fields ​

This plugin checks that tables assigned to a specific variable have a required field:

lua
return { ---@type wowlua.plugin.Plugin
    code = "missing-handler",
    run = function(ctx)
        for _, var in ipairs(ctx:find_locals({name = "config", init = "table"})) do
            local init = var.init
            if init then
                local has_on_click = false
                for _, field in ipairs(init:fields()) do
                    if field.name == "onClick" then
                        has_on_click = true
                        break
                    end
                end
                if not has_on_click then
                    ctx:warn(var.range, "'config' table is missing an 'onClick' handler")
                end
            end
        end
    end,
}

Sandbox ​

Plugins run in a restricted Lua 5.1 environment:

  • Standard library: the Lua 5.1 standard library minus os, io, debug, loadfile, dofile, require, load, and collectgarbage. Lua 5.1 has no goto, so skip loop iterations with nested if blocks.
  • Instruction limit: 1,000,000 instructions per file per plugin. Exceeding this logs an error and skips the plugin for that file.
  • Failure tolerance: A plugin that fails 5 times in a row is automatically disabled until the LS is restarted.

print() is available and outputs to the LS log via [plugin] prefix.

Suppressing plugin diagnostics ​

Plugin diagnostics can be suppressed with @diagnostic, just like built-in diagnostics:

lua
---@diagnostic disable-next-line: my-plugin-code
local x = something()

The LS recognizes plugin diagnostic codes automatically - no unknown-diag-code warning.

Limitations ​

  • Plugins only see file-scope variables: file-level locals and the globals the file assigns. Locals declared inside functions or blocks are not queryable. (find_event_declarations is the exception - it returns workspace-wide event metadata.)
  • Plugins cannot access resolved types. They work with structural patterns (field names, method calls, literal values), not the type system.
  • The Lua VM is shared across files within a session. Global assignments inside run() persist across calls. Avoid polluting the global scope - use local for all variables.
  • Plugin files are loaded when the language server starts. Adding a plugin to plugins or editing a plugin file requires restarting the language server to pick up the change.