Getting Started

"C3 is a programming language that builds on the syntax and semantics of the C language, with the goal of evolving it while still retaining familiarity for C programmers."

About this book

This is being written with 0.6.x in mind. It may include nightly releases that have fixed problems with 0.6.2. If an example doesn't quite work with 0.6.2, try using the latest compiler.

This was inspired by the Zig Cookbook which displays common and more idiomatic ways to use Zig around certain problems. I wanted to create a community driven book, that would do the same for C3. I believe it is something that can help developers pickup C3, as it provides another source to browse.

I have used new languages where documentation and community driven media was scarce, which made it very hard to get an understanding of the language in a more practical manner. Hopefully this book can help with this issue and get some more people into C3.

Since I'm writing this as a newbie to C3, there are bound to be issues or better ways to achieve things.

Contributing to the book

Installing C3

Use one of the following sources to download or build the C3 compiler from source:

Editors / Tooling

There is a work in progress LSP that can be found here.

General Overview

🚧 Not all topics are written yet and are a work in progress

These examples are meant to get you started with C3, however, these will not be too in-depth around the whole language. For more detailed explanations of the language itself, you can visit the official guide. This book will only cover a few topics to help understand C3, but will not describe everything.

Hello World!

Let's write the classic example, Hello World! But before that, we have two options with how to proceed:

Running directly

We can create a new file with the .c3 extension. Then we can write this code:

module myproject;
import std::io;

fn void main() {
    io::printn("Hello, World!");
}

We will use the compiler to build, run and then dispose of the executable:

$ c3c compile-run --run-once main.c3

This should then print to the console

Program linked to executable 'myproject'.
Launching ./myproject
Hello, World!
Program completed with exit code 0.

You can also just compile the file and run the executable directly:

$ c3c compile main.c3 -o hello
$ ./hello

Hello, World!

Done

Using a project

Creating a project is quite simple in C3. We can use the c3c executable to initialise, build and run our project.

Let's create a new project:

$ c3c init myproject
$ cd myproject

We now have a new project directory and this would have created a few directories within it, a license, a project.json and a readme. This includes the build directory, which is where your project will be compiled into. In C3 we use the src directory and this is where you will find the main.c3 file. You can see more details about project structure in the C3 guide.

Let's open the main file src/main.c3 in your editor and write this code:

module myproject;
import std::io;

fn void main() {
    io::printn("Hello, World!");
}

We can now run this code with:

$ c3c run

This should then print to the console

Program linked to executable 'myproject'.
Launching ./myproject
Hello, World!
Program completed with exit code 0.

Explaining our program

You have now written your first C3 program. Let's break down what is happening in our code. We can start with the first line:

module myproject;

Every file should start with a module name, if we omit this, then the compiler will try its best to generate one using the file. We can think of a module like a namespace, as each file that has the same module name is considered the same module. Module names are not tied to files or the directory they are in, so it is up to you to name them correctly. To avoid clashing, we must use longer and/or more detailed names. This could be something like projectname::foo::bar::baz.

import std::io;

Next is our import. This is how we import modules in C3. One thing that might confuse people who might be used to namespaces, is that this is actually a sub-namespace/module within std. However, as described above, this is just a name. You cannot create sub-modules as you might expect from other languages, but this is just a naming style to keep things organised logically.

fn void main() {
    io::printn("Hello, World!");
}

Functions in C3 use the fn keyword to denote a function declaration. It is then followed by the return type and then a function name and its parameters. This should look pretty familiar for anyone who's used a C-style language. The interesting thing here is our io::printn. If you tried to remove the io:: prefix, you will see this error:

Error: Functions from other modules must be prefixed with the module name.

Any function that comes from a different module, must be prefixed with its module name. This helps the reader also understand where this function comes from. We will see this often later, when it comes to types as well. More with modules here.

There is another way we can write this function. This will be familiar for those who have used JavaScript:

