Skip to content

Development

Building from source

bash
git clone https://github.com/TradeSkillMaster/wowlua-ls.git
cd wowlua-ls
cargo build --release

The binary is at target/release/wowlua_ls. Configure your editor to run it as an LSP server over stdio for Lua files.

By default, WoW API stubs are embedded in the binary so it works as a standalone executable. To build a smaller binary that loads stubs from a stubs/ directory next to the executable instead:

bash
cargo build --release --no-default-features

Project structure

src/
├── main.rs              # CLI entry point (check, evaluate, test-query, dump-stubs, LSP)
├── types.rs             # Core IR: ValueType, Expr, Symbol, Scope, Function, TableInfo
├── ast.rs               # AST node definitions and casts over SyntaxNode
├── config.rs            # .wowluarc.json loading
├── flavor.rs            # Retail/classic/classic_era flavor bitmask
├── stub_gen.rs          # Stub generation from WoW API primary sources
├── annotations/
│   ├── mod.rs           # Core types (AnnotationType, ClassDecl, AliasDecl, etc.)
│   ├── annotation_types.rs    # Type expression parsing and formatting
│   ├── annotation_scanning.rs # Shared helpers, type conversion
│   ├── scan_globals.rs  # Workspace-wide global scanning
│   ├── scan_defclass.rs # @defclass annotation processing
│   └── scan_built_name.rs     # @built-name annotation processing
├── analysis/
│   ├── mod.rs           # Ir struct, two-tier lookups, scope-chain walking
│   ├── prescan.rs       # Phase 0: class/alias pre-scan, annotation type resolution
│   ├── build_ir.rs      # Phase 1: AST walk → scopes, symbols, expressions
│   ├── lower_expression.rs    # Expression lowering (identifiers, calls, operators)
│   ├── narrowing.rs     # Type narrowing from control flow guards
│   ├── resolve.rs       # Phase 2: fixpoint type resolution loop
│   ├── resolve_call.rs  # Function call resolution, generic binding, backward inference
│   ├── checks.rs        # Diagnostic check orchestration
│   ├── queries.rs       # LSP queries: hover, definition, completion, signature help
│   └── semantic_tokens.rs
├── diagnostics/
│   ├── mod.rs           # DiagnosticDef catalog, DiagnosticPass trait, run_all()
│   └── *.rs             # 39 diagnostic pass modules (60 diagnostic codes)
├── pre_globals/
│   ├── mod.rs           # PreResolvedGlobals — WoW API stubs, shared across files
│   └── build_on_stubs.rs      # Workspace incremental builds on precomputed stubs
├── syntax/
│   ├── parser.rs        # Recursive descent + Pratt parser
│   ├── tree.rs          # Arena-based syntax tree
│   ├── lexer.rs         # Tokenization
│   └── syntax_kind.rs   # SyntaxKind enum
└── lsp/
    ├── main_loop.rs     # LSP server loop, request handlers
    ├── diagnostics.rs   # Diagnostic publishing with suppression
    └── uri.rs           # URI/path conversion utilities

How analysis works

Each file goes through three phases:

Phase 0: Pre-scan (prescan.rs)

Imports external classes and aliases from the shared PreResolvedGlobals, then scans the file for local @class and @alias declarations. This establishes the type namespace before any expressions are analyzed.

Phase 1: Build IR (build_ir.rs)

Walks the AST and creates the intermediate representation:

  • Scopes — nested lexical scopes tracking variable visibility
  • Symbols — local variables, parameters, globals (with version tracking for reassignment)
  • Functions — parameter/return annotations, overloads, generic constraints
  • Tables — fields, class names, parent classes, metatable links
  • Expressions — lowered to Expr nodes by lower_expression.rs (symbol refs, field access, function calls, literals, etc.)

Type narrowing from control flow (nil guards, type checks, flavor filtering) is handled by narrowing.rs during this phase.

Phase 2: Resolve types (resolve.rs)

A fixpoint loop that iterates until no more types change. Each iteration resolves expressions by walking their dependencies — if a symbol's type depends on a function call, the function's return type must be resolved first. The loop handles:

  • Function call return types (via resolve_call.rs)
  • Metatable __index chains
  • Generic type parameter binding
  • Nil narrowing (from guards analyzed in Phase 1)
  • Backward parameter type inference
  • Correlated return overload synthesis

Diagnostic passes

