Intermediate Language

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.

What it's like

Still a tree

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.

Statements do, expressions compute

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.

Every name resolved

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.

What's left to the backends

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.

Types where things are declared

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.

Reading it

--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.

The program

program

#ir-program
IrProgram

The 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.

file

#ir-file
IrFile

One 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.

struct Name [&& Other ...] [&& Error]

#ir-structdef
IrStructDef

A 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.

enum Name

#ir-enumdef
IrEnumDef

An 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.

global name: Type

#ir-global
IrGlobal

A 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.

function name(local.0 a: A, ..local.1 rest: B): Result

#ir-function
IrFunction

A 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.

@name(arguments)

#ir-attribute
IrAttribute

An 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.

macro @kind name

#ir-macrodef
IrMacroDef

One 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.

apply macro to definition arguments

#ir-macroapply
IrMacroApply

A 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.

implement Interface for Type

#ir-implementation
IrImplementation

An 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.

method name for Type is function

#ir-method
IrMethod

What 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.

Statements

set local.n value

#ir-set
IrSet

The 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.

set-field target name value

#ir-setfield
IrSetField

A struct's field set in place - set item.price ≔ 2.0.

set-index target index value

#ir-setindex
IrSetIndex

A list's element or a map's value set in place - set prices[0] ≔ 1.5.

evaluate value

#ir-evaluate
IrEvaluate

An expression worked out for what it does - a call, most often - and its value dropped. An expression written as a statement becomes one.

if condition

#ir-if
IrIf

Runs 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.

block $label

#ir-block
IrBlock

A 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.

loop $label

#ir-loop
IrLoop

Runs 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.

break $label

#ir-break
IrBreak

Leaves the block or loop with that label, however deeply nested the break is inside it.

continue $label

#ir-continue
IrContinue

Starts the loop with that label again, from the top of its body - which gets its next item.

return value

#ir-return
IrReturn

The function ends, giving value - or undefined when there's none. In a closure's body, the closure ends.

fail error

#ir-fail
IrFail

The 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.

inject name value

#ir-inject
IrInject

Runs 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.

reject name count

#ir-reject
IrReject

Runs 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.

defer

#ir-defer
IrDefer

Runs 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.

yield value

#ir-yield
IrYield

A generator's next value - it pauses there until the next one is asked for.

await local.n value

#ir-await
IrAwait

An 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.

Expressions

(literal text: Type)

#ir-literal
IrLiteral

A 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.

local.n

#ir-localget
IrLocalGet

A local's value - a parameter, a name bound in the function, or a temporary. Written without brackets.

(global name)

#ir-globalget
IrGlobalGet

A top-level const's value - computed the first time any code reads it, and kept.

(function name)

#ir-functionref
IrFunctionRef

One 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.

(builtin name)

#ir-builtin
IrBuiltin

Something 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 (+)).

(type Type)

#ir-typeref
IrTypeRef

A type as a value - S64 in x as S64, Map in Map.of(...), a type passed to a function.

(context name)

#ir-context
IrContext

An injected value - &stdout, &limit - whatever the nearest inject around the running code, in this function or a caller, set it to.

(call name arguments)

#ir-call
IrCall

One 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.

(call-value callee arguments)

#ir-callvalue
IrCallValue

Any function value called - a closure in a local, a builtin, a function passed as an argument.

(call-method receiver name<types> arguments)

#ir-callmethod
IrCallMethod

receiver.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.

(call-type Type name<types> arguments)

#ir-calltype
IrCallType

A function for a type, called on the type: Map.of(...), Array.filled(...), Cursor.over(...), or one of the program's.

(operator op [INTRINSIC] operands)

#ir-operator
IrOperator

An 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).

(construct Type field≔value ...)

#ir-construct
IrConstruct

A 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.

(make-case Type Case values fields)

#ir-makecase
IrMakeCase

An 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.

(field target name)

#ir-fieldget
IrFieldGet

A field's value - of a struct, of an enum case that carries fields, or anything else with one, like an error's text.

(element target n)

#ir-element
IrElement

A tuple's element, by its position - how let (a, b) ≔ pair takes a tuple apart.

(index target index)

#ir-index
IrIndex

xs[i] - a list's element, a map's value, a string's character. Undefined when there's none there: it never fails.

(case-value target n)

#ir-casevalue
IrCaseValue

The 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.

(is-case value Type Case)

#ir-iscase
IrIsCase

Whether value is that case of that enum - a match's test for case Shape.Circle(...).

(is-type value Type)

#ir-istype
IrIsType

Whether 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.

(is-undefined value)

#ir-isundefined
IrIsUndefined

Whether value is undefined - the test behind ??, ?. and ?().

(is-error value)

#ir-iserror
IrIsError

Whether value is an error - the test behind !!, ??, !. and !().

(error-with value)

#ir-errorwith
IrErrorWith

What an error carries with it - error e with value - and undefined for anything else: a match's test for case Error(e) with value.

(is-missing local.n)

#ir-ismissing
IrIsMissing

Whether 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.

(tuple elements)

#ir-tuple
IrTuple

A tuple made from its elements - (1, "a").

(list items)

#ir-list
IrList

A list made from its items - [a, ..rest, b]: each item one element, or with .. every element of something iterable.

(conditional condition whenTrue whenFalse)

#ir-conditional
IrConditional

whenTrue 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.

(closure (parameters) (captures locals) { body })

#ir-closure
IrClosure

A 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.

(text parts)

#ir-text
IrText

A 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().

(convert value Type)

#ir-convert
IrConvert

value as Type - a number converted to another number type, or a value to a type it's known to be.

(make-error message details)

#ir-makeerror
IrMakeError

An 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.

(iterate iterable)

#ir-iterate
IrIterate

An iterator going through anything a for loop can - a list, a range, a string's characters, a map's entries, a generator.

(next iterator)

#ir-next
IrNext

The iterator's next item - or, when there are no more, a value is-end recognizes.

(is-end value)

#ir-isend
IrIsEnd

Whether a next gave the end rather than an item.

(embedded #LANGUAGE parts)

#ir-embedded
IrEmbedded

A 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.

(macro-use #name macro input)

#ir-macrouse
IrMacroUse

A 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.

(comptime { statement; ... })

#ir-comptime
IrComptime

Code 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.

Lowering

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.

let, and taking values apart

#lower-let
lowering

A 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.

set - a field, an element

#lower-set
lowering

Setting a field or an element changes the value in place; the target is worked out first, then the new value.

if, ? :, and, or

#lower-choices
lowering

An 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.

match

#lower-match
lowering

A 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.

match - types, errors and literals

#lower-match-types
lowering

case 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.

for loops

#lower-for
lowering

A 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.

!! and error

#lower-errors
lowering

v!! 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.

defaults, ?. and ??

#lower-missing
lowering

A 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.

structs and enum cases

#lower-building
lowering

A struct is a construct with every field - count given its default here. An enum's case is a make-case, with what it carries.

calls - methods, types, builtins, lambdas

#lower-calls
lowering

A 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.

functions inside functions

#lower-closures
lowering

A 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.

inject, defer, format strings and consts

#lower-context
lowering

inject 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.

What isn't lowered yet

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.