Annotations
An annotation attaches something to a construct that the construct itself does not say: a
custom section for a tool to read, a hint about which way a branch goes, or a requirement that
a function be inlined. Every annotation starts with @, the same marker the WebAssembly text
format uses for its own (@name ...) annotations.
There are five, in two kinds:
| Annotation | Kind | Effect |
|---|---|---|
@custom("n", "p") | descriptive | emits a custom section |
@likely / @unlikely | descriptive | records a branch hint |
@deprecated("note") | descriptive | warns at each use, and marks the entry in generated docs |
@inline | directive | requires a function's calls to be substituted |
The first four cannot change what your program does — they are information about the
module. @inline is different: it changes what the emitted module contains, and it restricts
what you may do with the function. It still cannot change what an accepted program computes;
it can only reject one.
@custom — custom sections
@custom("name", "payload"); emits a WebAssembly custom section. A custom section is by
definition not interpreted by the engine, so the audience is a tool: a debugger, a linker, a
metadata reader, wasm-tools objdump.
@custom("producers", "reedc");
export func check as "check";
func check() -> i32 { return 7; }
That emits, at the end of the module:
;; @custom("producers", ...)
(@custom "producers" "reedc")
and shows up in the binary as a real section:
$ wasm-tools objdump out.wasm
types | ...
functions | ...
exports | ...
code | ...
custom "producers" | 0x5b - 0x60 | 5 bytes | 1 count
The payload is optional. Omitting it emits an empty section, which is how a marker is spelled:
@custom("my.tool.processed");
Both strings are byte strings, not identifiers. A section name usually contains a . or
a -, which no Reed identifier allows, and the payload is arbitrary bytes. Escaping into
WAT is handled for you, so a quote or a control character in a payload is emitted correctly
rather than producing WAT that does not parse:
@custom("weird", "tab\there and \"quotes\"");
export func check as "check";
func check() -> i32 { return 2; }
(@custom "weird" "tab\09here and \"quotes\"")
A section name is in no namespace. It resolves to nothing, collides with nothing, and may
be repeated — duplicate custom sections are meaningful in the binary format. For the same
reason pub cannot be applied to one: there is no name to make visible.
Placement
An optional clause positions the section relative to the module's standard sections, which matters to a tool that cares whether its section precedes the code:
@custom("early") before first;
@custom("pre.code", "a") before code;
@custom("post.data", "b") after data;
@custom("late", "c") after last;
export func check as "check";
func check() -> i32 { return 1; }
The four targets are first, code, data, and last, each usable with before or
after. With no clause, placement is up to the compiler.
@likely / @unlikely — branch hints
A branch hint goes in front of an if and says which way the condition usually goes. It
lowers to an entry in the metadata.code.branch_hint custom section, which an engine's
optimizing compiler may consult and may equally ignore.
export func check as "check";
func check() -> i32 {
var total: i32 = 0;
for i in 0..8 {
@unlikely if (i == 3) {
total = total + 100;
} else {
total = total + 1;
}
}
@likely if (total > 0) { return total; }
return 0;
}
That returns 107 — seven iterations adding 1, plus one adding 100. The hints changed
nothing about it, which is the point: they are advisory, and a hint that altered behavior
would be a bug.
(@metadata.code.branch_hint "\00") ;; @unlikely hint
(if (i32.eq ...) (then ...) (else ...))
There are two hints, not a probability. The branch-hint section encodes exactly one bit
per branch, so a @likely(0.75) would be a number the format cannot represent and the
compiler would have to quietly round it.
A hint must attach to an if. It describes one specific branch instruction, so there is
nothing for a floating one to annotate:
@likely return 0;
syntax error: '@likely' annotates an 'if' statement, but found keyword 'return'
It works on every condition form, including the narrowing ones, which lower to a branch just as a plain condition does:
struct Widget { size: i32 }
export func check as "check";
func check() -> i32 {
let thing: ?any = new Widget { size: 5 };
@likely if (thing is &Widget) {
return thing->size;
}
return 0;
}
Each arm of an else if chain takes its own hint, since each is a separate branch:
@likely if (a) {
...
} else @unlikely if (b) {
...
}
@inline — always-inlined functions
@inline on a function requires every call to it to be replaced by its body. Unlike the other
three annotations this is a requirement, not a hint: the function is not emitted at all.
@inline
func double(a: i32) -> i32 { return a * 2; }
export func check as "check";
func check() -> i32 { return double(3) + double(18); }
That returns 42, and the emitted module contains no double function and no call $double.
Each call site instead holds:
;; inlined double(...)
(block $inline_double_0 (result i32)
(local.set $a_1 (i32.const 3))
;; return a * 2;
(br $inline_double_0 (i32.mul (local.get $a_1) (i32.const 2))))
That shape is a block with the callee's result type, which is exactly what an inlined call
is — the parameter becomes a fresh local of the calling function, and return becomes br
to the end of the block.
@inline goes before pub, since it describes the function rather than its visibility:
@inline
pub func double(a: i32) -> i32 { return a * 2; }
What it guarantees
An argument is evaluated exactly once, in source order, even when the parameter is read several times. This is the classic macro-inlining bug, and inlining here binds the argument rather than pasting it:
global Calls: mut i32 = 0;
func next_value() -> i32 {
Calls = Calls + 1;
return 10;
}
@inline
func triple(a: i32) -> i32 { return a + a + a; }
export func check as "check";
func check() -> i32 {
let tripled: i32 = triple(next_value());
// 30 + 1*1000. Pasting the argument per use would give 30 + 3*1000.
return tripled + Calls * 1000;
}
A return leaves the inlined body, not the caller. Otherwise the caller's remaining work
would be abandoned — a wrong answer rather than an error:
@inline
func clamp_low(a: i32) -> i32 {
if (a < 0) {
return 0;
}
return a;
}
export func check as "check";
func check() -> i32 {
// The `return 0` ends only the first inlined body; the second term still runs.
return clamp_low(0 - 5) + clamp_low(7);
}
An inline function can call other functions, inline or not, and nest freely.
What it forbids
Because the function is not emitted, a direct call is the only way to use it. Every other route is an error rather than a silent fallback to a real call:
export func double as "double";
error: 'double' is an '@inline' function, so it is not emitted as a callable function
and cannot be exported; remove '@inline' to make it an ordinary function
let f: &Unary = &double;
type error: cannot take a reference to '@inline' function 'double': its calls are
substituted, so no callable function exists to reference
The same applies to start, a table initializer, and an elem segment.
Recursion is rejected, directly or through any chain of inline calls, since substitution would not terminate:
@inline
func endless(a: i32) -> i32 { return endless(a); }
type error: '@inline' function 'endless' is recursive (endless -> endless), which cannot
be inlined; remove '@inline' to make it an ordinary call
And a non-unit inline function must return on every path, the same rule a block with a
result already follows:
@inline
func bad(a: i32) -> i32 { if (a > 0) { return 1; } }
type error: '@inline' function 'bad' can reach the end of its body without returning a
value, which is only valid when its result is '()'
When not to use it
Inlining trades code size for call overhead, and the trade is yours to make — there is no
size heuristic and no threshold. A large @inline function called from twenty sites is
twenty copies. An engine's own optimizer will often inline a small ordinary function anyway,
so reach for @inline when you want the guarantee, not as a performance reflex.
The body is still type-checked even if it is never called, so an unused @inline function
cannot hide an error. It will, however, draw an unused-decl warning like any other unused
function.
One backend emits the function anyway
Everything above describes what reedc build produces, and it is the language's contract: the
function is elided, and every route to it other than a direct call is rejected.
The Binaryen backend is the one exception,
and it does not weaken the guarantee. It emits the definition as well, so that a
pub @inline function has something for a generated Rust call builder to name, and attaches
Binaryen's always-inline hint so the remaining calls — including one an embedder makes through
that builder — are still inlined. Every call written in Reed was already replaced before
Binaryen ever sees the module. Nothing about reedc build's output changes.
@deprecated — scheduled for removal
@deprecated marks a function you intend to remove. It changes nothing about the emitted
module: the function is still compiled, still callable, still exportable. What it changes is
that every use of it draws a deprecated-call warning, and that generated documentation
labels it.
/// Adds one.
@deprecated("use inc_checked instead")
func inc(x: i32) -> i32 { return x + 1; }
export func check as "check";
func check() -> i32 { return inc(41); }
type warning at 6:33: 'inc' is deprecated: use inc_checked instead [deprecated-call]
The note is optional. A bare @deprecated still warns, just without naming a replacement:
@deprecated
func legacy() -> i32 { return 0; }
Supply the note when you can. "inc is deprecated" tells a reader to stop; "use
inc_checked instead" tells them what to do, which is the whole point of announcing a
removal ahead of performing it.
Every way of naming it warns
A deprecated function is flagged wherever it is named, not only at a plain call:
| Where | Example |
|---|---|
| a call | inc(1) |
| a method call | x.triple() and i32.triple(x) |
| a reference | &inc |
| the escape hatch | wasm.call<inc>(1), wasm.ref.func<inc>() |
| an export | export func inc as "inc"; |
a start | start boot; |
a table or elem entry | table Funcs(1 ?Unary) = { &inc }; |
| a global initializer | global G: ?Unary = wasm.ref.func<inc>(); |
The last four are reported at the @deprecated annotation rather than at the reference,
because an export and a table entry resolve to a bare function index with no source position
of their own. Exporting a deprecated function is worth flagging loudest: it is the one case
where the affected caller is outside your codebase entirely.
Deprecated code may use deprecated code
A use inside a function that is itself @deprecated does not warn:
@deprecated
func old_leaf() -> i32 { return 1; }
@deprecated("goes away with old_leaf")
func old_branch() -> i32 { return old_leaf(); } // no warning
func live() -> i32 { return old_branch(); } // warns
There is no migration to perform inside code already marked as going away, so the warning
would name work nobody intends to do. The same call from live() still warns.
Silencing it
--allow deprecated-call suppresses the lint for a whole compilation, like any other:
reedc build app.reed --allow deprecated-call
That is the right tool while a migration is in progress and the noise is drowning real diagnostics. It is a blunt one: it silences every deprecation in the unit, not the one you have decided to keep.
With @inline
The two compose, in either order, and both take effect:
@inline
@deprecated("use scale2")
func scale(x: i32) -> i32 { return x * 2; }
A call still warns even though it is inlined away and scale never appears in the module.
The warning is about the source naming it, not about the lowering.
Errors name what you meant
An unrecognized annotation says what exists rather than failing generically, and a module-level annotation written inside a function body says where it belongs:
@typo("x");
syntax error: unknown module annotation '@typo'; the module-level annotations are
'@custom("name", "payload")', and '@inline'/'@deprecated' on a 'func'
func f() -> i32 { @nope if (1) { } return 0; }
syntax error: unknown annotation '@nope'; the statement annotations are '@likely'
and '@unlikely', which attach to an 'if'
func f() -> i32 { @custom("x", "y"); return 0; }
syntax error: '@custom' is a module-level annotation; move it outside this function body
@deprecated("nope")
global Counter: mut i32 = 0;
syntax error: '@deprecated' is only allowed on a 'func' declaration, not on a global
What annotations are not
- Not a general attribute system.
@inlineaside, an annotation does not modify a declaration's meaning, visibility, or type.@deprecatedis the edge of that: it changes no meaning, only what the compiler says about each use. There is still no@noinline— it would instruct an optimizer this compiler does not have. - Not a comment. A
//comment reaches the WAT output but never the binary. A@customsection reaches the binary and is what a tool downstream can actually read. - Not an escape hatch. Unlike
wasm.*, an annotation cannot emit an instruction, so it cannot affect validation or behavior.
See also
- Spec section 5.1 — the normative grammar and the rule that an annotation must not change program behavior.
- Diagnostics — the
deprecated-calllint and how to suppress it. - Tooling —
reedc doc, which renders a deprecated function with a banner rather than hiding it. - Control flow — the
ifstatement a branch hint attaches to. - Low-level operations — the other, very different, escape into WebAssembly's own vocabulary.