Skip to content

feat: LuaCATS type checks, type guards and callback wrappers - #12

Merged
ChatDisabled merged 36 commits into
Qbox-project:mainfrom
loaf-scripts:main
Sep 30, 2026
Merged

ChatDisabled merged 36 commits into
Qbox-project:mainfrom
loaf-scripts:main

Conversation

@loaf-scripts

Copy link
Copy Markdown
Contributor

Changes

This adds LuaCATS type checking to qbx-lua-ls, together with the annotation, inference and completion work it builds on. It is 35 commits, one per feature or fix, and each commit message describes its change in full; the summary below groups them.

New rules

Rule Default Reports
missing-parameter warning A call that leaves out an argument the function's annotations require. qbx-lua-ls also checks the payload of ---@callback wrapper calls against the registered handler.
undefined-doc-name warning A type name that no @class, @alias or @enum declares, or that only the other side declares.
missing-fields warning A table typed as a class that leaves out required fields.
assign-type-mismatch warning A value stored in a class field that the field's type does not take.
undeclared-field warning A field that a strict class does not declare.
return-type-mismatch, missing-return warning A function that does not return what its @return declares.
impossible-comparison info An == or ~= between types that share no value, such as GetState() == "invalid" for a function that returns "active"|"busy".
no-unknown off A parameter, local or loop variable that has no type.

missing-parameter is reported by qbx-lint and the server. The others need the types only the server indexes, so qbx-lint registers them for levels, qbxlint.toml and ---@diagnostic comments and leaves the check to qbx-lua-ls. The .luarc.json fallback maps the LuaLS codes of the same names.

The type checks only report clear cases: a different kind of value, or a literal the type does not list. Values of unknown type are skipped, and so is a local that is assigned again after its declaration.

Annotations

  • (server) and (client) attributes on @class, @field, @alias, @enum and @overload. Scoped declarations follow the manifest side of the file, and scoped overloads follow the side of the call.
  • ---@callback register|await|trigger [family] marks a function that wraps a callback system. Calls through it complete and navigate the registered names, show the handler's payload, and return the handler's @return types.
  • ---@class (strict), LuaLS's (exact), and (loose), with strict_classes = true in qbxlint.toml to make every workspace class strict.
  • @return false | (string, string) lists sets of returned values. This is a qbx extension of LuaCATS.
  • A @return line now declares every comma-separated value; before, only the first was read.
  • ---@field [1] number and other literal keys are kept one by one instead of replacing each other.

Inference

  • Type guards: a local loses nil and false after if not name then return end, inside if name then, after assert(name) and in similar guarded code. A guard on one value of local ok, err = f() narrows the others too.
  • A local keeps the type of the call that declares it, so local state = GetState() is "active"|"busy" rather than string.
  • Overloads are picked by the literal values a call passes.
  • An undocumented function's return type comes from every exit of its body, not the first return.
  • A doc comment above local handler = RegisterNetEvent(name, function(id) end) now documents the handler.
  • Smaller fixes: the receiver of a : call to a function defined with a dot, and a and b staying unknown when b is.

Completion and navigation

  • Call snippets that write out a callback argument, a function literal offered where an argument takes a function, and the string values a parameter lists.
  • Field names inside a table typed as a class.
  • Snippet strings use the formatter's quote_style; the new qbx/quote request lets editors do the same.
  • Enter continues a ---@ annotation line, through on-type formatting.
  • Find References on an event or callback name lists its registrations and triggers.

Configuration and stubs

  • An [[overrides]] entry can set side for scripts the manifest does not list.
  • The stubs gain fivem-lls-addon's type names, CfxLua functions that were reported as undefined, LuaLS's type alias, and the vector names type() returns.
  • Fixes: enums a module returns are indexed, each stub sees the stubs loaded before it, and open files keep their hints when the client spells the URI differently.

