Adding a Diagnostic
Diagnostics use a trait-based architecture. Each diagnostic pass lives in its own module under crates/wowlua_analysis/src/diagnostics/ and implements the DiagnosticPass trait. All diagnostic codes are defined centrally in crates/wowlua_analysis/src/diagnostics/mod.rs.
1. Define the diagnostic code
Add a DiagnosticDef constant to crates/wowlua_analysis/src/diagnostics/mod.rs:
pub const MY_NEW_CHECK: DiagnosticDef = DiagnosticDef { code: "my-new-check", severity: DiagnosticSeverity::WARNING };Also add it to the CATALOG array in the same file so it's recognized by the suppression and validation systems.
The code string is what users reference in @diagnostic disable:my-new-check and in .wowluarc.json - suppression works automatically by matching this string.
2. Create the module
Create crates/wowlua_analysis/src/diagnostics/my_new_check.rs. The DiagnosticPass trait has four methods, all with default implementations - implement whichever fits your diagnostic:
run(): full-analysis pass
Best for diagnostics that walk the IR (symbols, functions, expressions) after type resolution:
use crate::analysis::AnalysisResult;
use crate::syntax::tree::SyntaxTree;
use crate::types::SymbolIdentifier;
use super::{DiagnosticPass, WowDiagnostic};
pub struct MyNewCheck;
impl DiagnosticPass for MyNewCheck {
fn run(&self, analysis: &AnalysisResult, _tree: &SyntaxTree, diags: &mut Vec<WowDiagnostic>) {
// Iterate this file's symbols through the IR query surface
for (_, sym) in analysis.local_symbols() {
let SymbolIdentifier::Name(name) = &sym.id else { continue };
for ver in &sym.versions {
if /* condition */ {
super::MY_NEW_CHECK.emit(
diags,
format!("'{name}' is wrong because ..."),
ver.def_node.start as usize,
ver.def_node.end as usize,
);
}
}
}
}
}The IR arenas are private, so read them through the query surface on AnalysisResult: local_symbols(), local_functions(), local_tables(), and local_exprs() iterate the file's own entries, and sym(), func(), table(), expr(), and scope() resolve an index, including indices into the shared WoW API stubs.
visit_node(): AST walk pass
Best for diagnostics that check syntax patterns. Called once per AST node during a shared tree walk:
use crate::analysis::AnalysisResult;
use crate::ast::{AstNode, BinaryExpression};
use crate::syntax::SyntaxKind;
use super::{DiagnosticPass, WowDiagnostic};
pub struct MyNewCheck;
impl DiagnosticPass for MyNewCheck {
fn visit_node(&self, node: crate::syntax::SyntaxNode<'_>, _analysis: &AnalysisResult, diags: &mut Vec<WowDiagnostic>) {
if node.kind() != SyntaxKind::BinaryExpression { return; }
let Some(bin) = BinaryExpression::cast(node) else { return };
// Check the node and emit diagnostics
}
}run_inject(): inject-field pipeline
For diagnostics that participate in the type-mismatch → inject-field pipeline. Used by type_mismatch, return_mismatch, field_type_mismatch, assign_type_mismatch, and inject_field. You probably don't need this unless your diagnostic feeds the inject-field system.
runs_in_meta(): @meta files
Passes are skipped in @meta (declaration-only stub) files by default. Override this to return true only for a run() pass that validates the annotations themselves, such as one flagging an undefined type name or a malformed annotation.
3. Register the module
In crates/wowlua_analysis/src/diagnostics/mod.rs:
- Add
mod my_new_check;to the module declarations at the top - Add your pass to the appropriate list in
run_all():run_passes: forrun()implementations (most diagnostics)node_passes: forvisit_node()implementations (AST walks)inject_passes: forrun_inject()implementations (type-mismatch pipeline)
let run_passes: &[&dyn DiagnosticPass] = &[
// ... existing passes ...
&my_new_check::MyNewCheck,
];4. Add a test
Add test assertions to the appropriate test file. If your diagnostic is default-on, add it to tests/diagnostics/test.lua. If it's default-off, create a subdirectory with a .wowluarc.json that enables it.
-- Test that the diagnostic fires
local x = badThing()
-- ^ diag: my-new-checkThe harness fails on any diagnostic without a diag: assertion, so valid code needs no assertion to prove the diagnostic stays quiet (see Testing).
Run cargo test --workspace to verify.
5. Document it
- Add the diagnostic to the table in
docs/reference/diagnostics.md. - Add a section to
docs/guide/diagnostics.mdunder the matching category. - Add the code to the
$defs.diagnosticCode.exampleslist ineditors/vscode/wowluarc.schema.json, which drives.wowluarc.jsoncompletion. - If it's default-off, also add it to the default-off list in that schema's
diagnostics.enabledescription and to the "Diagnostics disabled by default" list indocs/guide/configuration.md.
Severity guidelines
| Severity | Use for |
|---|---|
| Error | Analysis failures, not code findings (safety-limit) |
| Warning | Likely bugs, type errors, annotation problems |
| Hint | Code quality suggestions, unused variables, style |
Default-off diagnostics
Some diagnostics are too noisy for unannotated codebases. Make them default-off by adding the code to DEFAULT_DISABLED_CODES in crates/wowlua_analysis/src/diagnostics/mod.rs. Users opt in via diagnostics.enable in .wowluarc.json.
Examples: need-check-nil, implicit-nil-return, unknown-param-type.
Hybrid modules
Some diagnostic modules are "hybrid" - they implement DiagnosticPass for the post-analysis phase AND export pub helper functions called from build_ir.rs or resolve.rs during IR construction. Both roles share the same DiagnosticDef constants from the catalog. This is used when a diagnostic needs to emit during IR construction (e.g. when specific AST context is available) and also during post-analysis passes.
