Imported from briar-systems/mach (
.github/skills/mach/SKILL.md). Install upstream withnpx skills add briar-systems/mach --skill mach. Copyright stays with the author.
name: mach description: Use when writing, editing, or reviewing Mach (.mach) source files. Covers the full Mach 5 language: project/module structure, use/fwd imports and re-exports, the shadow-module pattern, all declaration forms (def, rec, uni, tag, fun, ext fun, val/var, test), tagged values with sel and lexical guards, the std failure tags res/opt/err, the type grammar with the ^ secret qualifier, literals, operators and casts, statements, docstring conventions, stdlib idioms (std.print), the comptime channel ($mach./$project./$bin.* reads, $if/$or, $each, intrinsics, comptime parameters, variadic packs), #[...] decorators including #[deprecated], and inline assembly (asm x86_64/aarch64/riscv64).
Mach
Mach is a low-level, explicitly-typed language: no type inference, no garbage
collection, no hidden control flow. Files use .mach. This skill is the fast
path for authoring correct Mach; doc/language/ in the Mach repository is the
authoritative reference and wins on any disagreement.
Hard rules - get these right
- No type inference. Every binding declares its type.
val x = 42;is an error; writeval x: i64 = 42;. - No compiler-known type aliases.
bool,usize,str,charare stdlibdefs, not built-ins. Import them before use (use std.types.bool.bool;).true/falseare stdlibvals (1/0); comparisons and logical operators produceu8. - Decorators are
#[...]attributes on the line(s) above a declaration:#[symbol("main")],#[inline],#[align(64)]. The backtick form was removed in v2.4.0 and is a migration error - never emit backticks. - File embeds are typed byte arrays.
#[embed("asset.bin")]applies only to an uninitializedvaldeclared as[_]u8(length from the file) or[N]u8(length checked). The path is relative to the declaring source file; bytes become read-only data at compile time, with no runtime I/O. - Variadics are comptime packs. A trailing
va: ...parameter, consumed by$each a in va. There is nova_list/va_start/va_arg. A bare...is a different thing entirely: the C-variadic marker, legal only on anext fun(ext fun open(path: *u8, flags: i32, ...) i32), and a removed-syntax error anywhere else. - Strings are
*u8, single-line."hello"is a pointer to null-terminated bytes. No fat-pointer string type (strisdef str: *char;); no multi-line string literal - use\nescapes. - Tagged values are
tag, tested withsel, read under a guard. A discriminated value istag Name: u8 { case; case: T; }, constructed asName.case{payload}, tested withsel place.case, and its payloadplace.caseis readable only inside a lexical guard (theifarm whose condition is exactlysel place.case, the rest of the block after a chain whose every arm exits, or the right operand of&&). There is nomatchand no==on a tag. std 2.0.0'sres[T, E],opt[T]anderr[E]are ordinary tags imported fromstd.types.result,std.types.optionandstd.types.error. selis a keyword. Never name a binding, field or functionsel.- No compound assignment.
+=etc. do not exist; writex = x + 1;. fwdis bare and always public (nopub fwd).ext funis the only body-less function form.
Project and module structure
A project has a mach.toml at its root; [project] id roots every module
path. A file at src/foo/bar.mach in project id = "myproj" is the module
myproj.foo.bar. There is no this. self-prefix - always use the full
project-rooted path, including for sibling modules. A one-segment use <id>;
binds the entry shared by that project's library artifacts marked
default = true (e.g. a library glfw imported as use glfw;); std 2.0.0
marks none, so always import full std.* paths.
An artifact build roots its module graph at [artifact.*].entry and compiles only
that module plus its active transitive use/fwd dependencies. A sibling source
file is not part of the build cell merely because it is under [project].src, so
one project may hold artifacts for disjoint targets. mach test deliberately roots
at every own-source module so it can collect otherwise-unreferenced tests.
A file reads top-down: module docstring, use/fwd lines, declarations.
use - private import
use std.types.size; # binds module `size`; use as size.usize
use sz: std.types.size; # module under alias; use as sz.usize
use std.types.size.usize; # binds the symbol; use bare as usize
The resolver binds whatever the path ends at: a module (members reached
qualified) or a symbol (used bare). Importing a module does not pull its
members in unqualified. No splat, no use foo.{a,b} - one name per line. A
module uses every dependency it directly names, even ones reachable through
a re-export: the dependency graph is visible at the top of every file.
fwd - public re-export
fwd impl.Point; # re-export as Point
fwd Pt: impl.Point; # re-export as Pt
fwd impl.helpers; # a module path re-exports the whole module
Mirrors use grammar; always publishes.
Shadow-module pattern
A surface file foo.mach co-exists with directory foo/ holding split
implementations. The surface uses each split and fwds its public symbols;
consumers use myproj.foo; and never name the splits. Topical splits forward
everything unconditionally; multiplatform splits pick one impl per target:
$if ($mach.build.os == $mach.os.linux) {
use impl: myproj.os.linux;
}
$or ($mach.build.os == $mach.os.windows) {
use impl: myproj.os.windows;
}
$or {
$error("myproj.os: unsupported target");
}
fwd impl.page_size;
Entrypoint and output
An artifact's out expands {artifact.suffix} using its selected target's naming
rules. out = "bin/app{artifact.suffix}" gives app.exe on Windows and app on
Linux/Darwin with one stable artifact identity. Literal output paths stay literal.
mach init emits one artifact with this placeholder. need entries are qualified:
step.generate, artifact.support, or globs such as artifact.shader-*. A root
manifest declares at least one [profile.<name>], and every declared profile
states all five policy keys: opt, debug, simd, vectorize, float_reassoc.
The stdlib provides the platform _start, which calls whatever function
exports the linker symbol main. use std.runtime; is required to link it in
even though nothing references it by name:
use std.runtime;
use std.print;
#[symbol("main")]
fun main(argc: i64, argv: **u8) i64 {
print.println("hello, mach");
ret 0;
}
use std.print; binds the leaf module print (std itself is not in scope).
It exposes print/println (stdout), eprint/eprintln (stderr), and the
format family printf/printlnf/eprintf/eprintlnf - pack-variadic, with
{} holes filled in argument order plus {:x}-style specs ({:X}, {:c},
{:5}, {:<5}, {:08x}; {{/}} for literal braces). print/println
return res[usize, WriteError], the format family res[usize, FormatError];
a call whose result is discarded is fine.
Windows executable resources
An executable artifact may declare project-root-relative PE assets:
[artifact.game]
kind = "bin"
entry = "main.mach"
out = "bin/game.exe"
targets = ["*"]
link = []
need = []
icon = "assets/game.ico"
manifest = "assets/game.manifest"
icon is a valid ICO container; manifest is embedded byte-for-byte. Either
adds PE icon/manifest resources plus version information. Version strings come
from [project].version, InternalName/ProductName from the artifact table
key, and OriginalFilename from the resolved output basename. These keys are
accepted but not read on non-Windows targets, and are rejected on static or
shared artifacts. If a build step generates an asset, name that step in
need and make its output exactly match the resource path.
print.printlnf("built {} in {}ms", name, elapsed);
Declarations
Modifiers: pub (public surface; without it a declaration is file-private)
applies to fun, rec, uni, tag, def, val, var; ext (C-ABI
external) applies to functions and globals.
def - type alias
pub def Age: i64;
pub def BinOp: fun(i64, i64) i64;
Aliases name any type; alias and underlying type are interchangeable.
rec / uni
pub rec Point { x: i64; y: i64; }
pub rec Pair[T, U] { left: T; right: U; } # generic
pub uni Number { i: i64; f: f64; } # fields overlap; size of largest
The compiler does not track which uni field is live; a uni is raw
overlapping storage for bit views and foreign formats. A value with one
active alternative is a tag, below. #[packed] removes padding and
#[align(N)] raises a type's or global's alignment.
tag - tagged value
pub tag Reply: u8 { empty; value: i64; } # explicit u8/u16/u32/u64 discriminator
pub tag ParseError: u8 { invalid; overflow; }
pub tag Tree[T]: u8 { leaf: T; empty; } # generic
val r0: Reply = Reply.empty{}; # payloadless case: empty braces
val r1: Reply = Reply.value{42}; # one positional payload
var r2: Reply; # zero: the first declared case
fun read(reply: Reply) i64 {
if (sel reply.value) { ret reply.value; } # the arm guards reply.value
ret 0; # reply.value here is a compile error
}
sel place.case is a bool reading only the discriminator; the operand is a
place (binding, field, index, dereference, or a pointer to a tag, auto-
dereferenced), never a call: bind the call first. A guard is lexical, not a
flow fact: the or arm of if (sel r.err) does not guard r.ok; || opens
no guard; after a chain whose every arm exits (ret, or brk/cnt of an
enclosing loop) the untested case is guarded for the rest of the block, and
the guarded place cannot be whole-assigned there (rebind a fresh val). The
debug profile traps a guarded read whose case changed; release does not.
Whole-tag ==, .kind and match do not exist. Reflection: $is_tag(T),
$cases(T) walked with $each c in $cases(T) (sel v.[c], v.[c],
T.[c]{...}), $discriminant_of(T).
The std failure tags, declared in std.types.result, std.types.option and std.types.error and imported like any
declaration (use std.types.result.res;):
pub tag res[T, E]: u8 { err: E; ok: T; } # err first: a zero res is a zero err
pub tag opt[T]: u8 { none; some: T; }
pub tag err[E]: u8 { err: E; ok; } # unit success; not opt[E]
fun increment(input: str) res[i64, ParseError] {
val r: res[i64, ParseError] = parse(input);
if (sel r.err) { ret res[i64, ParseError].err{r.err}; }
ret res[i64, ParseError].ok{r.ok + 1}; # r.ok guarded: the chain exits
}
fun
pub fun add(a: i64, b: i64) i64 { ret a + b; }
pub fun identity[T](value: T) T { ret value; } # generic; call: identity[i64](42)
pub fun load($order: u8, p: *i64) i64 { ... } # comptime value param (see Comptime)
pub fun sum(va: ...) i64 { # variadic pack (see Comptime)
var t: i64 = 0;
$each a in va { t = t + a; }
ret t;
}
Generic params [T] take types only, no constraints; monomorphized per
instantiation; call sites always supply the types explicitly.
ext fun
#[symbol("write")]
pub ext fun libc_write(fd: i64, buf: *u8, n: i64) i64;
Body-less, ends in ;, C ABI is the contract.
A C-variadic callee ends its parameter list in a bare ..., after at least one
fixed parameter — ext only, call side only (mach defines no va_arg callee):
ext fun open(path: *u8, flags: i32, ...) i32;
Tail arguments get no implicit conversion, so write C's default argument
promotions yourself: an integer narrower than 32 bits and an f32 are rejected
with the cast to apply (x::i32, x::f64), and a secret may not enter a tail.
Declaring a variadic callee at fixed arity instead is silently wrong on Apple
arm64, which passes the whole tail on the stack. See doc/language/ext-fun.md.
Provide the definition at link
time (mach build . -l c, a [link.X] manifest requirement, or an explicit
.o/.obj/.a/.lib/.so/.dylib/.dll). On PE and Mach-O targets, pin each dynamic
import with #[library("name")]. The value is the requirement's stable
library identity (defaulting to the [link.X] table name); exact loader names
remain accepted. A discovered Darwin @rpath/ install name retains its selected
library directory as an LC_RPATH command.
For a bare -l name, every target probes .o/.a; only PE/COFF also probes
the .obj/.lib spellings. Explicit paths retain their spelling so a format
mismatch produces a direct diagnostic.
Windows COFF inputs compiled with C/C++ dllimport may leave __imp_X
undefined. Attribute the real export X normally; the linker strips the object
prefix for loader lookup and points the foreign reference at X's IAT cell. A
direct X reference and __imp_X share one import entry — never map __imp_X as
a separate loader export. If the same link graph instead supplies a strong X,
__imp_X is a local pointer cell initialized to X: it creates no loader import
and needs no #[library] attribution, while direct references still target X.
An import-library record selected before that later definition becomes inert,
and an alias referenced only by a discarded weak COMDAT creates no cell.
val / var
val pi: f64 = 3.14159; # immutable; initializer required
var counter: i64 = 0;
var buf: [256]u8; # default-initialized to zero
Work at module top level (pub exports) and in function bodies.
test
Tests are declarations, inline next to the code they cover, in any module:
test "point: add is commutative" {
if (add(1, 2) != add(2, 1)) { ret 1; }
ret 0;
}
The label is a required string literal. Its internal format is a convention,
not an enforced standard: choose whatever names the test clearly and stays
consistent within a project. The compiler uses a fully-qualified
module.path.symbol:behavior form (e.g.
"mach.cli.cmd.doc.write_doc:summary_only") that pins each test to the exact
unit it exercises and groups cleanly under --filter; the libraries favor a
shorter "topic: behavior" (e.g. "abs: i64 min saturates"). Either reads
well. The body checks like an i32 function: ret 0 (or falling off the end)
passes, any non-zero return fails. There are no assertion builtins; return
early on a failed check. mach test collects every test in the project
(dependencies excluded unless --include-deps), builds one executable per
test, and reports per-module; --filter <pattern> narrows, --list
enumerates.
Types
Compiler-seeded primitives (the complete set): u8 u16 u32 u64,
i8 i16 i32 i64, f32 f64, and the untyped pointer ptr.
SIMD vector types are a spelling, not a list: any primitive numeric element,
a single x and a lane count from 2 to 65535 (f32x4, i32x8, f32x3);
the 128-bit shapes f32x4 f64x2 i8x16 i16x8 i32x4 i64x2 u8x16 u16x8 u32x4 u64x2 fill a vector register and the rest are realized piecewise or
per-lane. A rec, uni, tag or def may not take a vector spelling as
its name. Literals are full-arity (f32x4{1.0, 2.0, 3.0, 4.0}), lane access v[i] takes
a comptime-constant index, and the operators apply lane-wise with a comparison
producing a same-shape unsigned mask. Integer vector / follows scalar division
per lane using the lane type's signedness. It scalarizes where packed integer
division is unavailable, and secret dividends or divisors are rejected. Vector
% and shifts remain unsupported. See doc/language/types.md.
*T # pointer ?x address-of, @p dereference
[N]T [N][M]T # array val a: [4]i64 = [4]i64{1, 2, 3, 4};
[_]u8 # inferred array only on a #[embed("path")] val
fun(T1, T2) R # function pointer val op: BinOp = add; op(2, 3)
^T # secret-qualified (see below)
Pointers index like arrays: p[i] reads the i-th element (this is how str
is walked). A fun(...) type may carry a trailing ... for FFI only.
^ - the secret qualifier
^T marks data as secret for the constant-time discipline. Secrets may move
and be stored but may never reach an observable position: a branch or loop
condition, the left operand of &&/||, a memory index, or a //%
operand - each is a compile error. Public flows up to secret implicitly; the
only downgrade is the explicit strip cast x:>T (the result type is required; the 4.30 spellings x:^ and x:^T were removed in 5.0.0 and are refused with a diagnostic naming :>T). Any operation
with a secret operand yields a secret result; uni variants must agree on
secrecy; a secret-welded pointer (*^T) cannot be erased to ptr. Also
rejected: a secret float operand, a secret integer multiply or variable shift
(target capability permitting), and a secret passed to a variadic pack. The
weld checks are deep (a secret nested in an aggregate counts) and fail closed.
Carry the obligation through codegen with #[oblivious].
Constant-time support is an experimental preview: the guarantee is
incomplete and unaudited, with a proven secret-disclosure path still open. Read
doc/language/secrecy.md before touching crypto code, and do not write
production cryptography against it.
Literals
| Form | Example | Notes |
|---|---|---|
| Int (dec/hex/bin/oct) | 42 0xDEAD 0b1010 0o755 1_000_000 |
untyped until context |
| Typed suffix | 7i64 255u8 2.5f64 |
use when context doesn't constrain |
| Float | 1.5 1.5e10 |
. must be followed by a digit |
| Char | 'M' |
u8; escapes \n \t \r \\ \' \0 \xHH |
| String | "hi\n" |
*u8, null-terminated, single-line; adds \" |
nil |
nil |
null address; coerces to any pointer or function type |
Untyped literals are checked against the declared type, never used to infer
it. nil with no context types as *u8; var cb: fun(u32) = nil; is legal.
Operators and casts
- Arithmetic
+ - * / %(ints and floats;%truncated remainder, sign of the dividend, floats included). Bitwise& | ^ ~ << >>(ints). - Comparison
== != < > <= >=→u8. Mixed int signedness/width compares mathematical values (a negativei64< anyu64). Int vs float comparison is a compile error - cast explicitly.==/!=on arec/univalue is rejected (padding and inactive variants make representation equality meaningless) - compare field-wise. - Logical
&& || !- short-circuit,u8operands and result. - Pointer:
?expraddress-of,@pdereference (@p = x;writes through). - Casts (postfix):
expr::Tvalue conversion (resize, int↔float);expr:~Tbit reinterpret (same byte size required);expr:>Tstrips the secret qualifier (the only one that can). - Assignment
=is an expression form used in statement position; right-associative, lowest precedence.
Precedence is C-family: * / % > + - > << >> > relational > equality >
& > ^ > | > && > || > =. Note bitwise binds looser than
comparison, as in C - parenthesize (a & b) != 0.
Statements
Statements end with ; unless they end with a block. Bodies are always
blocks - there is no brace-less form.
if (cond) { ... } or (cond) { ... } or { ... } # `or {}` is the catch-all
for (cond) { ... } # condition loop
for { ... } # no condition: infinite loop
ret expr; ret; # value / void return
brk; cnt; # break / continue enclosing for
fin { ... } # run at enclosing block's exit, reverse order
{ ... } # bare block = new scope
fin requires a block - fin stmt; is rejected. There is no for-each; loop a
counter or a pointer cursor. $if/$or (comptime) and $each (comptime
unroll) also appear in statement position - see Comptime below.
Stdlib idioms
- Every fallible std API answers
res[T, E]with a closed error tagEper domain (allocator.Error,FormatError,io_error.Error,FsError, ...), a unit outcomeerr[E], and absenceopt[T]; there are nounwrap,is_okor constructor helpers. Bind, test withsel, read under the guard:
val got: res[*T, A.Error] = A.allocate[T](a, n);
if (sel got.err) { ret res[*T, A.Error].err{got.err}; }
val p: *T = got.ok;
- Address-bound owners (allocators, sinks, runtime registrations) are
initialized in place,
init(?storage, ...) err[E], never returned inside ares. stris*char(null-terminated).std.types.stringprovidesstr_lenand comparison.std.types.view.View { data, len }borrows a length-counted slice and owns no storage.- No methods, no UFCS: everything is a free function taking an explicit
receiver (usually a pointer), reached through the module alias -
vec.push[T](?v, x), notv.push(x). - Naming:
snake_casefunctions/bindings,PascalCasetypes,SCREAMING_SNAKEconstants. Indent 4 spaces; align:columns in field blocks, import groups, and consecutiveval/varruns when it aids scanning.
Docstrings
# comments immediately above a declaration: a bare lowercase summary line
(no name: prefix, no trailing period), then - only when there are elements
to document - a # --- separator and one aligned component line per
parameter/field/return:
# read the wall-clock time
# ---
# out: pointer to Timespec to populate
# ret: 0 on success, negative errno on failure
pub fun realtime(out: *Timespec) i64 { ... }
Component identifiers: parameter name, $name, [T], ret, field name,
variant name. Summary-only docstrings omit the separator. Every file opens
with a module docstring (summary, optional paragraphs separated by blank #
lines) before any use/fwd/decorator. Decorators sit between the docstring
and the declaration. Document every pub entity.
Comptime channel
$ opens the compiler-owned comptime channel - read-only: it selects and
expands, never executes or mutates. The parser disambiguates by shape:
| Shape | Meaning |
|---|---|
$mach.* / $project.* / $bin.* |
read a compiler-owned tree (roots are reserved) |
$sym(args) |
comptime call - the closed intrinsic set |
$if / $or |
comptime control flow |
$each x in SEQ { } |
comptime unroll over $fields(T) or a variadic pack |
A bare $ident ($mode, $foo) is none of these and is rejected: comptime
parameters are referenced without $; comptime paths are rooted. The
$sym.attr = value; setters were removed in v2.0.0 - codegen directives are
#[...] decorators. Not in the channel: no $<Type>.* reflection, no
comptime function definitions, no comptime loops beyond $each over
fields/packs.
$mach.* - compiler and build state
All reads, all comptime constants, closed tree:
$mach.build.os / .arch / .abi / .mode # live; compare against the tag tables
$mach.build.pointer_width # live; integer byte count (8 on 64-bit)
$mach.build.pie # live; 1 when building position-independent
$mach.version / .major / .minor / .patch # live; compiler version
$mach.compiler.name / .version # live
$mach.os.linux .darwin .windows .freestanding
$mach.arch.x86_64 .aarch64 .riscv64
$mach.abi.sysv64 .win64 .aapcs64 .lp64
$mach.mode.debug .release
$mach.build.{timestamp,host,git.*}, $mach.project.*, and $mach.source.*
are reserved stubs - reading one is a compile error. The tag tables are closed;
an unrecognized tag is a compile error, never a silent fold.
Tag comparison is path-value - plain ==, no .id suffix, no unwrap:
$if ($mach.build.os == $mach.os.linux) { ... }
$if ($mach.build.arch == $mach.arch.riscv64) { ... }
A $mach.build.<NAME> that names none of the reserved facts is a compile
error (no manifest define named); no manifest key declares one. Spell a
project constant as a val selected with $if over the facts above.
A $mach.* read can fold into a runtime binding - the binding still declares
its type:
pub val IS_LINUX: u8 = $mach.build.os == $mach.os.linux;
pub val COMPILER: *u8 = $mach.compiler.name;
$project.* / $bin.* - manifest state
$project.id / .version # [project] metadata
$project.version.major / .minor / .patch # folded integer components
$project.target.os / .arch / .abi # the selected target's declared *strings*
$bin.name # the artifact being built
$project.target.* carries the manifest's string spellings ("linux",
"x86_64") - distinct from $mach.build.*'s numeric tags. $project.name
and $project.description were removed in 5.0.0 with their manifest keys and
are refused by name. A field the manifest does not declare is reported
unavailable, not folded to "".
Decorators - #[...]
Declaration metadata on the line(s) above a declaration (after the docstring). One clause each, stackable on one line or several; they attach only to the immediately following declaration. Closed set:
| Decorator | Applies to | Argument | Purpose |
|---|---|---|---|
#[deprecated] / #[deprecated("message")] |
fun, ext fun, rec, uni, tag, def, val/var, use, fwd, tag case | zero or one literal string | warn once per external use, preserving re-export notice ownership |
#[symbol("name")] |
fun, ext fun, val/var | string | linker name override |
#[library("name")] |
ext import | string | dynamic import dependency pin |
#[inline] |
fun | none | force inlining |
#[align(expr)] |
val/var, rec/uni/tag | comptime int | alignment override |
#[packed] |
rec/uni/tag | none | no padding; payload straight after a tag's discriminator |
#[section(".name")] |
fun, ext fun, val/var | string | object section placement |
#[oblivious] |
fun | none | constant-time boundary (see ^ above) |
#[scalar] |
fun | none | opt out of auto-vectorization |
#[naked] |
fun | none | no prologue/epilogue; body as written |
#[symbol("read")]
pub ext fun sys_read(fd: i32, buf: *u8, n: u64) i64;
#[align(64)] #[section(".hot")]
pub var cache_line: u8 = 0;
align takes any comptime expression (#[align($align_of(T))]). A library
pin must name a selected dynamic dependency by its stable logical identity or
exact loader name; pinning to an absent library is a link error. PE and Mach-O
require attribution, while ELF validates the pin but emits global-search
binding. Logical identities may not collide with a different dependency's
loader name. Beware: a line comment starting #[ with no space parses as a
decorator - write # [...] in prose comments.
#[oblivious] marks a constant-time boundary: the backend may not introduce a
secret-dependent branch, a variable-latency op on a secret, or eliminate a
zeroizing write inside it, and inline asm is rejected there. A function that
computes on a ^ secret must carry it; one that only moves or declassifies
secrets need not. Constant-time support is an experimental preview with a
known open disclosure path - do not write production crypto against it.
#[scalar] excludes a function from loop auto-vectorization (which runs in the
release pipeline on targets with 128-bit vectors) and also blocks inlining, so
the opt-out survives. The project-wide lever is the vectorize profile key in
mach.toml, stated by every profile.
#[naked] emits the body exactly as written - no frame record, no stack
allocation, no argument moves, and no return. The body may hold only inline
asm (plus the $if $mach.build.arch chain that selects it); anything else is
rejected, because it would lower to code assuming a frame that does not exist.
Parameters and the return type are still checked at call sites, but no moves are
emitted for them: the arguments arrive in the ABI registers and the body reads
them there. Write the return yourself - none is generated, which is the point
for an interrupt handler that must leave through iret/rti. Rejected
alongside #[inline] and #[oblivious]. Merely containing an asm block
does not make a function naked: one that also calls still gets a frame.
#[naked] #[symbol("_start")]
fun start() {
$if ($mach.build.arch == $mach.arch.x86_64) {
asm x86_64 {
mov rdi, [rsp]
lea rsi, [rsp+8]
call main
}
}
$or { asm aarch64 { ... } }
}
Intrinsics - $name(args)
Closed compiler-shipped set. New ones require a compiler change; runtime
instruction emitters (trap, fence, pause) are not intrinsics - they
are stdlib functions with per-arch asm bodies.
Layout values - comptime unsigned integers; the binding declares the
storage type (pub val POINT_SIZE: i64 = $size_of(Point);):
$size_of(T) $length_of(T) $align_of(T) $offset_of(T, field_or_case)
$type_of(expr) produces a comptime type value, comparable with
==/!= against a type name inside $if. Dead arms are pruned before
type-checking, so each arm can use the value at its own concrete type with
no cast - the idiom for per-element dispatch inside $each bodies (stdlib
vformat is built exactly this way):
$if ($type_of(arg) == str) { write_str(w, arg); }
$or ($type_of(arg) == i64) { write_i64(w, arg); }
$or { $error("no writer for this argument type"); }
$fields(T) yields a comptime sequence of field descriptors for a
rec/uni, consumed by $each. Each descriptor f carries f.name
(*u8), f.type (type value), f.offset (integer); v.[f] projects the
concrete field off an instance - an lvalue, re-typed per iteration:
fun sum_fields(p: Pair) i64 {
var t: i64 = 0;
$each f in $fields(Pair) { t = t + p.[f]; }
ret t;
}
$each is statement-scope only. The body is spliced once per element -
not a runtime loop. Four sequence forms: $each f in $fields(T),
$each c in $cases(T), $each a in va (packs) and $each x in ARR (a
comptime-constant array val). Enclosing runtime variables thread across the
unrolled copies; nesting is allowed.
Diagnostics. $error("msg") fails the build when reached -
unconditional position or a selected $if/$or arm. A $error in a
discarded arm never fires, making it the natural exhaustiveness fallback for
target and type dispatch. Valid at declaration and statement scope.
There is no $assert intrinsic - write $if (!cond) { $error("msg"); }.
$if / $or - conditional compilation
$if (cond) { ... }
$or (cond) { ... }
$or { ... } # comptime else - no condition; there is no `$or $if`
Only the taken branch compiles: discarded branches are not resolved,
type-checked, or emitted - names inside them are never looked up. This is
what makes per-arch asm and per-OS use safe. Valid at declaration scope
(selecting use/fwd/declarations) and statement scope. $if selects at
compile time and discards the rest; runtime if emits a branch - never use
$if to fake reflection over code that must exist at runtime.
Conditions must be comptime: $mach.*/$project.* reads, comptime constants
(pub val), comptime parameters, $type_of comparisons. Comptime comparison
follows the runtime rules - mathematical values, mixed signedness fine;
overflow in comptime arithmetic is a compile error, not a wrap.
Comptime function parameters - $name: T
A $-marked parameter in the regular list must receive a comptime-evaluable
argument (literal, pub val constant - including imported ones - or another
comptime parameter). The compiler monomorphizes the body per distinct
value; each instance compiles only the arms its value selects. Referenced
bare inside the body:
val MODE_DOUBLE: u8 = 0;
val MODE_SQUARE: u8 = 1;
pub fun apply($mode: u8, n: i64) i64 {
$if (mode == MODE_DOUBLE) { ret n + n; }
$or (mode == MODE_SQUARE) { ret n * n; }
ret 0;
}
Rules that bite:
- Unlike target-gated
$if, all arms of a parameter-gated$ifare resolved and type-checked structurally; only the selected arm is emitted per instance. Each arm must be independently valid. - No storage:
?modeis rejected; the parameter is stripped from the lowered signature and ABI. - A comptime-parameter function is a template, not a value - it cannot be assigned, passed, or compared, only called.
- Not yet combinable with generic type params (
fun f[T]($m: u8, ...)is a clear diagnostic). - Function parameters only - never record fields.
Variadic packs
va: ... (trailing parameter) collects call-site arguments into a comptime
sequence; monomorphized per distinct type-list. $each a in va consumes it
(heterogeneous packs work - dispatch with $type_of per element), va.len
folds to the count, g(va...) forwards the whole pack (sole trailing argument
only; no partial forward). No runtime va_list exists. Pack functions have no
stable ABI symbol: not ext, not addressable. See doc/language/variadics.md.
Inline assembly
One form: an ISA-tagged block of raw instruction lines.
asm x86_64 {
# raw instructions, one per line, # for comments
mov rcx, {ptr}
mov rax, [rcx]
mov {result}, rax
}
Locked rules:
- The ISA tag is mandatory; bare
asm { }is rejected. The tag set is closed:x86_64,aarch64,riscv64- each with a working native assembler, all three exercised in CI (riscv64 under qemu; riscv64 is a self-hosting target with a byte-identical fixpoint). - The body is raw text, not tokens: unquoted lines in the ISA's native
syntax, captured to the brace-matched
}(nested braces balance). Theasmbody is not a string. #starts a line comment; everything after it on the line is inert.
Operand substitution - {name}
{name} substitutes a local in scope; the compiler resolves it to a register
or memory operand from liveness and the instruction's operand class. In
practice a {name} binds the local's storage - typically a stack slot -
so to reach a pointee, stage the pointer through a scratch register first;
never write a double indirection like [{ptr}]:
mov rcx, {ptr} # x86_64: load the pointer value
mov rax, [rcx] # then address through the register
# aarch64: ldr x12, {ptr} / ldr x9, [x12]
{name} is the only substitution: no in/out lists, no %0 positionals,
no = constraints. The compiler infers operand direction (from position),
the clobber set (from each instruction's semantics), and assumes a
conservative memory clobber for every block. Writing a clobber list is a
syntax error, not optional metadata. Branch targets inside a block are numeric
local labels with direction suffixes (1: … bnez a4, 1b, b.ne 2f), the
stdlib's convention across all three ISAs.
Multi-arch and multi-OS dispatch
No nested arch construct exists inside asm. Dispatch at the outer level with
$if on $mach.build.arch (or .os for syscall ABIs); discarded branches
never compile, so each block only needs to be valid for its own ISA:
$if ($mach.build.arch == $mach.arch.x86_64) {
asm x86_64 { hlt }
}
$or ($mach.build.arch == $mach.arch.aarch64) {
asm aarch64 { brk 0 }
}
$or ($mach.build.arch == $mach.arch.riscv64) {
asm riscv64 { ebreak }
}
$or {
$error("mymod.trap: unsupported architecture");
}
A platform-specific module guards itself at the top so misuse fails loudly at compile time - the stdlib pattern:
$if ($mach.build.arch != $mach.arch.x86_64) {
$error("myproj.os.linux.x86_64: requires x86_64 target");
}
Variant dispatch (memory orderings and similar) is one function with a
$name: T comptime parameter whose $if arms select per-variant asm - one
monomorphized instance per distinct value (see Comptime function parameters).
asm vs stdlib
Write asm only for truly target-specific operations with no stdlib wrapper:
raw syscalls, special-register reads, stack-frame surgery. For anything that
maps to a named function - atomics, fences, trap(), bit ops (popcount, clz,
bswap), syscall wrappers - call the stdlib: those functions already
contain the arch-dispatched asm, and reimplementing them inline duplicates
the dispatch. Rule of thumb: if the operation is a fixed instruction sequence
per arch, there is (or should be) a stdlib wrapper.
Secrecy - asm is a trusted-base crossing
The ^ secret qualifier's flow rules stop at an asm boundary: the type
system cannot check instruction streams, so an asm block can observe or
launder secrets silently. Inside asm that touches ^ data, constant-time
discipline (no secret-dependent branches, addresses, or variable-latency
instructions) is entirely on you.
Reference
The authoritative per-feature reference lives in the Mach repository under
doc/language/
- including
tag.md, the full EBNF ingrammar.md,variadics.md,secrecy.md,decorators.md, thecomptime-*.mdset,asm.md, andpolicy.md(the compiler-vs-stdlib boundary);doc/migration-v5.mdmaps every 4.x form to its 5.0 spelling. When this skill and the reference disagree, the reference wins. Tooling:mach check <path>runs the frontend alone (the inner loop),mach build <path> --planprints the effective build without running it;mach fmtis in a parallel lane and is not documented here until it merges.