After type resolution, run_all() in src/diagnostics/mod.rs orchestrates all diagnostic passes. Passes are organized into three groups that run sequentially:

  1. run passes — walk the IR to check resolved types (type mismatches, undefined globals, missing fields, etc.)
  2. visit_node passes — walk the AST for syntax-level checks (empty blocks, not precedence, unused varargs)
  3. run_inject passes — the type-mismatch → inject-field pipeline, where type mismatch passes produce excess field info and inject_field consumes it last

All 60 diagnostic codes are defined as DiagnosticDef constants in mod.rs. Each pass module implements the DiagnosticPass trait, emitting diagnostics via CONSTANT_NAME.emit(diags, message, start, end). See Adding a Diagnostic for the full walkthrough.

Hover regression testing

The dump-types subcommand outputs the hover type for every identifier in a project. The output is deterministic and sorted by file, making it suitable for diffing against a saved baseline to catch hover/type regressions.

bash
wowlua_ls dump-types path/to/addon --with-stubs

Each line has the format:

File.lua:line:col name → type

Identifiers that don't resolve show → <none>.

Save a baseline, then diff after making changes:

bash
# Save baseline
wowlua_ls dump-types path/to/addon --with-stubs > baseline.txt

# After changes, check for regressions
wowlua_ls dump-types path/to/addon --with-stubs | diff baseline.txt -

Any new <none> entries or changed types indicate a regression.

Fuzzing

Three cargo-fuzz targets live in fuzz/, covering the three main layers:

TargetWhat it exercises
fuzz_lexerTokenization via lexer::lex_all()
fuzz_parserRecursive descent parser via Parser::new(s).parse()
fuzz_analysisFull pipeline: parse → Analysis::new_with_treeresolve_types()

All targets require valid UTF-8 input (non-UTF-8 bytes are rejected early). The fuzz_analysis target uses PreResolvedGlobals::empty() (no WoW API stubs) to keep iterations fast, and caches it in a OnceLock so it's built only once.

Setup

bash
rustup install nightly    # cargo-fuzz requires nightly
cargo install cargo-fuzz

Running

bash
# Run a target against its seed corpus (runs until Ctrl-C)
cargo +nightly fuzz run fuzz_lexer    fuzz/corpus/fuzz_lexer
cargo +nightly fuzz run fuzz_parser   fuzz/corpus/fuzz_parser
cargo +nightly fuzz run fuzz_analysis fuzz/corpus/fuzz_analysis

# Run for a fixed duration (seconds)
cargo +nightly fuzz run fuzz_analysis fuzz/corpus/fuzz_analysis -- -max_total_time=600

Run fuzz_analysis after changes to the parser or analysis pipeline — it covers the most code and is most likely to find issues. The lexer and parser targets are useful after changes to lexer.rs or parser.rs specifically.

When it finds something

Crashes and timeouts are saved to fuzz/artifacts/<target>/. To minimize a crashing input to its smallest reproducing form:

bash
cargo +nightly fuzz tmin fuzz_analysis fuzz/artifacts/fuzz_analysis/<crash-file>

The minimized input should be added as a regression test or kept in the seed corpus.

Seed corpus

Hand-written seed files live in fuzz/corpus/<target>/ (small .lua files covering representative syntax). Auto-generated corpus entries (hex-named files created by libFuzzer during runs) are gitignored.

The two-tier index space

External globals (WoW API stubs) use indices ≥ EXT_BASE (1,000,000). Per-file locals use indices below that. This means lookups like sym(), func(), and table() route through an idx >= EXT_BASE check — external data lives in the shared PreResolvedGlobals while local data is on the per-file Analysis.

This avoids cloning ~9,000 external symbols per file, and external indices are stable across files, which is what makes workspace-wide find-references and rename work.

Workspace startup

Before any files are analyzed, the LS runs four scanning passes to collect cross-file information:

  1. Pass 1 — Scan annotations and file-level globals (@class, @alias, top-level functions)
  2. Pass 2 — Discover @defclass factory calls and extract constructor fields
  3. Pass 3 — Discover @built-name classes from builder-pattern call sites
  4. Pass 4 — Scan colon-method bodies for typed self.field assignments

This feeds into PreResolvedGlobals::build(), which runs five phases of its own to register classes, populate fields, build methods, resolve inheritance (fixpoint loop for deep hierarchies), and set up global functions.