fn void main() => io::printn("Hello, World!");

It is possible to write a single expression function, using the => arrow syntax. This is useful for writing simple functions that return a value.

This was probably quite a long explanation of Hello World, but this is essentially how this book should be structured. We will write an example, then give an explanation on the hows and why. Hopefully this can help give better understanding of the code written and leaves you both curious for more and with questions answered.

Modules

This is not full coverage of what modules are or do. If you want to learn more, visit the guide.

Modules in C3 are quite interesting. Every file must start with a module declaration, as such module mymodule. A module is a container that namespaces our code. Later on, I will show how modules are our gateway into using generics.

Here's a list of things that will be covered:

Using Modules

As mentioned in our Hello World example, we explained that a module is not tied to any file or directory. It was also mentioned that any file with the same module declaration, will be considered the same module and will share code. Let's take a look at what this means:

// foo.c3
module mymodule;
...

// bar.c3
module mymodule;
...

Here we can see that both our foo and bar files have mymodule declared. Some might see this as a potential error, since we're presumably on the same level using the same module. If you're coming from Go, this might be expected that these are the same. However, in C3, I can name these modules whatever I like, even when on the same level. So what if I change my code to this instead?

// foo.c3
module mymodule;
...

// hello/somewhere/bar.c3
module mymodule;
...

I have now moved bar into a new directory. Does this mean that I now have two mymodule modules? No. No matter where you declare the module, they will always be shared. So both of these are seen as the same module, which makes organisation so much easier. This is where I make the connection to languages with namespaces. If two files use the same namespace, wherever they be in my project files, I expect them to be part of said namespace. This now makes our imports so much easier, as we don't need to follow a path or traverse upwards or from the root. Here's how we would import this:

import mymodule;
...

We can now use mymodule and it's as easy as that.

Generics through modules

In C3, generics are expressed through modules. This is different to other languages, where you might write something like <T, U> on a type or function and be good. With C3, you do this at the module level instead.

module genericmodule(<Type>);

struct Foo {
  Type bar;
}

Notice the (<Type>) syntax. This is also used when passing the generic parameter(s). Since we declare generics on the module, how does that look when importing?

import genericmodule;

This is just importing as normal? So how do we use the generic? We can use our Foo type that we declared like this:

import genericmodule;

Foo(<int>) foo;

// or use define
def IntFoo = Foo(<int>);

IntFoo foo;

We can extend our generic module to include a function, that takes the generic value and returns it:

module genericmodule(<Type>);

struct Foo {
  Type bar;
}

fn Type return_t(Type v) {
  return v;
}

Then use the generic function we made like so:

fn void main() {
  // NOTE: The generic parameter is required here, as well as the module name prefix
  int a = genericmodule::return_t(<int>)(10);
}

More on generics, how generic modules work and constraints can be found in the guide.

Concepts

🚧 Not all topics are written yet and are a work in progress

From here, we will be covering specific topics in C3. These can range from common operations, to data structures and standard library usage. This is to give more examples on how these features and functions are used. This is in no way a deep dive into these, but enough to hopefully kickstart learning C3's ecosystem.

For most of these examples, we will slowly go over each part and then do a code dump at the end. If you're using a project to run these examples, you can use c3c run or c3c build. Otherwise you can compile and run a file with c3c compile-run --run-once <thefile>.c3.

Topics we will cover:

Using C with C3

🚧 This is a work in progress, so it may not be complete or finalised.

We will create a basic wrapper over a C function, then use it within C3. To keep this relatively simple, we'll use the standard file structure of a project as it is the simplest way of managing our files.

Setup

We can do this by:

$ c3c init mycproject

If we intend on making this a library and using the compiler directly, we can do something like this:

$ mkdir lib
$ mkdir src

C Code

Let us start writing our tiny wrapper. First, we will create our C header file, as this is only a few lines. Here's our header:

littlec.h

#ifndef LITTLEC_H
#define LITTLEC_H

