Skip to main content

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 ir helper module (loaded as a global; from runtime/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 valueApplies to
FunctionTop-level function / local function
MethodClass methods
Classclass declarations
Interfaceinterface declarations
FieldClass instance fields
StaticFieldClass static fields
PropertyGetter / setter accessors
Variablelocal / local mut declarations
ParameterFunction parameters
Moduledeclare module "..." blocks
Enumenum declarations
EnumMemberIndividual 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 how new ClassName(args) lowers to Lua. Applied to a class (or declare 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), not Player.new(args)). The nanos-world-types package emits @overrideCtor("$class($args)") on every class so:

    -- source
    local p = new Player()
    -- generated Lua
    local p = Player()

    When the template has no $args placeholder, 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-X instead.

Annotations are best for: deprecation tracking, performance hints, registering symbols (e.g. routes, event handlers), generating boilerplate, runtime type guards.