Decorators
A decorator is a codegen directive attached to a declaration. It controls how the compiler emits a symbol - its linker name, alignment, section placement, inlining, or PE import routing - and nothing else.
Decorators are codegen-only. Visibility (pub / ext) is a separate concern and is never controlled by a decorator.
Surface
A decorator is written as an attribute clause. A bare flag takes no arguments; a directive takes comptime-expression arguments.
#[name] # bare flag (e.g. inline)
#[name(args)] # directive with comptime-expr arguments
A line comment that begins #[ with no space opens an attribute. Write such a comment with a separating space: # [...].
Placement
Decorators appear before the declaration they target, one per line or space-separated on the same line. They attach to the immediately following declaration only and do not bleed across declarations.
#[inline]
#[symbol("big")]
fun big(a: i64, b: i64) i64 { ... }
#[align(64)] #[symbol("g_lit64")]
pub var g_lit64: u8 = 7;
Directives
The directive set is closed; new directives require a compiler change.
symbol(str) - linker name
Overrides the emitted or imported symbol name. Applies to functions and globals. Without it the compiler mangles the mach name; symbol gives the linker the exact name.
The mangled name is the source FQN as the source spells it - std.types.string.str_len, with generic arguments after a $ - so a profile, a crash report, and a disassembly all read the name you wrote. There is no prefix: a mangled name always contains a . and a C identifier never can. ext fun foreign symbols and #[symbol("...")] names are literal and unaffected.
#[symbol("main")]
fun entry(argc: i64, argv: **u8) i64 { ... }
#[symbol("write")]
ext fun libc_write(fd: i64, buf: *u8, n: i64) i64;
library(str) - dynamic import attribution
Pins an ext import to a specific dependency in the link set. Applies to ext functions only, and composes with symbol - the import is emitted under the renamed symbol within the named dependency.
#[library("ws2_32.dll")] #[symbol("WSAStartup")]
ext fun wsa_startup(ver: u16, data: *u8) i32;
- The value normally names a
[link.X]requirement's stable logical identity: itslibraryvalue, orXwhen that key is omitted. A bare command-line-l namealso exposesname. Exact canonical loader names remain accepted. Pinning to an absent dependency is a link error, never a silent fallback. - PE and Mach-O use two-level namespaces, so every dynamic import on those targets needs a
libraryattribution. - On ELF (Linux) the loader resolves imports by global search, so
libraryhas no effect on the emitted binary; the value is still validated against the link's dependency set.
inline - force inlining
Marks a function for inlining at every call site, overriding the compiler's size- and use-count heuristics. Applies to functions only and takes no arguments.
#[inline]
fun fast_path(x: i64) i64 { ret x * 2; }
It also crosses a module boundary. A dependency's ordinary function body never reaches an importer, so a four-instruction fetch_add in another module cost a call as well as its instructions. #[inline] declares that the body is part of the function's public surface, so an importer may materialize it - the same axis a generic, a comptime-param, and a pack-tailed function are already on, and the first member an author asks for rather than the type system forcing. The copy is emitted weak under the origin's own mangled name, so the defining module's definition still wins at link and the function stays one function with one address.
noinline - forbid inlining
The inverse of inline: forbids inlining a function into any caller, overriding the heuristics that would otherwise fold it in. Applies to functions only and takes no arguments.
#[noinline]
fun cold_path(code: i64) i64 { panic("unreachable state"); }
Use it to keep a function's frame and symbol real - for a profiler or stack sampler to attribute its cost correctly, to keep a cold path from bloating a hot caller's instruction cache, or to hold code size down on a constrained target.
inlineandnoinlineon the same function is a direct contradiction and is rejected in sema; neither wins silently.scalaralready declines inlining as a side effect, so pairing it withnoinlineis legal but redundant.- Purely a hint to the inliner; it does not otherwise change codegen. The debug pipeline runs no inlining pass at all, so
noinlineis inert (and unnecessary) there.
align(expr) - alignment override
Sets the alignment of a global variable or a record/union type. expr must be a comptime integer - a literal, or a comptime expression such as $size_of(T) or $align_of(T).
#[align(64)]
pub var cache_line: u8 = 0;
#[align($size_of(Pair))]
pub var g_cmp: u8 = 0;
#[align(32)]
rec Over { a: u8; }
- On a
var/val, it sets the global's section and address alignment. - On a
recoruni, it sets the type's own alignment, inherited by any global of that type. - It does not apply to
defaliases, which are transparent and have no layout of their own.
packed - no padding
Lays a rec or uni out with no padding: every field sits immediately after the previous one, there is no padding at the tail, and the type takes no alignment from its fields. Takes no arguments.
align only ever raises alignment. packed is the inverse, and it exists for the case where the layout is not mach's to choose - a C struct, a file header, a wire frame, a vertex whose stride a buffer fixes. Without it such a shape cannot be described as a record at all.
#[packed]
rec Header {
magic: u8; # offset 0
version: u16; # offset 1
length: u32; # offset 3
checksum: u64; # offset 7
} # $size_of == 15, $align_of == 1
Naturally the same shape is 24 bytes. $size_of, $align_of, and $offset_of all report the packed layout, and so does the code that reads and writes the fields - there is one layout, not a declared one and an emitted one.
It composes with align rather than conflicting, and each owns one question: packed decides padding (none between fields, none at the tail) and align(N) decides the record's own alignment, rounding its size up to a multiple of N.
#[packed] #[align(8)]
rec Frame { a: u8; b: u32; } # fields at 0 and 1; $align_of == 8, $size_of == 8
Packing is not transitive. A packed record packs its own fields; a record it contains keeps its own internal padding and is merely placed without padding. This matches C, and it is the rule that composes - an inner type's layout does not change depending on who holds it. A transitive rule would silently change the inner record's meaning inside its holder. If the inner record must be packed too, write #[packed] on it as well.
rec Point { x: u8; y: u32; } # natural: y at 4, size 8
#[packed]
rec Msg { tag: u8; p: Point; } # p at offset 1, still 8 bytes; $size_of(Msg) == 9
What is refused
- The address of a packed field.
?r.bwould yield a*u32, and a*u32states alignment 4 to everything downstream of it while the storage it names has none. The access through such a pointer is correct on the targets mach supports today; the pointer type is what is untrue, and it travels. The refusal covers the whole access chain, so?r.arr[0],?r.inner.x, and?p.bthrough a*Packedgo the same way.?ron the whole record stays legal - a*Packeddescribes an align-1 pointee correctly. To work with a field's value, copy it into a local. This is fail-closed on purpose: refusing can be relaxed later once alignment can ride in a pointer type; permitting cannot be tightened. - Atomics on a packed field, by that same rule rather than one of their own.
std.sync.atomicis ordinary functions over*i64, so a pointer is the only route an atomic has to a field, and there is no pointer to hand it. - Vector fields, including one reached through an array or a nested record. The reason is evidence rather than arithmetic: an unaligned scalar access is measured on real hardware, and that measurement is what
#[packed]rests on. This is a sequencing decision and is expected to be lifted. - Interface blocks.
packedcannot apply to a#[uniform]or#[storage]block: its member offsets are fixed by the std140 / std430 layout rules, which packing would contradict.
Unaligned access is permitted-but-may-trap on RV64, and where the hardware does not do it Linux emulates the access in the kernel. A packed field access there is expected to be correct and pathologically slow - a trap-and-emulate round trip per access rather than a load. x86-64 and aarch64 do unaligned scalar access in hardware.
section(str) - object section placement
Places a function or global variable in a named section instead of the default .text / .data. The section is created if absent; cross-section calls and accesses use ordinary relocations.
#[section(".hottext")] #[symbol("f_hot")]
fun f_hot(x: i64) i64 { ret x + 1; }
#[section(".machsec")] #[symbol("g_sec")]
pub var g_sec: u64 = 100;
oblivious - constant-time boundary
Marks a function as a constant-time boundary. Applies to functions only and takes no arguments. Inside it the backend must not introduce a secret-dependent branch or select a variable-latency instruction on a secret operand; a translation validator re-derives the secret taint over the lowered MIR and rejects any such leak.
#[oblivious]
fun ct_eq(a: ^[8]u8, b: ^[8]u8) u8 { ... }
- Inline
asminside such a function is validated rather than rejected: the block is parsed and walked for the same leaks, and refused only where a leak is found or where the construct cannot be modelled. - The decorator is purely subtractive - on a secret-free function it is a no-op. A function instance that computes on a
^secret is required to carry it; an instance that only moves, stores, or declassifies secrets stays annotation-free. - The zeroizing-write guarantee is not one of the decorator's obligations. A write into secret storage carries a taint keyed on the storage's secrecy rather than on any decorator, so a zeroizing wipe is protected in a function carrying no
#[oblivious]at all. - Rejected outright for a target whose back half emits a module for a downstream compiler rather than the executed instructions (the SPIR-V backend): the contract cannot be validated or upheld there.
The constant-time guarantee is not complete and has not been audited end to end. Do not build production cryptography on it at this version. See Secrecy for the known open holes.
scalar - opt out of auto-vectorization
Excludes a function from loop auto-vectorization, so its loops compile to scalar code even in the release pipeline on a vector-capable target. Applies to functions only and takes no arguments.
#[scalar]
fun reference_sum(a: *i64, n: usize) i64 { ... }
A #[scalar] function is also declined by the inliner, so the opt-out survives inlining - it cannot be lost by the body moving into an unflagged caller. Use it for a scalar reference twin in a differential test, or where vectorized codegen is undesirable for a specific function. The project-wide equivalent is the vectorize profile key.
naked - no prologue, no epilogue, body as written
Emits the function's body exactly as written and nothing else: no frame-pointer record, no stack allocation, no callee-save stores, no argument moves, and no return. Applies to functions only and takes no arguments.
#[naked] #[symbol("_start")]
fun start() {
$if ($mach.build.arch == $mach.arch.x86_64) {
asm x86_64 {
mov rdi, [rsp] # argc, straight off the kernel-supplied stack
lea rsi, [rsp+8] # argv
call main
}
}
$or { asm aarch64 { ... } }
}
The programmer owns the frame, the stack alignment, the link register, and the return. That is the whole point: a reset vector, an interrupt handler that must return with iret / rti rather than ret, a syscall or context-switch stub, or a thread entry point whose register state at entry is the interface.
- The body may contain only inline
asm- plus the$if $mach.build.archchain that is how mach spells per-ISA assembly. Any other statement is rejected, because it would lower to code assuming a frame the function does not have and would run and return a wrong answer rather than fail. - No return is generated. If the asm falls off the end, control runs into whatever the linker placed next. Write the return the ABI (or the interrupt controller) actually calls for.
- Parameters and the return type are still checked at every call site, so a naked function is called like any other. No moves are emitted for them: the arguments arrive in the ABI's registers and the body reads them there.
- Mutually exclusive with
inline- there is no coherent winner between a body spliced into a caller and one that owns its own frame - and withoblivious, which already forbids inline asm. Both combinations are rejected in sema.noinlineis redundant: the inliner declines a naked function unconditionally.
Frame elision is a separate, automatic thing: the compiler already omits the prologue for a leaf that provably never touches its frame. naked is the declared form, and it is unconditional. Merely containing an asm block does not suppress a frame: a function that also makes a call gets one, since an unaligned call boundary or a clobbered link register is not something the author asked for by writing assembly.
embed(str) - compile-time file embedding
Sources a val's bytes from a file at compile time: the file's content is the initializer. Applies to val only - not var (the storage is read-only data) and not an ext data import (which has no storage here). Takes one string-literal argument.
#[embed("assets/logo.qoi")]
val LOGO: [_]u8; # length taken from the file's byte count
#[embed("boot/sector.bin")]
val SECTOR: [512]u8; # length pinned; a size change fails the build
- The declaration carries no initializer of its own; writing one alongside
embedis rejected. This is a second exemption toval's requires-an-initializer rule, alongsideext. - The path resolves relative to the declaring source file's directory. An absolute path is taken as written. Escapes are not decoded, matching
symbolandsection- the path is taken as written. - The annotation must be
[_]u8or[N]u8.[_]is an inferred array length, legal only on an#[embed]declaration. A[_]u8embed can be asked for its own length:$length_of(LOGO)is its element count and$size_of(LOGO)its byte count, both folded at compile time. The explicit[N]u8form is for pinning a size by contract, not for recovering one. - Two
#[embed]globals whose files hold byte-identical content and whose final section name, kind, and alignment match share one read-only data placement within a module, so their addresses compare equal. This is specific to embedded data; an ordinary global is never merged this way. - An explicit
[N]u8whoseNdisagrees with the file is rejected, naming both counts. This is how a declaration pins a fixed-size asset - a boot sector, a ROM image - so the build fails the moment it stops being that size. - Bytes are placed in read-only data exactly like any other constant byte array: no runtime I/O, no copy. Works for every artifact kind and target, freestanding included.
- The embedded file is a build input: its content digest feeds the embedding module's incremental cutoff, so editing the asset invalidates that module and an untouched asset stays a cache hit.
Shader directives
Nine directives belong to the GPU pipeline surface: stage, workgroup, input, output, builtin, uniform, storage, sampler, and the two target-owned declarations handle and op. They are accepted on every target, because which target a module is built for is not a property of its source; only a target that forms pipeline stages acts on them. Each is documented with the surface it belongs to, on GPU shaders.
Applicability
Each directive targets a fixed set of declaration kinds. The full matrix:
| Directive | fun | ext fun | val / var | rec / uni |
|---|---|---|---|---|
symbol | yes | yes | yes | no |
library | no | yes | no | no |
inline | yes | no | no | no |
noinline | yes | no | no | no |
align | no | no | yes | yes |
packed | no | no | no | yes |
section | yes | yes | yes | no |
oblivious | yes | no | no | no |
scalar | yes | no | no | no |
naked | yes | no | no | no |
embed | no | no | yes | no |
stage | yes | no | no | no |
workgroup | yes | no | no | no |
input / output | no | no | yes | no |
builtin | no | no | yes | no |
uniform / storage | no | no | yes | no |
sampler | no | no | yes | no |
op | yes | no | no | no |
handle | a bodyless def only | |||
The val / var column is shared, but embed accepts only val - a var is refused.
See also
- Visibility -
pub/ext, the separate concern decorators never touch - Intrinsics -
$size_ofand$align_ofasalignarguments - Secrecy - the
^qualifierobliviousis the codegen contract for - Inline assembly - what a
nakedfunction's body may contain - Manifest - the
[link.X]requirements alibrarypin resolves against