courses/compiler/chapter_4.md
Simon From Jakobsen 1ad298acf5 add chapter 4
2024-09-18 14:16:22 +00:00

5.6 KiB

4 AST Evaluator

In this chapter, I'll show how you could implement an AST evaluator.

AST evaluation is the process of taking the parsed source in AST form and computing the values expression, ie. running the code. AST evaluation particularly, is a conceptually way of understanding execution of the code. The AST evaluator simply walks through the tree structure and evaluates the resulting value of each node.

4.1 Values

Values are what the expressions of the program will evaluate to. We'll define values as a variant type, just like StmtKind and ExprKind.

We'll start by defining the simple values, those being integers, strings, boolean values, null values and a special error value.

type Value =
    | { type: "error" }
    | { type: "null" }
    | { type: "int", value: number }
    | { type: "string", value: string }
    | { type: "bool", value: boolean }
    // ...

We'll also define a built in array type. (aka. list, vector).

type Value =
    // ...
    | { type: "array", values: Value[] }
    // ...

An array is implemented as a Typescript array of values.

We'll also define a struct type. (aka. object, table, dictionary, map).

type Value =
    // ...
    | { type: "struct", fields: { [key: string]: Value } }
    // ...

A struct is defined as key value map, where the key is a string. The struct fields will be accessible via. both field expressions, eg. my_struct.field, and index expressions, eg. my_struct["field"]. We'll also want to be able to index with integers, in that case, we have to convert the integer to a string before indexing.

Then we'll need a value for function definitions. This will simply consist of the node id of the function definition.

type Value =
    // ...
    | { type: "fn", fnDefId: number }
    // ...

Lastly we'll define a type for built in functions.

type Value =
    // ...
    | { type: "builtin_fn", action: BuiltInAction }
    // ...

type BuiltinAction = (...args: Value[]) => Value;

A builtin function will have an action function, which takes a varying number of parameters and returns a value.

4.2 Symbols

An identifier is just a name. A symbol is a definition with an associated identifier. When evaluating the code, we have to keep track of symbols, both their definitions and their usage.

4.2.1 Scopes

Symbols are dependent on which scope they're in, eg. a symbol a defined outside of a pair of { } will be visible to code inside the braces, but a symbol b defined inside the braces will not be visible outside. Eg.

let a = 5;
{
    let b = 4;
    a; // ok
}
b; // b is not defined

Symbols are introduced in such statements as let and function defitions, the latter where both the function identifier and the parameters' identifiers will be introduces as symbols. Symbols may also be defined pre evaluation, which is the case for builtin functions such as println and array.

4.2.2 Symbol maps

To keep track of symbols throughout evaluation, we'll create a data structure to store symbols, ie. map identifiers to their definition values.

type SymMap = { [ident: string]: Value }

class Syms {
    private syms: SymMap = {};

    public constructor(private parent?: SymMap) {}
    // ...
}

The SymMap type is a key value map, which maps identifiers to their definition. To keep track of symbols in regard to scopes, we also define a Syms class. An instance of Syms is a node in a tree structure.

We'll define a method for defining symbols.

class Syms {
    // ...
    public define(ident: string, value: Value) {
        this.syms[ident] = value;
    }
    // ...
}

Then a method for checking, if a symbol is defined in the current scope.

class Syms {
    // ...
    public definedLocally(ident: string): boolean {
        return ident in this.syms;
    }
    // ...
}

And then, we'll define a method for getting the value of a defined symbol.

class Syms {
    // ...
    public get(ident: string): { ok: true, value: Value } | { ok: false } {
        if (ident in this.syms)
            return { ok: true, value: this.syms[ident] };
        if (this.parent)
            return this.parent.get(ident);
        return { ok: false };
    }
}

If the symbol is defined locally, return the value. Else if a the parent node is defined, defer to the parent. Otherwise, return a not-found result.

4.3 Control flow

Most code will run with unbroken control flow, but some code will 'break' control flow. This is the case for return statements in functions and break statements in loops. To keep track of, if a return or break statement has been run, we'll define a data structure representing the control flow action of evaluted code.

type Flow = {
    type: "value" | "return" | "break",
    value: Value,
}

The 3 implemented options for control flow is breaking in a loop, returning in a function and the non-breaking flow. All 3 options have an associated value.

4.4 The evaluator class

class Evaluator {
    private root = new Syms();

    public withBuiltins(): Evaluator {
        this.root.define("println", (fmt, ...args) => {
            if (!fmt)
                throw new Error("incorrect arguments");
            let msg = fmt;
            for (const arg of args) {
                if (!msg.includes("{}"))
                    throw new Error("incorrect arguments");
                msg.replace("{}", )
            }
        });
    }

    public evalStmts(stmts: Stmt[]): Flow {
        // ...
    }

    public evalStmt(stmt: Stmt): Flow {
        // ...
    }

    public evalExpr(expr: Expr): Flow {

    }
}