Introduction

Antimony is a bullshit-free (©) programming language that gets out of your way. It is meant to "just work", without adding unnecessary and bloated language features.

To get started with Antimony, continue with the installation.

Note: Some parts of this documentation have been copied from the Rust book.

Note: I consider this documentation usable, but it may be incomplete in some places. If you feel like that a feature or behavior should be documented, feel free to contact the maintainers. You can search for the word TODO, if you want to help improving this documentation but don't know where to start. Any help is always welcome!

Installation

Note: An installation of the Rust programming language is needed to compile Antimony.

Cargo

The fastest way to get up and running is to install the latest published version via cargo:

cargo install antimony-lang

Git

To get the current development version, you can clone the Git repository and run the following command:

cargo install --path .

Docker

Antimony provides a Docker image. It currently only supports the x64 architecture. Please reach out if you need a ARM variant (needed for Raspberry Pi). If you don't want to wait, you can build the image yourself by running this command in the root of the project:

docker build . -t antimony

The command line interface

Now that you have installed Antimony, it is time to write our first program. This is a program that will simply print a string to the screen.

Creating a project directory

Let's begin by setting up our development workspace. Antimony really doesn't care where you store the code, so feel free to choose a different directory, than the one in this example.

mkdir ~/sources
cd ~/sources
mkdir hello_world
cd hello_world

Writing and running a program

Next, make a new source file and call it main.sb. Antimony files should always end with .sb by convention.

Now open the main.sb file you just created and enter the following code:

fn main() {
    println("Hello, world!")
}

Save the file and go back to your terminal window. Now, run the following command to compile and run your program:

$ sb run main.sb

You should see the string Hello World! on the screen. Congrats! You have officially written a Antimony Program!

Common language concepts

This chapter covers concepts that appear in almost every programming language and how they work in Antimony. Many programming languages have much in common at their core.

Specifically, you’ll learn about variables, basic types, functions, comments, and control flow. These foundations will be in every Antimony program, and learning them early will give you a strong core to start from.

Variables

If you are familiar with some other programming language, the way Antimony handles variables won't surprise you.

To declare a variable, the let keyword is used. The type of the variable is infered, but can be specified explicitly.

Note: Type inference currently only works when using the node-backend. For most other backends, the types need to be specified, until proper type inference is implemented.

// variables.sb
fn main() {
    let x = 10
    let y: int = 5
    println(x + y)
}

Run this code using the antimony CLI:

$ sb run variables.sb
15

Datatypes

Antimony comes with some generic data types.

The Boolean type

As in most other programming languages, a Boolean type in Antimony has two possible values: true and false. Booleans are one byte in size. The Boolean type in Antimony is specified using bool. For example:

fn main() {
    let t = true
    let f: bool = false // with explicit type annotation
}

The main way to use Boolean values is through conditionals, such as an if expression. We’ll cover how if expressions work in the "Control Flow" section.

The Integer type

The integer datatype represents a 4 byte decimal number.

fn main() {
    let sum: int = 1 + 2
    println("1 + 2 is ", sum)
}
$ sb run main.sb
1 + 2 is 3

Decimal, binary, hexadecimal and octal number systems are supported. The number 255 can be written in these formats:

let binary = 0b11111111
let octal = 0o37
let decimal = 255
let hexadecimal = 0xFF

To make large numbers more readable, you can insert _ characters at arbitrary places. These characters will be ignored by the compiler.

let one_billion = 1_000_000_000

The String type

A string is a sequence of characters.

fn main() {
    let name: string = "Jon"
    println("Hello " + name)
}
$ sb run main.sb
Hello Jon

The Array type

Arrays represent a sequence of values. They can hold any number of values of a specific type.

fn main() {
    let fruits: string[] = ["Banana", "Apple", "Pineapple"]

    for fruit in fruits {
        println(fruit)
    }
}
$ sb run main.sb
Banana
Apple
Pineapple

Arrays have a fixed capacity. In most cases, the capacity of an array can be infered. In the example above, the compiler knows that three elements are in the array, so it can be inferred. If the capacity can't be inferred by the compiler, it is necessary to mark it explicitely. This is the case for uninitialized arrays:

