Rig Tutorial


tl;dr
project: @com.github.username.project
language: @C11
version: {
	min: @24.04.0
}
default_target: @all
options: {
	clang: {
		type: @bool
		default: false
		desc: "Whether to use Clang instead of GCC."
	}
}
c_files: []str = find_src_ext("src", "c");

o_files: []str =
foreach c_file: c_files
{
	o_file: str = c_file +~ ".o";

	target o_file: c_file
	{
		if config_bool["clang"]
		{
			$ gcc -c -o @(tgt) @(file_dep);
		}
		else
		{
			$ clang -c -o @(tgt) @(file_dep);
		}
	}

	o_file;
};

target "exec": o_files
{
	if config_bool["clang"]
	{
		$ gcc -o @(tgt) %(file_deps);
	}
	else
	{
		$ clang -o @(tgt) %(file_deps);
	}
}

target @all: "exec";

Or if you have a build that doesn’t use C or C++, contact Yzena for help.


This tutorial will walk you through setting up a simple build with Rig, as well as why Rig is the way it is.

First, there are two directories that matter in Rig: the build directory and the source directory.

The build directory is where the build artifacts, including file targets, are placed. It is also the working directory for any commands run as part of the build.

The source directory is where the source files are.

A Rig build requires two files in the source directory:

But before you can understand those files, you must understand Rig’s config step and build step.

Config Step

A Rig build is typically one step, but before a Rig build can be run, it must be set up using a one-time config step.

The config step takes the source and build directories from the user, creates a build database in build directory, stores the user’s selected build configuration in the database.

However, it does not execute any code in build.rig.

This is different from build systems like CMake, where the configuration step executes CMake code.

The problem with the CMake model is that creating a list of build options from executing code may require running the configuration code multiple times before all options are found.

Instead of listing options among executable code, Rig requires them in build.gaml upfront. This means that the config step can gather all build options at once.

The downside is that the build configuration may have options that don’t matter for that particular configuration.

But the upside is that it can be automated, and Rig does automate it.

The manual way of running the config step is to run the rigc binary in the build directory, defining build options on the command-line and giving it the path to the source directory, like so:

$ rigc -Dsafe=true ../path/to/source/dir

There are two ways of automatically running the config step:

If rig is run in the source directory, it automatically selects ./build/ as the build directory (unless given the --build-dir option), and it sets the build configuration to the default_development preset, if it exists, or the default build configuration. And then it also automatically runs the build step.

If rigr is run in the source directory, it does the same thing as rig, except that it automatically selects ./release/ as the build directory (unless given the --build-dir option), and it sets the build configuration to the default_release preset, if it exists, or the default build configuration.

Once the config step has happened, the build step can happen as many times as necessary, so running either of those two binaries multiple times in the source directory is okay, even encouraged; rig and rigr will detect that the config step has already happened and just run the build step.

Build Step

A Rig build step runs in three parts, one after the other.

The first part is executing the code in the build.rig, like you would run a Yao script. This is done in a single thread.

The second part is checking that the checking that the requested targets exist, queueing them, and then recursively queueing their dependencies. This is done in a single thread, and it does not execute any code in build.rig.

If no targets are given, the same treatment is given to the default target.

The third part is executing all of the queued targets, as soon as they are ready, with as many threads as the user specified with the -j flag.

If no -j flag was specified, the number of threads is set to the number of logical cores on the machine.

The first part (minus the build options) is equivalent to what CMake does. The second and third parts together are equivalent to what Ninja does.

Rig runs the build from end-to-end; in other words, it’s a direct build system.

build.gaml

The build.gaml file is GAML file. GAML stands for Gavin’s Accessible Minimal Language, and it is a superset of JSON. It is designed to be useful as a configuration language.

For more information about build.gaml, see build.gaml(5).

This is the list of changes:

Symbols are like strings, except that they begin with the @ character, which is not part of the symbol, and they can only contain the same characters as keys that are not surrounded by double quotes. They are useful for things like enums, versions, identifiers, etc.

Binary data always begins with a & character, which is not part of the binary data. The data is encoded in URL- and filename-safe base64.

A minimal GAML file looks like this:

project: @com.example.project
language: @C11
version: {
	min: @24.04.0
}

The build.gaml file must have items under the following keys:

In addition, it can have items under these keys:

project

The item under the project key must be a symbol. It can be anything, but best practice is to make it the reverse fully-qualified domain name (RFQDN) of the project.

For example, for code examples with Yzena software, the project name is:

com.yzena.example.<name>

where <name> is replaced by the name of the example.

The RFQDN for projects by single individuals should be:

user.<username>

where <username> is replaced by the username of the individual.

language

The item under the language key must be a symbol, and it should be language that the project is written in. This includes the language version.

For example, Rig’s own language is defined like so:

language: @C11

because it is written in ISO C11.

