The intermediate language is the program as the compiler sees it right before generating code: checked, every name resolved, and every construct that exists to make Refingere pleasant to write taken apart into a small set of simpler ones. It sits between the stages that understand the program and the ones that produce something runnable, so neither side has to know about the other - the backends never see a match, a for loop over anything iterable, a default parameter, !! or a format string, and the front of the compiler never has to know how WebAssembly or JavaScript want them.
This page defines it: every node, what it means, and what each construct of the language becomes. The bootstrapping compiler builds it in bootstrapping/5-intermediate, and --ir prints it - Lowering, in the Compiler section, is about the step that makes it. Both backends are to be generated from it, and it's what the compiler runs while compiling: macros and #comptime(...) code are run from it, so they behave exactly as the same code does compiled. That's why it's made right after type checking, before macros are expanded - a use of one of the program's own macros is in it as a macro-use, until the macro has run and what it gave has been checked and lowered in its place.
It isn't a list of instructions with jumps between them. Control flow stays structured: an if has two branches of statements, a block is left early with break, a loop repeats until it's left. That's the shape WebAssembly's own control flow has, and JavaScript's, so a backend writes each node as the matching construct without first rebuilding structure out of jumps - and it's still readable when printed.
An expression never hides a statement inside it. Where the source's does - v!! returns from the function, a ?? b tests before choosing, a match runs code in each case - the lowering writes the statements first, and a temporary local carries the value into the expression. Only conditional chooses between expressions, and only when neither side needs a statement; otherwise it's an if setting a temporary. So an expression can always be worked out in one go, left to right.
Inside a function, every value is in a numbered local: its parameters first, then each name bound in it - by a let, a loop, a match case, a lambda's parameters - and each temporary the lowering needs, whose names start with $. Two lets of the same name in different blocks are two locals. Everything defined at the top of a file is named by its file and name - shop.rfn#area, shop.rfn#Item - so two files' area are two functions, and a call says which one it means. A method is file#Type.name.
Some choices depend on the values themselves while the program runs, and stay as they are: operator + adds whatever numbers it's given, and call-method finds a method by the receiver's type. That's how both backends work today, and it keeps the intermediate language the same whether type checking knew a type or not. Binding a call's arguments to parameters is left to the called function too, which is why a call keeps its arguments' names, and a function's first statements give each parameter its default.
Every local, parameter, result, field and global has the type type checking worked out for it, and every literal has the type it settled on - an integer the source leaves open is an S64, as the backends take it today. Other expressions don't carry one. Where checking couldn't tell, the type is Unknown: the program can still be compiled, and the backend relies on the value's own type.
--ir writes each file's part - its structs and enums, its consts, its functions and which methods they are - a statement to a line, nested ones indented under the statement they're in. An expression is written in brackets, its name first; a local is written on its own, as local.n.
#ir-programThe whole program: every source file's part of it, and the function it starts from. main is the qualified name of the @main function when the program has one - a library, or a set of tests, may not.
#ir-fileOne source file's part of the program - what it defines. Keeping the program split by file lets a backend, or --out, keep one file's result apart from another's; nothing in a file's part refers to the file itself, since every name is already qualified with the file it's defined in.
#ir-structdefA struct the program defines, and its fields in order. Only the program's own fields are listed: a struct combined with Error (struct X {...} && Error) gets Error's fields from the runtime. Defaults aren't part of the definition - every construct is given each field it needs, defaults already filled in.
#ir-enumdefAn enum the program defines, and each of its cases: what it carries in order (case Circle(F64)), its fields (case Box { field width: F64 }), or nothing. An enum combined from structs (enum Shape ≔ Circle || Square) has a case for each, carrying that struct's fields.
#ir-globalA top-level const, or a struct's own - or a context declaration's default. Its value is computed by a function of its own, of no parameters, the first time it's read - so consts can use each other in any order, and one that's never read is never computed. The backends keep the value once it's computed.
#ir-functionA function or method. Its parameters are its first locals, in order; every other name bound in it, and every temporary the lowering needs, is a local after them, declared with its name and type. A lambda's parameters and locals are the function's too - see closure. A parameter's default is set at the start of the body, when the call left it out (is-missing). A function declared with a #JS or #TS body has that text as foreign, and no body of its own.
#ir-attributeAn attribute on a definition, its arguments lowered like a call's, so a backend can use them - the port @serve listens on, the name @test gives a test, the module @js calls.
#ir-macrodefOne of the program's own macros - implement macro name(input, ...) { ... } - lowered like a function, since compile-time code runs it from here: its first parameter is its input, and what it returns is what it gives. Its kind, from its attribute, says how it's used - Body, String, Derive, Attribute or Program. A macro whose kind couldn't be told - none given, more than one, a builtin macro's name - is reported, and not lowered.
#ir-macroapplyA derive or attribute macro put on a definition: each @derive(Name) naming one of the program's derive macros, each @name(...) naming one of its attribute macros. Compile-time code runs the macro with the definition - a derive macro's result is added beside it, an attribute macro's replaces it. A builtin derive - @derive(Equatable) - isn't one.
#ir-implementationAn implement block - the functions in it are among the file's methods; this records which interface it's for, so value is Interface holds for a value of the type.
#ir-methodWhat value.name(...) finds when the value is of type. A method is compiled as an ordinary function whose first parameter is self; the backends build a dispatcher per method name from these entries, choosing by the receiver's type while the program runs, the way they already dispatch builtin methods.
#ir-setThe local numbered n holds value from now on. Every let, every set of a name, and every temporary is one of these. A let - and a loop's variable, a pattern's name - is a new binding: a closure made before it keeps the one it captured, so each round of a loop has its own, while a set changes the one every closure sees.
#ir-setfieldA struct's field set in place - set item.price ≔ 2.0.
#ir-setindexA list's element or a map's value set in place - set prices[0] ≔ 1.5.
#ir-evaluateAn expression worked out for what it does - a call, most often - and its value dropped. An expression written as a statement becomes one.
#ir-ifRuns one list of statements or the other. Both branches are statement lists, possibly empty; --ir leaves out an empty else. The condition is an expression with no statements of its own - anything it needed has run before the if.
#ir-blockA list of statements that break $label leaves early. A match is one: each case ends by breaking out of it. Labels are unique in their function - a second $match is $match2, and so on - so a break always names exactly one block or loop.
#ir-loopRuns its body again and again. break $label leaves it, and continue $label starts its body again; there's no condition of its own - a for loop's end, and its while and until, are breaks at the start of the body. A for loop's label is its first variable's name: for row in rows is loop $row, which is how break row finds it.
#ir-breakLeaves the block or loop with that label, however deeply nested the break is inside it.
#ir-continueStarts the loop with that label again, from the top of its body - which gets its next item.
#ir-returnThe function ends, giving value - or undefined when there's none. In a closure's body, the closure ends.
#ir-failThe function ends, giving an error: its caller gets an error value, which it can handle, or pass on with !!. error "..." is a fail of a make-error; v!! is a fail of v itself when it's an error.
#ir-injectRuns body with &name set to value - for it, and for every function it calls. inject in the source lasts until the end of its block, so the rest of the block is the body: nothing after it in the source is outside it.
#ir-rejectRuns body without what its own block injected as &name - reads see whatever was injected further out. Like inject, its body is the rest of its block. A reject of a name its block didn't inject does nothing, so it isn't lowered at all.
#ir-deferRuns body, then cleanup - however the body ends: at its end, or by a return, a fail, or a break or continue out of it. The body is the rest of the block the defer was written in. A backend compiles it the way JavaScript's try ... finally works.
#ir-yieldA generator's next value - it pauses there until the next one is asked for.
#ir-awaitAn await in an @async function, a statement of its own just before what it's in - the function pauses until value settles, if it's a promise that hasn't, and the local gets what it settled with. A value that isn't a promise is itself, at once.
#ir-literalA value written in the source. A number is its text as written (0x1F, 1_000, 2.5), with the type checking settled it on - an integer the source leaves open is S64, the type the backends make of it. A string is its value, escapes already read: --ir writes it in quotes again. true, false and undefined are written on their own.
#ir-localgetA local's value - a parameter, a name bound in the function, or a temporary. Written without brackets.
#ir-globalgetA top-level const's value - computed the first time any code reads it, and kept.
#ir-functionrefOne of the program's functions as a value - passed to map, kept in a field. A call of a function by its name is a call instead.
#ir-builtinSomething built into the language, by name: a free function (range, panic), anything reached through a namespace (#builtin.range, #builtin.intrinsic.ADD_U64_U64, #runtime.fieldsOf), or an operator as a value (operator +, from (+)).
#ir-typerefA type as a value - S64 in x as S64, Map in Map.of(...), a type passed to a function.
#ir-contextAn injected value - &stdout, &limit - whatever the nearest inject around the running code, in this function or a caller, set it to.
#ir-callOne of the program's functions, called directly by name - the call a backend can make most cheaply. The arguments keep the names they were given with: binding them to parameters, and noticing one left out so its default applies, is the called function's part (see is-missing). A ..xs argument is marked as spread.
#ir-callvalueAny function value called - a closure in a local, a builtin, a function passed as an argument.
#ir-callmethodreceiver.name(arguments). Which function runs depends on the receiver's type while the program runs: one of the program's methods (see method), a builtin one, or one an interface provides. Type checking knows the type most of the time; leaving the choice to the backends keeps the intermediate language the same whether it did or not.
#ir-calltypeA function for a type, called on the type: Map.of(...), Array.filled(...), Cursor.over(...), or one of the program's.
#ir-operatorAn operator applied to one operand or two. Its meaning depends on the values' types - + adds numbers of any type, ++ joins strings or lists, == compares anything - so, like methods, it's chosen while running. The operators: + - * / // % ^, == != < <= > >=, ++, in, bor band bxor shl shr, ... ..< ..=, |> ||>, xor iff, and on one operand neg (prefix -), not, ~ and √ (with the degree as a second operand for √₃). and and or aren't among them: they're conditional, so their right side runs only when it's needed. Optimization marks one whose operands' types it knows with the intrinsic they make it - (operator + ADD_U64_U64 local.0 local.1) - which the backends work out at once when the values are of that type, and run the operator as it is otherwise (see the Optimization page).
#ir-constructA struct made from its fields, by name - however the source gave them (by name, in order, or Point{x, y} for locals named like the fields), each field left out that has a default is given it here, lowered where the struct is written. A field combined from Error is filled in by the runtime.
#ir-makecaseAn enum's case: Shape.Dot with nothing, Shape.Circle(1.0) with values in order, Shape.Box{width ≔ 2.0} with fields. A builtin enum's case - Ordering.Less - is one too, its type named as the builtin.
#ir-fieldgetA field's value - of a struct, of an enum case that carries fields, or anything else with one, like an error's text.
#ir-elementA tuple's element, by its position - how let (a, b) ≔ pair takes a tuple apart.
#ir-indexxs[i] - a list's element, a map's value, a string's character. Undefined when there's none there: it never fails.
#ir-casevalueThe value an enum case carries at position n - radius in case Shape.Circle(radius). Only read once is-case has said it's that case.
#ir-iscaseWhether value is that case of that enum - a match's test for case Shape.Circle(...).
#ir-istypeWhether value is of that type - v is String, and a match's test for case Error as e or case Circle{radius}. Any error is an Error.
#ir-isundefinedWhether value is undefined - the test behind ??, ?. and ?().
#ir-iserrorWhether value is an error - the test behind !!, ??, !. and !().
#ir-errorwithWhat an error carries with it - error e with value - and undefined for anything else: a match's test for case Error(e) with value.
#ir-ismissingWhether the call left out the parameter held in local n - true only at the start of a function, where each parameter with a default is given it.
#ir-tupleA tuple made from its elements - (1, "a").
#ir-listA list made from its items - [a, ..rest, b]: each item one element, or with .. every element of something iterable.
#ir-conditionalwhenTrue when the condition holds, whenFalse when it doesn't - only the one chosen is worked out. c ? a : b, a and b (a ? b : false) and a or b (a ? true : b) are all conditionals, as long as neither side needs statements of its own; when one does, they're an if setting a temporary instead.
#ir-closureA lambda, or a function defined inside a block. Its parameters are locals of the function it's in, numbered with the rest, and it reads and sets the function's other locals directly: they're shared, not copied, so a local set after the closure is made is seen by it, and one it sets is seen outside. captures lists those locals from outside it, for a backend that has to keep them somewhere the closure can reach - JavaScript's own closures need nothing more. --ir writes its body on one line, a nested block's statements in braces.
#ir-textA format string - #format"Hello, #{ name }!": each part's text, joined. A text part is a literal; a placeholder's value is turned into text with its toString().
#ir-convertvalue as Type - a number converted to another number type, or a value to a type it's known to be.
#ir-makeerrorAn error made from what error was given: a message makes a new error with it, and an error value - error Diagnostic{...} - is that error itself. details is what with added.
#ir-iterateAn iterator going through anything a for loop can - a list, a range, a string's characters, a map's entries, a generator.
#ir-nextThe iterator's next item - or, when there are no more, a value is-end recognizes.
#ir-isendWhether a next gave the end rather than an item.
#ir-embeddedA document in one of the embedded languages - #SQL {{{ ... }}}, #HTML, #JSON, ... and #regex"..." - its text and its placeholders' values in order, for the backend to build the typed value it stands for.
#ir-macrouseA use of one of the program's own macros - #shout"hi", #lines {{{ ... }}} - kept as it's written. The intermediate language is made before macros run, since it's what they run from: expanding macros finds each of these and checks it against its macro's kind, then compile-time code runs the macro with its input, and what it gives is lexed, parsed, analyzed, checked and lowered in this node's place. A program still holding one can't be compiled. It's the one expression that knows where it's written, since what the macro does with its input is reported there.
#ir-comptimeCode to run while compiling - #comptime(7 * 24) - whose value is put in its place. Each round of the macro stages runs it with the comptime engine and writes the value as a constant, which is parsed and put where the expression was, the way a macro's result is. Its statements work the value out, ending in a return, in the locals of the function it's in; reading one of those is reported, since their values aren't known while compiling.
What each construct of the language becomes - the source on the left of each example, and what --ir prints for it. Everything else is what it looks like: a call is a call, a list a list, an operator an operator.
#lower-letA let binds a new local. A destructuring one takes its value apart one name at a time - a tuple's elements, a list's indexes, a struct's fields - from a local holding the whole, so the value is worked out once. A compound set is the operator and a plain set.
#lower-setSetting a field or an element changes the value in place; the target is worked out first, then the new value.
#lower-choicesAn if stays an if. and, or and c ? a : b become conditionals, so what's on their right is only worked out when it's needed - and when that side needs statements of its own, an if setting a temporary instead.
#lower-matchA match is a block. The value matched is held in a local, and each case is an if testing its pattern - is-case for an enum's case, is-type for a type, == for a literal - nested so a case's value is only taken apart once it's known to be that case. Then the case binds its names, checks its guard, runs its body, and leaves the block; a case whose body already returns needs no break. A case with no test - _, or a name - always runs, if it's reached.
#lower-match-typescase Error as problem is an is-type test, the value bound to the name; a literal is compared with ==; a name on its own matches anything and binds it.
#lower-forA for loop goes through an iterator: iterate makes it before the loop, and each time round next gets an item, is-end checks for the end, and the pattern binds the item. An index counts in a hidden local. The modifiers come next, in order: while c breaks when c doesn't hold, until c when it does, if c continues past the body when it doesn't, skipping c when it does. The loop's label is its variable's name, so break row leaves the outer loop from inside the inner one.
#lower-errorsv!! holds v in a local and fails the function with it when it's an error - the statement before the expression - and the local is the value from then on. error m with d fails with a make-error.
#lower-missingA parameter's default is set at the function's start, when the call left the parameter out. v?.name holds v and is undefined when it is, its field when not; v ?? w is v unless it's undefined - then w, only worked out then; v !? w is the same for an error.
#lower-buildingA struct is a construct with every field - count given its default here. An enum's case is a make-case, with what it carries.
#lower-callsA method is a call-method, a function for a type a call-type, a namespace's member a builtin called with call-value, and one of the program's functions called by name a call. A lambda is a closure, its parameter a local of the function around it.
#lower-closuresA function defined in a block is a closure held in a local - so it can call itself, and the rest of the block can call it. It shares the locals it uses: limit and kept here are the outer function's own.
#lower-contextinject and defer take the rest of their block as their body, so what they cover is plain from the tree. A format string is a text, its placeholders lowered like any code; a const is read with global. A context declaration's default is a global of its own, injected before the program starts; without a default, a declaration lowers to nothing - it only gave the type.
A destructured top-level const, a type or implement block defined inside a function, and ... between a list's elements are errors at this stage for now. A use of one of the program's own macros isn't lowered but kept, as a macro-use, for compile-time code to run (see Macros). The builtin namespaces are builtin names, and the embedded languages embedded expressions, whose placeholders are parsed, linked and checked with the code around them, and lowered like it.