let arr: int[3]
arr[0] = 1
arr[1] = 2
arr[2] = 3

for element in arr {
    println(element)
}

The Any type

any can be used to specify that any type can be used in this place. This should be used with caution, as it might cause undefined behavior.

fn main() {

    print_anything(5)
    print_anything("Hello")
}

fn print_anything(x: any) {
    println(x)
}
$ sb run main.sb
5
Hello

any can also be used in conjunction with the array notation to allow a mixture of types within an array.

fn main() {

    let arr = [1, "Two", 3]

    for x in arr {
        println(x)
    }
}
$ sb run main.sb
1
Two
3

Functions

Functions are pervasive in Antimony code. You’ve already seen one of the most important functions in the language: the main function, which is the entry point of many programs. You've also seen the fn keyword, which allows you to declare new functions.

Antimony code uses snake_case as the conventional style for function and variable names. In snake case, all letters are lowercase and underscores separate words. Here’s a program that contains an example function definition:

fn main() {
    println("Hello, world!")
    another_function()
}

fn another_function() {
    println("Another function.")
}

We can call any function we’ve defined by entering its name followed by a set of parentheses. Because another_function is defined in the program, it can be called from inside the main function. Note that we defined another_function after the main function in the source code; we could have defined it before as well. Antimony doesn’t care where you define your functions, only that they’re defined somewhere.

Function parameters

Functions can also be defined to have parameters, which are special variables that are part of a function’s signature. When a function has parameters, you can provide it with concrete values for those parameters. Technically, the concrete values are called arguments, but in casual conversation, people tend to use the words parameter and argument interchangeably for either the variables in a function’s definition or the concrete values passed in when you call a function.

The following rewritten version of another_function shows what parameters look like in Antimony:

fn main() {
    another_function(5)
}

fn another_function(x: int) {
    println(x)
}

Return types

Functions can optionally return a value. To specify the return type, it is added to the function signature, similar to how variables and parameters do. Here's a simple example of a function that returns an integer:

fn add_one(x: int): int {}

Note that this function won't compile, since it doesn't actually return anything. Let's fix that by adding a return statement with an expression:

fn add_one(x: int): int {
    return x + 1
}

Now, if you call the function with 1 as its argument and read its value, you will see the computed result:

fn main() {
    let result = add_one(1)
    println(result)
}

fn add_one(x: int): int {
    return x + 1
}
$ sb run main.sb
2

Comments

All programmers strive to make their code easy to understand, but sometimes extra explanation is warranted. In these cases, programmers leave notes, or comments, in their source code that the compiler will ignore but people reading the source code may find useful.

Here’s a simple comment:

// I'm a comment!

In Antimony, the idiomatic comment style starts a comment with two slashes, and the comment continues until the end of the line. For comments that extend beyond a single line, you’ll need to include // on each line, like this:

// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.

Comments can also be placed at the end of lines containing code:

fn main() {
    let lucky_number = 7 // I’m feeling lucky today
}

But you’ll more often see them used in this format, with the comment on a separate line above the code it’s annotating:

fn main() {
    // I’m feeling lucky today
    let lucky_number = 7
}

Control Flow

Deciding whether or not to run some code depending on if a condition is true and deciding to run some code repeatedly while a condition is true are basic building blocks in most programming languages. The most common constructs that let you control the flow of execution of Antimony code are if expressions and loops.

if Expressions

An if expression allows you to branch your code depending on conditions. You provide a condition and then state, "If this condition is met, run this block of code. If the condition is not met, do not run this block of code."

Here is a basic example of an if expression:

fn main() {
    let number = 3

    if number < 5 {
        println("condition was true")
    } else {
        println("condition was false")
    }
}

All if Statements start with the keyword if, followed by a condition. In this case, the condition checks if the number has a value less than 5. The block of code we want to execute if the condition is true is placed immediately after the condition inside curly braces.

Optionally, we can also include an else expression, which we chose to do here, to give the program an alternative block of code to execute should the condition evaluate to false. If you don’t provide an else expression and the condition is false, the program will just skip the if block and move on to the next bit of code.

