Skip to content

FieldML stage by stage

What happens to a FieldML program between typing it and seeing a result? We'll follow one program through the compiler, from tokens and syntax tree to types, evaluation and the final value. Along the way you can see where errors and completion suggestions come from.

The FieldML Cheatsheet is the reference for the prelude, FieldML Examples is the tour of the language, and the FieldML JVM Bridge is how a program reaches Kotlin.

Where the listings come from

Every listing on this page is printed by the compiler. FieldMLStagePageTest recomputes each one from the program below and fails if the page and the compiler disagree.

The program

The program defines a function that applies another function twice, and pipes 5 through it:

let twice f x = f (f x)
let bump n = n + 1
5 |> twice bump

twice takes a function and applies it twice. Nothing in the program says what f works on, so inference has to give twice a polymorphic type. |> is a pipe, and it leaves no trace in the tree; the section on the surface AST shows what that means.

How the compiler runs

FieldMLCompiler.compile runs four stages in order (lex, parse, infer, lower) and returns a CompilationResult with one field per stage: tokens, surface, typed, core. It stops at the first stage that reports an error, and a field is null when its stage was not reached.

You can still inspect the stages reached by a partial compile. For example, bump "five" has a type error: its core is null, but its tokens and surface are available. Completion uses the same idea, reading names in scope from the tree it can build for a half-written line.

A non-null tree does not mean the program is correct. Check successful before evaluating anything.

Tokens

The lexer turns the text into tokens. The listing has one line per token: where it starts, its kind, and the text it was read from.

1:1   LET           let
1:5   IDENT         twice
1:11  IDENT         f
1:13  IDENT         x
1:15  EQ            =
1:17  IDENT         f
1:19  LPAREN        (
1:20  IDENT         f
1:22  IDENT         x
1:23  RPAREN        )
2:1   LET           let
2:5   IDENT         bump
2:10  IDENT         n
2:12  EQ            =
2:14  IDENT         n
2:16  PLUS          +
2:18  INT           1
3:1   INT           5
3:3   PIPE_FORWARD  |>
3:6   IDENT         twice
3:12  IDENT         bump
3:16  EOF

There is no token for whitespace or comments: the lexer skips them, so a token list cannot be printed back as the exact source it came from.

let is a keyword with its own kind, LET, so it cannot be used as a variable name.

EOF has empty text. The lexer adds it at the end of the source, so an error about a program that stopped too early still has a position to point at.

An INT token's text is still text: "1", not the number 1. The parser decodes literals, including a string's quotes and escapes.

The surface AST

The parser builds one node per piece of written syntax.

ELet twice f x
  ECall
    EVar f
    ECall
      EVar f
      EVar x
  ELet bump n
    EBinary ADD
      EVar n
      EInt 1
    ECall
      ECall
        EVar twice
        EVar bump
      EInt 5

Each ELet holds its value and then its body, and the body of a let is the rest of the program. A file of top-level bindings is therefore a nested chain of ELets rather than a list, with the program's last line innermost.

ELet twice f x keeps f and x as parameters of the let, because the surface tree records what was written: let twice f x = …, not a lambda. The lambda appears in the typed tree.

What happened to the pipe

5 |> twice bump has no pipe node. 5 |> twice bump and twice bump 5 parse to the same tree:

ECall
  ECall
    EVar twice
    EVar bump
  EInt 5

The parser rewrites x |> f as the application f x, so nothing after the parser knows a pipe was written. That is why a function meant for the end of a pipe takes its subject last, and why the prelude's functions are ordered that way.

The two nested ECalls show that application is curried: a call with two arguments is two calls of one argument each.

Inferred types

Before the typed tree, the summary: the type each binding got, and the type of the result.

twice : ('a -> 'a) -> 'a -> 'a
bump : Int -> Int
(result) : Int

'a is a type variable: twice works for any function whose argument and result have the same type. Inference worked that out from f (f x) alone, since f's result is passed back into f.

-> is right associative, so the trailing 'a -> 'a needs no parentheses. The leading pair is needed because that function is an argument.

The typed AST

The same shape as the surface tree, with the inferred type on every node.

TExprLet twice f x : Int
  TExprLambda f x : ('a -> 'a) -> 'a -> 'a
    TExprCall : 'a
      TExprVar f : 'a -> 'a
      TExprCall : 'a
        TExprVar f : 'a -> 'a
        TExprVar x : 'a
  TExprLet bump n : Int
    TExprLambda n : Int -> Int
      TExprBinary ADD : Int
        TExprVar n : Int
        TExprInt 1 : Int
    TExprCall : Int
      TExprCall : Int -> Int
        TExprVar twice : (Int -> Int) -> Int -> Int
        TExprVar bump : Int -> Int
      TExprInt 5 : Int

TExprLet twice f x has type Int, not a function type. A let is an expression whose type is the type of its body, and the body of the outermost let is the rest of the program, which evaluates to an Int. The function type is on the value, one line below.

The same name has two types in one tree. At its definition, twice's lambda is ('a -> 'a) -> 'a -> 'a; at its use, TExprVar twice is (Int -> Int) -> Int -> Int. The definition is generalised, and each use gets a fresh copy with its own types. Here the only use applies twice to bump, so 'a becomes Int at that node and stays 'a inside the definition. This is let-polymorphism.

let twice f x = … is now a TExprLambda holding the parameters, in the value of the let.

The 'a here is the same type variable as the 'a in the summary above. Both listings use one naming, so variables keep their names from one listing to the next.

Core

Lowering produces the tree the evaluator runs.

CLet twice (recursive) : Int
  CLam f : ('a -> 'a) -> 'a -> 'a
    CLam x : 'a -> 'a
      CApp : 'a
        CVar f : 'a -> 'a
        CApp : 'a
          CVar f : 'a -> 'a
          CVar x : 'a
  CLet bump (recursive) : Int
    CLam n : Int -> Int
      CBinary ADD : Int
        CVar n : Int
        CInt 1 : Int
    CApp : Int
      CApp : Int -> Int
        CVar twice : (Int -> Int) -> Int -> Int
        CVar bump : Int -> Int
      CInt 5 : Int

The core tree follows the typed tree node for node, with two differences.

Lambdas take one parameter each. TExprLambda f x became CLam f wrapping CLam x, so the evaluator has a single rule for application and partial application needs no special case. The types show it: ('a -> 'a) -> 'a -> 'a applied to one argument leaves 'a -> 'a.

Source positions are gone. Every TExpr carries a SourceRange and no CoreExpr does, so an evaluation error is reported against the box fragment that ran, not the subexpression that failed. Every core node still carries its type.

CLet twice (recursive) marks a function binding, let f x = …, whose own name is in scope in its body, so a FieldML function can call itself. A plain let x = … is not recursive, so let x = x + 1 shadows an outer x instead of referring to itself.

CLet has no parameter list: a binding's parameters are the CLam in its value.

The value

The evaluator walks the core tree and produces the result:

7 : Int

That is bump (bump 5). In a document, a run shows this value beside the last expression of a FieldML box.

Limits

A FieldML function that Kotlin calls as a suspend block, such as the body of frames, eachFrame, loop or animate, can wait. hostAwait "frame" () f calls Frames.frame() and continues on the next line one frame later, and hostAwait "delay" 40 f continues 40 ms later. A function called from inside an ordinary Kotlin callback, such as the function you give map, cannot wait, and hostAwait there stops the box with an error saying so. See the FieldML JVM Bridge.

There is no stage after core: no bytecode and no optimiser. The evaluator walks the core tree directly, which is why a box responds to an edit immediately. For code that needs to be fast, call Kotlin through the host bridge.