Things that change for existing projects

  • Seven of the new rules are warnings by default, so editors will show new findings. The CLI only gains missing-parameter.
  • Rule levels set in qbxlint.toml now take precedence over the client's diagnostics.rules; the client setting applies to the rules the file leaves alone.
  • Go to Definition on the name in a registration returns that registration, so VS Code lists the calls that trigger it.
  • ( and , are completion trigger characters. They only ever answer with the function-literal and value items.

Structure

The LuaCATS parser (luacats.rs, types.rs) moves from qbx_lua_ls into a new qbx_luacats crate, so the linter and the server share it. Documentation is updated in docs/rules.md, docs/reference.md, the server's README and docs/protocol.md.

The VS Code side is in a companion qbx-editor pull request.

Checks

  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets --locked -- -D warnings and cargo test --workspace --locked pass on Windows (287 tests). Not run on Linux or macOS.
  • Each feature has tests, most of them in crates/qbx_lua_ls/tests/lsp.rs.
  • The qbx-editor integration suite (165 tests) passes against a server built from this branch.
  • For the last two commits, whole-server diagnostics were compared before and after on four local server trees (Qbox, QBCore and ESX recipe deployments and one private server). impossible-comparison adds 26 findings and changes nothing else; all 26 were read and are real, for example a model hash compared with a string in qbx_adminmenu and not x == 'leo' in qb-policejob. Keeping the call's type in locals changes no diagnostics.

🤖 Generated with Claude Code

Report calls that leave out an argument the function's LuaCATS annotations
require, including functions defined in other files of the resource.

The LuaCATS parser moves out of qbx-lua-ls into a new qbx_luacats crate so
the linter and the language server share it. The server passes each file's
source to summarize, so its missing-parameter follows annotations in other
files as well.
A call like OnAction("keyPressed", function(key) end) fit both the declared
fun(action: string, ...) and the overload fun(action: "keyPressed", ...) by
kind alone, so the declared signature won and the callback parameters stayed
untyped. Literal arguments now count towards a signature's fit, a literal a
parameter does not list rules that signature out, and the best-fitting
signature wins instead of the first overload that beats the declared one.

A literal argument that the declared parameter lists among other values, as
an alias like `"jobUpdated"|"keyPressed"|string` does, would tie with the
overload that takes only that literal. That overload wins, so
`OnAction("jobUpdated", function(job) end)` types `job` from it.

Hovering the function name of a call shows the signature that call picks,
and signature help lists the declared signature with the overloads, the
picked one active.
Completing a function with a fun(...) or function parameter now also offers
a snippet that inserts the call with the callback written out, e.g.
TriggerCallback('event', function(...) end). Parameter names come from the
callback's type, a `...` or optional arguments after it get a stop after
`end`, and the snippet is left out when a `(` already follows the name, in
`function name`, for dot calls of `:` methods, and for names that already
have a hand-written snippet.
Handle textDocument/onTypeFormatting on newline. Enter at the end of a
`---@tag ...` line starts the next line with `---@` at the same indentation,
and Enter on a line that holds only `---@` removes it and keeps the cursor on
that line, so the code typed next stays attached to its annotations. Enter in
the middle of a line, plain comments and `---@` inside long strings are left
alone, and a bare `---` added by the editor counts as an empty line.
@Class, @field, @alias, @enum and @overload accept a (server) or (client)
attribute, alone or beside others such as (exact, server), and the parser
records the side. `---@alias (x) Name` and `---@overload (x) fun(...)` were
dropped before; a parenthesized overload type like (fun(): string) is still
read as a type. applies_on() tells whether a scoped declaration applies to
code on a side, with shared code seeing both.

missing-parameter ignores overloads scoped to the other side of the call,
using the same guard-aware side as the rest of the check.

In qbx-lua-ls, overloads scoped with @overload (server) or (client) only
compete for calls on that side, as decided by the file's manifest side and
any IsDuplicityVersion() or lib.context guard around the call, and signature
help lists only those. Classes, fields, aliases and enums scoped the same
way follow the manifest side of the file, so a client script no longer sees
a (server) class and each side gets its own type for a field declared once
per side. Shared files and files of an unknown side see both. Hovers on type
names prefer the declaration for the file's side.
`---@callback register|await|trigger [family]` marks a function that wraps a
callback system, the way lib.callback.register and lib.callback.await do.
Functions tagged with it form a callback system of their own. Their name,
handler or response function and payload come from the wrapper's
annotations: the first string parameter, the first function parameter and
the `...`.

Registrations are indexed like lib.callback.register, with the doc comment
above the registering statement typing the handler, so
AwaitServerCallback('name', ...) completes registered names, navigates and
hovers them, shows the handler's payload without `source` in signature help
and inlay hints, and returns the handler's @return types. A trigger wrapper
passes those types to its response function. The function type built from
the doc comment carries the role and family, so local aliases, table members
and `:` methods of a wrapper are recognized as well.

A function passed to a call also takes its parameter types from the @PARAM
lines directly above that call's statement. Open documents that tag a
wrapper are reindexed before the ones that call it, and a file the index did
not know yet, such as a new one opened in the editor that the manifest does
not list, gets its entry before its contents are indexed, so its calls to a
wrapper register right away rather than on its first edit.

After `---@callback ` completion offers the roles register, await and
trigger with a short description of each, and after the role the family
names that tagged wrappers in the index already use.

Functions tagged await or trigger get a call snippet even when they take no
function, e.g. AwaitServerCallback('$1'$2). The first stop is left empty
between the quotes of the callback name, wherever the wrapper takes it. When
the client lists editor.action.triggerSuggest under
capabilities.experimental.commands.commands, the item carries that command,
so the registered names are suggested right after insertion. Register
wrappers keep their name placeholder, since the name they take is new.
Without @return, a function's return type came from the first `return` that
had values, so a callback handler that returns early with a bare `return`
before `return FixAppearanceKeys(...)` read as returning Appearance instead
of Appearance?. Every `return` now counts: each position is the union of
what the returns pass there, and a return with fewer values and running past
the end of the body add nil.

A body cannot run past its end when it ends in `return`, `error(...)`, an
if/else whose branches all do, a `goto`, or a `while true do` or
`repeat ... until false` without a `break`, so waiting loops like
`while true do if ready then return value end Wait(0) end` keep the type of
the value they return. A value that cannot be inferred leaves its position
unknown rather than reading as nil, and `integer|number` reads as number.

Hovers that list the fields of an optional class now keep its `?`, as in
`Appearance? { ... }`.
Find References on the literal name in an event or callback registration or
trigger now returns every registration and every trigger call of that name
in the same family: native events, ox_lib, QB-Core and ESX callbacks, and
each ---@callback family stay separate. Without declarations, only the
triggers are listed.

Go to Definition on a registration's name now returns that registration
alone. VS Code answers a definition at the cursor with its references, so
Ctrl+click on RegisterNetEvent('name', ...) lists the calls that trigger it.
Definitions from a trigger still lead to the registrations.
Annotations written for LuaLS name the values type() returns `type`, as in
ox_lib's `---@PARAM expected type`. Declare it as an alias of lua_type.
`---@enum WheelType` above `return { Sport = 0, ... }`, as qbx_customs
declares its enums, was never indexed because enums were only read from
tables assigned to globals and top-level locals.
qbx-lua-ls warns on each type name in a LuaCATS annotation that no indexed
@Class, @alias or @enum declares, with "Undefined type or alias `Name`".
Built-in types, native handle types such as Vehicle, self, generic
parameters of the block, and those of generic classes declared in the file
count as known; the names a line declares and @see targets are not checked.

Type names are looked up for the file's side, so a client script that names
a (server) class, alias or enum is told "Type `QBJob` only exists on the
server, but this is a client script". Shared files and files of an unknown
side see both sides and are not reported.

Only the server indexes the declarations, so the linter registers the rule
for levels, config and ---@diagnostic comments and leaves the check to it,
like LuaLS's code of the same name that .luarc settings now map to.

The LuaCATS parser gains referenced_type_names, the type names a doc line
refers to without the name it declares or @see targets, and
declared_generics, the parameters of @Generic and @Class Name<T>.
qbx-lua-ls warns on a table constructor whose type is a LuaCATS class when
it leaves out fields the class requires: "Missing required fields in type
`Test`: `test`". Tables are checked where their type is known: `---@type`
locals and assignments, arguments to class-typed parameters, and the tables
such fields hold. Fields of parent classes count, optional ones (`?`, nil,
any) and fields scoped to the other side do not, and a class that several
resources declare under one name without one being visible is not checked.

Only the server knows the classes, so the linter registers the rule for
levels, config and ---@diagnostic comments, and maps LuaLS's code of the
same name.
Inside a table constructor whose type is a LuaCATS class, completion offers
the @fields it does not set yet, with each field's type as the detail and
its description as documentation, and inserts `name = `. Tables are typed
by `---@type` on a local or assignment, a class-typed parameter, `return` in
a function documented with @return, or a class-typed field of another such
table. Before, only tables passed as call arguments were completed.

The missing-fields module becomes class_tables, which finds the class-typed
tables of a file once for both features.
`a and b` is `b` whenever `a` holds a value. When `b` could not be
inferred, the union with `false` dropped it and typed the expression as
boolean, so `local slot = inv and inv.items?[id]` hovered as boolean and a
field check would read it as one.
qbx-lua-ls warns on a value stored in a field of a LuaCATS class that the
field does not take, e.g. "Cannot assign `integer` to field `test` of type
`string`". Table constructors typed as the class are checked, and so are
assignments such as `abc.test = 5` or `self.name = nil`. Only clear cases
count: a different kind of value, such as a number, string, boolean, table,
function or nil, or a literal the field does not list, like 'other' for
'male'|'female'. nil clears a field only when its type allows it. A table
for a class-typed field is checked as a table of that class.

A field without its own @field is typed by the value of an index that takes
its key, so `other = true` fails `---@field [string] number` and array
entries are checked against `[integer]`. Tables for index-typed keys are
checked as tables of their class too. Type::is_literal tells a literal-keyed
index such as `---@field [1] number` apart, which is left unchecked since
only the last such line is kept.

A local that is assigned again after its declaration is skipped, since its
declared type may not be what it holds; this avoids reports such as
`evidenceId = currentCasing` for a casing id that starts out as nil.

Type names are now looked up the way the declaring file sees them, since
resources declare the same names differently: ox_fuel's @Class State is not
qbx_vehicles' @enum State. A name that file cannot tell apart is not
checked. missing-fields and field completion use the same lookups.

Only the server knows the classes, so the linter registers the rule for
levels, config and ---@diagnostic comments, and maps LuaLS's code of the
same name.
A class marked `---@Class (strict)` or LuaLS's `(exact)` only takes the
fields it declares. qbx-lua-ls reports a name or key such a class does not
take, e.g. "Field `other` is not declared in strict class `Test`": set by a
table constructor typed as the class, set through a value of it by an
assignment or `function value:name()`, or read from one, like `abc[1]` when
the class only has a `[string]` index.

A field counts as declared by an @field of the class or a parent, or when
it is set on the table the ---@Class annotation declares, such as
`function Test:greet()`. The indexer now marks members set through values
typed as the class instead, like `abc.other = true`, so they do not declare
anything. A `[string]` index, or a parent that is no class, takes any name.

`strict_classes = true` in qbxlint.toml makes every ---@Class strict unless
it is marked `(loose)`, so projects can require declared fields everywhere
without annotating each class. It only applies to classes declared in
workspace files outside exclude and ignore_diagnostics; third-party
resources keep their classes loose unless they say (strict) themselves. The
check is skipped while no class is strict.

The parser records the attribute as DocClass.strict: Some(true),
Some(false), or None when the configured default decides. It combines with
a side, as in `(exact, server)`. Only the server knows the classes, so the
linter registers the rule for levels, config and ---@diagnostic comments,
and the reference describes when a field counts as declared.
A function documented with @return has to return values of those types.
return-type-mismatch reports a returned value its @return type does not
take, e.g. "Cannot return `integer` as return value Qbox-project#1 of type `string`",
with a trailing `...T` covering every further value. missing-return reports
a `return` with fewer values than required and, at `end`, a body that can
run past its end, e.g. an `if` without `else` or an empty body like
`function Add(a, b) end`. A value is required unless its type allows nil.
Only files marked `---@meta` keep their empty bodies unchecked.

Return types are taken from the doc comment of every function the @PARAM
lines apply to, including `M.f = function() end` and the functions passed
to the call below the comment, so the handler of
`RegisterServerCallback('add', function(a, b) end)` is checked as well.
Returned tables typed as a class get missing-fields, assign-type-mismatch
and undeclared-field like `---@type` tables.

Only qbx-lua-ls infers the values, so the linter registers the rules for
levels, config and ---@diagnostic comments, and maps LuaLS's codes of the
same names. The reference lists every LuaLS code the .luarc fallback maps,
including the type checks it already mapped.
`---@field [1] number` and `---@field [true] string` are now stored one by
one in DocClass::literal_fields instead of as the class's index, where each
such line replaced the one before and only the last survived, so every
`employee[n]` read as the type of the last line. A tuple-like class with
`[1] number`, `[2] string` and `[9] boolean` keeps all three; other keys
such as `[integer]` still make the index.

`employee[1]` is `number` and `employee[2]` is `string`. `employee[i]` with
an integer `i` may be any of them, pairs and ipairs yield them with
`integer` keys, and the class hover lists them.

Table constructors key their array entries by position, so `{ 'a', 1 }`
fails `[1] number`, `[2] string` with assign-type-mismatch, and a strict
class takes exactly the literal keys it declares. Such keys are named like
fields, e.g. "Field `[3]` is not declared in strict class `Test.Tuple`" and
"Cannot assign `string` to field `[1]` of type `number`", which also
replaces "has no `integer` keys" for `abc[1]`.
…overrides

An override's `side = "client"`, `"server"` or `"shared"` sets the side of
the Lua files it matches when the resource's fxmanifest.lua does not run
them, such as the files a loader like loaf_wrapper's load.lua runs from
`files`. They then see the globals, natives and (server) or (client)
annotations of that side, and their own globals only reach scripts of that
side. A manifest entry always wins, the last matching override with a side
decides, and sides are never guessed from folder names.

Such a file still counts as loaded on demand, since the manifest does not
run it.

qbx-lua-ls gives those files the same side, so the client files of such a
resource no longer see (server) classes, aliases, enums and overloads. When
qbxlint.toml changes, the files whose side moved are indexed again, closed
ones included, since their globals reach only scripts of their side.
Right after the `(` or `,` in front of an argument whose parameter takes a
function, completion now offers the function literal with the parameters
of the signature the arguments before it pick, e.g. `function(source)`
after `OnAction("playerUnloaded", ` from the overload that takes
`action: "playerUnloaded"`. A call that is still being written fits a
signature although its later arguments are missing, overloads that fit
equally well each get an item, and `(server)`/`(client)` overloads count
on their side only.

`(` and `,` are completion trigger characters that only ever answer with
this item, so Enter after the `,` of a table still inserts a newline. A
`,` right before the cursor gets a space, and no item is offered inside
tables, strings and comments or in front of an argument already written.
An argument whose parameter lists string values, through its type, an
alias such as `---@alias LoafWrapperActions "playerLoaded"|...|string` or
the `@overload`s that apply on the side of the call, now completes them in
the order they are declared: quoted as soon as the argument starts, on the
typed `(` or `,` or when asked, and inside its string, where they replace
the contents. Only the signatures the arguments before it fit count, and a
value lists the overloads that take it alone.

Call snippets leave the stop of such a parameter empty between the quotes,
and functions with one get a snippet without taking a callback. When an
overload takes one of the values alone, the snippet ends right after it,
e.g. `OnAction('$1'$0)`, so that the handler written after the `,` matches
the value picked. A snippet whose first stop is a list carries
editor.action.triggerSuggest for clients that run it, titled "Suggest
values". Strings use the formatter's quote_style, or else the quote most
strings of the document use.
Built-in snippets such as RegisterNetEvent and lib.callback.await, the
lib.onCache snippet, fxmanifest directive completions like
`fx_version '...'` and the bodies qbx/snippets returns now quote their
strings the way call snippets already did: with the formatter's
quote_style, or else the quote most strings of the document use, and `'`
when it has none. qbx/snippets without a document follows quote_style
alone.

The new qbx/quote request answers with that quote for an open document,
or for `null`, so that editors can write their own snippets and
templates in it too.
…s parameters

A call to a ---@callback await or trigger wrapper passes its `...` to the
handler registered under the name it gives, on the other side, so
missing-parameter now checks those values against the parameters that
handler requires, e.g. "Callback 'add' is called with 0 arguments, but
needs 2; 'num1' (number) will be nil" for AwaitServerCallback('add').

The player a handler outside the client receives first is not counted,
the handler that needs the fewest values decides when several are
registered, and calls ending in a call or `...` are skipped. qbx-lint
keeps checking the wrapper's own parameters.
`---@return boolean, string?` declared only the boolean, since the text
after the first type was taken as its description, so the second value of
`local found, name = GetName()` stayed unknown. Each comma separated entry
is now a return value with its own optional name; a description after the
last one belongs to it.
A local had one type wherever it was read, so `name` stayed `string?` after
`if not name then return end`. A read now leaves out what the conditions
around it rule out: `nil` and `false` where the local was found to hold a
value, everything else where it was found not to, and `boolean` becomes
`true` or `false`.

Guards are `name`, `not name`, comparisons with `nil`, `true` and `false`,
and those joined by `and` and `or`. They apply to the branches of an `if`
or `elseif`, to the code after an `if` whose other branches all end in
`return`, `error(...)`, `break` or `goto`, to the body of a `while`, to the
right side of `and` and `or`, and to the code after `assert(name)`. A
`goto` label ends the code a guard covers, since a jump may have skipped
the guard.

A local that is assigned again after its declaration is not narrowed,
since a guard says nothing about the new value; globals and fields are
left alone as well. Hovers and completion use the type at the position of
the name, as inference does for every expression that reads it.
…ng)`

A function often returns either one set of values or another, such as
`false` when it fails and otherwise two names. `@return` and the `:` of a
`fun(...)` type now take such sets separated by `|`, with the values of a
set in parentheses. Before, the line read as `false|string, string` with
`)` as its description. Each position holds what the sets have there, and
`nil` for a set that ends before it, so `local firstname, lastname =
GetName()` gives `false|string` and `string?`. The sets only hold on a
function's one `@return` line; further lines add values as before.

The locals that one call declares are narrowed together. A guard on one
of them rules out the sets that do not fit it, and the others are read
from the sets that are left: after `if not firstname then return end`
both names are `string`, and inside the branch `lastname` is `nil`.

An undocumented function gets its sets from its `return` statements when
they differ and pass several values, so `local ok, err = f()` is linked
without an annotation. A `false` or `true` a call returns beside values of
another kind stays, as `false|string` rather than `boolean|string`.

return-type-mismatch and missing-return compare each `return` with the
set it comes closest to, so `return "Joe"` is missing a value and
`return 1, "Doe"` returns the wrong type. Hovers and signature help show
the sets, and the type names in them are found like those of any other
annotation.
Off by default. Turned on, qbx-lua-ls reports each parameter, local and
loop variable whose type is unknown: none is declared with `@param` or
`@type`, and none can be inferred from its value, from the function a
callback is passed to, or from what a loop goes through. A local declared
without a value needs a `---@type`, since later assignments are not
followed, and a parameter that only takes `any` from the `fun(...)` its
function is passed as counts as untyped. `self` and names that start with
ignore_unused_prefix are left out.

Only the server infers types, so the linter registers the rule for
levels, config and ---@diagnostic comments, and maps LuaLS's code of the
same name.
… its own way

VS Code writes the URI of a file as `file:///c%3A/...`, while the index
spells it `file:///C:/...`. The pass that reports the files nobody has
open looked open documents up by URI, did not find them, and published
each open file a second time without its hints. VS Code takes both
spellings for one file, so that list replaced the first: in an open file
with a warning or an info, hints such as the fade of an unused argument
were never shown.

