Skip to content

JIR — jihoo IR text format (version 21)

JIR is the contract between the Rust frontend/VM and the C++ LLVM backend. jihoo emit-ir writes it, jihoo-llc reads it, and jihoo reads it back too (jihoo run file.jir, jihoo build file.jir). Both sides must agree on this document; the Rust definitions live in crates/jihoo-ir/src/lib.rs and the C++ ones in backend-llvm/src/jir.h. The typing rules are implemented once, in crates/jihoo-ir/src/types.rs, and checked by crates/jihoo-ir/src/verify.rs. Each side has its reader: crates/jihoo-ir/src/parse.rs and backend-llvm/src/jir_parser.cpp.

A module starts with jir N, the version of this format it is written in, and jihoo-llc compiles only the one version it was built for (jihoo-llc --version prints it). A module of another version is refused at that first line, so a frontend and a backend from different builds never silently disagree about what a module means.

The version goes up with every change that lets the two sides disagree: a new or removed instruction, type or header, a change of syntax, or a change of what something means (such as division by zero going from undefined to a trap). A change to this document that only clarifies it keeps the version.

JIR_VERSION in crates/jihoo-ir/src/lib.rs, jir::kVersion in backend-llvm/src/jir.h and the title above must agree; the version tests in crates/jihoo-ir check that. They also keep a fingerprint of this document, so editing it fails a test until you decide whether the edit needs a new version.

version change
0 every format before versions were kept, up to and including native division checks
1 the first version that is bumped on change; the header must come first
2 the target header
3 stringify, and hygiene marks in the code quote builds
4 float operations (sqrt, floor, ceil, trunc, round, abs, copysign) and bitcast; print in native modules; structs to and from C by value, and export; select cases separated by |
5 lists ([T]) with len, slice, repeat, push, pop, and array, elem, setelem and paths on them; on str: len, strbyte, slice, frombytes, ordering; readline
6 trap
7 atomics: atomic_load, atomic_store, atomic_swap, atomic_add, atomic_sub, atomic_and, atomic_or, atomic_xor, atomic_cas, and fence
8 spawn in native modules, starting a thread
9 lists in native modules, and clone and drop
10 str in native modules: string literals of type str, strbyte, frombytes, to_str, readline and comparisons
11 closure in native modules; native function values are {code, env} (16 bytes)
12 ref and cell in native modules, as counted boxes
13 chan, send, recv and select in native modules, on lib/channel.jh
14 codetext, code as text and text as code at compile time
15 typeof, and type holes in quote
16 take, moving a field out of a struct that is used again
17 tailcall, a call that takes the caller’s place
18 file headers and loc, source positions
19 take moves a part at a path (take %s, (field 0, elem %i)), not only a field
20 args, readfile, writefile, appendfile and parsefloat
21 own, views and moves lines: what the ownership pass decided
; jihoo IR
jir 4
profile freestanding
target x86_64
struct $Node { value: i64, next: *$Node } size 16 align 8
fn @sum(*$Node) -> i64 {
regs *$Node i64 i64 i64 *$Node bool *i64 i64 i64 **$Node *$Node
bb0:
%1 = const 0
%2 = copy %1
jmp bb1
bb1:
%3 = const 0
%4 = cast %3
%5 = ne %0, %4
br %5, bb2, bb3
bb2:
%6 = fieldptr %0, 0
%7 = load %6
%8 = add %2, %7
%2 = copy %8
%9 = fieldptr %0, 1
%10 = load %9
%0 = copy %10
jmp bb1
bb3:
ret %2
}
  • The format is line-oriented: one header, label, instruction or terminator per line.
  • ; starts a comment that runs to the end of the line (outside string literals).
  • %N is a register, @name a function, $Name a struct, bbN a block. Struct names that are not identifiers are quoted: instances of generic structs are $"Pair(i64)". Function names may contain .: instances of generic functions are @max.0, …
  • Integers are signed 64-bit decimals (-5, 42). Float constants (only in fconst) are decimals with a . or an exponent (1.5, -0.0, 1e-7), or inf, -inf, nan; they are read as the nearest f64.
  • Strings are double-quoted. Escapes: \n, \t, \\, \", \xHH (any byte).