Try running this code; You should see the following output:

$ sb run main.sb
condition was true

Let’s try changing the value of number to a value that makes the condition false to see what happens:

let number = 7

Run the program again, and look at the output:

$ sb run main.sb
condition was false

Note: It's worth noting that the condition in this code must be a bool. At the current state of the project, this is not the case, but it is subject to change at any time. TODO: Discuss this behavior.

Handling multiple conditions with else if

You can have multiple conditions by combining if and else in an else if expression. For example:

fn main() {
    let number = 6

    if number % 4 == 0 {
        println("number is divisible by 4")
    } else if number % 3 == 0 {
        println("number is divisible by 3")
    } else if number % 2 == 0 {
        println("number is divisible by 2")
    } else {
        println("number is not divisible by 4, 3, or 2")
    }
}

This program has four possible paths it can take. After running it, you should see the following output:

$ sb run main.sb
number is divisible by 3

When this program executes, it checks each if expression in turn and executes the first body for which the condition holds true. Note that even though 6 is divisible by 2, we don’t see the output number is divisible by 2, nor do we see the number is not divisible by 4, 3, or 2 text from the else block. That’s because Antimony only executes the block for the first true condition, and once it finds one, it doesn’t even check the rest.

Value matching

Working with if statements with multiple else branches can become tedious. match statements provide a cleaner syntax for this case. You can compare match statements to switch in many other languages. Let's look at a very simple match statement.

    let x = 42

    match x {
        1 => println("x is 1")
        2 => println("x is 2")
        42 => println("The answer to the universe and everything!")
        else => println("This will not be called")
    }

In this example, we check the value of x, and execute some code based on the value. Instead of having to type x == 1, x == 2 and so on, we instead provide the value only once, and decide what to do for each case. We can optionally provide a else case, which will be executed if no other case was triggered.

You can execute multiple statements inside a single case. A common case would be to log some debug output and then return a value.

fn invert(x: bool): bool {
    match x {
        true => {
            println("The value is true")
            return false
        }
        false => {
            println("The value is false")
            return true
        }
    }
}

Keep in mind that excessive use of this could hurt the readability of your code. Instead, you could try to outsource those statements into a function and call that instead.

Loops

It's often useful to execute a block of code more than once. For this task, Antimony provides different kind of loops. A loop runs through the code inside the its body to the end and then starts immediately back at the beginning.

Antimony has two types of loops: while and for. Let's go through each of them.

Conditional Loops with while

It’s often useful for a program to evaluate a condition within a loop. While the condition is true, the loop runs. When the condition ceases to be true, the program calls break, stopping the loop.

The example below loops three times, counting down each time, and then, after the loop, it prints another message and exits.

fn main() {
    let number = 3

    while number != 0 {
        println(number)

        number = number - 1
    }

    println("LIFTOFF!!!")
}

Looping Through a Collection with for

You could use the while construct to loop over the elements of a collection, such as an array. For example:

fn main() {
    let a = [10, 20, 30, 40, 50]
    let index = 0

    while index < 5 {
        println("the value is: " + a[index])

        index += 1
    }
}

Here, the code counts up through the elements in the array. It starts at index 0, and then loops until it reaches the final index in the array (that is, when index < 5 is no longer true). Running this code will print every element in the array:

$ sb run main.sb
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

All five array values appear in the terminal, as expected. Even though index will reach a value of 5 at some point, the loop stops executing before trying to fetch a sixth value from the array.

But this approach is error prone; we could cause the program to crash if the index length is incorrect. It's also slow, because the compiler adds runtime code to perform the conditional check on every element on every iteration through the loop.

As a more concise alternative, you can use a for loop and execute some code for each item in a collection. A for loop looks like the following:

fn main() {
    let a = [10, 20, 30, 40, 50]

    for element in a {
        println("the value is: " + element)
    }
}

When we run this code, we’ll see the same output as in the previous example. More importantly, the code is faster and less prone to errors.

For example, in the code in the previous example, if you changed the definition of the a array to have four elements but forgot to update the condition to while index < 4, the program would crash. Using the for loop, you wouldn’t need to remember to change any other code if you changed the number of values in the array.