Open documents are now found by path. When a file is closed, what it
showed is cleared under the client's spelling, and the file is reported
under the one the index has.
…tions

Annotations written for fivem-lls-addon name types the stubs did not have.
`EventHandler` is now an alias of `EventHandlerData`, whose `key` and
`name` had each other's type. `vector` is any of the three vector types,
and `json_options`, `json_encode_state` and `msgpack_options` are declared,
with `json.encode` taking the state. The vectors gain `r`, `g`, `b`, `a`,
`xyz`, `xyzw` and `n`, `EntityInterface` its `__data`, and `promise` its
`queue`, `success` and `failure`.

The comparison also showed functions CfxLua registers that were reported
as undefined: `dot`, `cross`, `inv`, `slerp`, `qua`, `mat` and the sized
matrix constructors, `vec1`, `ivec*`, `bvec*`, `each` and `scrub`; the
option, marker and extension functions of `json` and `msgpack`;
`Citizen.InvokeNative2` and `Citizen.ResultAsObject2`; `utf8.strlenutf8`
and `utf8.strcmputf8i`. Each is declared from the runtime's own
registration tables, and `for k, v in each(t)` types its variables like
`pairs`.
The functions CfxLua adds to the standard libraries, such as
`os.nanotime`, `string.strtrim` and `table.clone`, are declared in the
CfxLua stub, while `os`, `string` and `table` belong to the Lua stub. A
stub had no index entry while it was indexed, so it saw no other file's
globals and its `function os.nanotime()` went to a table of its own: the
function had no hover and its result was unknown. Each stub now gets its
entry first, as workspace files do.
… a dot