int add(int a, int b);

#endif

Obviously the most interesting function we could write here. Just to keep our code simple, we will just be adding two numbers in C and returning that to C3. Now for our implementation:

littlec.c

#include "littlec.h"

int add(int a, int b) {
  return a + b;
}

From here, we can take two routes:

Using in Source

For very small bindings, like this example, we can get away with using it directly in source without a lib. Let's start by opening src/main.c3 (or your main file) and take a look:

src/main.c3

module cinterop;
import std::io;

extern fn int add(int a, int b);

This is pretty straight forward for our add function, as it is basically identical in our C3 code. If we wanted to use a function that has a different name, we can use the @extern attribute, like so:

extern fn int add(int a, int b) @extern("ADD");

To use this function, we can now add our main function and call it like so:

src/main.c3

fn int main() {
	io::printfn("3 + 4 = %s", add(3, 4));
	return 0;
}

Before we can run the code, we must tell the compiler how to find our C code. If you're using a project, you can open the project.json and uncomment the c-sources property:

// C sources if the project also compiles C sources
// relative to the project file.
"c-sources": [ "csource/**" ],

If your C files are in a different directory, replace csource with your directory. You can also include the file directly:

// C sources if the project also compiles C sources
// relative to the project file.
"c-sources": [ "littlec.c" ],

That's the final thing we needed. Let's try running our code.

If you're using a project:

$ c3c run
# OR:
$ c3c build
$ ./build/<your_executable>

To compile directly, we must compile our C code to an object file first:

$ gcc -O3 -c little.c

This assumes using Linux, so you might have a .obj file instead. If you don't have gcc you can install mingw, or use your platforms C compiler.

Then we can compile our code, passing in the object file:

$ c3c compile src/main.c3 -o build/helloc -z ./csources/littlec.o

-z will pass in our object file. Use the path to your object file, if you're not using csources and/or a different name.

Output

Then finally the output of the executable:

3 + 4 = 7

We are now done! Head down to the end to learn more, or continue reading to create a library.

Creating a Library

Let's start by creating a directory in the lib folder called, "littlec.c3l". It will serve as our library that holds our wrapper code.

Note the .c3l extension of the directory. This is important, so it must be used.

Within our littlec.c3l directory, we will have four files:

  • littlec.c: C implementation code
  • littlec.h: C header file describing our code
  • littlec.c3i: C3 interface file, to describe our C code and how it binds to C3
  • manifest.json: JSON file outlining the library details

To begin with, we will get the manifest out of the way. We don't need too much here for our example, so we can keep this minimal:

{ 
  "provides" : "littlec",
  "c-sources": [ "littlec.c" ],
  "targets" : {
    "linux-x64" : {}
  }
}

I am using Linux, so my target is linux-x64. You can list multiple targets here if you want to support many targets. If you don't know what to put here, you can use c3c --list-targets to see all available targets.

This is a pretty small manifest and we don't need to add anything to our target for this example. For the project to compile, we need to list our target otherwise the compiler will error saying the platform is not supported.

Before we get to the code, lets go over what is here.

  • "provides": This is the name of our library. It does not need to be the same as the module name.
  • "c-sources": This is a list of our C source files that should be included in the build.
  • "targets": As before, these are our supported targets for our library. We can list other properties here to override or append to our global properties.

For more details and a list of properties that are allowed, use c3c --list-manifest-properties to view.

We can now start wrapping our code with our c3i file.

littlec.c3i

module littlec;

fn int add(int a, int b) @extern("add");

So what is this file doing? First of all, we create a module for our library littlec. This will be what we import into our code to use our library. This does not have to be the same as our library name, but it is preferred to keep it the same. If you need to change the module name, you should make a not somewhere to let users know what module is required.

Second, we are using a C3 function signature that reflects our add function in C. Just after our signature, we have the extern attribute which tells the compiler what symbol we're binding our function to. Our C function is called "add", so we use "add" inside our extern attribute.

