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
Simplified Function Syntax for Single Statements
Antimony supports a more concise syntax for functions that perform a single operation. This syntax is particularly useful for simple tasks, such as arithmetic operations, printing to the console, or returning a single expression. Instead of wrapping the function's body in curly braces, you can define the function using an equals sign (=
) followed by the expression that constitutes the function's body.
Syntax
The syntax for this simplified function declaration is as follows:
fn function_name(parameters): return_type = expression
This syntax removes the need for curly braces and the return
keyword for single-statement functions, making the code cleaner and more readable.
Examples
Below are examples demonstrating how to use this syntax:
Defining a function that adds two numbers:
fn add(x: int, y: int): int = x + y
Defining a function that concatenates two strings:
fn concat(a: string, b: string): string = a + b
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 importingfoo/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:
\
and"
are included unchanged (e.g."C:\\Users"
->C:\Users
)n
emits the newline control chracter (U+000A)r
emits the carriage return control chracter (U+000D)b
emits the backspace control character (U+000C)t
emits a horizontal tab (U+0009)f
emits a form feed (U+000C)- 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 Language | Identifier | Stability notice |
---|---|---|
Node.js | js | mostly stable |
QBE | qbe | work in progess |
LLVM | llvm | unstable |
C | c | unstable |
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
- Update version in
Cargo.toml
- Add entry in
CHANGELOG.md
- Commit change with semantic version number (
v0.1.1
) - Tag commit using
git tag -a <new release> -m "$(git shortlog <last release>..HEAD)"
- Push the tag using
git push --tags
- Publish package using
cargo publish