tl;dr
- Make a directory with a file called
build.gaml
and fill it with this content:
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."
}
}
- Change
com.github.username.project
to include your username and project. - Add a file called
build.rig
in the directory and fill it with 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";
- Change
"exec"
to whatever you want your program to be called. - Now create a
src/
directory and fill it with the C files of your program. - Run
rig
in the directory with thebuild.gaml
andbuild.rig
files.
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:
build.gaml
: A data-only configuration file.build.rig
: Script (code) for the build.
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:
- Run the
rig
binary in the source directory. - Run the
rigr
binary in the source directory.
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:
- Comments are allowed, both
//
style and/* */
style. - Opening and closing braces are not needed; the top level of the file is assumed to be an object.
- If an object key has only certain characters, it doesn’t need to be surrounded
by double quotes.
- Those characters are letters (
a
throughz
,A
throughZ
), numbers (0
through9
), underscore (_
), hyphen (-
), period (.
), and forward slash (/
).
- Those characters are letters (
- Numbers are arbitrary-precision, and will always survive round trips.
- Items in arrays and objects can be separated by newlines instead of commas.
- But commas are still valid.
- There is a symbol type.
- There is a binary type.
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:
project
(the project name).language
(the language of the project)version
(the versions of Rig that will work)
In addition, it can have items under these keys:
mode
(for restricting Rig’s power)default_target
(the target to execute if no target is given)options
(build options)presets
(preset build configurations)default_development
(the default preset for development builds)default_release
(the default preset for release builds)
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
@list
@string
@path
@option
@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:
- Target names should be made strings.
- Add the
target
keyword in front. - Surround the body with braces.
- Add a
$
character in front of commands. - Add a
;
character behind commands.
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!