A function defined as `function Base.new(class, ...)` and called as
`Stall:new(name)` receives the stall as `class`. The arguments were lined
up from the first parameter unless it was named `self`, so generics bound
to the wrong argument and hints named the wrong parameter:
`lib.array:new(string.strsplit('', iter))` of ox_lib came out as a string.
Functions built from a definition now list their receiver, while
`fun(...)` fields still need a `self` parameter for that.
A rule level from the client's `diagnostics.rules` replaced the one
`qbxlint.toml` sets, for every file, so a project could show other
findings in the editor than `qbx-lint` reports in CI, and a level set
for some files through `[[overrides]]` was lost. The levels of the
config file, in `[rules]`, in `[[overrides]]` or taken from LuaLS or
EmmyLua settings, now take precedence; `diagnostics.rules` sets the
level of the rules the file leaves alone.
A doc comment above `RegisterNetEvent(name, function(id) end)` documents
the handler, but it was not read once the statement kept what the call
returns: with `handlers[name] = RegisterNetEvent(...)` or
`local handler = RegisterNetEvent(...)`, `id` stayed `any` and
`no-unknown` reported it. The comment above such a statement now reaches
the functions passed to the call. Their parameters take its `@param`
types, their returns are checked against its `@return` lines, and a
callback registered this way is indexed with the documented signature.
Hovering such a parameter shows the description of its `@param` line,
above a call on its own too.
qbx-lua-ls reports an `==` or `~=` whose two sides can never be equal,
so that the comparison always gives the same answer, e.g. "Comparing
`"active"|"busy"|"ready"` with `"invalid_state"` is always false". The
sides are different kinds of value, such as a string and a number, or
they list literals and none is on both sides. That covers
`GetState() == "invalid_state"` for a function whose `@return` lists
its states, `not name == "admin"`, a model hash compared with a string,
and `type(value) == "tabel"`. The rule is an `info` by default.

