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
- Create a plugin file in your project:
-- .wowlua-ls/no-global-assign.lua
return { ---@type wowlua.plugin.Plugin
code = "no-global-assign",
run = function(ctx)
-- plugin logic here
end,
}- Register it in
.wowluarc.json:
{
"plugins": [".wowlua-ls/no-global-assign.lua"]
}- Restart the language server. The plugin then runs on every file whose nearest
.wowluarc.jsonlists 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:
| Field | Type | Description |
|---|---|---|
code | string | Unique diagnostic code. Used in @diagnostic disable: my-code. |
run | fun(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
| Property | Type | Description |
|---|---|---|
ctx.uri | string | Full file URI (e.g. file:///path/to/file.lua) |
ctx.file_name | string | File 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).
---@param opts? {name?: string, init?: "table"|"call"|"function"}
---@return LocalVar[]name: only return variables with this exact nameinit: 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.
---@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:
ctx:error(range, message) -- Error
ctx:warn(range, message) -- Warning
ctx:hint(range, message) -- Hint
ctx:info(range, message) -- InformationEach takes a {start: integer, end: integer} byte range and a message string.
LocalVar API
A variable returned by find_locals.
Properties
| Property | Type | Description |
|---|---|---|
var.name | string | Variable name |
var.range | Range | Byte range of the definition site |
var.init | Initializer? | The initializer expression, or nil |
Methods
| Method | Returns | Description |
|---|---|---|
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
| Property | Type | Description |
|---|---|---|
init.kind | string | One of "table", "call", "function", "literal", "other" |
Methods
| Method | Returns | Description |
|---|---|---|
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
| Property | Type | Description |
|---|---|---|
name | string | The field name |
range | Range | Byte range of the field definition |
value_kind | string | One of "nil", "function", "table", "number", "string", "boolean", "expr" (any other expression) |
FieldAccess
| Property | Type | Description |
|---|---|---|
field_name | string | The accessed field name |
range | Range | Byte range of the access |
MethodCall
| Property | Type | Description |
|---|---|---|
method_name | string | The called method name |
range | Range | Byte range of the call |
| Method | Returns | Description |
|---|---|---|
call:args() | ArgInfo[] | Arguments passed to this call |
MethodDef
| Property | Type | Description |
|---|---|---|
method_name | string | The defined method name |
range | Range | Byte range of the definition |
| Method | Returns | Description |
|---|---|---|
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
| Property | Type | Description |
|---|---|---|
name | string | Parameter name |
index | integer | 1-based parameter index |
type_name | string? | Annotation type name from @param (e.g. "ActionType"), or nil if untyped. Always nil for self |
nilable | boolean | true if declared as @param name? or the type contains nil (e.g. `string |
| Method | Returns | Description |
|---|---|---|
param:comparisons() | ComparisonInfo[] | Equality comparisons (==/~=) involving this parameter |
ArgInfo
| Property | Type | Description |
|---|---|---|
range | Range | Byte range of the argument |
kind | string | One of "string", "number", "boolean", "nil", "table", "function", "other" |
literal | string|number|boolean? | Literal value if a constant, otherwise nil |
ComparisonInfo
| Property | Type | Description |
|---|---|---|
range | Range | Byte range of the comparison |
literal | string|number|boolean? | Literal being compared against |
EventDecl
| Property | Type | Description |
|---|---|---|
type_name | string | Event type (e.g. "WowEvent", "FrameEvent") |
event_name | string | Event name (e.g. "ENCOUNTER_END", "OnLoad") |
params | EventParam[] | Payload parameters |
range | Range? | Byte range of the declaration in its source file (nil for built-in stubs) |
source_uri | string? | File URI where declared (nil for built-in stubs) |
EventParam
| Property | Type | Description |
|---|---|---|
name | string | Parameter name |
type_name | string | Type name (e.g. "number", "string") |
nilable | boolean | Whether the parameter is optional |
description | string? | Human-readable description |
Range
| Property | Type | Description |
|---|---|---|
start | integer | Byte offset of range start (0-based) |
end | integer | Byte 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:
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:
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, andcollectgarbage. Lua 5.1 has nogoto, so skip loop iterations with nestedifblocks. - 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:
---@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_declarationsis 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 - uselocalfor all variables. - Plugin files are loaded when the language server starts. Adding a plugin to
pluginsor editing a plugin file requires restarting the language server to pick up the change.
