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
- Found a typo or an example does not work? Create an issue
- Want to add more examples? Create a fork
Installing C3
Use one of the following sources to download or build the C3 compiler from source:
- Website
- Github Release
- More options on their Github page, to build from source or use an OS repository
Editors / Tooling
There is a work in progress LSP that can be found here.
- VSCode extension can be installed through the extension tab
- VIM / Emacs / Sublime / Zed / ... are located in a single repository
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: first program with details around its structure
- Modules and Generics: small examples of how C3 uses modules
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!
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
orc3c build
. Otherwise you can compile and run a file withc3c compile-run --run-once <thefile>.c3
.
Topics we will cover:
- Using stdio: reading and writing to the console with stdio
- The Filesystem: reading and writing files
- Compiletime, Macros and Reflection
- Using C with C3
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 havegcc
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 codelittlec.h
: C header file describing our codelittlec.c3i
: C3 interface file, to describe our C code and how it binds to C3manifest.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 usec3c --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
orc3c build
. Otherwise you can compile and run a file withc3c compile-run --run-once <thefile>.c3
.
Projects:
- Echo TCP Client/Server: a basic echo TCP client and server
- Writing an Allocator: write a basic allocator that you can use to handle memory
- Parser and Evaluator: create a small parser and evaluator for a calculator
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 variablepeek
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);
};
}