Structured data

When working with data, you often find yourself needing to group information together. This is where a struct could come into play. A struct, or structure, is a custom data type that lets you name and package together multiple related values that make up a meaningful group. If you’re familiar with an object-oriented language, a struct is like an object’s data attributes.

Defining structs

To define a struct, we enter the keyword struct and name the entire struct. A struct’s name should describe the significance of the pieces of data being grouped together. Then, inside curly brackets, we define the names and types of the pieces of data, which we call fields. The following example shows a struct that stores information about a user account.

struct User {
    username: string
    email: string
    sign_in_count: int
    active: bool
}

Structs can be nested as a type inside other structs. For example, we could assign each user an address, which itself is a struct.

struct Address {
    street: string
    number: int
    postal_code: string
    city: string
}

struct User {
    username: string
    email: string
    address: Address
}

Instantiating structs

To use a struct after we’ve defined it, we create an instance of that struct by specifying concrete values for each of the fields. We create an instance by stating the name of the struct and then add curly brackets containing key: value pairs, where the keys are the names of the fields and the values are the data we want to store in those fields. We don’t have to specify the fields in the same order in which we declared them in the struct. In other words, the struct definition is like a general template for the type, and instances fill in that template with particular data to create values of the type. Let's use our User struct from a previous example and create an user called alice.

struct User {
    username: string
    email: string
    sign_in_count: int
    active: bool
}

let alice = new User {
    email: "alice@example.com"
    username: "alice"
    sign_in_count: 1
    active: true
}

To get a specific value from a struct, we can use dot notation. If we wanted just alice's email address, we could use alice.email wherever we wanted to use this value. Fields of structs can also be reassigned using the dot notation:

let alice = new User {
    email: "alice@example.com"
    username: "alice"
    sign_in_count: 1
    active: true
}

alice.sign_in_count = 2

Struct methods

Antimony supports the concept of methods. A method can be described as a function on a struct. Let's take a look at a struct implementing a method.

struct User {
    first_name: string
    last_name: string

    fn full_name(): string {
        return self.first_name + " " + self.last_name
    }
}

Every instance of the User struct can now call full_name(). Note the usage of the word self. self is a special keyword referencing the struct instance the method was called on. Say we had the following instance of User, and called the full_name() method on it.

let alice = new User {
    first_name: "Jon"
    last_name: "Doe"
}

println(alice.full_name())

The full_name method described above will return the first name, a space and the last name of the user. If we run this code, we should see the expected output:

$ sb run main.sb
Jon Doe

Methods behave just like functions. They can return a value and take parameters. The only difference is the self keyword, which allows you to execute it on a specific instance of a struct.

Modules and Imports

Projects naturally grow over time, and digging through 10.000 lines of code in a single file can be cumbersome. By grouping related functionality and separating code with distinct features, you’ll clarify where to find code that implements a particular feature and where to go to change how a feature works.

The programs we've written so far have been in one file. As a project grows, you can organize code by splitting it into multiple modules with a clear name.

In Antimony, every file is also a module. Let's take a look at a project structure and identify its modules.

.
├── foo
│   ├── bar.sb
│   └── baz
│       └── module.sb
├── main.sb
└── some_logic.sb

As per convention, the entrypoint for this project is the main.sb file in the root directory.

There is a child-module called some_logic at the same directory-level.

Below it, there is a directory called foo, containing the submodule bar. To address the bar module from our entrypoint, we'd import the following:

import "foo/bar"

Note: File extensions in imports are optional. Importing foo/bar.sb would yield the same result as importing foo/bar.

Module entrypoints

In the foo directory, there is another directory called baz, containing a single file named module.sb. This file is treated as a special file, since it serves as the entrypoint for that module. So, instead of importing the file explicitely:

// main.sb
import "foo/baz/module"

we can simply import the module containing this file, and Antimony will import the contained module.sb instead.

// main.sb
import "foo/baz"

Using imported modules

To use code defined in a separate module, we first need to import it. This is usually done at the top of the file, but it technically doesn't make a difference where in the document the import is defined. Once the module is imported, we can use the code inside it, as if it were in the current file.

