Annotations
Annotations are compile-time metadata that can rewrite the IR. They are Nebra's answer to decorators (TypeScript / Python), attributes (C#), and macros (Rust): a way to attach behavior to declarations without changing the call sites.
@deprecated("use Vec2 instead")
function oldVec(x: number, y: number): { x: number, y: number }
return { x = x, y = y }
end
When the compiler encounters @deprecated, it looks up the annotation's definition, hands it the target declaration as a serialized IR tree, and runs the annotation's logic in a sandboxed Lua interpreter. The annotation can mutate the declaration, emit warnings, replace it with a new node, or wrap it - whatever its implementation does.
Declaring an annotation
An annotation is a regular Nebra function that uses the @annotation meta-annotation:
-- lib/deprecated.neb
import { Diagnostic } from "nebra:ir" -- IR helpers ship in the stdlib
@annotation(targets = [AnnotationTarget.Function, AnnotationTarget.Method])
export function deprecated(target, args)
local message: string = args.message or "deprecated"
target.annotations.push({
kind = "warning",
text = "this " .. target.kind .. " is deprecated: " .. message,
})
return target -- pass through unchanged; the warning shows on every call site
end
The targets array names which declaration kinds this annotation is allowed to decorate - see stdlib/annotation.d.neb for the full enum (Function, Method, Class, Interface, Field, Parameter, Variable, ...).
How an annotation runs
@deprecated("use Vec2 instead") function oldVec(...) ... end
│
▼
[ResolveAnnotations pass] Discover annotation source files (via [annotations] in nebra.toml)
│ Parse them, register exported `@annotation`-marked functions
▼
[ApplyAnnotations pass] For each annotated declaration:
1. Serialize the target IR node to a Lua table tree
2. Spin up a sandboxed NebraRuntime
3. Push the table + the annotation arguments
4. Call the annotation function
5. Deserialize the return value back into IR
6. Replace the original declaration in-place
The sandboxed runtime has access to:
- Standard Lua (
string,table,math,pcall, ...) - The
irhelper module (loaded as a global; fromruntime/ir_helpers.lua)
It does not have access to: io, os, package, require, load, dofile, debug. Annotations cannot read your filesystem, fetch URLs, or escape the compile.
Important note: Annotations run under a specific configuration which e.g. has its index base back to normal Lua behavior (1-based) regardless of the project's configured.
Registering annotation files
Tell the compiler where annotation definitions live:
# nebra.toml
annotations = [
"lib/annotations", # directory: every .neb in here is scanned
"lib/special.neb", # individual file
]
Every exported function with an @annotation(...) meta-annotation becomes available project-wide.
Annotation arguments
Call syntax mirrors function calls. Positional and named arguments both work:
@deprecated("use Vec2") -- positional
@deprecated(message = "use Vec2") -- named
@stable -- no args
@route(path = "/health", method = "GET")
In the annotation handler, args is a Dictionary<string, any> - positional values are bound by parameter order from the annotation function's declared signature, so the handler can write args.message regardless of call style.
Targets
Annotations attach to specific kinds of declarations. The targets array in @annotation is a whitelist - using @deprecated on a class when the annotation only declared targets = [Function] is an error.
| Target enum value | Applies to |
|---|---|
Function | Top-level function / local function |
Method | Class methods |
Class | class declarations |
Interface | interface declarations |
Field | Class instance fields |
StaticField | Class static fields |
Property | Getter / setter accessors |
Variable | local / local mut declarations |
Parameter | Function parameters |
Module | declare module "..." blocks |
Enum | enum declarations |
EnumMember | Individual enum members |
Built-in annotations
A few annotations ship with the stdlib:
@deprecated(message?)- mark a symbol as deprecated; emits a compile-time warning at every call site.@inline- hint that a function should be inlined when called (codegen heuristic; not guaranteed).@pure- declare that a function has no side effects; enables more aggressive dead-code-elimination of unused return values.@experimental- emit a one-time warning per file that references the symbol.
The full list lives in stdlib/annotation.d.neb.
Compiler builtins
A separate, smaller set of annotations is recognised directly by the
compiler - they don't go through the user-script annotation pipeline,
can't be redefined or rewritten, and don't need an [annotations] entry in
nebra.toml. They mutate compile-time behaviour rather than IR.
-
@side(client | server | shared)- multiplayer-sandbox execution-side scoping for declarations. See Sides for the full mental model. -
@overrideCtor("template")- overrides hownew ClassName(args)lowers to Lua. Applied to aclass(ordeclare class) decl. The template is a format string with two placeholders:$class- the resolved class identifier$args- the comma-separated rendered arguments
Example: nanos-world exposes every class as a global call (
Player(args), notPlayer.new(args)). Thenanos-world-typespackage emits@overrideCtor("$class($args)")on every class so:-- sourcelocal p = new Player()-- generated Lualocal p = Player()When the template has no
$argsplaceholder, the compiler appends a default(args)call so the arguments are never silently dropped.
Writing a non-trivial annotation
Here's @memoize - wraps a function so it caches results by argument tuple:
-- lib/memoize.neb
@annotation(targets = [AnnotationTarget.Function])
export function memoize(target, _args)
local original = target.body
local cacheVar = ir.newName("__memo_" .. target.name)
target.body = ir.block({
ir.localDecl(cacheVar, ir.table({})),
ir.functionDecl(target.name, target.params, ir.block({
-- if cache[k] ~= nil then return cache[k] end
-- local v = (original)(...)
-- cache[k] = v
-- return v
...
}))
})
return target
end
The ir helper module exposes constructors for every node kind (statements, expressions, declarations) so annotations can build syntax trees ergonomically without bookkeeping NodeIDs.
When NOT to use an annotation
Annotations are powerful and run at compile time, which makes them tempting. Don't reach for them when:
- A plain function would do (e.g.
validate(myValue)instead of@validate myValue). - The behavior depends on runtime state - annotations only see compile-time IR.
- You need it just to suppress a warning - use the relevant
[rules]knob or--allow-Xinstead.
Annotations are best for: deprecation tracking, performance hints, registering symbols (e.g. routes, event handlers), generating boilerplate, runtime type guards.