Anything is currently allowed, as long as it is a symbol, but Rig will use the language to query information about the compiler and other relevant items in its “language database.”

version

The item under the version key must be an object itself, and it must have an item under the key min that is a symbol. It can have a value under a max key that is also a symbol.

This information tells Rig the minimum version of itself that is needed to build the project, and optionally, it also tells Rig the maximum version of itself that will work.

mode

The item under the mode key must be an object. It is currently unused because the functionality to restrict Rig’s power is unimplemented.

default_target

The item under the default_target key must be a symbol.

It is usually the symbol @all.

If no default target, the last target created during the config step of the build is the default target.

options

The item under the options key must be an object.

Each item in the object is a build option, and the key for each item is the name of the build option.

Each build option must be an object containing items under the keys type and default.

Each build option can also contain a string item under the key desc, which is the human-readable description of the option.

The item under the type key must be a symbol, which must be one of the following:

@bool items must be booleans, so the item under the default key must be a boolean.

@list items must be an array of strings, so the item under the default key must be an array of strings.

@string and @path items must be a string, so the item under the default key must be a string.

@option items must be a symbol, so the item under the default key must be a symbol. They can also have an options key, which should contain an array of symbols; items in that array will be the only accepted symbols for the option.

presets

The item under the presets key must be an object.

Each item in the object is a preset and must be an object itself. The key is the name of the preset.

Each item in the preset object should have the name of one of the build options under options, and the value of the item should be the same type as the option.

If the preset is selected, the items in its preset object are applied to the build options. All build options not referred to by the preset keep their default options.

default_development

The item under the default_development key must be a symbol, and it must be the name of an existing preset.

If it exists, that preset is made the default build configuration for development builds, which will be built by default when running the rig binary from the source directory.

If it doesn’t exist, the defaults for all build options are used for the default development build.

default_release

The item under the default_release key must be a symbol, and it must be the name of an existing preset.

If it exists, that preset is made the default build configuration for release builds, which will be built by default when running the rigr binary from the source directory.

If it doesn’t exist, the defaults for all build options are used for the default release build.

build.rig

The build.rig file contains the code that Rig executes during the build step.

For more information about build.rig, see build.rig(5).

Let’s build up good code from scratch.

You must understand basic Yao code first!

Building a Single File

Let’s say you want to build a main.c file into an executable called exec using GCC.

If you did it in POSIX Make, it would look like this:

exec: main.c
	gcc -o exec main.c

In Rig, targets are specified much the same way, with these changes:

So in Rig, the above example would look like this:

target "exec": "main.c"
{
	$ gcc -o exec main.c;
}

This will work.

Building Multiple Files

But if you need to build more than one C file, it would be annoying to type that out for each one.

In POSIX Make, there is a way to say “build all C files this way.” You do it like this:

.c.o:
	gcc -c -o $@ $<

However, Rig uses a different model; it executes code instead of simply parsing a dependency tree.

Instead, you get a list of the C files to build, and you execute a target statement on each one:

// Pretend that the C files are in the c_files array.
o_files: []str =
foreach c_file: c_files
{
	// Create an object file name.
	o_file: str = c_file +~ ".o";

	// Register the target for the .o file from the given C file.
	//
	// The name of the target is the *value* of the o_file variable, and the
	// name of the dependency is the *value* of the c_file variable.
	target o_file: c_file
	{
		$ gcc -c -o o_file c_file;
	}

	// Add the object file name to the list.
	o_file;
};

Yes, the target keyword is the start of a statement; that statement registers a target with the given name, which is equal to the value of o_file in this case, and with the given dependencies, which in this case is just one, the name of the C file.

However, if you try to execute this code, it will execute and do the wrong thing!

Instead, for every target, GCC will try to find a file with the literal name c_file and try to create a file with the literal name o_file!

This is because in the shell, plain strings are taken as strings, not variable names. To use them as variables, you need to surround them with @( and ):

o_files: []str =
foreach c_file: c_files
{
	// Create an object file name.
	o_file: str = c_file +~ ".o";

	target o_file: c_file
	{
		$ gcc -c -o @(o_file) @(c_file);
	}

	// Add the object file name to the list.
	o_file;
};

Yet surprisingly, this will not even compile! Rig should complain about unknown variables.

There is a reason for this: the code run by targets is separate from code run to set up the targets. The body of the target statement is actually an anonymous function because it will run at a different time (in the third part of the build step).

Recall the POSIX Make example:

.c.o:
	gcc -c -o $@ $<

The weird $@ and $< items are there on purpose: they are meant to be replaced with the target name and dependency name, respectively.

Rig has analogous features: the keyword tgt and the keyword file_dep.

It is called file_dep because in Rig, targets can have non-file dependencies, but that’s an advanced feature.

Both of those keywords are expression keywords; they both return a string, the name of the target or the dependency.

Note that it is a runtime error if the keywords are executed outside of a target!

So if you fix the given code, it looks like this:

