Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/zirco category. 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 MethodDescription
zirconRecommended, simple CLI tool
debian packageFor Debian-based Linux distros
nix flakeFor Nix users
download prebuilt binariesFor users who don’t want to use a package manager
build from sourceFor 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.

ShellCommand
bashecho 'source <($HOME/.zircon/self/bin/zircon env)' >> ~/.bashrc; . .bashrc
zshecho '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/-L paths 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!
CommandExplanation
zrc main.zr -o main.oCompiles main.zr to an object file main.o.
ld -lc -lzr main.o -o mainLinks main.o with the C and Zirco standard libraries (-lc -lzr) to produce an executable main.
./mainRuns 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.

IdentWidth (bytes)SignedDescription
u81NoUnsigned 8-bit integer
u162NoUnsigned 16-bit integer
u324NoUnsigned 32-bit integer
u648NoUnsigned 64-bit integer
usizePlatform-dependentNoUnsigned integer type with the same width as a pointer
i81YesSigned 8-bit integer
i162YesSigned 16-bit integer
i324YesSigned 32-bit integer
i648YesSigned 64-bit integer
isizePlatform-dependentYesSigned integer type with the same width as a pointer
bool1NoBoolean 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 + 4 is (int), not a concrete i32)
  • Inferring the type of a variable from its initializer (e.g. let x = 4; infers x to be of type i32)
  • 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:

PrecedenceOperatorsDescriptionAssociativity
1x() x[] x.y x->y x++ x--Function call, array index, member access, postfix increment/decrementLeft-to-right
2!x -x ~x &x *x ++x --x y<-xUnary operators, prefix increment/decrement, prefix member accessRight-to-left
3asType castLeft-to-right
4* / %Multiplication, division, moduloLeft-to-right
5+ -Addition, subtractionLeft-to-right
6< <= > >=ComparisonLeft-to-right
7== !=EqualityLeft-to-right
8&Bitwise ANDLeft-to-right
9^Bitwise XORLeft-to-right
10|Bitwise ORLeft-to-right
11&&Logical ANDLeft-to-right
12||Logical ORLeft-to-right
13? :Ternary conditionalRight-to-left
14= += -= *= /= %= &= |= ^=AssignmentRight-to-left
15,CommaLeft-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:

ABBehavior
some type TTno-op
signed integer size nsigned integer size msign-extend or truncate to size m
unsigned integer size nunsigned integer size mzero-extend or truncate to size m
signed integer size nunsigned integer size msign-extend or truncate to size m
unsigned integer size nsigned integer size mzero-extend or truncate to size m
pointer type *Tpointer type *Upointer cast
pointer type *Tinteger type usizepointer to integer cast
integer type usizepointer type *Tinteger 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: addition
  • x - y: subtraction
  • x * y: multiplication
  • x / y: division
  • x % y: modulo

5.6. Comparison Expressions

Comparison expressions are always on two integer operands, and yield a bool result.

  • x == y: equality
  • x != y: inequality
  • x < y: less than
  • x <= y: less than or equal to
  • x > y: greater than
  • x >= 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: equality
  • x != 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 AND
  • x | y: bitwise OR
  • x ^ 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 AND
  • x || 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.