jir 18 ; format version, must come first
profile hosted|native|freestanding ; language profile
target x86_64|aarch64 ; the machine, 64-bit Linux
file N "path" ; zero or more source files, numbered from 0 in order
struct ... ; zero or more structs
enum ... ; zero or more enums
extern ... ; zero or more C functions (native only)
fn ... ; zero or more functions
profile runs on GC allowed builtins pointers C functions
hosted VM yes print, to_str no no
native LLVM + libc no print, syscall, asm yes extern, export
freestanding LLVM, no libc no syscall, asm yes export

The target is the machine the module was made for: its syscall numbers and asm are that machine’s, since the frontend picked per-target modules for it (import sys loads sys.x86_64.jh or sys.aarch64.jh). jihoo-llc compiles for that machine (x86_64-unknown-linux-gnu or aarch64-unknown-linux-gnu). The frontend always writes the line; a hand-written module may leave it out, and is then compiled for the machine jihoo-llc runs on. The VM ignores it. Both targets are LP64, which is what size_of and struct layouts assume.

The entry point is @main for hosted and native modules and @_start for freestanding ones. It returns unit or i64 and takes no parameters, except that a native @main may take C’s (i32, **u8) (argc and argv).

extern @puts(*u8) -> i32
extern @printf(*u8, ...) -> i32

A C function the module calls, by its symbol name, with the C calling convention. call and funcref name it like any other function, and a module cannot have a function and an extern function of the same name. Its parameter and result types are C types: integers, floats, bool, pointers, function types taking and returning those, structs whose fields are those (or arrays of them, or such structs; at least one field), and a unit result (C’s void). Enums and arrays go by pointer. Integers narrower than 32 bits and bools are sign- or zero-extended as the C ABI requires, and structs are passed and returned as the target’s C ABI says (Native ABI).

With ..., the function takes more arguments after the listed ones, like printf. A call may pass any integer, float, bool, pointer or function value there, and they get C’s default promotions (bools and integers narrower than 32 bits widen to 32, f32 to f64). Structs cannot go there. A variadic extern cannot be a funcref.

type meaning LLVM
unit no value {}
bool true / false i1
i8 … i64 signed integers i8 … i64
u8 … u64 unsigned integers i8 … i64
f32, f64 IEEE 754 binary32 and binary64 float, double
str immutable string of bytes; hosted and native {ptr, i64, i64}
*T raw pointer to T, native and freestanding only ptr
$Name struct or enum, by value named struct
expr, stmts, items code, inside macros; compile time only —
[N x T] array of N Ts, by value [N x T]
[T] list of any number of Ts, by value; hosted and native {ptr, i64, i64}
fn(T, ...) -> R function value ptr; native {ptr, ptr}
ref T immutable shared reference to a T; hosted and native ptr to {i64, T}
chan T channel of T values between tasks; hosted and native ptr
cell T shared mutable T; hosted and native ptr to {i64, T}

str and *T are deliberately separate: GC references and raw pointers must never mix. That separation is what will later allow GC-enabled native builds.

In the Rust IR and in the VM, an integer is stored as an i64 in canonical form: sign-extended for signed types, zero-extended for unsigned ones (u64 keeps its bit pattern). const values must already be canonical.

struct $Name { field: T, field: T, ... } size S align A

Field names are only for readability; instructions refer to fields by index. A struct may contain other structs and arrays by value, but not itself (directly or through other structs or arrays); use a pointer for recursive data.

size S align A is the struct’s layout as computed by the frontend (crates/jihoo-ir/src/layout.rs: C rules for 64-bit targets). It is what size_of/align_of were folded to, so the LLVM backend rejects the module if the target’s data layout disagrees. It is omitted for structs without a fixed layout (those containing str, which only exist on the VM).

Structs and arrays are values: copy copies all fields or elements, and setfield/setelem produce a new value.

enum $Name { A, B(T, U), ... } size S align A

A sum type: a value is one of the variants, and each variant carries its own payload values. Instructions refer to variants and payload values by index. Structs and enums share one namespace. Like a struct, an enum may hold other types by value but not itself, and size S align A is omitted when it contains str.

The layout is the C layout of struct { uint32_t tag; union { ... } payload; }, where each variant’s payload is laid out like a struct of its values. Natively the backend uses { i32, [N x iA] }, with A the largest payload alignment, and reads a variant’s payload through a struct of its value types at the payload’s address. On the VM an enum is a GC object holding the tag and the payload.