And that's our little wrapper completed. Now onto using it.

Using our Wrapper

We can now use our wrapper within our C3 code. If you created a project, we will be using src/main.c3, otherwise create a file within src or somewhere close to the lib. Here's our code:

module cinterop;
import std::io;

// C wrapper module
import littlec;

fn int main()
{
	io::printfn("3 + 4 = %s", littlec::add(3, 4));
	return 0;
}

Nothing too crazy here. We can simply import our new library, then we call it within main using the arguments 3 and 4.

Running

If you're wanting to compile directly:

$ c3c compile src/main.c3 --obj-out temp --output-dir temp --libdir lib --lib littlec -o helloc

Or build and run:

$ c3c compile-run --run-once src/main.c3 --obj-out temp --output-dir temp --libdir lib --lib littlec

Currently there is a bug that requires you use --obj-out <dir>.

If you're using a project:

$ c3c run
# OR:
$ c3c build
$ ./build/<your_executable>

Output

Then finally the output of the executable:

3 + 4 = 7

The end

Congratulations, we have put together a C wrapper in C3! This was quite a simple example to get the idea across, but if you're interested in larger examples or wrapping C libraries, check out the vendor repository. This repo contains some libraries that are wrappers over C code. You can open up one of the libraries and checkout their .c3i file to see how a more real-world wrapper library is created.

Projects

🚧 Not all topics are written yet and are a work in progress

This is a showcase of small projects you could create in C3. These examples are meant to serve as a "real world" usage of the language.

For most of these examples, we will slowly go over each part and then do a code dump at the end. If you're using a project to run these examples, you can use c3c run or c3c build. Otherwise you can compile and run a file with c3c compile-run --run-once <thefile>.c3.

Projects:

Calculator Evaluator

🚧 This is a work in progress, so it may not be complete or finalised.

We are going to create a small project, that can lex, parse and evaluate an expression for a basic calculator. We will try and keep this nice and easy and so we are going to merge a few things together for simplicity. You can see the full example at the end.

Getting Started

With this example, we can just use a single script that I am going to call calculator.c3. If you want to use a project, you can do c3c init calculator, move into the directory and open src/main.c3 in your editor.

To begin, we will import everything the project requires up front, so we don't need to worry about this later. We will also write up our main function too:

module project::calculator;

import std::io;
import std::core::string;
import std::core::mem;
import std::core::mem::allocator;
import std::collections::range;

def CharRange = range::Range(<char>);
CharRange range = { '0', '9' };

fn void main() {
    String expression = "2 + 3 * 4 / 5";
    float! result = calculate(expression);
    if (catch excuse = result) {
        io::printfn("Failed with error: %s", excuse);
        return;
    }
    io::printfn("Result = %s", result);
}

We import all the necessary modules required for the project, which includes allocators, string functions, io for printing and the range type just to make lexing numbers simpler. In our main function, we create an expression variable, which will be the code our evaluator runs. Next we pass it to the calculate function, which returns an optional float, as this can fail. We handle the error case, then print the result if all goes well. This will make more sense once we get to implementing the code.

I've opted to write my own lexer and parser, as using a library is overkill. Writing your own lexer and parser is also fun and can also be quite simple.

Tokens

Tokens are a small structure that are used to annotate pieces of code. In a programming language, these can be things like a keyword, operators, identifiers etc. Since we're making a calculator, this structure will be pretty simple, as we only have operators and numbers.

enum TokenKind : char {
    NUMBER,
    PLUS,
    MINUS,
    STAR,
    SLASH,
    EOF,
}

struct Token {
    TokenKind kind;
    String lexeme;
}

Our TokenKind is a simple enum, which will denote what the token represents. We also include EOF as a way to know we're done. The Token is also quite simple in this case, we have the kind and the lexeme. The lexeme will be a slice that we will use to parse the float for our numbers. We can also use this to print nicer error messages if we wanted to.

