LuaCATS and annotated Lua#

LuaCATS describes an existing Lua API with comments such as ---@class, ---@field, and ---@param. Nupp always reads recognized LuaCATS, EmmyLua, and typed LuaDoc comments in an ordinary .lua module: no manifest key enables it and no setting disables it.

---@alias UserId integer

---@class User
---@field id UserId
---@field name? string

local users = {}

---@param id UserId
---@return User
function users.find(id)
    return { id = id, name = "Ada" }
end

return users
local users = {}

local type UserId = integer
type users.UserId = UserId

local interface User
    id: UserId
    name: string?
end
type users.User = User

function users.find(id: UserId): User
    return { id = id, name = "Ada" }
end

return users

The Lua source remains the runtime source; the imported aliases, interfaces, parameters, results, local types, varargs, and overloads exist only while checking. Each example below pairs annotated Lua with a normalized .g.nupp spelling of the facts Nupp derives from it. The Nupp view is not generated runtime code: it omits the now-redundant comments and arranges declarations for readability, where migrate preserves the comments when it writes the real file.

A class comment becomes an interface, and Nupp does not invent a constructor or change how its tables are built. find has type function(id: UserId): User, and both UserId and User are published as type-only members of the returned module.

Unions, collections, and several results#

LuaCATS arrays and generic tables map to Nupp arrays and indexers. Separate return tags form one result pack.

---@param status "queued"|"running"|"done"
---@param tags string[]
---@param counts table<string, integer>
---@return boolean
---@return string?
local function describe(status, tags, counts)
    return counts[status] ~= nil, tags[1]
end

return describe
local function describe(
    status: "queued" | "running" | "done",
    tags: {string},
    counts: {[string]: integer}
): boolean, string?
    return counts[status] ~= nil, tags[1]
end

return describe

Literal types, unions, optionals, arrays, maps, shapes, tuples, and function types use the same checked meanings they have in Nupp syntax after their common dialect spellings are translated.

Local types and casts#

@type applies positionally to names in a local declaration. A positive @cast adds an explicit checker assertion without changing the Lua value.

---@type string, integer
local name, count = "Ada", 1

local payload = decode()
---@cast payload table<string, string>
payload.name = name

return payload, count
local name: string, count: integer = "Ada", 1

local payload = decode()
payload = payload as {[string]: string}
payload.name = name

return payload, count

A @type attached to a later assignment is also an assertion at that assignment boundary. Negative casts, and casts of expressions rather than local names, cannot be represented and recover instead.

Varargs#

---@param prefix string
---@vararg integer
---@return string
---@return integer
local function summarize(prefix, ...)
    return prefix, select("#", ...)
end

return summarize
local function summarize(prefix: string, ...: integer): string, integer
    return prefix, select("#", ...)
end

return summarize

The vararg tag describes every value in ...; it does not package them into an array or change Lua's value-adjustment rules.

Overloads#

Every @overload signature joins the parameter-and-return signature as an intersection. Calls are checked against the complete overload set.

local legacy = require("legacy")

---@overload fun(value: string): integer
---@param value integer
---@return string
local function normalize(value)
    return legacy.normalize(value)
end

return normalize
local legacy = require("legacy")

local type Normalize = function(value: string): integer
    & function(value: integer): string

local normalize: Normalize = function(value)
    return legacy.normalize(value)
end

return normalize

The function body is checked against the primary @param and @return signature. Overloads describe additional callable contracts; they do not select a body or add a runtime dispatcher.

Typed LuaDoc#

Typed LuaDoc puts the type before the parameter name. Nupp recognizes that ordering as well as the LuaCATS and EmmyLua forms above.

-- @tparam string name name to greet
-- @treturn string the greeting
local function greet(name)
    return "Hello, " .. name
end

return greet
local function greet(name: string): string
    return "Hello, " .. name
end

return greet