fn @name(T1, T2, ...) -> R {
regs T1 T2 ... Tn
bb0:
...
}
  • The regs line, right after the header, gives the type of every register %0 .. %(n-1). It must start with the parameter types: arguments arrive in %0 .. %(params-1). In native modules, own, views and moves lines may follow (Ownership).
  • Blocks are numbered bb0, bb1, ... in order. bb0 is the entry block.
  • Every block ends with exactly one terminator.
fn @geo.area($Rect) -> f64 export @rect_area {

In a native or freestanding module, export @sym after the result type also defines the function as the C function sym, which C code linked with the program can call with the C calling convention. The function is still called from JIR by its own name. Its parameter and result types are C types, as for extern functions; a symbol is exported once, is not also an extern function, and is not the entry point (main natively, _start freestanding).

Registers are not SSA: a register may be written more than once (the frontend maps each let variable to one register), but always with values of its declared type. The LLVM backend gives each register an alloca and relies on mem2reg.

In the table, int means any integer type and %a: T means %a has type T. The result type is what the destination register must be declared as.

syntax operands result meaning
%d = const N int or bool constant; bool is 0 or 1
%d = fconst X f32 or f64 float constant; for f32, X is exactly an f32 value
%d = unit unit the unit value
%d = str "..." str (hosted, native) or *u8 (native, freestanding) string literal, of the type of %d; compiled ones are NUL-terminated constant bytes
%d = copy %a T T copy
%d = neg %a signed int or float same wrapping negation; for floats, flips the sign (also of 0 and NaN)
%d = not %a bool or int same logical not, or bitwise not of an integer
%d = add|sub|mul %a, %b T, T (int) T wrapping arithmetic
%d = div|rem %a, %b T, T (int) T signed or unsigned by type; truncating; dividing by zero is a runtime error on the VM and traps natively; signed MIN / -1 is MIN, and MIN % -1 is 0
%d = add|sub|mul|div|rem %a, %b T, T (float) T IEEE 754, rounded to nearest; rem is C’s fmod (exact, sign of %a)
%d = sqrt %a float same the square root, correctly rounded (NaN below zero)
%d = floor|ceil|trunc|round %a float same the integral value toward −∞, toward +∞, toward zero, or nearest with ties away from zero (C’s round); a zero result keeps the sign of %a
%d = abs %a float same the magnitude: the sign cleared (also of 0 and NaN)
%d = copysign %a, %b T, T (float) T the magnitude of %a with the sign of %b; a NaN has no sign, so with a NaN %b it is the magnitude of %a
%d = bitcast %a f32, f64, u32 or u64 u32, u64, f32 or f64 the same bits as the other type: a float as the unsigned integer of its width, or back; every NaN reads as the quiet NaN with a clear sign (0x7fc00000, 0x7ff8000000000000)
%d = and|or|xor %a, %b T, T (int or bool) T bitwise (for bool: logical, both sides evaluated)
%d = shl|shr %a, %b T, T (int) T shift by %b modulo the bit width; shr is arithmetic for signed types, logical for unsigned
%d = add %a, %b str, str or [T], [T] the same concatenation
%d = add|sub %a, %b *T, i64 *T pointer offset in elements of T
%d = eq|ne %a, %b T, T: bool, int, float, str, *U bool equality (str compares contents; floats as IEEE: NaN is unequal to itself, 0.0 == -0.0); the frontend compares structs, enums, arrays, lists and refs with helper functions @fn.eq.N
%d = lt|le|gt|ge %a, %b T, T: int, float, str or *U bool ordered comparison, signed or unsigned by type; pointers unsigned; str byte by byte, a prefix first; false if either float is NaN
%d = cast %a see below dst type conversion
%d = call @f(%a, ...) parameter types of @f return type of @f call
%d = funcref @f fn(P...) -> R of @f function @f as a value
%d = call %f(%a, ...) %f: fn(P...) -> R, then P... R call a function value
%d = closure @f(%c, ...) the first parameter types of @f fn(P...) -> R, the rest of @f’s signature hosted and native: a function value that calls @f with %c, ... before its own arguments; natively the captured values move into memory the value owns

cast allows: int → int (truncate, or sign-/zero-extend by the source type), bool → int (0/1), int → float and float → float (to nearest), float → int (dropping the fraction, saturating at the limits, NaN → 0), *T → *U, *T ↔ i64/u64, and any type to itself.

syntax operands result meaning
%d = struct $S(%a, %b, ...) every field, in order $S build a struct
%d = field %s, N $S type of field N read a field
%d = setfield %s, N, %v $S, type of field N $S copy of %s with field N replaced
%d = getpath %s, (step, ...) a struct or array; each step field N or elem %i (%i: i64) type at the end of the path read a nested part; elements are bounds-checked
%d = take %s, (...) as getpath type of the part move the part out, leaving it empty (native; see Ownership)
%d = setpath %s, (step, ...), %v as getpath, then the type at the end of the path type of %s copy of %s with the nested part replaced

setfield, setelem and setpath describe values: natively and on the VM they update in place when %d is %s (on the VM, only if no other reference to the object exists).

syntax operands result meaning
%d = variant K(%a, %b, ...) the payload of variant K the enum (type of %d) build variant K
%d = tag %e an enum u32 the index of the variant %e holds
%d = payload %e, K, N an enum type of value N of variant K read a payload value; %e must hold variant K (the VM checks; natively it is undefined)

On the VM refs and cells are GC objects. Natively each points to a box { i64 count, T value } from malloc that its copies share: clone adds one to the count, drop takes one away and frees the box at zero (see Ownership). A ref’s count changes atomically, since refs may reach other threads; a cell’s does not, since cells may not. deref gives a view of the value; cellget gives a value the ownership pass copies, since other holders of the cell can change it. A cell that holds itself (through other cells) is never freed.

syntax operands result meaning
%d = ref %v T ref T a new reference to a copy of %v
%d = deref %r ref T T the value %r refers to

A ref field breaks the rule that a struct or enum may not contain itself. On the VM, types containing a ref or cell have no fixed layout, like those containing str; natively each is a pointer (8 bytes).

syntax operands result meaning
%d = cell %v T cell T a new cell holding a copy of %v
%d = cellget %c, (step, ...) cell T, a path into T (may be empty) type at the end of the path read what the cell holds, or a part of it
cellset %c, (step, ...), %v cell T, a path, the type at its end replace what the cell holds, or a part of it
syntax operands result meaning
%d = chan %n i64 chan T a new channel buffering up to %n values (0: sender and receiver meet)
send %c, %v chan T, T send %v, waiting while the channel is full
%d = recv %c chan T T receive a value, waiting until there is one
spawn %f(%a, ...) %f: fn(P...) -> R, then P... call %f in a new task; the result is dropped. Also native: in a new, detached thread of the C library’s
%d = select [case | ...] each case recv %c -> %v (%v: T) or send %c, %v, optionally a last default i64 do the first case that can go ahead, waiting until one can; %d is its index. With default it does not wait: %d is the number of cases if none can go ahead

The VM runs tasks on one thread with a deterministic round-robin scheduler; the run ends when the entry function returns. A task is only switched out at a call, at a jump to a block with a lower or equal number (every loop has one, since blocks are numbered breadth-first), or when it waits on a channel.

In a native module, spawn copies %f and the arguments into memory from malloc and starts a thread with pthread_create, which calls %f with them, frees the memory and ends; the thread is detached (pthread_detach), so nothing waits for it, and returning from main ends the program with it. Running out of memory or threads traps. Freestanding modules have no spawn.

Natively a chan T points to a channel of lib/channel.jh (its Raw), which the module must contain: jihoo-llc calls the functions that module exports as jihoo_chan_new, jihoo_chan_send, jihoo_chan_recv, jihoo_chan_hold, jihoo_chan_release, jihoo_chan_take, jihoo_chan_free, jihoo_select_new, jihoo_select_on and jihoo_select_run (the loader adds the module to a native program that uses channels). Values go in and out as bytes: send moves its value in, recv moves one out. A channel is counted like a ref (see Ownership), and the last copy to go frees the values still in it. A select send case sends a copy, which is freed if another case goes ahead; at most 16 cases. Natively a channel never carries a cell, which would then be shared by two threads.

index operands are i64. Element access is bounds-checked: the VM reports a runtime error and native code traps.

syntax operands result meaning
%d = array(%a, %b, ...) N values of type T [N x T] build an array
%d = splat %v T [N x T] an array with every element %v
%d = elem %a, %i [N x T], i64 T read an element
%d = setelem %a, %i, %v [N x T], i64, T [N x T] copy of %a with element %i replaced

Lists and strings (hosted, and lists natively)

Section titled “Lists and strings (hosted, and lists natively)”

A list [T] is an array whose length is known only when the program runs. Natively, it is a header { ptr data, i64 len, i64 cap } held by value, with the elements in memory from malloc; see Ownership for who frees it. A native str is the same header over bytes; one with cap 0 is a literal’s constant bytes, which are never freed, and copying it copies the header only. array builds one when %d is a list (of as many elements as it lists), and elem, setelem, getpath, setpath, cellget and cellset work on lists as on arrays, bounds-checked against the current length. len and slice also take a str, which is a sequence of bytes.

syntax operands result meaning
%d = len %a str or [T] i64 the number of bytes or elements
%d = slice %a, %lo, %hi str or [T], i64, i64 the type of %a bytes or elements %lo up to %hi; an error unless 0 <= lo <= hi <= len
%d = strbyte %s, %i str, i64 u8 byte %i; bounds-checked
%d = frombytes %b [u8] str the string of those bytes
%d = repeat %v, %n T, i64 [T] %n copies of %v; an error if %n is negative
push %r, (step, ...), %v a register, a path to a [T] in it (may be empty), T append %v to the list at the path in %r, or in what %r holds if it is a cell
%d = pop %r, (step, ...) as push T remove the last element of that list and give it; an error if the list is empty
%d = readline str the next line of standard input, with its \n; "" at the end of the input

push and pop change register %r (or the cell): as for setpath %r, %r, the list is changed in place unless something else refers to it, so a loop of pushes takes time in proportion to the number of elements.

Arguments, files and numbers in text (hosted and native)

Section titled “Arguments, files and numbers in text (hosted and native)”
syntax operands result meaning
%d = args [str] the program’s arguments, without its name (natively C’s argv[1] on)
%d = readfile %p str a struct of a str and an i64 the bytes of the file at path %p and 0, or "" and the error number
%d = writefile %p, %s str, str i64 makes the file at %p hold %s, creating it (mode 0666, less the umask) if missing; 0 or the error number
%d = appendfile %p, %s str, str i64 the same, but adds %s at the end of the file
%d = parsefloat %s str f64 the f64 nearest to the decimal number %s (ties to even), or NaN if %s is not one

Error numbers are Linux’s (errno), the same on both targets: what open, read or write failed with. A path with a NUL byte in it is EINVAL (22), which C’s open could not be asked. readfile’s struct is any whose fields are a str and an i64, in that order (the frontend uses the tuple (str, i64)).

A decimal number for parsefloat is [+-]digits[.digits][(e|E)[+-]digits] and nothing else: no spaces, no inf or nan, no hex. A number too large for an f64 is infinite, and one too small is zero, with its sign. Natively the number is read with C’s strtod after the check, so it assumes the C locale.

A value owns memory if it is a list or a str, or holds one by value (in a field, an element or a variant’s payload). Natively there is no GC, and copying a list header copies no elements, so a native module states who frees what with two instructions, which the frontend’s ownership pass inserts (crates/jihoo-ir/src/ownership.rs):

syntax operands result meaning
%d = clone %a any T T a copy: the lists and strings in %a are shared, counted once more (a closure’s captured values are copied, sharing theirs); %d may be %a
drop %a any T frees the memory %a owns; %a must not be used again before it is written

In a native module:

  • copy, building a value from parts (struct, variant, array), writing a part (setfield, setelem, setpath, push), ret and spawn move an owning operand: the operand’s register must not be used again before it is written. Writing a part frees the part it replaces.
  • Reading an owning part (field, elem, getpath, payload) gives a view that shares the memory of the value it was read from; the value must outlive the view and not change while the view is used, and the view must not be dropped. clone %v, %v makes a view a value of its own.
  • pop moves the element out of the list, and take %s, (path) the part at the path out of %s, which keeps the part empty: dropping %s or writing the part frees nothing of it. The pass makes take where the part is not read again before it, or a part holding it, is written, or %s dies; the VM reads the part.
  • Call arguments are borrowed: the callee must not change, move or drop its parameters (it clones one first), and the caller still owns its argument. The pass also makes a consuming version @f.own of a function that clones parameters, which owns those parameters instead; a call whose arguments for them are at their last use calls it and moves them. A thread started with spawn owns its arguments and frees them when the function returns.
  • tailcall moves its arguments, as ret does its value: the caller’s frame is gone, so the callee owns them. It calls the version @g.tail of @g, which the pass makes, and which owns every parameter of @g that owns memory (and frees one it never reads).
  • repeat, splat, slice and add on lists copy the elements they take (cloning owning ones) and leave their operands as they were.
  • Registers that own memory start out empty, so dropping one that was never written frees nothing.

The VM ignores both: on it, clone is copy and drop does nothing.

Natively, the heap memory of a list or a string starts with a count of its holders (an i64, 8 bytes before where the header points). clone counts one more; drop one fewer, and the last holder frees the elements and the memory. Strings never change in place, so their copies share their bytes for good. Every instruction that writes into a list’s memory (push, pop, setelem, and setpath, take and cellset through elements) first makes each list on its path unique: memory counted more than once is copied (the elements, each counted once more), and the copy is the value’s own. So a write never shows through another copy, as on the VM. Counts are atomic.

The pass also writes down what it decided, after a function’s regs line, so that the frontend can check it (crates/jihoo-ir/src/owncheck.rs):

own 0 2
views %5 %7
moves %12
  • own: the parameters, by position, that the caller hands over (in a consuming or tail version); the others are borrowed.
  • views: the registers that are views (of a part read, or a copy of a value still used): never dropped or moved, used only while what they share memory with lives unchanged.
  • moves: the registers written by field or payload reads that move the part out of a value taken apart. Such a value is not dropped; its owning parts must all be moved out (the pass reads the ones it does not need and drops them).

The check follows every owning register along every path: each value is moved or dropped exactly once and not used after, none is left at a ret or tailcall, only the function’s own values are dropped, moved or changed in place, a view is not used after its value changes (except in place at a disjoint part), and a value is in the same state on every way into a block. A failure is an internal error of the compiler. The backend reads past these lines.

syntax operands result meaning
%d = load %p *T T read memory
store %p, %v *T, T write memory
%d = addr %r T *T address of register %r (valid until the function returns)
%d = fieldptr %p, N *$S *F (F = type of field N) address of a field
%d = elemptr %p, %i *[N x T], i64 *T address of an element; bounds-checked

Atomics read and change memory that threads share, each as one indivisible step. All of them are sequentially consistent, and the memory must be aligned to its type (as everything jihoo lays out is). T is an integer, or for the rows that say so also a pointer *U.

syntax operands result meaning
%d = atomic_load %p *T (int or pointer) T read memory
atomic_store %p, %v *T, T (int or pointer) write memory
%d = atomic_swap %p, %v *T, T (int or pointer) T write %v; the result is the old value
%d = atomic_add|sub|and|or|xor %p, %v *T, T (int) T replace the value x by x op %v (wrapping, as add and sub do); the result is the old value
%d = atomic_cas %p, %e, %n *T, T, T (int or pointer) bool if the value is %e, replace it by %n; the result says whether it did
fence a full memory barrier; does nothing on the VM, which has one thread

The VM has no pointers, so the atomics other than fence are runtime errors there, as load is.

syntax operands result meaning
%d = syscall(%n, %a, ...) int or *T, 1 to 7 operands i64 native and freestanding only: raw Linux syscall %n
trap stop the program: a runtime error on the VM, llvm.trap natively (as a failed bounds or division check); the block ends with unreachable after it
loc F, L, C nothing: the instructions after it, up to the next loc in the block, come from line L, column C of file F (a file header). jihoo writes them only when asked (build --cost), and reads them to say where a copy is made; the backend reads past them
print %a number, bool, str (hosted, native) or *u8 (native) hosted and native: print the value and a newline (floats as the shortest text that reads back exactly, ties rounded up: 0.1, 1.0, 1e100, inf, NaN; a *u8 is a NUL-terminated string); natively through the C library’s stdout
%d = to_str %a number or bool str hosted, native and macros: the value as text, as print writes it
%d = unique %p str str compile time only: p plus a number unique in this compilation
%d = stringify %c expr, stmts or items str compile time only: the source text of code, with the hygiene marks of its names removed
%d = codetext %a code or str str or code compile time only: the same text as the other type; code to str keeps the hygiene marks, str to code takes the text as it is
%d = typeof %c expr str compile time only: the type the code would have where the macro was called, as the text of a type; the compiler supplies it and runs the macro again if it was not known
%d = asm "tmpl", "cons"(%a, ...) int, bool or *T int, *T or unit native and freestanding only: inline assembly
%d = quote ["p0", "p1", ...](kind %h, ...) see below expr, stmts or items compile time only: code from template pieces and holes

Each quote hole has a kind: expr (code in parentheses, or an int, bool or str literal), ident (a str or expr that is an identifier, inserted as is), stmts (stmts or expr code on lines of its own), items (items code on lines of its own) or type (a str or expr with the text of a type, inserted as is).

The template pieces carry hygiene marks: a name the template wrote is followed by # (let tmp# = $x). The compiler numbers them per expansion (tmp#7) and resolves them once the code is parsed (Compiler); stringify gives the text without them.

asm passes tmpl and cons to LLVM unchanged: the template uses LLVM operand references (${0} is the output if there is one, then the inputs, in order; $$ is a literal $) and cons is an LLVM constraint string (=r, {rdi}, 0, ~{memory}, …) with one input constraint per operand. On x86_64 the backend uses the Intel dialect and adds ~{dirflag},~{fpsr},~{flags}, as clang does. Bool operands are passed as i8.

syntax meaning
jmp bbN jump
br %c, bbT, bbF %c: bool; go to bbT if true, else bbF
ret %a return %a, which must have the function’s return type
tailcall @g(%a, ...) return what @g returns given the arguments, without nesting: the caller’s frame is gone before @g runs
unreachable control never gets here

Returning from @_start exits the process: with the returned value if @_start returns i64, with 0 if it returns unit.

tailcall calls a function of the module, not an extern, that returns the caller’s type, with arguments of its parameter types; its signature may otherwise differ from the caller’s. A chain of tail calls runs in constant stack. The VM runs the callee in the caller’s frame. Natively the functions that make or take tail calls get bodies with LLVM’s tailcc convention, and a tail call is a musttail call between two of them; each keeps the C convention under its own name, for function values, exports and main. Aggregates go through a buffer (per thread natively) instead of the caller’s frame, which is gone when the callee copies them, as it does on entry. From @_start, which has no caller, it is a call followed by the exit.

A function value is an index into the module’s functions on the VM, a code pointer in freestanding modules (8 bytes, aligned to 8), and { code, env } in native ones (16 bytes). There is no null function value, and function values have no equality. A call through a function value uses the same calling convention as a direct call, including the aggregate rules below.

A closure is a function value too, which passes the values it captured as the first arguments. On the VM it is a GC object holding the function and the captured values. Natively, env points to memory from malloc holding a function that frees it, one that copies it, and the captured values; code takes env after the sret pointer, before the arguments. A function value that is not a closure has a null env, and a call through a function value tests which it is. Function values own memory as lists do (see Ownership), but may be read and written through pointers: one read through a pointer is borrowed from that memory. C gets code alone; passing it a closure traps. The frontend names the functions it lifts out of anonymous functions @fn.N; fn is a keyword, so these never clash with user names.

Between the program’s own functions, jihoo-llc passes aggregates (structs and arrays) by pointer: the caller passes the address of its copy and the callee copies it into its own storage, and an aggregate result is written through a hidden first parameter. Aggregates are moved with memcpy; a freestanding module defines weak memcpy, memmove, memset, fmod and fmodf (which float rem becomes), and floor, ceil, trunc, round and their f forms (which LLVM calls where the machine has no instruction for them), because it has no libc; a native one uses libc’s and libm’s.

Scalars and pointers are passed the C way, so a jihoo function whose parameters and result are floats, integers of at least 32 bits, pointers or unit can be handed to C as a callback (as qsort’s comparator, say). Functions that make or take tail calls have a second body with LLVM’s tailcc convention, which the program’s direct calls use (see Terminators).

Calls to extern functions and the C functions of exported ones follow the target’s C ABI, structs included, as a C compiler would:

  • x86_64 (System V): a struct of at most 16 bytes is split into eightbytes. One holding only floats goes in an SSE register, any other in a general register; if the registers a struct needs are not all free, the whole struct goes on the stack. Larger structs go on the stack (byval), and a larger result is written through a pointer the caller passes first.
  • aarch64 (AAPCS64): a struct of one to four floats of one type goes in as many SIMD registers; any other struct of at most 16 bytes in one or two general registers. A larger struct is passed as a pointer to a copy the caller makes, and a larger result is written through a pointer in x8.

An exported function’s C function takes its arguments that way and calls the program’s function with them.

The program’s functions are internal symbols named jihoo.<name>, so they never clash with C symbols, except a freestanding module’s _start. An exported function is also the external symbol it exports. A native module defines C’s int main(int argc, char **argv), which calls jihoo.main and returns its result truncated to int (0 for unit). Native objects are position independent, since the C compiler that links them usually makes a PIE.