The "Lexer"

Our lexer is the system that will take our source and convert it into a stream of tokens. This is the first merge we will do with our types, where it will actually act as the parser as well. For such a small example like our calculator, this is fine, but you might want to have a lexer and parser seperately for more serious projects. Here is how we will define the lexer/parser combo:

struct Parser {
    String source;
    usz ip;
    Token current;
}

Quite a small structure too, as it takes our source, the ip for keeping track of where we are in the source, then a Token that we will use later for parsing. Let's create a small helper function to create a parser for us:

fn Parser new_parser(String source) {
    return { source, 0, { EOF, "" } };
}

We simply pass the source in, then 0 out the rest of the fields. We put a dummy token in for the current token field, as we will set it later. Before we write the lexing function, we will setup a few methods to make lexing easier.

fn char Parser.peek(&self) @inline {
    if (self.at_end()) {
        return '\0';
    }
    return self.source[self.ip];
}

fn void Parser.advance(&self) @inline  {
    self.ip++;
}

fn bool Parser.at_end(&self) @inline  {
    return self.ip >= self.source.len;
}

fn void Parser.skip_whitespace(&self) {
    // Basic whitespace
    while (!self.at_end() && self.peek() == ' ') {
        self.advance();
    }
}

These are some pretty simple methods, to help us get the current char, advance, check if we're at the end of the source and for skipping whitespace.

In a more complex lexer, we would account for tabs, newlines, breaks etc in our skip_whitespace. We might also have variable peek to look-ahead as well as viewing the current char.

Now for the meat of our lexer, the function that will generate a token. For our calculator, we can write this pretty easily.

fn Token! Parser.get_token(&self) {
    self.skip_whitespace();
    if (self.at_end()) {
        return { EOF, "" };
    }

    switch (self.source[self.ip]) {
        // Operators
        case '+':
            self.advance();
            return { PLUS, "+" };

        case '-':
            self.advance();
            return { MINUS, "-" };

        case '*':
            self.advance();
            return { STAR, "*" };

        case '/':
            self.advance();
            return { SLASH, "/" };

        // Numbers
        case '0'..'9': return self.make_number()!;

        // Unknown character found
        default: return EvalErr.BAD_SYNTAX?;
    }
}

This will skip whitespace, if we're at the end, then generate an EOF token. Then we check the current char and see whether it's an operator, or if it's a number. Otherwise, we will return a fault of BAD_SYNTAX. You may also notice the make_number method and we will implement that shortly, but let us create the fault.

fault EvalErr {
    BAD_SYNTAX,
}

Nothing too crazy for our fault here. We will also re-use this in our parser, when we get an unexpected symbol. Back to the lexer! We can now look at lexing a number:

fn Token! Parser.make_number(&self) {
    usz start = self.ip;
    self.advance(); // Increment as we know the first character

    // Consume numbers
    while (!self.at_end() && range.contains(self.peek())) {
        self.advance();
    }

    if (self.peek() == '.') {
        // Decimal number
        self.advance();

        // Consume numbers again
        while (!self.at_end() && range.contains(self.peek())) {
            self.advance();
        }
    }

    return { NUMBER, (String)self.source[start..self.ip-1] };
}

Almost the same size as our get_token method itself! We capture the current index of our source and then advance, as we have already checked the first character. We then advance as long as there is a number as the current char. Then we check for a . to support basic decimal numbers, then advance again for trailing numbers. After that, we then slice our source to capture the number, using the start variable we created for our anchor.

That's the end of our lexing methods and now we head over to parsing.

Parsing Expressions

This is where the fun begins! Parsing is the step where our syntax matters. For a programming language, this is how we might collect variables, functions, structures etc into an AST. We will still do this for our calculator, but our AST will be pretty simple. All we need for the calculator, is a binaryop and a literal.

As an exercise, you can also implement unary expressions, like negative numbers.