Plain LuaDoc @param is prose when the LuaDoc dialect is selected; @tparam and @treturn carry types. The interoperable subset across the three dialects needs no configuration: primitives, named and literal types, unions, optionals, arrays, maps, shapes, tuples, functions, varargs, classes, aliases, fields, parameter and result tags, and callable overloads.

Recovery#

An annotation is never allowed to make the Lua file unreadable. A malformed or unsupported type becomes any at the narrowest affected position and reports a NUPP1008 warning. A malformed overload is omitted. Other annotations in the same block still apply.

A declaration file is never allowed to make the rest of its tree unreadable either. A file in a kind = "types" dependency that cannot be read, parsed, or checked is skipped and reported as a NUPP1009 warning against the file, and the declarations beside it are imported as usual. A pinned upstream tree is somebody else's source: one corner of it spelled in a way this importer has yet to learn costs that corner, not the API.

Foreign generic declarations currently recover their generic positions to any, because Lua annotation dialects do not state Nupp's ownership mode or result-preservation contract:

---@generic T
---@param value T
---@return T
local function keep(value)
    return value
end

return keep
local function keep(value: any): any
    return value
end

return keep

The generic comment remains in place for a later manual contract. Nupp does not invent one or weaken public capability checks.

Migration#

The migrate command dispatches by file extension, and for annotated .lua the destination is the same module at .g.nupp:

nupp migrate --check --json src/users.lua
nupp migrate src/users.lua

--check returns the destination, complete text, edits, and warnings without writing. The writing form refuses an existing destination, writes the destination atomically, parses and checks it, then removes the source only after that check succeeds. --dialect accepts auto, luacats, emmy, or luadoc as an ambiguity hint for migration; it does not control ordinary annotation ingestion.

The Visual Studio Code action Migrate annotated Lua to Nupp sends the current unsaved text to the same planner. Nupp does not register its full language service for Lua, so LuaLS can continue to own Lua completion, formatting, and semantic highlighting. VS Code asks for confirmation before applying the create-and-remove workspace edit.

LuaLS compatibility corpus#

The repository carries no vendored upstream corpus and the ordinary suite is offline. Run the explicit compatibility task when changing the importer:

nupp task annotated-lua-corpus

The task lazily downloads a pinned MIT-licensed LuaLS source archive, verifies its SHA-256 digest, caches it under build/corpus, and feeds every real annotation comment in every .lua file through the importer. Recoverable warnings and files using Lua syntax outside LuaJIT's grammar are counted. Every syntax-compatible annotated file is also passed through the checker. An importer or checker crash, or a dropped annotation, fails the task.

Shared type definitions#

A kind = "types" dependency imports a whole LuaCATS definition tree as ambient checker facts. Use this for APIs supplied by a host or another external tool, rather than copying an adapter into the project.

nupp.lua
return {
   dependencies = {
      host_api = {
         kind = "types",
         format = "luacats",
         source = {
            git = "https://example.com/host-api.git",
            rev = "<full commit id>",
         },
         path = "library",
      },
   },
}

format = "luacats" reads only .lua declaration files beneath path. Their annotations and ambient assignments become globals and types available while checking the project. The files are never executed, generated into the output, or added to package.path.

Pin the source#

source.rev is required and must name a full Git commit ID. On nupp check or nupp build, Nupp resolves that revision under .nupp/deps/host_api. The language server resolves the same revision the first time it reads the project, so an editor types a project the way its build does. A checkout already at the pinned revision is confirmed locally and nothing is fetched, which is every open after the first; the one that does fetch says so in the server's log.

To update a definition, choose a new upstream commit and change source.rev. That keeps a project's checked API reproducible and makes the type-surface change visible in version control.

Runtime limits#

A type dependency does not install a runtime package. If the host provides a global, the definition merely describes that global. If the program needs an ordinary Lua module at runtime, obtain it through the host, LuaRocks, or the project's own deployment process.

The provider is intentionally generic: future readers can use the same kind = "types" lifecycle and select their representation through format. Today, luacats is the supported format.