Chapter 2

Hello, Goose

This chapter gets a compiler onto your machine, runs a first program, and looks at the handful of commands you will use every day. By the end of it you will know how Goose programs are compiled, which is a little different from what you are used to.

#Getting the compiler2.1

The Goose compiler is a single executable called goose. The project publishes prebuilt compilers for Windows, Linux and macOS on its releases page: unpack one somewhere and add that directory to your PATH.

You can also build it from source. You need CMake, a C++20 compiler and Python 3:

git clone https://github.com/aardappel/goose.git
cd goose
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build

That leaves the compiler at build/goose. The standard library is not a separate download: the compiler finds it in the stdlib/ folder of the source tree it came from.

To check that everything works, ask for the version:

$ goose version
goose runtime 4d3da949e563

#The first program2.2

Create a file called hello.goose:

fn main() {
    print("Hellope from Goose!");
}

and run it:

$ goose hello.goose
Hellope from Goose!

That is the whole ceremony. There is no project file, no package declaration and no build directory to set up. Let's go through the program line by line anyway, because even three lines have something to say.

fn main() { … } declares the program's entry point. Functions are declared with fn, parameters go in the parentheses, and a result type would follow a ->. Like Odin and Rust, and unlike C, there is no header and no forward declaration: top-level declarations can appear in any order.

print("Hellope from Goose!"); calls the built-in print, which writes its arguments followed by a newline. The string literal is a run of bytes in the program's read-only data. Goose has no string type; you will see in Chapter 9 what a string literal really is.

The semicolon ends the statement. Goose needs semicolons after expression statements, but not after a statement that ends with a block, such as an if or a loop.

#Two ways to run a program2.3

The command you just ran compiled the program and ran it inside the compiler's own process. Goose compiles every program to C, and the compiler carries a small C compiler of its own, TinyCC, which turns that C into machine code in memory and calls main. That makes goose file.goose (or goose run file.goose, which is the same thing) about as quick as running a script, and you do not need any other tools installed.

When you want a real executable, use goose build:

$ goose build hello.goose
built hello
$ ./hello
Hellope from Goose!

build writes the same C, but hands it to the optimizing C compiler it finds on your machine (Clang, GCC or MSVC), and puts the executable next to the source file. This is what you use for anything you want to ship or measure, and it is required for programs that use threads, which the in-process backend cannot run.

You can also stop one step earlier and look at the C:

$ goose -o hello.c hello.goose

The result is a single C file holding the whole program. It is readable, but it is not meant to be edited; think of it as the compiler's assembly output.

#Checking and formatting2.4

Two more commands are worth knowing from the start.

goose check file.goose parses and type-checks a program and everything it imports, without generating or running anything. Editors use the same checks through goose lsp, the compiler's language server.

goose format file.goose rewrites a file in the standard layout: indentation, the spacing between tokens, blank lines and trailing commas. It keeps your line breaks where they are. goose format --check writes nothing and only lists the files that are not formatted, which is handy in continuous integration.

#Printing2.5

You will see print in almost every example, so it is worth knowing exactly what it does. It takes any number of arguments, of any types, renders each one as text, and writes them all with a newline at the end. It inserts nothing between the arguments, so you add your own spaces:

fn main() {
    let geese = 12;
    let average_weight = 4.25;
    print("geese: ", geese, ", average weight: ", average_weight, " kg");
    print("more than ten? ", geese > 10);
}
geese: 12, average weight: 4.25 kg
more than ten? true

There is no format string, so there is nothing to get wrong about %d versus %ld. Every value can be printed, not just numbers and strings: arrays print in brackets, structs print as their type name and fields. That makes print the quickest debugging tool you have.

struct Egg { weight: f32, day: i32 }

fn main() {
    let nest = [Egg { 61.5, 3 }, Egg { 72.0, 4 }];
    print(nest);
}
[Egg { 61.5, 3 }, Egg { 72.0, 4 }]

Each call to print is a single write to standard output, so lines printed by different threads never get mixed together. print has two relatives that you will meet in Chapter 9: str, which builds the same text as a new string, and format, which appends it to an existing one.

#Comments2.6

Line comments start with //. Block comments are /* … */, and unlike in C they nest, so you can comment out a region that already contains a block comment:

fn main() {
    /* print("not today");
       /* a comment inside a comment */
       print("nor this"); */
    print("honk");   // only this line runs
}
honk

#When something is wrong2.7

Misspell a function name and the compiler tells you where:

fn main() {
    prnt("Hellope from Goose!");
}
hello.goose:2: error: unknown function: prnt
    prnt("Hellope from Goose!");
  in fn main() instantiated from hello.goose:1

The last line is the first sign of something you will see a lot. Goose type-checks generic code only where it is used, with concrete types, so every error comes with the chain of calls that led to it. For a mistake in main the chain is one line long. For a mistake deep inside a generic library function, the chain tells you which of your calls caused it.

Some mistakes can only be caught while the program runs. An index out of range is one of them, and Goose always checks:

fn main() {
    let eggs: i64[3] = [61, 72, 58];
    var i = 0;
    for day in 5 { i += 1; }
    print(eggs[i]);
}
goose runtime error: index 5 out of bounds (length 3) (hello.goose:5)

The program stops with a message naming the line. It never reads past the end of the array. As you will see in Chapter 23, the compiler removes most of these checks from the compiled program when it can prove they cannot fail.

#Release and debug2.7.1

By default a program is compiled for release, which has one effect you should know about early: signed integer overflow wraps around. Here an 8-bit counter is pushed past its maximum of 127:

fn main() {
    var honks: i8 = 120;
    for i in 10 { honks += 1; }
    print(honks);
}
-126

Compile with --debug and the same program stops at the overflow instead, naming the operation and the line:

fn main() {
    var honks: i8 = 120;
    for i in 10 { honks += 1; }
    print(honks);
}
goose runtime error: integer overflow (debug): 127 + 1 at i8 (hello.goose:3)

--debug also checks conversions that lose information. Use it while you develop and test. Unsigned arithmetic is different: it wraps in every build, because that is what unsigned arithmetic is for. Chapter 3 has the details.

#Program arguments2.8

A program's command-line arguments come from the os module in the standard library. import os; makes its functions available, and args() returns all the arguments as an array of strings, with the program itself first. When you run a program in-process, everything after -- is passed to it:

import os;

fn main() {
    let a = args();
    print(a.len, " arguments");
    for x in a[1..] { print("  ", x); }
}
$ goose args.goose -- one "two three"
3 arguments
  one
  two three

a[1..] is a slice of the array that leaves out the first element. Slices get a whole chapter later on. To end a program early with an exit code, call exit(code); it is a built-in and needs no import.

#Exercises2.9

  1. Write a program that prints the numbers from 1 to 5, each on its own line, followed by a line that says liftoff. You will need for i in n, which counts from 0 up to n - 1.

  2. Run the overflow example above with a u8 counter that starts at 250 instead of an i8 that starts at 120, with and without --debug. What happens, and why is it the same in both builds?