Building a project
Starting one
lyric new myapp # a program
lyric new mylib --lib # a module someone imports
An app arrives ready to build:
myapp/
├── lyric.json where the modules live
├── build.lyr what to build
├── .gitignore
├── src/main.lyr
├── src/greeting.lyr
└── tests/greeting_tests.lyr
cd myapp && lyric run
It also arrives with a tests/ directory and one test in it, which lyric test runs.
A library has no build.lyr, because there is nothing to build: it is source another project points
its sourceRoot at and imports. Its module file is named after it, so import mylib finds
src/mylib.lyr.
The name becomes a module name, so it has to be one: letters, digits and _, not starting with a
digit. lyric new refuses to write into a directory that already holds something.
Building one
For a single file there is nothing to set up:
lyric build app.lyr -o app.lyrbc
A project is a directory. lyric build there, without naming a file, builds it: from its
build.lyr when it has one, and by convention when it has none.
lyric build with a file still means "compile this file" and goes to the compiler, as it always
did. Only a directory, or no argument at all, builds a project.
Without a script
A project that says in lyric.json where its modules are needs nothing more:
lyric.json { "name": "myapp", "sourceRoot": "src" }
src/main.lyr
lyric build compiles src/main.lyr into out/debug/myapp.lyrbc. Without a lyric.json, the
main.lyr in the directory is the program, named after the directory. A source root without a
main.lyr is a library: every module under it is checked as a whole and nothing is written,
because a library is source another project imports. A directory with neither is an error that
names both ways (LYR-CLI0011).
The script
A project with more than one program, one that generates part of its own source, or one that packs
what it built, says so in a build.lyr at its root:
import std.build { executable };
pub fn build() {
executable("app", "src/main.lyr");
executable("mktex", "tools/mktex.lyr");
}
lyric build # the working directory
lyric build ../game # somewhere else
An artifact has a name, an identifier, and an entry file relative to the directory holding
build.lyr. The name is what it lands as: out/debug/app.lyrbc, and out/release/app.lyrbc from
lyric build --release. Two profiles, two directories: the classic mistake of starting a stale
debug build cannot happen when the two never share a file.
Every artifact is compiled on its own, whole, from its entry file. There is no link step and nothing is shared between two of them but the source on disk.
Nothing is compiled while the script runs
executable collects; the compiles happen once build has returned. That is why a field set on
the next line still applies — and why a file the script writes is finished before anything reads it.
What an artifact carries
executable returns the artifact, and the script configures it after the fact. Every artifact
starts from the profile the build was started with, and each of its fields is one of the profile's:
import std.build { executable, Profile };
pub fn build() {
let app = executable("app", "src/main.lyr");
app.sourceMap = false; // this one field, whatever the profile says
let tool = executable("mktex", "tools/mktex.lyr");
tool.use(Profile.release()); // the whole bundle, name included: out/release/
tool.denyWarnings = true; // and a field on top of it
app.output = "dist/app.lyrbc"; // or say where it lands outright
}
Profile.selected() is the one the command line chose, Profile.debug() and Profile.release()
the two the toolchain knows. What each means is the table under Two profiles; the
script asks the toolchain rather than carrying a copy.
Packing from the script
import std.build { executable, packed };
pub fn build() {
let app = executable("app", "src/main.lyr");
packed(app);
}
packed takes an executable's artifact and declares a second one: the same entry and the same
fields, packed with the stub runtime once compiled, as lyric pack does. It lands as
out/<profile>/app, with the platform's suffix (app.exe on Windows) when the output names none.
A library in the script
import std.build { library };
pub fn build() {
library("geometry", "src");
}
Every module under the root is checked as one compilation, and nothing is written. It is what
lyric build does by convention for a source root without a main.lyr, made explicit for a project
that holds a library beside its programs.
It is a program, not a list
A build script runs with every capability and has the whole standard library. It may write files, read them, and start processes:
import std.build { executable };
import std.io.file { writeText };
pub fn build() {
writeText("src/version.lyr", "module version; pub fn text(): string { return \"1.2.0\"; }");
executable("app", "src/main.lyr");
}
Relative paths mean the same everywhere in the script: they are resolved against the directory
holding build.lyr, not against the directory you started the build from.
This is code you are running. lyric build in a repository you did not write executes a program
you did not write, exactly as make and cmake do.
Options from the command line
import std.build { executable, option, flag };
import std.io.file { writeText };
pub fn build() {
let version = option("version", "what version.lyr reports") ?? "0.0.0-dev";
writeText("src/version.lyr",
"module version; pub fn text(): string { return \"" + version + "\"; }");
executable("app", "src/main.lyr");
if (flag("tools", "also build the tools")) {
executable("mktex", "tools/mktex.lyr");
}
}
lyric build -D version=1.2.0 -D tools
lyric build --help # the fixed options, then the script's
option answers null when the name was not given and "" when it was given without a value;
flag answers whether it was given. A -D the script never asked about is an error that names the
ones it did (LYR-CLI0003), because an option that is accepted and does nothing is the one that
costs an afternoon. lyric build --help in a project runs build to learn the options, collecting
and compiling nothing.
After the build
import std.build { executable };
import std.io.file { writeText };
pub fn build() {
executable("app", "src/main.lyr");
}
pub fn after() {
writeText("out/debug/VERSION", "1.2.0");
}
A pub fn after() runs once every artifact was written, and not otherwise: the work after the
build, copying assets beside the program or zipping a release, that a Makefile needs another target
for.
Building some of it
lyric build --only mktex
Names one or more artifacts; the others are declared and not built. A name nobody declared is an
error that lists the ones that were (LYR-CLI0019).
Running and packing what you built
In a project the verbs need no file either:
lyric run # build the default artifact and run it
lyric run mktex -- a b # that one, with its own arguments
lyric pack # build it in the release profile and pack it
lyric check # check the whole project, sources and tests
lyric test # run the tests
The default artifact is the first executable the script declares — explicit, and in the order the script is read. A project without a script has one program and that is it.
An argument decides itself: one that is on disk or carries .lyr/.lyrbc is a file and
goes to the compiler, as it always did; anything else is the name of an artifact. That is
the same rule build uses to tell "compile this file" from "build this project".
lyric check in a project is worth its own sentence: it reads the source root as ONE
compilation and the test root as a second one that imports it. A test calling a function that
was renamed is a question no per-file check can answer, and this is the answer.
What the script does not say
Where modules live is a property of the project, not of a build, so it stays in
lyric.json:
{
"name": "game",
"sourceRoot": "src",
"nativeRoots": { "engine": "sdk" },
"dependencies": { "geometry": "../geometry" },
}
Both files are read for every artifact. The script never repeats a root or a dependency, and an
editor learns the layout from lyric.json without running anything.
Two profiles
A compile is one of two shapes, and the shape is a named bundle rather than a list of flags to remember:
debug |
release |
|
|---|---|---|
| IR optimizations (inlining, scalar replacement, devirtualization) | off | on |
| source map (line numbers) | kept | kept |
| debug info (slot and field names) | kept | dropped |
lyric build, lyric run, lyric check and lyric test compile the debug profile unless
told otherwise; lyric pack compiles the release profile unless told otherwise. The reason
is what each shape is for. A debug build keeps every frame, so a panic's backtrace names the
function that failed rather than the caller it was spliced into, and a debugger stops in it. A
release build is what ships. Both keep the source map: a panic in production that names its line
is worth the bytes.
lyric build app.lyr --release # or --profile release; --debug is the other name
lyric build --release # the whole project, every artifact under out/release/
lyric run app.lyr --release # the compile flags travel to the compiler
lyric pack app.lyr --debug # a pack you can debug
Every field of a profile stays individually overridable, and a field flag wins over the profile:
lyric build app.lyr --release --debug-info # optimized, with the names
lyric build app.lyr --no-source-map # the debug profile without line numbers
--optimize/--no-optimize, --source-map/--no-source-map and --debug-info/--no-debug-info
are the six; --deny-warnings stays its own flag. The environment variable LYRIC_PROFILE
names the default for a whole process — LYRIC_PROFILE=release dotnet test is how a suite runs
in the other shape — and any flag beats it. In a script the same six are fields on the artifact,
and use is the bundle.
What the profiles cost, measured on this interpreter with tools/Bench: a loop that builds and
adds structs runs about two and a half times slower in the debug profile, a for-in over a range
about one and a half times, and array and iterator loops run about as fast either way. Compiling
is about a tenth faster without the optimizer, which is why the standard library's own test suite
is quicker in the debug profile.
Switches for bisecting
lyrc takes four more switches that belong to no profile: --no-inline,
--no-scalar-replacement, --no-devirtualize and --no-fusion. Each takes one optimization or
the fused instruction forms out of a --release build, which is how a program that answers
differently or runs slower optimized is narrowed down to the pass responsible. They are
diagnostic aids and carry no compatibility promise beyond that.
Every tool refuses an option it does not know, by name (LYR-CLI0003). A flag that is accepted
and does nothing is the one that costs an afternoon.
The form before 4.5
addExecutable("src/main.lyr", "out/app.lyrbc"), the entry first and the output named outright,
still builds and warns (LYR-SEM0076) until 5.0 removes it. It is executable("app", "src/main.lyr") with app.output = "out/app.lyrbc". The old arguments in the new call,
executable("src/main.lyr", …), are refused with a message that says which way round they go.
When something goes wrong
No build.lyr, and nothing to build by convention |
LYR-CLI0011 |
| The script does not compile | its own diagnostics, with file, line and column |
No build function, or it panics |
LYR-CLI0012 |
build declared nothing to compile |
LYR-CLI0012 — silence would look like success |
| An entry file is not there | named before anything is written |
A -D nobody asked about |
LYR-CLI0003, with the options the script declares |
An --only nobody declared |
LYR-CLI0019, with the artifacts the script declares |
| An artifact asked for no warnings and got one | LYR-CLI0016 |