Let's say we have a module named math.sb in the same directory as out main.sb, and it defines the function add(x: int, y: int): int. To call it in our main.sb, we'd do the following:

import "math"

fn main() {
    println(add(1, 2))
}

If we run main.sb, we should see the expected output. Antimony has imported the add function from the math module.

$ sb run main.sb
3

This chapter includes resources that might be helpful for developers hacking on the Antimony compiler.

Specification

Note: This specification is a work in progress.

Introduction

This is a reference manual for the Antimony programming language.

Antimony is a general-purpose language designed with simplicity in mind. It is strongly typed and supports multiple compile-targets. Programs are constructed from modules, whose properties allow efficient management of dependencies.

Notation

The syntax is specified using altered Extended Backus-Naur Form (EBNF):

Production  = production_name "=" [ Expression ] "." .
Expression  = Alternative { "|" Alternative } .
Alternative = Term { Term } .
Term        = production_name | token [ "..." token ] | Group | Option | Repetition .
Group       = "(" Expression ")" .
Option      = "[" Expression "]" .
Repetition  = "{" Expression "}" .

Productions are expressions constructed from terms and the following operators, in increasing precedence:

|   alternation
()  grouping
[]  option (0 or 1 times)
{}  repetition (0 to n times)

Lower-case production names are used to identify lexical tokens. Non-terminals are in CamelCase. Lexical tokens are enclosed in double quotes "" or single quotes ''.

The form a ... b represents the set of characters from a through b as alternatives. The horizontal ellipsis ... is also used elsewhere in the spec to informally denote various enumerations or code snippets that are not further specified. The character (as opposed to the three characters ...) is not a token of the Antimony language.

Source Code Representation

Source code is Unicode text encoded in UTF-8. The text is not canonicalized, so a single accented code point is distinct from the same character constructed from combining an accent and a letter; those are treated as two code points. For simplicity, this document will use the unqualified term character to refer to a Unicode code point in the source text.

Each code point is distinct; for instance, upper and lower case letters are different characters.

Implementation restriction: For compatibility with other tools, a compiler may disallow the NUL character (U+0000) in the source text.

Characters

The following terms are used to denote specific Unicode character classes:

newline        = /* the Unicode code point U+000A */ .
unicode_char   = /* an arbitrary Unicode code point except newline */ .
unicode_letter = /* a Unicode code point classified as "Letter" */ .
unicode_digit  = /* a Unicode code point classified as "Number, decimal digit" */ .

Letters and digits

The underscore character _ (U+005F) is considered a letter.

letter        = unicode_letter | "_" .
decimal_digit = "0" ... "9" .
binary_digit  = "0" | "1" .
octal_digit   = "0" ... "7" .
hex_digit     = "0" ... "9" | "A" ... "F" | "a" ... "f" .

Lexical elements

Comments

Comments serve as program documentation. A comment starts with the character sequence // and stop at the end of the line.

A comment cannot start inside a string literal, or inside a comment.

Tokens

Tokens form the vocabulary of the Antimony programming language. There are four classes: identifiers, keywords, operators and punctuation, and literals. White space, formed from spaces (U+0020), horizontal tabs (U+0009), carriage returns (U+000D), and newlines (U+000A), is ignored except as it separates tokens that would otherwise combine into a single token.

Identifiers

Identifiers name program entities such as variables and types. An identifier is a sequence of one or more letters and digits. The first character in an identifier must be a letter.

identifier = letter { letter | unicode_digit } .
a
_x9
This_is_aValidIdentifier
αβ

Keywords

The following keywords are reserved and may not be used as identifiers.

break
continue
else
false
fn
for
if
import
in
let
match
new
return
self
struct
true
while

Operators and Punctuation

The following character sequences represent operators (including assignment operators) and punctuation:

+
+=
&&
==
!=
(
)
-
-=
||
<
<=
[
]
*
*=
>
>=
{
}
/
/=
++
=
,
;
%
--
!
.
:

Integer Literals

