The Zirco Programming Language
by Logan Devine, with contributions from the Zirco community
The Zirco Programming Language is an introduction and reference for the programming language Zirco, which helps you write clean, expressive code while maintaining explicitness, control, and compatibility with existing systems languages.
Special thanks to all of the project’s contributors, a comprehensive list of whom can be found in the README. Zirco is made possible by the brilliant minds in the PLD (Programming Language Development) Discord server, which you can participate in on Discord or on Matrix.
Introduction
Welcome to The Zirco Programming Language, an introduction and reference for the programming language Zirco, which helps you write clean, expressive code while maintaining explicitness, control, and compatibility with existing systems languages. Zirco is intentionally very similar to languages like C, with a focus on improved syntax and quality of life features.
Caution
Zirco is an unstable, rapidly evolving language. Absolutely nothing about the language and its semantics are stable, and that includes this book. This book is a best-effort attempt to document Zirco, but it may be out of date. Please refer to the Stability section for more information on Zirco’s stability and how it may affect you.
Who This Book Is For
This book is intended to be primarily a reference for experienced users, but it can also be used as an introduction. It covers the syntax and semantics of the language, as well as best practices for writing Zirco code. It also includes a reference section that provides detailed information about the language’s features and standard library.
This book assumes the reader has at least some prior experience with low-level programming, manual memory management, and systems programming concepts. It is not intended for complete beginners to programming, and will not cover some of those concepts in depth. However, we do not assume any prior experience with Zirco or C itself, and will cover all of the language’s features from the ground up.
How to Use This Book
This book is organized into several sections, each covering a different aspect of the language. The sections are designed to be read in order, but you can also jump around, using this book as a reference manual / specification instead.
Contributing
This book is open source and contributions are welcome! If you find a mistake, have a suggestion for improvement, or want to add something new, please feel free to open an issue or submit a pull request. We appreciate any help in making this book better for everyone.
Stability
Zirco is a highly experimental language that is under active development. The language and its semantics are not stable, and they may change without warning. This includes the language’s syntax, semantics, compiler API, and standard library. Zirco is not intended for production use.
This book is not a stable reference for the language. It is a best-effort attempt to document the language as it evolves, but it may be out of date. The content of this book may change without warning (along with the language itself), and it may not be a reliable reference for the language.
Please refer to the Zirco GitHub organization for the latest information about the language and its development.
Caution
DO NOT USE THIS LANGUAGE IN PRODUCTION. Code that compiles under one version may break, no longer compile, or entirely change semantics under a different version. Additionally, the compiler itself is not stable, and it may crash or produce incorrect code.
If you want to use Zirco, we highly recommend pinning a specific commit of the compiler, and only updating after you have thoroughly tested your code against the new version. Even then, there is no guarantee that your code will continue to work.
Use at your own risk. You have been warned.
Getting Help
Support for Zirco is available in the following channels:
- Matrix (preferred): Join us at #zirco:zirco.dev for real-time discussion and support.
- Discord: Join LogN’s Discord server in the
/home/zircocategory. This server is not as regularly monitored as Matrix. - GitHub Issues: For bug reports and feature requests, please open an issue on the project’s GitHub repository. This is not intended for general support, but we will do our best to help if you have trouble.
Getting Started
Let’s jump right in and write some Zirco code! This chapter will help you install the Zirco toolchain, set up your development environment, and write your first Zirco program. By the end of this chapter, you’ll have a working Zirco installation and a basic understanding of how to write and compile Zirco code.
Installation
The first step is to install the Zirco compiler (zrc) and its associated tools and libraries. We
call this the “Zirco toolchain”.
The toolchain has host support for the following targets:
Tier 1 targets (tested):
x86_64-unknown-linux-gnu(Linux on x86_64, prebuilt binaries available)
Tier 2 targets (built, but not tested in CI):
aarch64-unknown-linux-gnu(Linux on ARM64, prebuilt binaries available)x86_64-apple-darwin(macOS on x86_64, prebuilt binaries available, deprecated)aarch64-apple-darwin(macOS on Apple Silicon, prebuilt binaries available)
Note
Nix support for all targets (including Tier 1 targets) is considered Tier 2.
Other platforms may be supported via cross-compilation or by building Zirco with a custom LLVM build, but this is not currently documented.
Zirco does not support Windows, but the compiler runs normally on Windows via WSL. This guide also assumes your system has a C toolchain installed (for linking).
Note
Command Line Notation
Throughout the book, we’ll show some commands used in the terminal. Lines that you should enter in a terminal all start with
$. You don’t need to type the$character; it’s the command line prompt shown to indicate the start of each command. Lines that don’t start with$typically show the output of the previous command.
Selecting an Installation Method
The recommended way to install the Zirco toolchain is via the zircon toolchain installer, which
allows you to easily install and manage multiple versions of the Zirco toolchain.
Other installation methods are included below, alongside instructions for their use. If you don’t
know what installation method to use, we recommend using zircon.
| Installation Method | Description |
|---|---|
zircon | Recommended, simple CLI tool |
| debian package | For Debian-based Linux distros |
| nix flake | For Nix users |
| download prebuilt binaries | For users who don’t want to use a package manager |
| build from source | For users who want to build Zirco themselves |
Install via zircon
Zircon requires libarchive, curl, and openssl to be installed on your system. On Debian-based
Linux distros, you can install these dependencies with the following command:
$ sudo apt install libarchive-dev curl openssl
Now, you can run the Zirco bootstrap script to set up zircon in to ~/.zircon.
$ curl -sL https://zirco.dev/zstrap.sh | bash
✓ Zircon installed successfully
Next steps:
1. Add the following line to your shell profile (e.g., ~/.bashrc, ~/.zshrc):
source <($HOME/.zircon/self/bin/zircon env)
2. Restart your terminal or run 'source ~/.bashrc' (or the appropriate command for your shell) to apply the changes.
3. Run 'zircon install' to install the latest Zirco toolchain.
Add the required environment variables to your shell profile, as zircon will not do it for you.
| Shell | Command |
|---|---|
| bash | echo 'source <($HOME/.zircon/self/bin/zircon env)' >> ~/.bashrc; . .bashrc |
| zsh | echo 'source <($HOME/.zircon/self/bin/zircon env)' >> ~/.zshrc; . .zshrc |
After that, you can install the latest Zirco toolchain with the following command:
$ zircon install nightly
✓ Successfully imported toolchain: nightly
✓ Set as current toolchain
To use this toolchain, run:
source <(zircon env)
*** You may need to restart your shell or source your profile for changes to take effect. ***
Now, re-configure your shell environment to use the new toolchain:
$ source <(zircon env)
You’re all done! You can verify that the Zirco compiler is installed and working by running:
$ zrc --version
zrc_cli 0.2.0 (commit ..., release build, ...)
Install via Debian Package
Even on Debian-based systems, we recommend using zircon to install the Zirco toolchain, as it
allows you to easily upgrade, switch, and manage multiple versions of the Zirco toolchain. However,
if you prefer to install the Zirco toolchain via dpkg, you can download the latest Debian package
from the releases page.
$ dpkg -i zrc-0.2.0+1234567_amd64.deb
Install via Nix
Zirco provides a Nix flake that provides the latest Zirco toolchain.
You can add our package to your flake inputs as follows:
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
zirco-pkgs.url = "github:zirco-lang/zrc/main";
};
outputs = { self, nixpkgs, zirco-pkgs }:
let
system = "x86_64-linux";
pkgs = import nixpkgs { inherit system; };
in
{
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
zirco-pkgs.packages.${system}.zrc
zirco-pkgs.packages.${system}.libzr
];
};
};
}
Note
System-wide Nix installations often lack required environment variables for the Zirco toolchain. If you encounter issues with missing libraries or include paths, you may need to manually set the
-I/-Lpaths for headers and libzr.
Download Prebuilt Binaries
Release tarballs are available on the releases page.
Ensure that LD_LIBRARY_PATH and ZIRCO_INCLUDE_PATH are set to the appropriate directories for the downloaded toolchain, and add the bin directory to your PATH.
Build from Source
Instructions for building from source are available in the compiler’s README file.
Troubleshooting
If you encounter issues with the installation, we recommend checking the following before reaching out for support.
To check that the Zirco compiler is installed and working, run:
$ zrc --version
zrc_cli 0.2.0 (commit ..., release build, ...)
If you don’t see this information, check that the zrc binary is in your PATH. If you installed
with Zircon, you will see an entry for .zircon/toolchains/current/bin. Ensure this symlink
points to the correct toolchain directory.
If you encounter issues with missing libraries or include paths, ensure that LD_LIBRARY_PATH
and ZIRCO_INCLUDE_PATH are set to the appropriate directories for your toolchain installation.
Hello, World!
Now that you’ve installed the Zirco toolchain, let’s write our first Zirco program! As is tradition, we’ll write a simple program that simply prints “Hello, World!” to the screen.
Project Setup
Zirco’s compilation unit is the file, similar to C. There is no first-class concept of a “project”. This comes with the benefit of placing your source files wherever you want!
To create a Zirco source file, simply create a new file with the .zr extension. It is typical to
call the main source file main.zr.
Filename: main.zr
#include <libc/stdio.zh>
fn main() -> i32 {
printf("Hello, World!\n");
return 0;
}
Save the file and run the following commands to compile, link, and run the program:
$ zrc main.zr -o main.o
$ ld -lc -lzr main.o -o main
$ ./main
Hello, World!
| Command | Explanation |
|---|---|
zrc main.zr -o main.o | Compiles main.zr to an object file main.o. |
ld -lc -lzr main.o -o main | Links main.o with the C and Zirco standard libraries (-lc -lzr) to produce an executable main. |
./main | Runs the main executable, which prints “Hello, World!” to the terminal. |
If you see “Hello, World!” printed to the terminal, congratulations! You’ve successfully written and run your first Zirco program! If you don’t see the expected output, take a look at the installation troubleshooting section.
Anatomy of the Program
Let’s break down the main.zr program to understand what each part does:
#include <libc/stdio.zh>
This line includes the Zirco header file for the C standard library (libc)’s Standard I/O functions.
This directive is used to “copy-paste” the contents of a file into another, similar to C’s #include
directive. If you check your toolchain’s include/libc/stdio.zh file, you’ll see that it contains
“extern” declarations for a bunch of functions, including printf and scanf which we later use.
fn printf(a: *u8, ...) -> i32;
fn scanf(a: *u8, ...) -> i32;
These functions are not defined in Zirco, but they exist here so the compiler is aware of their signatures and can generate the correct code to call them.
fn main() -> i32 {
This line defines a function named main that takes no parameters and returns an i32 (32-bit
integer). The main function is the entry point of the program, where all Zirco programs start
execution. The function’s return value represents its exit code, where 0 typically indicates
success.
printf("Hello, World!\n");
This line calls the printf function to print the string “Hello, World!” followed by a newline
character (\n) to the terminal.
return 0;
}
This line returns 0 from the main function, indicating that the program executed successfully.
Using the zrx Tool
Oftentimes, for one-off programs (such as many of the examples in this book), it’s more convenient
to compile and run the program in one step. The zrx tool does exactly this: it compiles, links,
and runs your program with a single command via a JIT.
To run your main.zr program with zrx, simply run the following command:
$ zrx -lzr main.zr
Hello, World!
Many larger Zirco projects choose to use a build system such as make.
Congratulations!
In subsequent chapters, we’ll cover the Zirco language in more depth, but for now, your toolchain is working and you can write and run Zirco programs! In the next chapter, we’ll cover the basics of the Zirco language and syntax.
Programming a Guessing Game
Let’s dive in deep by programming a simple guessing game together! This chapter introduces you to the following Zirco concepts:
- Tools from the libc standard library for:
- Random number generation
- String formatting
- Input handling
- Loops
- Variables
These topics will all be covered far more thoroughly in later chapters, but this classic game is a perfect opportunity to build something tangible for the first time.
As with all projects, start by creating a project directory.
$ mkdir guessing-game
$ cd guessing-game
For this project, we will use zrx to execute our program, but you may also choose to use a build
system like make or just to compile and run your program.
Create a file named main.zr in your project directory with the “Hello, World!” program from the
previous chapter.
Filename: main.zr
#include <libc/stdio.zh>
fn main() -> i32 {
printf("Hello, World!\n");
return 0;
}
Execute the program to double check that everything is working:
$ zrx main.zr
Hello, World!
Handling User Input
We’ve learned how to print text to the terminal, but for our guessing game it’s just as important to
be able to read user input as well. The scanf function from the C standard library is perfect
for this, as it avoids many pitfalls common with raw input handling (such as buffer overflows) by
allowing us to specify an exact “format” for the input we expect (hence, “scan format”, or scanf).
Enter the code below into your main.zr file to read a number from the user and print it back to
the terminal.
#include <libc/stdio.zh>
fn main() -> i32 {
printf("Enter a number: ");
let guess: i32;
scanf("%d", &guess);
printf("You entered: %d\n", guess);
return 0;
}
This code prompts the user to enter a number, reads the input using scanf, and then prints it back
to the terminal. The scanf function takes a format string (in this case, %d for an integer) and
a pointer to the variable where the input should be stored (&guess).
Try to execute this code, and enter a number when prompted. You should see the number you entered printed back to you!
Error Handling
Try entering something that isn’t a number, such as “hello”. What happens?
The program will likely crash or behave unexpectedly, because scanf expects an integer input based
on our format string. To handle this gracefully, we can check the return value of scanf, which
indicates how many items were successfully read. If it returns anything other than 1, we know the
input was invalid.
#include <libc/stdio.zh>
fn main() -> i32 {
printf("Enter a number: ");
let guess: i32;
if (scanf("%d", &guess) != 1) { // <<<< notice that `scanf` is now inside an `if` statement
printf("Invalid input! Please enter a valid number.\n");
return 1; // Exit with an error code
}
printf("You entered: %d\n", guess);
return 0;
}
This is a common paradigm in both C and Zirco for error handling where functions return a status code to indicate success or failure. Checking the return value of many functions is not optional, see below.
Undefined Behavior?
Before we introduced validation, the program had “undefined behavior” when the user entered invalid
input because the value of guess was never initialized by scanf if anything non-numeric was
entered. This is a common source of undefined behavior, which is prevalent in many low level
languages.
The language assumes undefined behavior will never happen. This allows the compiler to make aggressive optimizations, but it also means that if you do encounter undefined behavior, all bets are off. The program could crash, it could print random garbage, or far, far worse.
“Permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, to having demons fly out of your nose.” ~ from comp.std.c
Some examples of undefined behavior include, but are not limited to:
- Accessing an array out of bounds
- Dereferencing a null pointer
- Using an uninitialized variable
- Integer overflow (in some cases)
- Violating type safety (e.g., treating a pointer to one type as a pointer to another type)
- Modifying a string literal
- Using a pointer after it has been freed
If you do not take care to avoid “UB”, you will encounter it sooner or later. One form of mitigation often necessary is checking the return values of functions (which may return null pointers, error codes, or other indicators of failure) before using their results.
Generating a Random Number
To make our guessing game actually fun, we need to generate a random number for the user to guess.
The C standard library provides the random function for this purpose, which generates a
pseudo-random number. To use it, we need to include the appropriate header, set the random seed,
and then call random to get a random number.
#include <libc/stdio.zh>
#include <libc/time.zh>
#include <libc/stdlib.zh>
fn main() -> i32 {
// Set the random seed based on the current time
srandom(time(0 as *TimeHandle) as u32);
// Generate a random number between 1 and 100
let secret_number = ((random() % 100) + 1) as i32;
printf("I have selected a number between 1 and 100. Can you guess it?\n");
// ... rest of the game logic goes here ...
return 0;
}
This code calls time(null) to get the current time as a 64-bit unsigned integer (u64). We then
cast it to a 32-bit unsigned integer (u32) to use as the seed for srandom. The random function
generates a random number, which we then take the modulus of 100 to get a number between 0 and 99,
and add 1 to shift it to the desired range of 1 to 100.
Comparing the Guess
Now that we have a random number and user input, we can compare the user’s guess to the secret number and give feedback.
// ... after generating secret_number and reading guess from user
if (guess < secret_number) {
printf("Too low! Try again.\n");
} else if (guess > secret_number) {
printf("Too high! Try again.\n");
} else {
printf("Congratulations! You guessed the number!\n");
}
This code compares the user’s guess to the secret number and prints feedback accordingly.
Looping Until the User Guesses Correctly
Finally, we want to allow the user to keep guessing until they get it right. We can achieve this
with a simple infinite (while (true)) loop that breaks when the user guesses correctly.
#include <libc/stdio.zh>
#include <libc/time.zh>
#include <libc/stdlib.zh>
fn main() -> i32 {
srandom(time(0 as *TimeHandle) as u32);
let secret_number = ((random() % 100) + 1) as i32;
printf("I have selected a number between 1 and 100. Can you guess it?\n");
while (true) {
printf("Enter your guess: ");
let guess: i32;
if (scanf("%d", &guess) != 1) {
printf("Invalid input! Please enter a valid number.\n");
continue; // Skip the rest of the loop and prompt again
}
if (guess < secret_number) {
printf("Too low! Try again.\n");
} else if (guess > secret_number) {
printf("Too high! Try again.\n");
} else {
printf("Congratulations! You guessed the number!\n");
break; // Exit the loop when the guess is correct
}
}
return 0;
}
This code will keep prompting the user to enter a guess until they guess the correct number,
providing feedback on whether their guess is too high, too low, or correct. We use the continue
statement to skip the rest of the loop and prompt the user again if they enter invalid input, and
the break statement to exit the loop when the user guesses correctly.
Congratulations! You’ve just built a complete guessing game in Zirco!
Language Reference
1. Introduction
Zirco is a compiled programming language with a strong type system and modern syntax, with a focus on clarity. The language is designed to provide a balance between expressiveness and compatibility with systems programming tasks.
1.1. Stability
Zirco is a highly experimental language that is under active development. The language and its semantics are not stable, and they may change without warning. This includes the language’s syntax, semantics, compiler API, and standard library. Zirco is not intended for production use.
This book is not a stable reference for the language. It is a best-effort attempt to document the language as it evolves, but it may be out of date. The content of this book may change without warning (along with the language itself), and it may not be a reliable reference for the language.
2. Preprocessor
The preprocessor is an initial pass over a Zirco source program that performs simple text substitution.
The following directives are supported:
2.1. #include
#include copy-pastes the contents of another file into the current source file.
There are two forms of #include:
2.1.1. #include <file>
Form: #include <[^>\n]+>
This form of #include is used to include files from the provided include paths. The include
paths are specified to the compiler via command line flags or via the $ZIRCO_INCLUDE_PATH environment variable.
2.1.2. #include "file"
Form: #include "[^"\n]+"
This form of #include is used to include files relative to the current source file. The path is
resolved relative to the directory of the current source file.
2.2. Historical: #pragma once
This directive is deprecated and should be ignored.
2.3. Behavior
Implementations MUST NOT ever include the same file more than once in a single compilation unit. If a file is included multiple times, the compiler MUST ignore all but the first inclusion.
3. Lexical Structure
The lexer follows the maximal munch rule: a+++b is lexed as a ++ + b.
3.1. Character Set
Zirco source files are UTF-8 encoded text files.
3.2. Whitespace
Whitespace is ignored by the lexer, except when it is used to separate tokens.
The following characters are considered whitespace:
- Space (U+0020)
- Horizontal tab (U+0009)
- Line feed (U+000A)
- Carriage return (U+000D)
- Form feed (U+000C)
3.3. Comments
3.3.1. Line Comments
At any point on a line, the sequence // indicates that the rest of the line is a comment and
should be ignored by the lexer up until the following linefeed.
3.3.2. Block Comments
The sequence /* indicates the start of a block comment, terminated by */.
Block comments may be nested, and may span multiple lines. The contents are ignored by the lexer.
/* /* This is a block comment. */ */
3.4. Keywords
The following identifiers are keywords or reserved words and may not be used in the IDENTIFIER position:
true false if else while
do for four break continue
return let const fn as
struct union enum match sizeof
type switch default new unreachable
3.5. Terminals
3.5.1. IDENTIFIER
The IDENTIFIER terminal is used to represent user-provided identifiers.
Form: [a-zA-Z_][a-zA-Z0-9_]*
Implementations MAY reserve the following identifiers for their own use:
- Identifiers beginning with
__(two underscores) - Identifiers beginning with
_(one underscore) followed by an uppercase letter (e.g._Z)
3.5.2. NUMBER_LITERAL
The NUMBER_LITERAL terminal is used to represent numeric literals.
There are three acceptable forms:
- Decimal:
[0-9][0-9\._]*(multiple.is forbidden) - Hexadecimal:
0x[0-9a-fA-F_]+ - Binary:
0b[01_]+
In any of these forms, _ is allowed as a visual separator.
NUMBER_LITERAL may be followed by an optional IDENTIFIER “type suffix”, such as 4u8 or 4 u8. The lexer MUST handle this case properly even without whitespace.
3.5.3. BOOLEAN_LITERAL
The BOOLEAN_LITERAL terminal has two possible keyword values: true and false.
3.5.4. STRING_LITERAL
The STRING_LITERAL terminal is used to represent string literals.
Form: "([^"\\]|\\.)*"
The following escape sequences MUST be permitted within string literals:
\0: Null character (U+0000)\n: Line feed (U+000A)\r: Carriage return (U+000D)\t: Horizontal tab (U+0009)\\: Backslash (U+005C)\": Double quote (U+0022)\xHH: Hexadecimal byte value (e.g.\xFF)\u{H+}: Unicode code point (e.g.\u{1F600})
Other escape characters are implementation defined.
3.5.5. CHAR_LITERAL
The CHAR_LITERAL terminal is used to represent character literals.
Form: '([^'\\]|\\.)'
They support the same escape sequences as STRING_LITERAL, alongside:
\': Single quote (U+0027)
3.6. Punctuation
Zirco uses the following operators and punctuation:
3.6.1. Arithmetic Operators
+ - * / % ++ --
3.6.2. Comparison Operators
== != < > <= >=
3.6.3. Logical Operators
&& || !
3.6.4. Bitwise Operators
& | ^ ~ << >>
3.6.5. Assignment Operators
= += -= *= /= %= &=
|= ^= <<=
3.6.6. Other Punctuation & Delimiters
. -> <- :: ? : ,
: ; => ... ( ) [
] { }
4. Type System
Zirco has a static type system.
All types have the following form. The identifier variant includes the primitive types, which are
included in the global scope by default.
type ::= identifier
| "*" type
| "[" NUMBER_LITERAL "]" type
| "struct" "{" field_list? "}"
| "packed" "struct" "{" field_list? "}"
| "union" "{" field_list? "}"
| "enum" "{" field_list? "}"
| "fn" "(" field_list? ")" "->" type
| "(" type ")"
field_list ::= field_decl ("," field_decl)*
field_decl ::= identifier ":" type
4.1. Primitive Types
Zirco has the following primitive types, available in any scope. Like any other scoped identifier, they may be shadowed by user-defined types, but this is highly discouraged for obvious reasons.
| Ident | Width (bytes) | Signed | Description |
|---|---|---|---|
u8 | 1 | No | Unsigned 8-bit integer |
u16 | 2 | No | Unsigned 16-bit integer |
u32 | 4 | No | Unsigned 32-bit integer |
u64 | 8 | No | Unsigned 64-bit integer |
usize | Platform-dependent | No | Unsigned integer type with the same width as a pointer |
i8 | 1 | Yes | Signed 8-bit integer |
i16 | 2 | Yes | Signed 16-bit integer |
i32 | 4 | Yes | Signed 32-bit integer |
i64 | 8 | Yes | Signed 64-bit integer |
isize | Platform-dependent | Yes | Signed integer type with the same width as a pointer |
bool | 1 | No | Boolean type |
The following type aliases (see below) are also always available in the global scope:
struct void {}
enum never {}
4.2. Pointer Types
The * prefix type operator converts a type into a pointer type.
*i32 // pointer to i32
**i32 // pointer to pointer to i32
*struct{} // pointer to an anonymous struct
A function pointer is represented as a pointer to a function type:
*fn(a: i32, b: i32) -> i32
4.3. Array Types
Arrays are fixed-size homogenous sequences of elements. The array type is represented as [N]T,
where N is a NUMBER_LITERAL and T is a type.
[5]i32 // array of 5 i32s
[10]bool // array of 10 booleans
[3][4]u8 // array of 3 arrays of 4 u8s
4.4. Struct Types
Structs are user-defined product types that group named fields together. They are defined with
struct or packed struct.
Structs may be defined inline:
*struct { x: i32 }
Or with a named struct:
struct Point {
x: i32,
y: i32,
}
Field ordering is always in declaration order. For packed structs, there is no padding between
fields. For structs, padding is implementation and architecture defined.
4.5. Union Types
Unions are user-defined sum types that group named fields together, but only one field may be active at a time. Accessing a field that is not active is undefined behavior.
Unions may be defined inline:
*union { left: i8, right: u8 }
Or with a named union:
union Either {
left: i8,
right: u8,
}
See Declarations for more information on union declarations.
4.6. Enum Types
Enums are user-defined sum types that group named fields together. They are similar to unions, but they have a discriminant that indicates which field is active (tagged union, akin to Rust).
Enums may be defined inline:
*enum { A: i32, B: u32 }
Or with a named enum:
enum Option {
Some: i32,
None: void,
}
4.7. Type Aliases
Type aliases are user-defined types that are equivalent to another type. They are defined with the
type keyword.
type MyInt = i32;
type Complex = struct { ... };
4.8. Function Types
Function types are user-defined types that represent functions. They are defined with the fn
keyword. They are not valid values except when used in a pointer type.
4.9. Type Inference
Zirco has very limited type inference. All types of an expression must be inferred immediately given the information of sub-expressions and program context - no higher-level analysis is performed.
The type inference mechanism MUST be capable of at least the following:
- Handling arbitrarily-sized integers (i.e.
4 + 4is(int), not a concretei32) - Inferring the type of a variable from its initializer (e.g.
let x = 4;infersxto be of typei32) - Performing implicit coercions as described below
4.10. Implicit Coercions
The following type coercions are permitted implicitly:
4.10.1. *T -> *void
Any pointer *T can be implicitly coerced to a *void pointer, useful for generic programming.
fn f(x: *void);
let x: i32;
f(&x); // &x is of type *i32, but can be coerced to *void
4.10.3. []T -> *T
An array []T can be implicitly coerced to a pointer *T, useful for passing arrays to functions.
fn f(x: *i32);
let x: [5]i32;
f(x); // x is of type [5]i32, but can be coerced to *i32
4.10.4. Arbitrary Integer Literals
Any integer literal has the inferred type (int) until used in a position which forces it to have
a given width and signedness, otherwise inferred to be i32. This allows expressions like:
4i32 + 2 to be valid, where 2 is inferred to be of type i32 because it is used in an
expression with an i32.
4.11. Type Behavior
Zirco uses “duck typing,” if a type has the same structure as another type, it is considered to be the same type.
5. Expressions
Expressions are anything that produces a value. They MAY have side effects.
This section defines their syntax, semantics, and type rules.
field_init ::= identifier ":" expr
type_in_constr := IDENTIFIER | struct ... | union ... | enum ...
expr ::= IDENTIFIER
| NUMBER_LITERAL IDENTIFIER? # Number with optional type suffix
| CHAR_LITERAL
| STRING_LITERAL
| "true" | "false"
| "sizeof" type
| "sizeof" "(" expr ")"
| "[" (expr ("," expr)*)? "]"
| type_in_constr "{" (field_init ("," field_init)*)? "}"
| "(" expr ")"
| expr "[" expr "]"
| expr "." IDENTIFIER
| expr "->" IDENTIFIER
| expr "(" (expr ("," expr)*)? ")"
| expr "++"
| expr "--"
| ("!" | "-" | "~" | "&" | "*" | "++" | "--") expr
| IDENTIFIER "<-" expr
| expr "as" type
| expr ("/" | "*" | "%") expr
| expr ("+" | "-") expr
| expr (">" | ">=" | "<" | "<=") expr
| expr ("==" | "!=") expr
| expr ("&" | "|" | "^") expr
| expr ("&&" | "||") expr
| expr "?" expr ":" expr
| expr ("=" | "+=" | "-=" | "*=" | "/=" | "%=" | "&=" | "|=" | "^=") expr
| expr "," expr
5.1. Operator Precedence
Operators are listed from highest to lowest precedence:
| Precedence | Operators | Description | Associativity |
|---|---|---|---|
| 1 | x() x[] x.y x->y x++ x-- | Function call, array index, member access, postfix increment/decrement | Left-to-right |
| 2 | !x -x ~x &x *x ++x --x y<-x | Unary operators, prefix increment/decrement, prefix member access | Right-to-left |
| 3 | as | Type cast | Left-to-right |
| 4 | * / % | Multiplication, division, modulo | Left-to-right |
| 5 | + - | Addition, subtraction | Left-to-right |
| 6 | < <= > >= | Comparison | Left-to-right |
| 7 | == != | Equality | Left-to-right |
| 8 | & | Bitwise AND | Left-to-right |
| 9 | ^ | Bitwise XOR | Left-to-right |
| 10 | | | Bitwise OR | Left-to-right |
| 11 | && | Logical AND | Left-to-right |
| 12 | || | Logical OR | Left-to-right |
| 13 | ? : | Ternary conditional | Right-to-left |
| 14 | = += -= *= /= %= &= |= ^= | Assignment | Right-to-left |
| 15 | , | Comma | Left-to-right |
5.2. Primary Expressions
5.2.1. Literals
5.2.1.1. IDENTIFIER
IDENTIFIERs assume the type of any variable in the value scope (see Statements) with the same name.
If no such name is found, an error MUST be produced.
5.2.1.2. NUMBER_LITERAL
NUMBER_LITERALs have the type of the literal’s suffix, if present. If no suffix is present, the
type is inferred to be an arbitrary integer until used in a context where the type is forced to be
any concrete type, otherwise it is inferred to be i32.
5.2.1.3. BOOLEAN_LITERAL
The keywords true and false are of type bool.
5.2.1.4. CHAR_LITERAL
CHAR_LITERALs are of type u8, and represent a single byte.
5.2.1.5. STRING_LITERAL
STRING_LITERALs are of type *u8 and contain often-immutable data.
5.2.2. sizeof
The sizeof operator returns the size of a type or expression as a usize in bytes.
Form: sizeof type or sizeof (expr)
The size of a type is implementation defined.
5.2.3. Array Construction
The [a, b, c, d] syntax constructs an array of the given elements.
All elements must have the same type, and the array’s type is inferred to be [N]T, where N is
the number of elements.
An empty array [] is not valid, as the type cannot be inferred.
5.2.4. Struct Construction
Any identifier (named struct), inline struct/union/enum can be constructed.
type Point = struct { x: i32, y: i32 };
let p = Point { x: 1, y: 2 }; // p is of type Point
let q = struct { x: i32, y: i32 } { x: 3, y: 4 }; // q is of type struct { x: i32, y: i32 }
5.2.5. Parenthesized Expressions
Parentheses can be used to group expressions and override operator precedence.
5.3. Postfix Expressions
5.3.1. Array/Pointer Indexing
A pointer x : *T where i: [ui]size can be indexed with x[i]. This is equivalent to *(x + i),
indexing i * sizeof(x) bytes into the pointer.
Arrays [N]T automatically coerce to *T, so they can also be indexed with x[i].
5.3.2. Member Access
A struct/union/enum x can have its members accessed with x.y, where y is the name of a field
in the struct/union/enum. The type of x.y is the type of that field.
5.3.3. Pointer Member Access
A pointer x : *T where T is a struct/union/enum can have its members accessed with x->y,
where y is the name of a field in the struct/union/enum. The type of x->y is the type of that
field.
This can also be done in a “reversed” format, y<-x, which is equivalent to x->y.
This is a joke feature, but is a real part of Zirco.
5.3.4. Function Calls
A function or function pointer f can be called with f(a, b, c), where a, b, and c are the
arguments to the function. The number and types of the arguments must match. If the function has
a variadic ... trailing in its type, an infinite number of arbitrary arguments may be passed.
5.3.5. Postfix Increment/Decrement
The postfix increment x++ and decrement x-- operators increment or decrement the value of
x by 1, and return the original value of x. The type of x must be an integer type.
5.4. Unary Expressions
5.4.1. Unary NOT
The unary NOT operator !x returns the logical negation of x. The type of x must be bool,
and the result is also of type bool.
5.4.2. Unary Negation
The unary negation operator -x returns the arithmetic negation of x. The type of x must be
an integer type, and the result is also of the same integer type.
5.4.3. Bitwise NOT
The bitwise NOT operator ~x returns the bitwise negation of x. The type of x must be an
integer type, and the result is also of the same integer type.
5.4.4. Address-of
The address-of operator &x returns a pointer to x. The type of x must be a variable, and the
result is of type *T, where T is the type of x.
5.4.5. Dereference
The dereference operator *x returns the value pointed to by x. The type of x must be a
pointer type *T, and the result is of type T.
5.4.6. Prefix Increment/Decrement
The prefix increment ++x and decrement --x operators increment or decrement the value of
x by 1, and return the new value of x. The type of x must be an integer type.
5.5. Cast Expression (as)
The cast expression a as b (a: A, b: B) is valid on the following pairs of types, and has
the following semantics:
A | B | Behavior |
|---|---|---|
| some type T | T | no-op |
| signed integer size n | signed integer size m | sign-extend or truncate to size m |
| unsigned integer size n | unsigned integer size m | zero-extend or truncate to size m |
| signed integer size n | unsigned integer size m | sign-extend or truncate to size m |
| unsigned integer size n | signed integer size m | zero-extend or truncate to size m |
pointer type *T | pointer type *U | pointer cast |
pointer type *T | integer type usize | pointer to integer cast |
integer type usize | pointer type *T | integer to pointer cast |
5.6. Arithmetic Expressions
Arithmetic expressions are always on two integer or pointer operands, and yield the same type as the operands.
x + y: additionx - y: subtractionx * y: multiplicationx / y: divisionx % y: modulo
5.6. Comparison Expressions
Comparison expressions are always on two integer operands, and yield a bool result.
x == y: equalityx != y: inequalityx < y: less thanx <= y: less than or equal tox > y: greater thanx >= y: greater than or equal to
5.7. Equality Expressions
Equality expressions are always on two integer operands, and yield a bool result.
x == y: equalityx != y: inequality
5.8. Bitwise Expressions
Bitwise expressions are always on two integer operands, and yield the same type as the operands.
x & y: bitwise ANDx | y: bitwise ORx ^ y: bitwise XOR
5.9. Logical Expressions
Logical expressions are always on two bool operands, and yield a bool result. They are short
circuiting (side effects are not evaluated if the result can be determined from the first operand).
x && y: logical ANDx || y: logical OR
5.10. Ternary Conditional Expressions
The ternary conditional expression x ? y : z evaluates x, and if it is true, evaluates and
returns y, otherwise evaluates and returns z. The type of the expression is the common type
of y and z, which must be the same type.
5.11. Compound Assignment Expressions
Compound assignment expressions are always on two integer operands, and yield the same type as the operands. They are equivalent to the corresponding binary operator, but with the left operand being modified in place.
5.12. Comma Expressions
The comma expression x, y evaluates x, discards the result, and then evaluates and returns
y. The type of the expression is the type of y.
6. Statements
Statements are program flow control constructs, not things that produce values.
stmt ::= ";"
| expr ";"
| "{" stmt* "}"
| "if" "(" expr ")" stmt ("else" stmt)?
| "while" "(" expr ")" stmt
| "do" stmt "while" "(" expr ")" ";"
| "for" "(" expr? ";" expr? ";" expr? ")" stmt
| "four" stmt
| "switch" "(" expr ")" "{" switch_case* "}"
| "match" "(" expr ")" "{" match_case* "}"
| ("let" | "const") let_decl_list ";"
| "continue" ";"
| "break" ";"
| "return" expr? ";"
| "unreachable" ";"
switch_case ::= expr "=>" stmt
match_case ::= IDENTIFIER ":" IDENTIFIER "=>" stmt
let_decl_list ::= let_decl ("," let_decl)*
let_decl ::= IDENTIFIER (":" type)? ("=" expr)?
6.1. Empty Statement
The statement ; is a no-op.
6.2. Expression Statements
The expression statement expr; evaluates the expression expr, discards the result, and
proceeds to the next statement.
6.3. Block Statements
Block statements are a sequence of statements enclosed in braces {}. They create a new scope for
variables declared within the block.
6.4. If Statements
If statements have the form if (condition) then_branch else else_branch, where condition
is an expression of type bool, and then_branch and else_branch are statements. The else
branch is optional.
6.5. While Statements
While statements have the form while (condition) body, where condition is an expression of
type bool, and body is a statement. The loop continues as long as the condition evaluates to
true.
6.6. Do-While Statements
Do-while statements have the form do body while (condition);, where body is a statement, and
condition is an expression of type bool. The loop executes the body at least once, and then
continues as long as the condition evaluates to true.
6.7. For Statements
For statements have the form for (init; condition; increment) body, where init is an optional
expression statement, condition is an optional expression of type bool, increment is an
optional expression statement, and body is a statement. The loop executes the init statement
once, then continues to execute the body as long as the condition evaluates to true, executing
the increment statement after each iteration.
6.8. Four Statements
Four statements have the form four body, where body is a statement. The loop executes the
body statement four times.
6.9. Switch Statements
Switch statements have the form switch (expr) { case1 => stmt1; case2 => stmt2; ... }, where
expr is an expression, and each case is a pair of a constant integer expression and a statement.
switch is not valid for dynamic case values, as a jump table is commonly used.
6.10. Match Statements
Match statements have the form match (expr) { case1: x => stmt1; case2: x => stmt2; ... },
where expr is an expression, and each case is a pair of an enum variant name and a captured
variable from the tagged union.
6.11. Variable Declarations
The let and const statements declare variables. The difference between the two is that let
declares a mutable variable, while const declares an immutable variable.
Either a type or an initializer must be provided, but not neither.
Variables must be initialized before they are used, and the type of the initializer must match the declared type.
Multiple variables can be declared in a single statement, separated by commas.
let x: i32 = 5;
const y: i32 = 10;
let a: i32 = 1, b: i32 = 2, c = 4;
6.12. Continue Statements
The continue; statement causes the current iteration of the nearest enclosing loop to end, and
control to jump to the next iteration of that loop.
6.13. Break Statements
The break; statement causes the nearest enclosing loop to terminate, and control to jump to the
statement immediately following that loop.
6.14. Return Statements
The return expr; statement causes the current function to terminate, and control to jump to the
statement immediately following the function call. The value of expr is returned to the caller.
6.15. Unreachable Statements
Reaching a unreachable; statement is undefined behavior. It is used to indicate that a certain
code path should never be reached, and can be used to optimize code generation.
7. Declarations
program ::= decl*
decl ::= "struct" IDENTIFIER "{" field_decl_list? "}"
| "packed" "struct" IDENTIFIER "{" field_decl_list? "}"
| "union" IDENTIFIER "{" field_decl_list? "}"
| "enum" IDENTIFIER "{" field_decl_list? "}"
| "type" IDENTIFIER "=" type ";"
| "fn" IDENTIFIER "(" param_list? ")" ("->" type)? block
Declarations introduce new names into the program at the global scope.
7.1. Struct, Union, and Enum Declarations
See Type System for more information on struct, union, and enum types.
7.2. Type Alias Declarations
See Type System for more information on type aliases.
7.3. Function Declarations
Functions are declared as follows with the fn keyword. The return type, arguments, etc may be
optionally specified. If the return type is not specified, it is inferred to be void.
fn foo(a: i32, b: i32) -> i32 {
return a + b;
}
If the body is elided, it is assumed to be an extern declaration, using the C calling convention.
If the body is elided, you may also include ... to indicate that the function is variadic.
8. Program Structure
8.1. Entry Point
All Zirco programs follow the crt0.S entry point of either of the following signatures:
fn main() -> i32;
fn main(argc: i32, argv: **u8) -> i32;
8.2. Compilation Units
The Zirco compiler compiles a single source file at a time, and each source file is a compilation
unit. Each compilation unit has its own global scope, and the contents of one compilation unit are
not visible to another compilation unit unless they are explicitly imported via #include.
9. Standard Library
Zirco’s standard library, libzr, is currently under development. The correct API surface is as is
defined by the libzr project in the zrc monorepo.