o_files: []str =
foreach c_file: c_files
{
	// Create an object file name.
	o_file: str = c_file +~ ".o";

	target o_file: c_file
	{
		$ gcc -c -o @(tgt) @(file_dep);
	}

	// Add the object file name to the list.
	o_file;
};

In shell, @( and ) can contain any valid Yao expression that can be turned into a string.

But there is a problem: how do we get the list of C files?

The answer is find_src_ext. This is a keyword that acts like a function. It takes two string arguments and returns an array of strings.

The first is the directory to search, recursively. The second is the extension of files to search for. And it returns the list of files.

So to search for all of the C files in the src/ directory and store the list in the c_files variable, you would do this:

c_files: []str = find_src_ext("src", "c");

Put that before the foreach loop, and it will work wonders.

Building an Executable

But we have another problem: that code will only build .o files; it won’t actually build an executable out of them.

So we need to add a target for the executable itself.

That begs a question: how do we use an array of strings to specify dependencies for a target?

Easy; you just put the array in the dependency spot:

target "exec": o_files
{
	$ gcc -o @(tgt) %(file_deps);
}

This is why target is a keyword; it will detect if an dependency expression is an array and act accordingly!

Note, however, that the file_dep keyword has been changed to file_deps, plural. The file_deps keyword will return a string array of all of the dependencies of a target that are files.

Note also that file_deps is not wrapped in @( and ); instead, it is wrapped in %( and ).

@( and ) are used for string expressions, and %( and ) are used for string arrays. Crucially, every item in the array will be a separate argument to the command!

This means that every .o file will be a separate argument, which is what we want.

Adding a Command-Line Target

Finally, we have just one more problem: by default, Rig does not accept file arguments on the command-line, so to build the executable, you need to add a non-file target that depends on the executable.

This is done like this:

target @all: "exec";

If the @all looks familiar, that’s because it is; it is a symbol, exactly like symbols in GAML files.

Since @all is the last created target, it should become the default target if the build.gaml did not list one. If build.gaml did list one, it should be @all.

And you’re done!

The code should look like this:

c_files: []str = find_src_ext("src", "c");

o_files: []str =
foreach c_file: c_files
{
	o_file: str = c_file +~ ".o";

	target o_file: c_file
	{
		$ gcc -c -o @(tgt) @(file_dep);
	}

	o_file;
};

target "exec": o_files
{
	$ gcc -o @(tgt) %(file_deps);
}

target @all: "exec";

And you build everything by running any of the following commands in the source directory:

$ rig all
$ rig

“But why are there non-file targets?”

To remove one problem that Make has.

If you have a install target, you usually want it to run unconditionally. But if the install target invokes a script called install, Make will assume that the install file is the output of the install target, check its file metadata, and refuse to execute the install target if it thinks the file is up-to-date.

If there is a separation between file and non-file targets, Rig can avoid that problem.

Using the Build Config

All of the code we wrote above will work, but it’s bland.

What if we wanted to build our project with Clang?

We can add a build option build.gaml that will let us build with Clang:

options: {
	clang: {
		type: @bool
		default: false
		desc: "Whether to use Clang instead of GCC."
	}
}

Then when executing a target, we can query the option with the config_bool keyword, which acts like a dictionary or map.

o_files: []str =
foreach c_file: c_files
{
	o_file: str = c_file +~ ".o";

	target o_file: c_file
	{
		// Execute GCC or Clang on the C file to produce the .o file.
		if config_bool["clang"]
		{
			$ gcc -c -o @(tgt) @(file_dep);
		}
		else
		{
			$ clang -c -o @(tgt) @(file_dep);
		}
	}

	o_file;
};

target "exec": o_files
{
	// Execute GCC or Clang on the object files to produce an executable.
	if config_bool["clang"]
	{
		$ gcc -o @(tgt) %(file_deps);
	}
	else
	{
		$ clang -o @(tgt) %(file_deps);
	}
}

Notice that we are querying the config option in the target!

Unlike Make, it is possible to run more than just commands in a target. In fact, because a target’s code is an anonymous function, any valid Yao/Rig code can go inside a target body.

Putting It All Together

The code should now look like this:

c_files: []str = find_src_ext("src", "c");

o_files: []str =
foreach c_file: c_files
{
	o_file: str = c_file +~ ".o";

	target o_file: c_file
	{
		if config_bool["clang"]
		{
			$ gcc -c -o @(tgt) @(file_dep);
		}
		else
		{
			$ clang -c -o @(tgt) @(file_dep);
		}
	}

	o_file;
};

target "exec": o_files
{
	if config_bool["clang"]
	{
		$ gcc -o @(tgt) %(file_deps);
	}
	else
	{
		$ clang -o @(tgt) %(file_deps);
	}
}

target @all: "exec";

And now, you can build your project on any Unix-like system with GCC or Clang.


Is this tutorial not enough? Feel free to contact Yzena for help!