An integer literal is a sequence of digits representing an integer constant. An optional prefix sets a non-decimal base: 0b or 0B for binary, 0, 0o, or 0O for octal, and 0x or 0X for hexadecimal. A single 0 is considered a decimal zero. In hexadecimal literals, letters a through f and A through F represent values 10 through 15.

For readability, an underscore character _ may appear after a base prefix or between successive digits; such underscores do not change the literal's value.

int_lit        = decimal_lit | binary_lit | octal_lit | hex_lit .
decimal_lit    = "0" | ( "1" … "9" ) [ [ "_" ] decimal_digits ] .
binary_lit     = "0" ( "b" | "B" ) [ "_" ] binary_digits .
octal_lit      = "0" [ "o" | "O" ] [ "_" ] octal_digits .
hex_lit        = "0" ( "x" | "X" ) [ "_" ] hex_digits .

decimal_digits = decimal_digit { [ "_" ] decimal_digit } .
binary_digits  = binary_digit { [ "_" ] binary_digit } .
octal_digits   = octal_digit { [ "_" ] octal_digit } .
hex_digits     = hex_digit { [ "_" ] hex_digit } .

42
4_2
0600
0_600
0o600
0O600       // second character is capital letter 'O'
0xBadFace
0xBad_Face
0x_67_7a_2f_cc_40_c6
170141183460469231731687303715884105727
170_141183_460469_231731_687303_715884_105727

_42         // an identifier, not an integer literal
42_         // invalid: _ must separate successive digits
4__2        // invalid: only one _ at a time
0_xBadFace  // invalid: _ must separate successive digits

Floating-point literals

TO BE IMPLEMENTED

Rune literals

TO BE IMPLEMENTED

String literals

A string literal represents a string constant obtained from concatenating a sequence of characters. String literals are character sequences between double quotes, as in "bar". Within the quotes, any character may appear except newline and unescaped double quote.

If \ character appears in the string, the character(s) following it must be interpreted specially:

  1. \ and " are included unchanged (e.g. "C:\\Users" -> C:\Users)
  2. n emits the newline control chracter (U+000A)
  3. r emits the carriage return control chracter (U+000D)
  4. b emits the backspace control character (U+000C)
  5. t emits a horizontal tab (U+0009)
  6. f emits a form feed (U+000C)
  7. Unknown escape sequences must raise a compile error

TODO: byte values

TODO: Currently, " and ' are valid string characters. Remove ' and only use them for runes.

string_escape =
    "\n" | # Newline (U+000A)
    "\r" | # Carriage return (U+000D)
    "\t" | # Horizontal tab (U+0009)
    "\f" | # Form feed (U+000C)
    "\b" | # Backspace (U+0008)
    `\"` | "\\"
any = /* Any Unicode code point except newline (U+000A) and double quote (U+0022) */ .
string_lit = `"` { any | string_escape } `"` .

"abc"
"Hello, world!"
"Hello\nworld"
"C:\\Users" # Should emit C:\Users
"日本語"

Backends

Antimony currently implements a JavaScript backend, but C and QBE backends are in development. WASM, ARM and x86 are planned.

Backend can be specified when running on building with --target (-t) option, default is js:

sb -t c build in.sb --out-file out

Available Backends

Target LanguageIdentifierStability notice
Node.jsjsmostly stable
QBEqbework in progess
LLVMllvmunstable
Ccunstable

LLVM also requires to enable llvm feature when building:

cargo build --features llvm

Debugging

NOTE: Currently, debugging is still nearly impossible in Antimony.

This document will give you some hints on debugging the Antimony compiler.

Viewing the generated source code

Programs can be compiled to stdout. Use the -o - flag in combination with the target backend:

cargo run -- -t js build -o - examples/fib.sb

Or, if Antimony is installed in your path:

sb -t js build -o - examples/fib.sb

Release Workflow

  1. Update version in Cargo.toml
  2. Add entry in CHANGELOG.md
  3. Commit change with semantic version number (v0.1.1)
  4. Tag commit using git tag -a <new release> -m "$(git shortlog <last release>..HEAD)"
  5. Push the tag using git push --tags
  6. Publish package using cargo publish