Writing Doc Comments
Last updated
May be stale
This page was last updated ago, on 19 September 2026. Check what it says against the code before relying on it.
This site is built by generate.py from two independent sources, so a doc comment lives or dies depending on which one actually reads it:
lua-language-server's--docJSON export. LuaLS parses the codebase and exports every doc comment it understands — that means real LuaCATS annotations (--- @param,--- @return,--- @class, ...) attached immediately above a declaration, with no blank line in between. A blank line between the comment and the code it describes means LuaLS never associates the two, and the comment is silently dropped from the JSON entirely.generate.py's own raw-source scan, used only for each file's leading header comment (description,@namespace, etc). This one tolerates a blank line before the code that follows. Write headers as---lines.
Everything else in this page is about what each of those two paths actually understands. For the annotations themselves — every tag, its fields, the full type syntax, and the Garry's Mod-specific conventions — see LuaCATS Annotations.
Pipeline
# 1. LuaLS walks the gamemode and exports its own understanding of every doc comment.
lua-language-server --doc=gamemodes/cityrp_2_by_limelightgaming/gamemode \
--doc_out_path=tools/docs/out
# 2. generate.py turns that JSON (plus docs/**/*.md) into this site.
python3 tools/docs/generate.py --json tools/docs/out/doc.json \
--output tools/docs/build \
--style-dir tools/docs/style \
--topics docs
Files under core/server, core/autogen, core/plugins/antihack, core/plugins/screengrab, and every core/items/<category> subfolder are excluded from the export entirely — no doc comment in those paths will ever appear on the site.
Seeing every feature at once
tools/docs/fixtures/stubs.lua is a stub file using every tag, realm, callout, type and section the site renders. It is never loaded, and it sits outside the gamemode so its fake symbols can never reach the published site. Build just that file when changing generate.py or the stylesheet — it is far quicker than hunting for a real function that happens to use the feature you touched:
lua-language-server --doc=tools/docs/fixtures --doc_out_path=tools/docs/stub-out \
--configpath=tools/docs/luarc-docs.json
python3 tools/docs/generate.py --json tools/docs/stub-out/doc.json \
--output tools/docs/stub-build \
--style-dir tools/docs/style --topics docs \
--source tools/docs/fixtures
That produces two pages: stubs for the library-style symbols and StubEntity for the metatable ones. test_generate.py fails if the stub stops covering a callout kind, a realm, or the example/section/alias tags, so it cannot quietly fall behind the generator.
Topic files and folders
Every markdown file under docs/ is published, and the folder it sits in decides where. Top-level files are listed directly under the sidebar's Topics heading. A subfolder becomes a single collapsed entry at the bottom of that same list, linking to the folder's index page — docs/design/x.md publishes to topics/design/x.html, reachable from the Design Docs entry. The folder's pages only unfold in the sidebar while you're on one of them, so the Topics list is the same length from everywhere else and a body of design docs doesn't crowd out the technical topics.
Folders nest to any depth. docs/design/car-boosting/hotwiring.md sits under Car Boosting inside Design Docs, and opening it unfolds both — every ancestor of the page you're on, and nothing else. A folder is labelled by its own leaf name; the parent it sits under supplies the rest of the context. Each level of the tree also gets its own index page, and a parent's index links down into its children under a Sections heading, so nothing is reachable only from an already-expanded sidebar.
There is no per-folder config; creating the folder is the whole step. Add an index.md to give the folder a name and an intro — its title becomes the sidebar label. The list of what's in the folder is always generated, never hand-maintained.
topics/ mirrors docs/, so a relative link between two topic files stays correct on the site as well as on GitHub — generate.py only swaps the .md for .html, and leaves any link that doesn't resolve to a topic (../AGENTS.md, absolute URLs) alone. That mirroring cuts both ways: moving a doc into a folder changes its depth, so its own ../ links and everyone else's links to it need updating in the same change. Headings get GitHub's own anchor slugs, so plugins.md#keys works in both places too.
A relative link that points above docs/ — ../AGENTS.md, ../../gamemodes/…/db.lua — has no
page to land on, because only docs/ is published. Those are rewritten to the repo browser rather
than left to 404. test_generate.py runs the whole docs/ tree through the generator and fails on a
link or heading anchor that doesn't resolve, which is the only thing that reliably catches a doc
being moved between folders.
Topic pages with more than two ## headings get a Contents block in the sidebar, and the sidebar
search box covers every topic, module and symbol — it reads search.json, fetched on first use.
A page's opening <h1> is dropped from the rendered HTML — the title already sits in the header bar above it, so keeping both would print it twice. Keep writing the heading; it's what makes the file readable on GitHub, and it's the fallback when there's no front-matter title. Only the first one goes, and only if the page opens with it.
Front matter
Any topic may open with a --- block. It's flat key: value lines, not real YAML — the docs build has no YAML dependency, and a block that isn't in that shape is left in the page as ordinary markdown rather than silently swallowed.
| Key | Effect |
|---|---|
title |
Sidebar label, page header, and index row. Overrides the first heading, which is otherwise what the title comes from. |
status |
Index column. Free text — Proposed, Landed, Implemented, whatever the folder uses. |
date |
Index column. |
created / updated |
Filled in by the build from git: the first and last commit touching the doc, following renames. Set either by hand only when git's history is wrong for the doc; a hand-written value wins. updated is shown under the page title, and a page whose updated is more than 30 days old when it's read (checked in the reader's browser, since the site is only rebuilt on push) warns that it may be stale. Both are carried into api.json; neither is tabulated. |
description |
Index column. One line on what the doc covers. |
pinned |
true/yes/1 lifts the doc to the top of its list, marked with a pin. Anything else, including omitting it, leaves the doc in filename order. |
featured |
true/yes/1 adds the doc to the Important Topics list on the site's front page, regardless of where it lives in the tree. Independent of pinned — pinning only affects sidebar order, this is what puts it on the homepage. |
index |
true promotes the doc to be its folder's landing page. See below. |
hidden |
true/yes/1 drops the page from the build entirely: no HTML page, and no entry in the sidebar, folder index, search box, api.json, or llms.txt. A draft still living in the repo, not a published one — commit it, work on it across sessions, and flip the flag off when it's ready. It keeps the page off the site only: the contrib sync still mirrors the file to contributors (footgun 28 in AGENTS.md). |
A folder index tabulates only the columns some page in that folder actually sets, so a folder using none of them stays a plain list of links. Every field, including ones not listed above, is carried into api.json under the topic's meta for the cityrp-docs MCP server — except hidden, since a hidden page has no api.json entry to carry it on.
Hiding prunes backwards through the folder tree: if every doc in a folder is hidden — its own pages and, recursively, everything in its subfolders — the folder itself has nothing left to show and disappears from the sidebar too, the same way an empty folder always would. Hide only some of a folder's docs and the folder stays, just without the hidden ones.
Promoting a page to be the folder index
A folder whose landing page should be one of its own docs — rather than a summary written for the purpose — marks that doc index: true. docs/design/clans/design.md does this: it already explains what clans are, so the Clans sidebar entry points straight at it.
A doc named after the folder it sits in is promoted the same way without needing the flag, since that naming almost always means "this is the overview". docs/design/car-boosting/car-boosting.md is the Car Boosting and Ringing entry for its folder on that basis alone. The name has to match in full: car-boosting-live-test-plan.md is not a match.
Precedence, most deliberate first — index: true, then an index.md, then a folder-name match:
| Folder contains | Landing page |
|---|---|
a doc with index: true |
that doc, whatever it's called |
index.md |
index.md, with the generated listing below its prose |
<folder-name>.md |
that doc |
| none of the above | a generated page listing the folder |
A promoted page keeps its own URL (topics/design/clans/design.html, not index.html), so links written to it still resolve, and it's written once rather than existing at two addresses. It drops out of the folder's own list, since the folder entry above is now the way to reach it, and its content stands in for the generated listing rather than being prefixed to it. Links down into any subfolders are still appended, so promoting a page can't orphan a folder nested below it.
The folder's other pages are then reachable from the sidebar, which is unfolded whenever you're on any page in that folder — including the promoted one. If you want a listing as well as prose, use an index.md instead: that keeps the generated table and puts your text above it. Where two of the three mechanisms are present in one folder the table above decides, and the loser becomes an ordinary page in the list; that isn't an error, but it's worth not doing on purpose.
Pinning is scoped to the list the doc is already in — a pinned top-level doc rises within Topics, a pinned doc inside a folder rises within that folder's sidebar list and its index table. It doesn't lift a doc out of its folder, and folders themselves can't be pinned; they always sit below the loose files. Pinning several docs keeps them in filename order relative to each other.
Function/variable-level: use real LuaCATS annotations
Write these as --- @tag ... directly above the function/assignment they describe, no blank line. generate.py reads the native args/returns LuaLS already resolved from the function signature itself (so types are accurate even if you never repeat them) and only borrows the tag's text for the human-readable description.
| Annotation | Syntax | Notes |
|---|---|---|
@param |
--- @param <name> <type[?][|type...]> [description] |
Primary way to document a parameter. ? suffix on the type marks it optional. |
@return |
--- @return <type> [<name> [comment]] |
Primary way to document a return value. One @return line per return value, in order. |
@class |
--- @class [(exact)] <name>[: <parent>...] |
Defines a struct/type, optionally with inheritance. |
@field |
--- @field [scope] <name> <type[?]> [description] |
Documents a field of a @class. scope is public/protected/private/package. |
@type |
--- @type <type> |
Declares the type of a variable, e.g. --- @type string[]. |
@generic |
--- @generic <name> [:parent_type] |
Generic type placeholder for reusable code. |
@overload |
--- @overload fun([param: type...]): [return_type] |
Documents an additional call signature. |
@alias |
--- @alias <name> <type> |
Names a type (or enum) for reuse elsewhere. |
@enum |
--- @enum [(key)] <name> |
Marks a table as an enum, usable at runtime. |
@cast |
--- @cast <value_name> [+|-]<type|?>[, ...] |
Narrows/widens a variable's type from this point on. |
@deprecated |
--- @deprecated |
Marks the item outdated (LuaLS strikes it through in editors). This is the LuaCATS spelling — see also the codebase's own @deprecated callout below, which the two share. |
@nodiscard |
--- @nodiscard |
Warns if the return value is ignored at the call site. |
@async |
--- @async |
Marks a function as asynchronous. |
@see |
--- @see <symbol> |
Cross-reference to another documented symbol. |
@operator |
--- @operator <op>[(param_type)]:<return_type> |
Documents an operator metamethod (__add, etc). |
@package / @private / @protected |
--- @package / --- @private / --- @protected |
Restricts where the item is considered accessible. |
@version |
--- @version [<|>]<version> |
Required Lua/LuaJIT version. |
@meta |
--- @meta [name] |
Marks a whole file as definition-only (not real, executable code). |
@as |
--[[@as <type>]] |
Inline cast forcing a type onto an expression. |
@diagnostic |
--- @diagnostic <state>:<diagnostic> |
Suppresses/enables a specific LuaLS diagnostic. |
Two annotations are explicitly not used here, even though LuaLS supports them:
@module— LuaLS reserves this for its ownrequire()-return-type inference and silently drops any doc comment that uses it. Use the codebase's own@namespacetag instead (see below) — this is the single most common mistake when porting old LDoc comments.@vararg— deprecated by LuaLS itself in favor of--- @param ... <type> [description]for documenting varargs.
Only @param and @return currently have their description folded back into the rendered page by generate.py (by parameter name, and by return position, respectively) — the other annotations above are understood natively by LuaLS/your editor for hover/autocomplete/type-checking, but generate.py doesn't render most of them specially yet.
Minimal example:
--- Gets the value of an env-var.
--- @param name string The env-var to get
--- @return string The value found, nil otherwise
function ENV:Get(name)
...
end
Function/table-level: this codebase's own tags
These are custom to generate.py, not LuaCATS — but like @param/@return, they're written directly above the function or table they describe, with no blank line, since generate.py only reads the tags attached to that specific declaration's own doc comment.
Important
Write these with a space after the dashes —
--- @state server, not---@state server.LuaLS parses
---@tagas an annotation and drops any tag it doesn't recognise, so the tag never reachesgenerate.pyand the whole line vanishes with no error.--- @tagis description text as far as LuaLS is concerned, which is exactly how these survive into the export forgenerate.pyto re-parse. The one@usagein the tree was written---@usageand had therefore never rendered anything.
The tags:
| Tag | Purpose |
|---|---|
@section <title> |
Starts a new named subsection on the module page. Every following item in the file (until the next @section) is grouped under it, so in practice it's usually only written once per group rather than on every function. The tag's own value is the heading, so write it as you want it read — @section Rank Colours. A one-word lowercase slug (@section functions, the older form) is title-cased into "Functions". |
@state <word> |
One of client, server, menu, shared, global, clmenu — drives the realm badge shown next to the item. If a function omits it, it falls back to whatever @state the file's header comment declared. |
@example <code> |
Renders an "Example" code block after the parameters and returns. Continuation lines are kept as lines, so a multi-line example stays multi-line. Not @usage — see below. |
Callout tags
Seven tags render as callout boxes, in the manner of the GMod wiki's note boxes. They come out as the same boxes a topic gets from > [!WARNING], so the site has one visual language for "read this before you use it" rather than two, and each has its own colour.
They always appear in this order on an entry — whether you should be using this at all, then what is wrong with it, then advisories — regardless of the order they were written in:
| Tag | Box | Stock text when no text is given |
|---|---|---|
@deprecated [text] |
Deprecated | "This function is deprecated and may be removed in a future version." |
@internal [text] |
Internal | "This function is internal. Whilst you can call it, you probably shouldn't." |
@bug <text> |
Bug | — |
@warning <text> |
Warning | — |
@ambig [text] |
Ambiguous | "This entry is unclear or incomplete." |
@validate [text] |
Validate | "This entry has not been checked against the code recently." |
@note <text> |
Note | — |
@bugs, @warns, @notes and @ambiguous are accepted as alternative spellings. Several spellings of the same tag on one function collapse into a single box rather than producing two. The text is rendered as markdown, so links and code work inside a callout.
Topics can use any of these as GFM alerts too — > [!BUG], > [!DEPRECATED] — alongside the standard [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] and [!CAUTION].
--- Old warrant lookup, kept for legacy callers.
--- @deprecated Use cityrp.warrants.issue instead.
--- @bug Does not account for expired warrants.
--- @state server
--- @example cityrp.warrants.legacyLookup(ply)
function cityrp.warrants.legacyLookup(ply)
...
end
Not @usage: it is an LDoc tag, and standards-diff.py rejects it in CI. Supporting it here would mean the docs rendering something nobody is allowed to write.
Constant families: @alias gathers the constants it names
A page that declares a long run of prefixed constants — the 87 COL_* colours, the 26 TEAM_*
jobs — would otherwise list every one of them as a top-level entry, interleaved alphabetically with
everything else the page declares. An @alias over those constants turns them into a section of
their own instead: the alias becomes the heading, its constants sit beneath it, and it gets its own
line in the page's Contents.
--- Every colour the gamemode ships as a constant.
--- @section Colours
--- @alias COL
---| `COL_WHITE`
---| `COL_BLACK`
---| `COL_UI_GREY`
--- @section Generic
COL_WHITE = Color(255)
COL_BLACK = Color(0)
--- @section Limelight UI Guidelines
COL_UI_GREY = Color(41, 41, 41, 230)
- The alias's description and
@sectionare its own —@sectionnames the heading (defaulting to the alias name, soCOLwithout one), and the description renders as the section's intro. @sectionon the constants themselves subdivides the family, exactly as it groups functions elsewhere on the page.- The constants must be documented on the same page as the alias. An alias whose members are string
literals (
@alias WARRANT "bolo" | "search" | "arrest") or other types (@alias Colour \COL` | `Color``) has no constants to gather, so it stays an ordinary entry — rendered as an Accepted values list rather than the raw code block LuaLS hands over. - Two constants is the minimum; below that a heading costs more than it saves.
Deep links are unaffected: #COL_WHITE still lands on the constant, and #COL now lands on the
family heading it used to be an entry in.
Class pages: @namespace <ClassName> gathers a class's members
A metatable class is re-opened all over the tree — Player in nine files, so that a library can hang
one helper on it without owning the whole metatable. Each member would otherwise land on the page of
whichever file happens to declare it, or, if that file claims no @namespace of its own, on a page
derived from a different file's path entirely.
Declaring @namespace <ClassName> on one of the class's files makes that the class's page, and every
<ClassName>.member follows it there, wherever it was written:
--- Shared Player Metafunctions.
--- @namespace Player <-- claims the page
--- @class Player
local pMeta = FindMetaTable("Player")
--- Access and Flags - Server.
--- @namespace cityrp.access <-- this file's own page
--- @class Player
--- @field BlacklistTime fun(self: Player, access: string): integer|string
local pMeta = FindMetaTable("Player")
ply:BlacklistTime() renders on Player; cityrp.access.hasAccess stays on cityrp.access.
Both are declared in the second file.
This is opt-in, and deliberately so. Player, GM, PLUGIN, ITEM, CONSOLE and
WEIGHTED_RANDOM claim a page this way. A @class that exists to describe a library's own table —
Pass, Logger, RankData — declares no such namespace, so its fields stay on that library's page
next to the thing they describe, which is where they belong.
The page's own intro comes from a file that claims the namespace, not from whichever contributor file sorts first, so the Player page does not introduce itself as "Access and flags - server.".
Metatables and panels get their own sidebar sections
Two groups are split out of the flat Modules list, both classified by where the code lives rather than by a tag every file has to remember:
- Metatables — a
@classdeclared ingamemode/core/metatables/.Player,EntityandVehicle. These claim a page the same way a@namespaceclass does without needing to declare one:Playeralready did, butEntityandVehicledeclare only@class, so their members were scattering onto whichever library page LuaLS listed first — exactly the problem the@namespaceopt-in exists to solve, never applied to them. - Panels — a module whose members all come from a
derma/folder. "All", not "some", so a library that happens to define one panel helper stays under Modules.
Neither needs anything added to a Lua file. A section with nothing in it is omitted, so the Panels
heading only appears once derma files are documented — today almost none are, since a panel's methods
hang off a file-local PANEL table that LuaLS can't attribute to anything.
File-header tags: this codebase's own set
The very first comment in a file (--- line comments, blank line before the code is fine) is scanned separately for these tags — they are not LuaCATS and LuaLS doesn't know about them:
| Tag | Purpose |
|---|---|
@namespace <name> |
Not @module. Groups every doc'd item in this file onto one page named <name>, merging with any other file that declares the same namespace (e.g. a cl_*.lua/sv_*.lua pair). Without it, the page falls back to the file's own dotted path (core.libraries.sv_foo). Must not contain ... Only makes sense here — write it once, on the file header. |
@state <word> |
Same word list as the function-level tag above. Written on the file header, this becomes the default realm badge for every item in the file that doesn't declare its own @state. |
@copyright, @release, @author |
Accepted and parsed, but not currently rendered anywhere on the generated site — keep them for humans reading the source, don't rely on them showing up on the page. |
@hidden [reason] |
Drops the whole namespace from the build — no module page, no sidebar/index/search/api.json entry, nothing to link to from another page's prose. The code-side equivalent of a topic's hidden: true front matter, for a plugin whose functions shouldn't be documented publicly (a secure/anti-cheat plugin, say) without needing a generate.py edit for every one. |
Write the file header as a run of --- lines:
--- One-line description.
--- @copyright 2015-2026 Limelight Gaming
--- @author Someone
--- @namespace some.namespace
@hidden takes effect from any file that contributes to the namespace — put it on just one side of a cl_*.lua/sv_*.lua pair sharing an @namespace and the whole merged page is hidden, not only that file's own items. That's deliberate: a secure plugin's client and server halves should disappear together, without needing the tag repeated on every file that happens to declare the same namespace.
Putting it together
--- Player-facing warrant helpers.
--- @namespace cityrp.warrants
--- Issue a warrant against a player.
--- @param ply Player The player to warrant.
--- @param reason string Why the warrant was issued.
--- @return boolean Whether the warrant was successfully issued.
--- @state server
function cityrp.warrants.issue(ply, reason)
...
end