carlos-algms / agentic-lua-class
Enforced Lua style: class pattern, visibility prefixes (_private, __protected), optional-type syntax, LuaCATS annotations. Use before editing any .lua file; skipping it causes luals and selene failures.
Lua in agentic.nvim
Class shape
--- @class agentic.ui.Counter
--- @field label string
--- @field _count integer
local Counter = {}
Counter.__index = Counter
--- @param label string
--- @return agentic.ui.Counter
function Counter:new(label)
return setmetatable({ label = label, _count = 0 }, self)
endExtending a class or adding inheritance: read references/inheritance.md first.
Lua 5.1, not 5.4
Neovim runs LuaJIT 2.1, which follows Lua 5.1.
table.packandtable.unpackarenilat runtime. Useunpackand{ ... }withselect("#", ...)goto/::label::and the\zstring escape run in LuaJIT, but Selene
(std = "vim", Lua 5.1 parser) rejects them: parse errors forgoto,bad_string_escapefor\z.make validatefails
a and b or c is not a valid ternary
It returns c whenever b is false or nil, even when a is true. Use it
only when b can never be false or nil; otherwise write an if:
-- Bad: returns "default" when opts.wrap is false
local wrap = opts.wrap ~= nil and opts.wrap or "default"
-- Good
local wrap = opts.wrap
if wrap == nil then
wrap = "default"
endNamed locals over inlined calls
Keep an intermediate result in a named local instead of inlining the call,
especially in a for header:
-- Bad: StyLua wraps the header, and LuaLS infers less
for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(self:get_visible_tab_id())) do
-- Good
local all_windows = vim.api.nvim_tabpage_list_wins(tab_id)
for _, winid in ipairs(all_windows) doNever inline an existing named local during a change.
Private methods over module-level locals
Prefer a private method (function Name:_helper()) over a module-levellocal function, even when the method does not use self:
Methods can sit anywhere in the file; module-level locals must be defined
before their first use- Tests can mock methods but not local functions
- Do not convert a method to a local only because
selfis unused
LuaLS does not narrow on reassignment
Reassigning a variable does not narrow its type. Afterlocal lines, err = fn(); lines = lines or {}, LuaLS still types lines asstring[]|nil. Introduce a new local instead:
-- Bad: `lines` stays `string[]|nil`
local lines, err = read_lines(path)
lines = lines or {}
-- Good: `lines` is `string[]`
local result, err = read_lines(path)
local lines = result or {}LuaLS resolves types across files. A type annotation can move to another file
during a refactor (for example payload types into acp_payloads.lua) as long as
its name stays the same.
Visibility
Expose a field only when other modules read it. Everything else is private.
Visibility prefixes are configured in .luarc.json:
_name: private. Class methods and fields only__name: protected, visible to subclasses. Add--- @protected(a LuaLS
limitation); private members need no@private- no prefix: public
Module-level locals never take _: local function helper() andlocal config = {} are already private by scope. local function _helper()
is wrong.
--- @class agentic.ui.Counter
--- @field label string read by other modules
--- @field _count integer internal state
local Counter = {}
Counter.__index = CounterLuaCATS annotation syntax
Space after --- for descriptions and annotations. Do NOT write param/return
descriptions unless requested. Group related annotations together.
Return format
@return {type} return_name description (type first, then name).
- RIGHT:
@return boolean success Whether the operation succeeded - WRONG:
@return boolean Whether the operation succeeded(missing name) - WRONG:
@return success boolean(wrong order)
Optional types
Format depends on annotation type. See
LuaLS issue #2385
for the underlying validator limitation.
@param and fun() - MUST use type|nil:
- RIGHT:
@param winid number|nil - RIGHT:
@param callback fun(result: table|nil) - WRONG:
@param winid? number(LuaLS does not validate optional syntax) - WRONG:
fun(result?: table)(optional syntax ignored)
@field - Use variable? type:
- RIGHT:
@field _state? string - RIGHT:
@field diff? { all?: boolean }(inline tables also use?) - WRONG:
@field _state string|nil(use?here instead) - WRONG:
@field _state string?(?goes after variable name, not type)
For a partial variant of an existing class, use @class (partial) extending the
source type instead of re-declaring every field as optional.
- RIGHT:
@class (partial) MyOptsOverride: MyOpts - WRONG: re-listing
@field field? typefor every field fromMyOpts
@return, @type, @alias - Use explicit type|nil:
RIGHT:
@return string|nil result,@type table<string, number|nil>,@alias MyType string|nil- WRONG: trailing
?on the type (e.g.string?,number?)
Typed variables before return
LuaLS cannot infer types from inline returns of complex types. Use a typed
intermediate variable:
-- Bad: LuaLS cannot infer the return type
function M.create_block(lines)
return {
start_line = 1,
end_line = #lines,
content = lines,
}
end
-- Good: Type annotation enables proper type checking
--- @return MyModule.Block block
function M.create_block(lines)
--- @type MyModule.Block
local block = {
start_line = 1,
end_line = #lines,
content = lines,
}
return block
endAppending to typed arrays
Use arr[#arr + 1] = value, not table.insert(arr, value), when arr has a
LuaCATS element type (string[], agentic.acp.Content[], a typed field, etc.).
table.insert's second arg is variadic any, so LuaLS skipsassign-type-mismatch (an Error in .luarc.json). The indexed-assignment form
is type-checked against the element type and catches wrong-type appends and
stale @return / @type annotations.
- RIGHT:
lines[#lines + 1] = value(flags a non-string pushed tostring[]) - WRONG:
table.insert(lines, value)(push silently accepted)
table.insert stays fine for positional inserts (table.insert(t, i, v)) and
untyped scratch tables where no element type is declared.