LuaCATS Annotations
Last updated
May be stale
This page was last updated ago, on 3 October 2026. Check what it says against the code before relying on it.
The complete annotation reference for this codebase — what every tag means, what its fields are, and which ones look correct but are silently ignored here.
LuaCATS ("Lua Comment And Type System") is the annotation dialect understood by lua-language-server (LuaLS). It is the mandatory documentation format for this repository: every function must carry one. Annotations do three jobs at once:
- Type checking and editor intelligence — hover text, autocomplete, go-to-definition, and wrong-argument warnings, in any editor with the LuaLS extension installed.
- The public docs site — CI runs LuaLS
--docover the gamemode and feeds the resulting JSON totools/docs/generate.py. If LuaLS doesn't understand your comment, the docs site never sees it. See Writing Doc Comments for the pipeline itself. - Machine-readable intent — the annotations are frequently the only statement of what a function is supposed to accept and return, in a codebase with no runtime type system.
Related reading: Writing Doc Comments (how the docs pipeline consumes these), Modern APIs (which APIs to annotate against), Coding Standards (checklist + CI reject criteria).
The rules that decide whether an annotation counts
Everything below is conditional on these four. Break any one of them and the annotation still "looks" fine in the diff, still passes lint, and is silently dropped:
- Three dashes, always.
--- @paramcounts,-- @paramdoes not. A two-dash comment is a plain comment to LuaLS regardless of what it contains. - Directly above the declaration, with no blank line. LuaLS attaches a doc comment to whatever
declaration immediately follows it. One blank line between the comment and the
function, and the two are never associated — the annotation is dropped from the doc JSON entirely and no hover text appears in editors. - Description first, tags after. The human-readable description lines come before the
@tags, not interleaved with them. - Names must match the real signature.
--- @param ply Playeronly binds if the function actually has a parameter calledply. A renamed parameter with a stale annotation is aundefined-doc-paramwarning at best, wrong docs at worst.
Enforcement note: standards-diff.py fails a changed line that introduces --- @module
(use --- @namespace on file headers), an LDoc tag, --- @vararg, a two-dash -- @tag, or a
----prefixed inline cast. It also fails a file-level function declaration your diff adds
with no doc comment directly above it, and one whose doc comment a blank line has detached.
Declarations nested inside another function — panel callbacks such as function combo:OnSelect(...)
and local helpers — are out of CI's reach but still a review requirement; do not ship undocumented
functions.
House style in this repo:
- Write
--- @tagwith a space after the dashes. Both--- @paramand---@paramparse identically, but the spaced form is what the overwhelming majority of the codebase uses — match it. - Indent with tabs, like all other Lua here.
- Descriptions start with a capital letter and end with a full stop.
- Descriptions may span multiple lines; each line needs its own
---.
--- Issues a warrant against a player.
--- Fails silently if a live warrant of the same type already exists.
--- @param ply Player The player to warrant.
--- @param reason string Why the warrant was issued.
--- @return boolean issued Whether a new warrant was created.
--- @state server
function cityrp.warrants.issue(ply, reason)
...
end
Documenting functions
@param
--- @param <name> <type>[?][|<type>...] [description]
One per parameter, in declaration order. The name comes first, then the type — the reverse of
LDoc's @tparam, which is the single most common porting mistake.
The ? goes on the type, as a nullable suffix, and marks the parameter as optional (callers may
omit it or pass nil):
--- Formats a number of seconds into a human-readable string.
--- @param time number The number of seconds to format.
--- @param showMins boolean? Whether to include minutes in the output.
--- @return string formatted The formatted time string.
function cityrp.formattime(time, showMins)
...
end
showMins boolean? and showMins boolean|nil mean the same thing — ? is shorthand for |nil
appended to the type. For a union, the ? goes on the end of the whole union:
base string|table? reads as "string|table, or nil".
Varargs are documented as a parameter literally named ...:
--- Prints each supplied name.
--- @param ... string The names to print.
function PrintNames(...)
...
end
@return
--- @return <type> [<name>] [description]
One @return line per returned value, in order. LuaLS does accept
--- @return boolean, string on a single line, but this codebase writes them one per line — it is
the only form where each value gets its own name and description.
--- Attempts to unlock a door for a player.
--- @return boolean success Whether the door was unlocked.
--- @return string? reason Why the unlock failed, or nil on success.
function cityrp.door.tryUnlock(ply, door)
...
end
Naming the return value is optional but strongly preferred: @return boolean success reads far
better in hover text and on the docs site than a bare @return boolean.
@overload
--- @overload fun([<name>: <type>, ...]): [<return type>[, ...]]
Declares an additional call signature beyond the one described by @param/@return. Use it for
functions that genuinely branch on arity or argument type, rather than widening every @param to a
union:
--- Resolves a player from a name, SteamID, or the player itself.
--- @overload fun(ply: Player): Player
--- @param identifier string A name fragment or SteamID.
--- @return Player? ply The matched player, or nil if no unique match.
function cityrp.util.findPlayer(identifier)
...
end
@generic
--- @generic <name>[: <parent type>][, <name2>[: <parent type>]]
Introduces a type variable so the return type can be tied to an argument type, instead of collapsing
to any:
--- Returns the value unchanged.
--- @generic T
--- @param value T The value to return.
--- @return T value The same value.
function Identity(value)
return value
end
Genuinely useful for table/collection helpers (table.Copy-alikes, pick, filter) where any
would throw away every downstream type.
@nodiscard
Warns at the call site if the return value is thrown away. Correct for pure getters and predicates
(IsAdmin, CanAfford, GetInventoryItems), wrong for anything whose side effect is the point.
--- @return boolean isAdmin Whether the player is an admin.
--- @nodiscard
function pMeta:IsAdmin()
...
end
@async
Marks a function that does not complete before it returns. In this codebase that means anything
built on DB_MAIN:Run, HTTP, timer.Simple, or a net round trip. It is a documentation signal
to the caller, not a language feature — and it pairs with the async footgun in
AGENTS.md: the callback
must re-validate entities itself, because the player may have disconnected before it fires.
--- Loads a player's bank balance from the database.
--- @async
--- @param ply Player The player to load for.
--- @param callback fun(balance: number) Invoked with the loaded balance.
function cityrp.banking.loadBalance(ply, callback)
...
end
Describing shapes: classes, fields, and types
@class
--- @class [(exact)] <name>[: <parent>[, <parent2>...]]
Defines a named type. Attach it to the table it describes (the declaration on the line below), or use
it standalone as a pure type definition. (exact) forbids fields that weren't declared — useful for
config tables where a typo'd key should be an error rather than silently ignored. Inheritance is
: Parent, and multiple parents are comma-separated.
--- A single entry in the image-host allowlist.
--- @class ImageHostDefinition
--- @field host string The hostname players link to, matched exactly and case-insensitively.
--- @field enabled boolean Whether the host is currently accepted.
--- @field reason string Why the host is trusted, or why it is currently disabled.
--- A vehicle owned by a player.
--- @class Vehicle : Entity
--- @field _Owner Player The owning player.
--- @field _Petrol number Fuel level, 0-100.
@field
--- @field [<scope>] <name> <type>[?] [description]
--- @field [<key type>] <value type> [description]
Declares one member of the @class it belongs to. The rules that matter:
@fieldlines must sit in the same contiguous comment block as their@class, immediately after it. A blank line, or an unrelated tag block in between, orphans them.?marks an optional field and goes on the type, as a nullable suffix:--- @field reason string?. An optional field may be absent entirely;string|nilsays the same thing spelled out.<scope>is optional and is one ofpublic(the default),protected,private, orpackage. It controls where LuaLS considers the field accessible —privateto the defining class,protectedto it and its subclasses,packageto the same file.- A bracketed key type declares an index signature rather than a named field — the shape of
arbitrary keys.
--- @field [string] numbermeans "any string key maps to a number", and--- @field [integer] Playermeans "integer-indexed, holding players".
--- A logging sink.
--- @class Logger
--- @field namespace string The namespace this logger reports under.
--- @field private level number The minimum level this logger emits at.
--- @field sinks Sink[] Sinks this logger writes to.
--- @field parent Logger? The logger this one inherits configuration from.
--- @field [string] any Arbitrary caller-attached metadata.
@field also documents members of a table you're describing in place, and is the right tool
whenever a function takes or returns a "config table" — declare the table's shape as a @class once,
then reference that class by name in @param, instead of typing the parameter as a bare table:
--- @class WarrantOptions
--- @field reason string Why the warrant is being issued.
--- @field expires number? Unix timestamp the warrant lapses at.
--- @field silent boolean? Suppress the player-facing notification.
--- Issues a warrant.
--- @param ply Player The player to warrant.
--- @param opts WarrantOptions Warrant configuration.
function cityrp.warrants.issue(ply, opts)
...
end
@type
--- @type <type>
Declares the type of a variable or table where LuaLS can't infer it — most often an empty table that gets populated later.
--- @type table<string, Player>
local playersBySteamID = {}
--- @type {[PASS]: Pass}
local passes = {}
@type on a function is wrong — use @param/@return.
@alias
--- @alias <name> <type>
--- @alias <name>
--- | <literal> [# description]
Names a type for reuse. The single-line form is a straight synonym; the multi-line ---| form
enumerates the permitted values, which is what gives autocomplete a fixed list to offer:
--- @alias TeamID number
--- @alias NotifyKind
--- | "generic" # Neutral information.
--- | "error" # Something went wrong.
--- | "hint" # Contextual tip.
@enum
--- @enum [(key)] <name>
Attaches directly to a real, runtime table of constants, and makes the type usable as a
type-checked set of that table's values. (key) switches it to using the table's keys rather than
its values.
--- Notification kinds accepted by ply:Notify().
--- @enum NotifyType
local NOTIFY = {
GENERIC = 0,
ERROR = 1,
UNDO = 2,
HINT = 3,
}
The difference from @alias: @alias is a type that exists only in annotations, @enum describes a
table that exists at runtime. Use @enum for the CAPITALIZED_WITH_UNDERSCORES constant tables this
codebase already has; use @alias when there is no backing table.
@operator
--- @operator <operator>[(<param type>)]: <return type>
Documents a metamethod so LuaLS knows a + b is legal on your class:
--- @class Money
--- @operator add(Money): Money
--- @operator unm: Money
Visibility, lifecycle, and cross-references
| Tag | Syntax | What it does |
|---|---|---|
@deprecated |
--- @deprecated [text] |
Marks the item outdated. LuaLS strikes it through at call sites; generate.py renders it as a "Deprecated" callout on the docs site. Always say what to use instead. |
@private |
--- @private |
Accessible only within the defining class. |
@protected |
--- @protected |
Accessible within the defining class and its subclasses. |
@package |
--- @package |
Accessible only within the same file. |
@see |
--- @see <symbol> |
Cross-reference to another documented symbol. |
@version |
--- @version [<|>]<version> |
Required Lua/LuaJIT version. Rarely needed — the whole server is LuaJIT. |
@source |
--- @source <path>[:<line>] |
Points at the real definition when the code LuaLS sees is a stub. |
--- Old warrant lookup, kept for legacy callers.
--- @deprecated Use cityrp.warrants.issue instead.
--- @see cityrp.warrants.issue
--- @param ply Player The player to look up.
function cityrp.warrants.legacyLookup(ply)
...
end
Note that @deprecated does double duty: it is both the LuaCATS tag (editor strikethrough) and one
of generate.py's own callout tags (docs-site banner). The one line drives both.
Narrowing and escape hatches
@cast
--- @cast <variable> [+|-]<type>[, ...]
Re-types an existing variable from that line onward. + adds a type to the union, - removes
one. The most common legitimate use here is stripping nil after a guard LuaLS can't follow:
local ent = ply:GetEyeTrace().Entity
if not IsValid(ent) then return end
--- @cast ent Entity
@as
local ent = ents.FindByClass("prop_vehicle_jeep")[1] --[[@as Vehicle]]
An inline, expression-level cast. Note the syntax carefully: it is a block comment containing only
@as <type> — --[[@as Vehicle]]. It is not written with the --- prefix that every other
annotation uses, and --[[--- @as Vehicle]] does not work.
@diagnostic
--- @diagnostic <state>:<diagnostic name>
<state> is disable, enable, disable-line, or disable-next-line. Suppress the narrowest
scope that solves the problem, and only when the diagnostic is genuinely wrong — a file-wide
disable hides real errors from everyone who touches the file afterwards.
--- @diagnostic disable-next-line: undefined-global
local plugin = PLUGIN
@meta
--- @meta [name]
Marks an entire file as definition-only — types and signatures with no executable body. Correct for a hand-written stub file describing an external API; wrong for any file that actually runs.
Type syntax
Types are written the same way everywhere — in @param, @return, @field, @type, and @alias.
| Form | Example | Meaning |
|---|---|---|
| Primitive | number, string, boolean, nil, table, function, integer, any |
Stock Lua types. integer is a narrower number. |
| Engine class | Player, Entity, Vector, Angle, Color, Panel |
Any @class name, including Garry's Mod's own. |
| Union | string|number |
Either type. |
| Optional | string? |
Shorthand for string|nil. |
| Array | string[], Player[] |
Sequential table of that type. |
| Dictionary | table<string, number> |
Keys of the first type, values of the second. |
| Table literal | { x: number, y: number } |
Inline anonymous shape. |
| Function | fun(ply: Player, amount: number): boolean |
Callable with those parameters and return. |
| Function, varargs | fun(...: string): number |
Callable taking varargs. |
| Literal | "red"|"green"|"blue" |
Only those exact values. |
| Parenthesised | ("red"|"blue")[] |
Needed to array-ify or optional-ify a union. |
--- @param teams table<number, TeamID> Map of team indices to team IDs.
--- @param predicate fun(ply: Player): boolean Called per player; return true to keep.
--- @param bounds { min: Vector, max: Vector } The region to search.
--- @param colours ("red"|"blue"|"green")[] Allowed colours, in priority order.
Prefer the most specific type that is actually true. table and any are legal and sometimes
unavoidable, but each one is a place where the annotation stopped being useful — if the table has a
known shape, give it a @class and reference that instead.
Garry's Mod and CityRP specifics
-
Use the engine class names, not
table.Player,Entity,Weapon,Vehicle,NPC,Vector,Angle,Color,IMaterial,Panel,ConVar,PhysObj,CUserCmd,CMoveData,VMatrix,File,IGModAudioChannel,CTakeDamageInfo.Playeris a subtype ofEntity, so a function that accepts any entity should sayEntity, notPlayer. -
Playerdoes not imply valid. An annotation is documentation, not a runtime guarantee — every entity argument still needs itsIsValid()check, and every async callback needs that check inside the callback. Annotating a parameterPlayernever removes the guard. -
Metatable methods. Methods added via
FindMetaTable("Player")extend the existingPlayerclass; document the method with@param/@returnas normal, and add new fields you attach to players with a@class Player+@fieldblock rather than leaving them undeclared.--- @class Player --- @field _Money number The player's current wallet balance. --- @field _LocalPlyVars table<string, any> Per-player variables networked only to that player. local pMeta = FindMetaTable("Player") --- Checks whether the player holds an access flag. --- @param flag string The access flag to test, e.g. "a" for admin. --- @return boolean hasAccess Whether the player holds the flag. --- @nodiscard function pMeta:HasAccess(flag) return cityrp.access.hasAccess(self, flag) end -
Plugin and item tables.
PLUGINandITEMmethods are documented exactly like any other function. Callbacks registered throughhook.Addare usually best documented on thePLUGINmethod they delegate to, since that is the thing with a real signature. -
Realms. LuaCATS has no concept of client/server. That is what this codebase's own
@statetag is for — see below.
Tags that are not LuaCATS but live in the same comment
These are generate.py's, not LuaLS's. They are written in the same --- block, and LuaLS ignores
them harmlessly. Full detail in Writing Doc Comments:
| Tag | Where | Purpose |
|---|---|---|
@namespace <name> |
File header | Groups the file's items onto one docs page. Use this instead of @module. Naming it after a @class also makes that the class's page, gathering members declared in other files. |
@state <realm> |
File header or item | client, server, shared, menu, global, clmenu — drives the realm badge. On the file header it is the default for every item in the file. |
@section <title> |
Item | Starts a named subsection on the module page; the tag's value is the heading. On an @alias over a run of constants, it names the constant family those constants are gathered under. |
@example <code> |
Item | Renders an "Example" code block after the parameters and returns. Continuation lines stay separate lines. Use this instead of @usage. |
@uniqueid <id> |
Item | The registration ID a documented class is stored under, rendered as a "Registration ID" chip. See Registration IDs below. |
@hook |
Item | Marks a GM: method as the default of a gamemode hook other code can add to. Not rendered yet (#2204). See Hooks. |
@warns <text> |
Item | Renders a "Warnings" callout. |
@bugs <text> |
Item | Renders a "Known Issues" callout. |
@internal [text] |
Item | Renders an "Internal" callout. |
@copyright, @release, @author |
File header | Required by the style guide; parsed but not currently rendered. |
@hidden [reason] |
File header | Drops the whole namespace from the build — no module page, no sidebar/index/search/api.json entry. From any file contributing to the namespace, not just the one declaring @namespace. See File-header tags. |
Registration IDs
A class whose name does not contain the string it is registered under needs @uniqueid, or nobody
can find it. ITEM.ModelBodygroup is registered as base_modelbg; without the tag, searching the
docs or the cityrp-docs MCP for base_modelbg returns nothing at all.
--- The bodygroup modifier item base. Contains helpers for setting model bodygroups.
--- @uniqueid base_modelbg
--- @class ITEM.ModelBodygroup : ITEM
local ITEM = ITEM:New()
The ID is what search_api matches on (an exact hit outranks everything else), what get_symbol
prints as Registration ID, and what the module page renders as a chip. Repeat the tag once per
ID if one declaration registers several.
Two constraints, both of which fail silently:
- The tag needs a class of its own. LuaLS only exports a declaration it can name. A plain
documented
local, or one annotated@type ITEM, exports nothing, and the tag disappears with it. Re-opening a shared class (@class ITEMin fifteen different files) is worse than useless: LuaLS merges the declarations and every one of them reports the first file's description, so fourteen files' tags are silently discarded. Give the thing its own@class X : Parent. - Keep it adjacent. A blank line between the description and
@classdrops the description — and the tag with it — from the docs export, with no warning anywhere.
Annotations that look right and are not
| Written | Problem | Write instead |
|---|---|---|
--- @module "cityrp.util" |
LuaLS reserves @module for its own require() return-type inference and silently drops the whole doc comment. The most common porting mistake in this repo. |
--- @namespace cityrp.util |
--- @vararg string |
Deprecated by LuaLS itself. | --- @param ... string |
--- @usage foo() |
LDoc, not LuaCATS. LuaLS does not understand it and generate.py does not render it — the line is dead weight. There are still a few in the tree. |
--- @example foo(), which renders (see above). A fenced Lua block in the description also works, and keeps the example where the prose puts it. |
--- @tparam Player ply |
LDoc. | --- @param ply Player |
--- @treturn bool |
LDoc — and bool is not a Lua type. |
--- @return boolean |
--- @classmod ENV |
LDoc. | --- @class ENV (plus --- @namespace ENV on the file header) |
--- @number x / --- @string s / --- @bool b |
LDoc typed-parameter shorthand. | --- @param x number etc. |
--- @function / --- @local |
LDoc. | Omit; use @private/@package for visibility. |
-- @param ply Player |
Two dashes — a plain comment. | --- @param ply Player |
--- @param Player ply |
Type and name the wrong way round. | --- @param ply Player |
--- @param ply Player? where ply is required |
? on a mandatory parameter. |
--- @param ply Player |
--- @param ply? Player |
? on the name instead of the type. |
--- @param ply Player? |
--[[--- @as Entity]] |
Inline casts take no ---. |
--[[@as Entity]] |
--[[-- Description. @namespace x --]]-- as a file header |
Legacy pre-LuaCATS syntax, not real LuaCATS — LuaLS gives it no hover/autocomplete/type-checking. Only generate.py's raw-source scan tolerates it, and only to keep old files rendering on the docs site. |
A run of --- lines (see Writing Doc Comments). |
--- @class Player as the last line of a file header |
The header is the comment attached to the class, so the file's description, @copyright and @author all become the class's description — and because LuaLS merges every re-opening of a class, that one header then describes the class everywhere it appears. Player was documented sitewide as "Serverside Inventory Metatable." this way. |
End the header at @namespace, and put --- @class Player on its own directly above local pMeta = FindMetaTable("Player"). |
--- @alias meta above local meta = FindMetaTable("Entity") |
LDoc's @alias, meaning "this local is the module table". LuaCATS reads it as a type alias with no definition — it declares a junk type and documents nothing. |
Delete it. |
Blank line between the comment and the function |
LuaLS never associates them; the annotation vanishes from the docs and from hover. | Delete the blank line. |
LDoc was this project's previous documentation format and its tags are still scattered through older files. They are not a style to match — when you touch a function carrying LDoc tags, convert them.
Worked example
--- Player-facing warrant helpers.
--- @copyright 2015-2026 Limelight Gaming
--- @release CityRP 2
--- @author Someone
--- @namespace cityrp.warrants
--- @state server
cityrp.warrants = cityrp.warrants or {}
--- The kinds of warrant that can be issued.
--- @enum WarrantType
local WARRANT = {
ARREST = 0,
SEARCH = 1,
WANTED = 2,
}
--- A live warrant against a player.
--- @class Warrant
--- @field target Player The player the warrant is against.
--- @field issuer Player The officer who issued it.
--- @field kind WarrantType What the warrant authorises.
--- @field reason string Why the warrant was issued.
--- @field expires number? Unix timestamp the warrant lapses at, nil if indefinite.
--- @type table<string, Warrant>
local activeWarrants = {}
--- Issues a warrant against a player.
--- Fails if the player already has a live warrant of the same kind.
--- @param ply Player The player to warrant.
--- @param issuer Player The officer issuing the warrant.
--- @param kind WarrantType What the warrant authorises.
--- @param reason string Why the warrant was issued.
--- @return boolean issued Whether a new warrant was created.
--- @return string? failure Why issuing failed, or nil on success.
--- @section warrants
function cityrp.warrants.issue(ply, issuer, kind, reason)
if not IsValid(ply) then return false, "Invalid player." end
...
end
--- Returns every live warrant against a player.
--- @param ply Player The player to look up.
--- @return Warrant[] warrants The player's live warrants, empty if none.
--- @nodiscard
function cityrp.warrants.getFor(ply)
...
end
--- Old warrant lookup, kept for legacy callers.
--- @deprecated Use cityrp.warrants.getFor instead.
--- @param ply Player The player to look up.
--- @return table warrants The player's live warrants.
--- @bugs Does not filter out expired warrants.
function cityrp.warrants.legacyLookup(ply)
...
end
Checklist
Before considering an annotated change done:
- Every function you added or touched has a description ending in a full stop, plus a
@paramper parameter and a@returnper returned value. - No blank line anywhere between a doc comment and the declaration it documents.
- Every
@paramname matches the real parameter name, in order. - No LDoc tags (
@tparam,@treturn,@classmod,@usage,@number,@local) introduced. - No
@module— file headers use@namespace. - Table parameters with a known shape are typed as a
@class, not as baretable. - Optional parameters and fields carry
?on the type, as a nullable suffix. - File header has
@namespace,@state,@copyright,@release, and@author. -
glualintpasses on the file — see CONTRIBUTING.md.
To verify the docs pipeline actually picked your annotations up, reproduce what CI does — if a function you annotated is missing from the JSON, the cause is almost always rule 1 or rule 2 above:
lua-language-server --doc=gamemodes/cityrp_2_by_limelightgaming/gamemode \
--doc_out_path=tools/docs/out
python3 tools/docs/generate.py --json tools/docs/out/doc.json \
--output tools/docs/build \
--style-dir tools/docs/style \
--topics docs
CI pins LuaLS in tools/docs/luals-version, which the docs build and the standards sweep both read; annotations added after that release may parse
locally and not in CI.