A side only has a type that something declares: a literal, an operator,
a `@param`, `@type` or `@return`, a `@field` of a class, an alias or
enum, a stub or a native. A local that is never assigned again has the
type of the value it is declared with, so `local state = GetState()` is
checked like the call, and the guards around a read still narrow it.

Only clear cases count. Types inferred from assigned values are left
out, since `Config.Webhook = ''` tells what is stored today and not that
`Config.Webhook == false` is never true; the same goes for what an
undocumented function returns, for generics, and for `a or b` and
`a and b`. Comparisons with `nil` are not reported, as annotations often
leave out the `?` of a value that may be missing. A native documented as
`boolean` can give scripts `1` instead of `true`, so
`IsPedInAnyVehicle(ped) == 1` is left alone.

The `lua_type` alias that `type` returns now lists the names CfxLua
adds: `vector2`, `vector3`, `vector4`, `quat` and `matrix`.

Only the server infers types, so the linter registers the rule for
levels, config and ---@diagnostic comments.
A local that takes what a call returns was widened, so the `state` of
`local state = GetState()` was a `string` for a function documented with
`---@return "active"|"busy"|"ready"`. It now has the type the call
returns, as hovers show, and so does each name of
`local level, label = GetLevel()`.

A local that is assigned again after its declaration stays widened,
since it may hold other values of that kind, and a literal written out
is widened as before: `local mode = 'dev'` is a `string`.
Escape literal completions for their Lua string delimiter, including
completions inside an existing string. Check class table arguments against
the overload selected by the call rather than the base signature.

Retain side attributes on literal fields and general indices through the
LuaCATS parser, index, inference, hovers and diagnostics. Recognize repeat
bodies that always return while preserving fallthrough when they can break.

Add LSP regression coverage for all four cases, including valid and invalid
calls, both script sides, both quote styles and repeat loops with exits.

Validation: cargo fmt, Clippy with warnings denied, cargo build, all 291
workspace tests, and the qbx-editor PR Qbox-project#5 integration suite on Windows.
@ChatDisabled
ChatDisabled merged commit 41eeb9f into Qbox-project:main Sep 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants