← Back to all posts

2. Building a Dynamic Language in C: From Calculator to Loops

[ Code & Dev ]
[ c ] [ programming-languages ] [ compiler-design ] [ lexer ] [ parser ] [ ast ] [ interpreter ] [ systems-programming ]

[!NOTE] This tutorial details the journey of building a dynamic scripting language in C—from a basic mathematical expression calculator to a language supporting dynamic types, variable bindings, environment scopes, comparison operators, if / else control flow, and while loops.


Introduction

In static programming languages like C, variable types are fixed at compile time (int, double, char*). In dynamic programming languages (like Python, Lua, or JavaScript), variables do not have types—values have types. A variable can store a number at one moment and a boolean or string at the next.

In this tutorial, we explore how to build a dynamic language interpreter in C step-by-step.

graph TD
    A["Step 1: Dynamic Value Representation<br/>(Tagged Union: Number, Bool, Nil)"] --> B["Step 2: Variables & Environment<br/>(Symbol Table & Mutability)"]
    B --> C["Step 3: Expressions & Control Flow<br/>(Comparisons, Blocks, If/Else)"]
    C --> D["Step 4: Loops & Repeated Execution<br/>(While Loops)"]

Step 1: Dynamic Value System (Tagged Union)

The Concept

To handle multiple data types dynamically at runtime without static types, we use a Tagged Union. A tagged union pairs an enum (the tag) with a union (the payload data).

Implementation

In ast.h, we define ValueType and Value:

#include <stdbool.h>

typedef enum {
    VAL_NIL,
    VAL_NUMBER,
    VAL_BOOL
} ValueType;

typedef struct {
    ValueType type;
    union {
        double number;
        bool boolean;
    };
} Value;

Helper Constructors

Value value_number(double number) {
    return (Value){.type = VAL_NUMBER, .number = number};
}

Value value_bool(bool boolean) {
    return (Value){.type = VAL_BOOL, .boolean = boolean};
}

Value value_nil(void) {
    return (Value){.type = VAL_NIL};
}

Updating the Evaluator

Rather than returning a primitive C int or double, our evaluation function now returns a dynamic Value:

Value evaluate(ASTNode *node) {
    if (node == NULL) return value_number(0);

    if (node->type == AST_NUMBER) {
        return value_number(node->number_val);
    }

    if (node->type == AST_BINARY_OP) {
        double left = evaluate(node->binary_op.left).number;
        double right = evaluate(node->binary_op.right).number;
        
        switch (node->binary_op.op) {
            case TOKEN_PLUS:  return value_number(left + right);
            case TOKEN_MINUS: return value_number(left - right);
            case TOKEN_STAR:  return value_number(left * right);
            case TOKEN_SLASH: return value_number(left / right);
            default: exit(1);
        }
    }
    return value_nil();
}

Step 2: Variables & Environment (Symbol Table)

The Concept

To support variable declarations and assignments (e.g. x = 10, y = x + 5), the runtime needs a Symbol Table or Environment that maps string identifiers to dynamic Values.

classDiagram
    class Environment {
        +Symbol symbols[100]
        +int count
        +Environment* parentEnv
        +env_set(name, value)
        +env_get(name, out_val)
    }
    class Symbol {
        +char name[64]
        +Value val
    }
    Environment "1" *-- "many" Symbol

Implementation

1. Defining the Environment Structure

typedef struct {
    char name[64];
    Value val;
} Symbol;

typedef struct Environment {
    Symbol symbols[100];
    int count;
    struct Environment *parentEnv; // For nested scope chains
} Environment;

2. Environment Mutators & Lookups

void env_set(Environment *env, const char *name, Value val) {
    // Update existing symbol if present
    for (int i = 0; i < env->count; i++) {
        if (strcmp(env->symbols[i].name, name) == 0) {
            env->symbols[i].val = val;
            return;
        }
    }
    // Otherwise add new symbol
    strncpy(env->symbols[env->count].name, name, 64);
    env->symbols[env->count].val = val;
    env->count++;
}

bool env_get(Environment *env, const char *name, Value *ret_val) {
    for (int i = 0; i < env->count; i++) {
        if (strcmp(env->symbols[i].name, name) == 0) {
            *ret_val = env->symbols[i].val;
            return true;
        }
    }
    return false; // Undefined identifier
}

3. AST Nodes for Identifier and Assignment

// AST_IDENTIFIER node reads 'x'
// AST_ASSIGNMENT node writes 'x = expr'
if (node->type == AST_ASSIGNMENT) {
    Value val = evaluate(node->assignment.expr);
    env_set(env, node->assignment.name, val);
    return val;
}

if (node->type == AST_IDENTIFIER) {
    Value val;
    if (env_get(env, node->identifier_name, &val)) {
        return val;
    }
    printf("Runtime Error: Undefined identifier '%s'\n", node->identifier_name);
    exit(1);
}

Step 3: Comparison Operators & Control Flow (if / else)

The Concept

Control flow requires two features:

  1. Comparison Expressions: Operators (<, >, ==) that evaluate to VAL_BOOL.
  2. Block Statements & Branching: Executing statements inside { ... } blocks based on truthiness.
x = 10
if x > 5 {
    y = 100
} else {
    y = 0
}
y

Implementation

1. Adding Lexer Keywords & Two-Character Tokens

// Two-character equality '=='
if (current == '=') {
    if (**scpy == '=') {
        (*scpy)++;
        return (Token){.type = TOKEN_EQUAL};
    }
    return (Token){.type = TOKEN_ASSIGN};
}

// Single character comparisons & braces
case '<': return (Token){.type = TOKEN_LESS_THAN};
case '>': return (Token){.type = TOKEN_GREATER_THAN};
case '{': return (Token){.type = TOKEN_LBRACE};
case '}': return (Token){.type = TOKEN_RBRACE};

Keywords like "if" and "else" are detected when scanning identifiers:

if (strcmp(buf, "if") == 0) return (Token){.type = TOKEN_IF};
if (strcmp(buf, "else") == 0) return (Token){.type = TOKEN_ELSE};

2. Parsing Blocks { ... } and if Expressions

ASTNode *astparse_block() {
    advance(); // Consume '{'
    ASTNode **stmts = malloc(sizeof(ASTNode*) * 64);
    int count = 0;
    while (current_token.type != TOKEN_RBRACE && current_token.type != TOKEN_EOF) {
        stmts[count++] = astparse_expression();
    }
    advance(); // Consume '}'
    return astnode_create_block(stmts, count);
}

ASTNode *astparse_if() {
    advance(); // Consume 'if'
    ASTNode *condition = astparse_expression();
    ASTNode *then_branch = astparse_block();
    ASTNode *else_branch = NULL;
    
    if (current_token.type == TOKEN_ELSE) {
        advance(); // Consume 'else'
        if (current_token.type == TOKEN_IF) {
            else_branch = astparse_if(); // Chain "else if"
        } else {
            else_branch = astparse_block();
        }
    }
    return astnode_create_if(condition, then_branch, else_branch);
}

3. Evaluating AST_IF and Truthiness

In dynamic languages, both true booleans and non-zero numbers are considered truthy:

if (node->type == AST_IF) {
    Value cond = evaluate(node->if_stmt.condition);
    
    // Evaluate truthiness
    bool is_truthy = (cond.type == VAL_BOOL && cond.boolean) ||
                     (cond.type == VAL_NUMBER && cond.number != 0);

    if (is_truthy) {
        return evaluate(node->if_stmt.then_branch);
    } else if (node->if_stmt.else_branch != NULL) {
        return evaluate(node->if_stmt.else_branch);
    }
    return (Value){.type = VAL_NIL};
}

Step 4: Loops & Repeated Execution (while)

The Concept

A while loop continuously evaluates its condition expression, and as long as that condition remains truthy, it re-evaluates its body block { ... }.

i = 0
sum = 0
while i < 5 {
    sum = sum + i
    i = i + 1
}
sum

Implementation

1. Adding TOKEN_WHILE & AST_WHILE

In ast.h, update TokenType and ASTNodeType:

typedef enum {
    // ... existing tokens ...
    TOKEN_WHILE
} TokenType;

typedef enum {
    // ... existing AST types ...
    AST_WHILE
} ASTNodeType;

// ASTNode union addition:
struct {
    struct ASTNode *condition;
    struct ASTNode *body;
} while_loop;

2. Lexer & Parser for while

In the lexer identifier scanner:

if (strcmp(buf, "while") == 0) return (Token){.type = TOKEN_WHILE};

In the parser:

ASTNode *astparse_while() {
    advance(); // Consume 'while'
    ASTNode *condition = astparse_expression();
    ASTNode *body = astparse_block();
    return astnode_create_while(condition, body);
}

In astparse_factor():

if (current_token.type == TOKEN_WHILE) {
    return astparse_while();
}

3. Evaluating AST_WHILE

At runtime, evaluation uses a C while loop to re-evaluate the condition and body until the condition turns falsy:

if (node->type == AST_WHILE) {
    Value last_val = (Value){.type = VAL_NIL};
    while (1) {
        Value cond = evaluate(node->while_loop.condition);
        bool is_truthy = (cond.type == VAL_BOOL && cond.boolean) ||
                         (cond.type == VAL_NUMBER && cond.number != 0);
        if (!is_truthy) break;
        
        last_val = evaluate(node->while_loop.body);
    }
    return last_val;
}

Summary of Execution Flow

[!TIP] With all 4 steps implemented, our dynamic language now runs loops and state updates seamlessly:

Input:
i = 0
sum = 0
while i < 5 {
    sum = sum + i
    i = i + 1
}
sum

Output:
10

Future Horizons

  • Functions (fn) & Stack Frames: Parameters, local scopes, recursion, and return statements.
  • Dynamic Objects & Arrays: Structs, tables, and heap memory management.
  • Garbage Collection: Simple Mark-and-Sweep or reference counting GC for strings and objects.