Since our AST is simple, we can define it in a few lines of code.

enum NodeType : char {
    BINARY,
    LITERAL,
}

struct BinaryOp {
    Token op;
    Node *lhs;
    Node *rhs;
}

struct Node {
    NodeType type;
    union data {
        BinaryOp binary;
        float number;
    }
}

We create an enum so we can tag our Node to know what it contains. Our BinaryOp is a node that represents something like 1 + 2. Then we have our number which is just a float that we will collect from our token.

As before with our lexer, we will create a helper method for consuming tokens:

fn Token! Parser.consume(&self, TokenKind kind) {
    if (self.current.kind == kind) {
        Token current = self.current;
        self.current = self.get_token()!;
        return current;
    }

    // Basic error
    io::printfn("Error: Expected '%s' but received '%s'", kind, self.current.kind);
    return EvalErr.BAD_SYNTAX?;
}

If the current token in our parser matches the kind provided, it will consume the token, fetch the next token and return the old token. This is so we can capture the token and use it later. If the token doesn't match, then we print a basic error message and return a fault. This is the same fault used before in our get_token.

This will be a large section of code, but it will implement the entire parsing of expressions.

fn Node*! Parser.parse(&self) {
    return self.term()!;
}

fn Node*! Parser.term(&self) {
    Node *node = self.factor()!;

    while (self.current.kind == PLUS || self.current.kind == MINUS) {
        Token op = self.consume(self.current.kind)!;
        node = mem::new(Node, {
            NodeType.BINARY,
            {
                .binary = {
                    op,
                    node,
                    self.factor()!,
                },
            },
        });
    }

    return node;
}

fn Node*! Parser.factor(&self) {
    Node *node = self.primary()!;

    while (self.current.kind == STAR || self.current.kind == SLASH) {
        Token op = self.consume(self.current.kind)!;
        node = mem::new(Node, {
            NodeType.BINARY,
            {
                .binary = {
                    op,
                    node,
                    self.primary()!,
                },
            },
        });
    }

    return node;
}

fn Node*! Parser.primary(&self) {
    // We only have one value, so we make sure it's a number and return
    if (self.current.kind != NUMBER) {
        io::printfn("Error: Expected number but received '%s'", self.current.kind);
        return EvalErr.BAD_SYNTAX?;
    }

    Token t = self.consume(NUMBER)!;

    return mem::new(Node, {
        NodeType.LITERAL,
        {
            .number = t.lexeme.to_float()!!,
        },
    });
}

We are using a recursive descent parser, so that means we call one function after another and recursively (basically). So we start in parse, which then calls term, then factor then primary. So on a call to parse, we start in parse and end up in primary, then work our way back up the call stack. In both term and factor we have similar code checking for operators, then setting the node to a BinaryOp. Notice how we pass node in as the second argument, then call the next function down eg. term will call factor. This is used for parsing precedence, so we can evaluate our expression correctly.

That is now the end of the parser. A simple helper function for consuming tokens, then the parsing functions.

Evaluating our AST

To start our evaluation, we will implement the calculate function we saw in main. This is our entry that will handle parsing and evaluating.

fn float! calculate(String source) {
    DynamicArenaAllocator dynamic_arena;
    defer dynamic_arena.free();
    
    dynamic_arena.init(1024, allocator::heap());

    // Create our parser
    Parser parser = new_parser(source);
    // Get initial token
    parser.current = parser.get_token()!;

    mem::@scoped(&dynamic_arena) {
        // Parse and evaluate
        Node *root = parser.parse()!;
        return evaluate(root);
    };
}

To keep our memory handling simple, we use the DynamicArenaAllocator which will expand as we need and free all the memory at the end. Notice the mem::@scoped(&dynamic_arena). This is where our mem::new calls end up allocating to. Let's dive into evaluate:

fn float evaluate(Node *node) {
    switch (node.type) {
        case NodeType.BINARY: return evaluate_binary(node);
        case NodeType.LITERAL: return evaluate_literal(node);
        default: unreachable("UNKNOWN NODE");
    }
}

This function is pretty safe, as the unreachable means we probably haven't implemented something. Our calculator only has two nodes, so our cases are covered here. Something to note, is that every evaluate_* function returns a float. Since this is our main value type in the calculator, we keep things simple.

In a scripting language using tree-walk evaluation (which is what this implementation is), we might use something like a Value type to express more complex types like strings, bools and functions.

We can take a look at the simplest function: evaluate_literal

fn float evaluate_literal(Node *node) @inline
    => node.data.number;

Since our AST node for the literal holds the value, we can simply return the number. Next is the evaluate_binary function, which will do the work of operators:

fn float evaluate_binary(Node *node) {
    BinaryOp *bin = &node.data.binary;
    switch (bin.op.kind) {
        case PLUS:  return evaluate(bin.lhs) + evaluate(bin.rhs);
        case MINUS: return evaluate(bin.lhs) - evaluate(bin.rhs);
        case STAR:  return evaluate(bin.lhs) * evaluate(bin.rhs);
        case SLASH: return evaluate(bin.lhs) / evaluate(bin.rhs);
        default: unreachable("UNKNOWN OPERATOR");
    }
}

For sanity reasons, we take reference to the binary field so we can juse use a variable throughout. We switch on the operator's token kind, then return the evaluation of lhs <op> rhs. If we were to implement another operator, our unreachable will trigger to let you know at run-time.

And that's it! We've implemented a basic parser and evaluator for a calculator! Below is the result of running our code and the full source in case something is broken. If you want to try more examples, change the expression variable to something else and see what happens.

If you want to do more, try to implement negative numbers and the % operator. Languages are cool and if you thought this was fun, take a look at Crafting Interpreters by Bob Nystrom.

Result

Running the project:

$ c3c run

Using the compiler directly:

$ c3c compile-run --run-once calculator.c3

Output:

Program linked to executable 'calculator'.
Launching ./calculator
Result = 4.400000
Program completed with exit code 0.

Final Code

module project::calculator;

import std::io;
import std::core::string;
import std::core::mem;
import std::core::mem::allocator;
import std::collections::range;

def CharRange = range::Range(<char>);
CharRange range = { '0', '9' };

fn void main() {
    String expression = "2 + 3 * 4 / 5";
    float! result = calculate(expression);
    if (catch excuse = result) {
        io::printfn("Failed with error: %s", excuse);
        return;
    }
    io::printfn("Result = %s", result);
}

fault EvalErr {
    BAD_SYNTAX,
}

struct Parser {
    String source;
    usz ip;
    Token current;
}

enum TokenKind : char {
    NUMBER,
    PLUS,
    MINUS,
    STAR,
    SLASH,
    EOF,
}

struct Token {
    TokenKind kind;
    String lexeme;
}

enum NodeType : char {
    BINARY,
    LITERAL,
}

struct BinaryOp {
    Token op;
    Node *lhs;
    Node *rhs;
}

struct Node {
    NodeType type;
    union data {
        BinaryOp binary;
        float number;
    }
}

fn Parser new_parser(String source) {
    return { source, 0, { EOF, "" } };
}

fn char Parser.peek(&self) @inline {
    if (self.at_end()) {
        return '\0';
    }
    return self.source[self.ip];
}

fn void Parser.advance(&self) @inline  {
    self.ip++;
}

fn bool Parser.at_end(&self) @inline  {
    return self.ip >= self.source.len;
}

fn void Parser.skip_whitespace(&self) {
    // Basic whitespace
    while (!self.at_end() && self.peek() == ' ') {
        self.advance();
    }
}

fn Token! Parser.get_token(&self) {
    self.skip_whitespace();
    if (self.at_end()) {
        return { EOF, "" };
    }

    switch (self.source[self.ip]) {
        // Operators
        case '+':
            self.advance();
            return { PLUS, "+" };

        case '-':
            self.advance();
            return { MINUS, "-" };

        case '*':
            self.advance();
            return { STAR, "*" };

        case '/':
            self.advance();
            return { SLASH, "/" };

        // Numbers
        case '0'..'9': return self.make_number()!;

        // Unknown character found
        default: return EvalErr.BAD_SYNTAX?;
    }
}

fn Token! Parser.make_number(&self) {
    usz start = self.ip;
    self.advance(); // Increment as we know the first character

    // Consume numbers
    while (!self.at_end() && range.contains(self.peek())) {
        self.advance();
    }

    if (self.peek() == '.') {
        // Decimal number
        self.advance();

        // Consume numbers again
        while (!self.at_end() && range.contains(self.peek())) {
            self.advance();
        }
    }

    return { NUMBER, (String)self.source[start..self.ip-1] };
}

fn Token! Parser.consume(&self, TokenKind kind) {
    if (self.current.kind == kind) {
        Token current = self.current;
        self.current = self.get_token()!;
        return current;
    }

    // Basic error
    io::printfn("Error: Expected '%s' but received '%s'", kind, self.current.kind);
    return EvalErr.BAD_SYNTAX?;
}

fn Node*! Parser.parse(&self) {
    return self.term()!;
}

fn Node*! Parser.term(&self) {
    Node *node = self.factor()!;

    while (self.current.kind == PLUS || self.current.kind == MINUS) {
        Token op = self.consume(self.current.kind)!;
        node = mem::new(Node, {
            NodeType.BINARY,
            {
                .binary = {
                    op,
                    node,
                    self.factor()!,
                },
            },
        });
    }

    return node;
}

fn Node*! Parser.factor(&self) {
    Node *node = self.primary()!;

    while (self.current.kind == STAR || self.current.kind == SLASH) {
        Token op = self.consume(self.current.kind)!;
        node = mem::new(Node, {
            NodeType.BINARY,
            {
                .binary = {
                    op,
                    node,
                    self.primary()!,
                },
            },
        });
    }

    return node;
}

fn Node*! Parser.primary(&self) {
    // We only have one value, so we make sure it's a number and return
    if (self.current.kind != NUMBER) {
        io::printfn("Error: Expected number but received '%s'", self.current.kind);
        return EvalErr.BAD_SYNTAX?;
    }

    Token t = self.consume(NUMBER)!;

    return mem::new(Node, {
        NodeType.LITERAL,
        {
            .number = t.lexeme.to_float()!!,
        },
    });
}

fn float evaluate(Node *node) {
    switch (node.type) {
        case NodeType.BINARY: return evaluate_binary(node);
        case NodeType.LITERAL: return evaluate_literal(node);
        default: unreachable("UNKNOWN NODE");
    }
}

fn float evaluate_binary(Node *node) {
    BinaryOp *bin = &node.data.binary;
    switch (bin.op.kind) {
        case PLUS:  return evaluate(bin.lhs) + evaluate(bin.rhs);
        case MINUS: return evaluate(bin.lhs) - evaluate(bin.rhs);
        case STAR:  return evaluate(bin.lhs) * evaluate(bin.rhs);
        case SLASH: return evaluate(bin.lhs) / evaluate(bin.rhs);
        default: unreachable("UNKNOWN OPERATOR");
    }
}

fn float evaluate_literal(Node *node) @inline
    => node.data.number;

fn float! calculate(String source) {
    DynamicArenaAllocator dynamic_arena;
    defer dynamic_arena.free();
    
    dynamic_arena.init(1024, allocator::heap());

    // Create our parser
    Parser parser = new_parser(source);
    // Get initial token
    parser.current = parser.get_token()!;

    mem::@scoped(&dynamic_arena) {
        // Parse and evaluate
        Node *root = parser.parse()!;
        return evaluate(root);
    };
}