Introduction
git commit
before trying it out.
Werk is a command runner and build system. It is intended to replace Make in
projects that need a simple build tool adjacent to a main build system, such as
Cargo or NPM. It can also replace just
in most cases.
The motivating use case is an asset building pipeline for a video game, which must perform a series of expensive steps to produce an asset archive that can be hot-reloaded by a game engine, but it can build anything, including C/C++ binaries, or integrate with external build systems, like Cargo.
Werk is limited and opinionated. It is not suited for all use cases, and it can not replace more advanced solutions, like CMake or scons. However, it is suited to work together with such systems, and can be used to invoke them in a convenient way. See the Depfile support chapter for more details.
werk
tries to be really clever about when to rebuild files. In addition to
file modification times, it also takes things like the path to any commands
invoked in a recipe, any environment variables used in the recipe, or changes in
the results of glob patterns (like *.txt
) into account when deciding whether
or not to rebuild a given file. See the Outdatedness
chapter for more details.
werk
also tries to be extremely helpful when diagnosing problems. The
command-line option --explain
provides detailed information about why a given
target was rebuilt, without excessive information. The command-line option
--dry-run
allows evaluating the dependency graph without executing any
commands.
werk
is religiously portable. It works natively on all major platforms
(Linux, Windows, macOS), without any external dependencies - no sh
required!
Use cases
Examples of suitable use cases:
- Simple build processes for things like shaders, WASM modules, small C libraries, assets, etc.
- Command runner for "housekeeping" tasks, like running tests, publishing binaries, or downloading static file dependencies.
- Driving other build systems.
Examples of less suitable use cases:
- Building cross-platform C/C++ projects with system dependencies. There is no
built-in way to discover "traditional" dependencies via
pkg-config
,vcpkg
, or similar. Use CMake instead. - Builds requiring detailed concurrency management. Werk assumes that all
recipes that don't have an edge between them in the dependency graph can be
run in parallel, and there is no way to limit parallelism outside of the
--jobs
parameter. - Multiple outputs per recipe. Driving things like
bison
with Werk may require workarounds. - Recursive workspaces.
Getting Started
werk
is installed from source, and prebuilt binaries are not provided at this
time. That said, werk
is a self-contained binary that can easily be
transferred between machines.
Installation Dependencies
rustc
>= 1.83.0
Installation steps
git clone https://github.com/simonask/werk
cd werk
cargo install --path werk-cli
Running
If Cargo is configured properly on your system, your $PATH
should already
contain the path to Cargo-installed binaries.
- Cargo installs binaries in
$CARGO_HOME/bin
- On UNIX-like systems, the default install location is
$HOME/.cargo/bin
- On Windows, the default install location is
%USERPROFILE%\.cargo\bin
Verify that werk
is installed correctly by running werk --help
.
Language support for VS Code
If you are using Visual Studio Code, there is a simple language extension
providing syntax highlighting in the werk-vscode
subdirectory.
- From the command-line:
code --install-extension <path-to-werk-vscode>
- From within VS Code: Run "Developer: Install Extension from Location..." and
point it to the path to the
werk-vscode
directory within this repository.
Other IDEs
If your IDE supports .sublime-syntax
definition files (such as Sublime Text),
point your IDE to werk.sublime-syntax
in the repository's root to add syntax
highlighting support.
My first Werkfile
When running werk
, it looks for a Werkfile
in the current directory, and all
its parent directories.
Create a Werkfile
in a directory with the following contents:
default target = "hello"
task hello {
info "Hello, World!"
}
Run werk
in the directory:
$ werk
[info] Hello, World!
[ ok ] hello
Features
-
Cross-platform: Windows is a first-class citizen - no dependency on a POSIX-compliant shell (or any shell). Werk files work on all platforms out of the box.
-
Simple language: Werk files are written in a simple and human-friendly language that's easy to understand. It is designed to be written by hand.
-
Task recipes: Real support for executing project scrips, similar to
just
recipes or.PHONY
Make targets. A command recipe will be run at most once perwerk
invocation. -
Build recipes: Files can be built from Make-like patterns, and rebuilt according to modification time.
-
Advanced outdatedness:
werk
does more than just compare file modification timestamps. Metadata is cached between runs to support additional sources of "outdatedness". -
Separate output directory: All files produces by
werk
are put in a separate output directory, which is always what you want. This is hard to achieve with Make. -
Globbing: Filesystem glob patterns work out of the box, and can be used reliably in dependencies.
werk
caches a hash of the glob result between builds, so file deletion is detected as a change. Globbing is based on theglobset
crate, which comes fromripgrep
. -
Paths can contain spaces: Make simply cannot deal.
-
Depfile support: Depfile output from C-like compilers such as
clang
,gcc
,cl
,glslc
, etc. are specially supported in build recipes. When a recipe contains adepfile
dependency, it is automatically built and included when evaluating the dependencies of that recipe. -
.gitignore support: The
ignore
crate is used to hide files fromwerk
. -
Dry-run: Pass
--dry-run
to diagnose the build process without generating any output. -
Concurrency: Build recipes and tasks run in parallel when possible.
-
(TODO) Autoclean: Werk is aware of which files it has generated, and can automatically clean them up from the output directory.
-
(TODO) Autowatch: Werk can be run in
--watch
mode, which waits for file changes and automatically rebuilds when any change is detected.
Limitations
-
Cross-platform: Paths and commands must work across all platforms.
werk
does not require a shell (likesh
orbash
), but that also means that common shell features are not available. The language provides cross-platform alternatives in most cases. -
Declarative: Very advanced build logic can be difficult to express. If the limited expression support in
werk
is insufficient (and can't be easily supported in the model), consider using a more advanced solution like GNU make, CMake, ninja-build, scons, cargo-script, etc. -
Separate output directory: It is not possible to put output files next to input files, and files in the output directory are assumed to be generated by
werk
. -
Multiple recipes matching the same pattern: As opposed to Make, build recipes are not disambiguated by their inputs. This means that for example it is not possible to have two recipes that match
%.o
, where one takes%.c
as input and the other takes%.cpp
as input.- Workaround 1: Define separate recipes
%.c.o
and%.cpp.o
to build the two kinds of source files with different output file extensions. - Workaround 2: Use capture patterns, so the build recipe pattern is
%.(c|cpp)
, and usematch
expressions to generate the correct compiler arguments based on the captured pattern{0}
, which will evaluate to either "c" or "cpp".
- Workaround 1: Define separate recipes
-
Multiple outputs from a single recipe: This is not supported. The typical example is a code generator that outputs both a header file and a source file, like
bison
. Note: This may be explicitly supported in the future.- Workaround: For the common case of a
bison
parser, define the recipe for the generatedparser.h
file, and add another recipe for theparser.c
file that has no commands, but just depends onparser.h
.
- Workaround: For the common case of a
-
Detailed parallelism control:
werk
currently does not support marking specific recipes as "non-parallel". The only way to control parallelism is by supplying the--jobs
command line parameter, which controls the number of worker threads.
Why not make?
GNU Make is an incredibly powerful tool that has proven its worth through ~50 years of reliable use. But its age is also showing, and its behavior is often surprising to people who are used to more modern tools.
Put simply, it solves many hard problems that I don't have, and it doesn't solve many of the easy problems I do have.
The most glaring problem with Make is that it does not work well on Windows. It can be made to work, but not portably - often projects will have specific Makefiles for each platform, which must be maintained in isolation.
Key differences from Make
- Truly cross-platform:
werk
treats Windows as a first-class target platform. werk
has a clear separation between "input" files (workspace) and "output" files (output directory).werk
will never produce files alongside input files.- Globbing "just works".
werk
tracks the result of glob operations between runs and detects that a file is outdated if one of its dependencies was removed since the last run. - No shell requirement:
werk
does not execute commands in a shell, but performs its own$PATH
lookup etc., meaning allWerkfile
s work natively on all platforms without needing a POSIX emulation layer. - No shell (2): This also means that common POSIX shell utilities are not available. Most operations usually delegated to the shell can be performed using built-in language features instead, but explicitly invoking a shell is also an option.
- Build recipes and tasks recipes are separate things: No need for
.PHONY
targets. - Language semantics matching modern expectations:
werk
distinguished between lists and strings, meaning that file names can contain spaces. - No implicit rules.
- Automatic documentation.
werk --list
prints the list of available recipes and configuration variables to the command line, along with any preceding comment. - Dry-run mode: Running
werk --dry-run
does not run any commands, but still goes through the process of figuring out which commands would have been run. - Better debugging: Running
werk --explain
explains why a recipe was run. Also works with--dry-run
. - Better error messages.
werk
tries very hard to be helpful when an error occurs.
Why not just?
just is a command runner that is very popular. It fits the niche where what you need is a collection of "housekeeping" tasks for your project.
It's very easy to use, and has a syntax inspired by Make, but it isn't able to actually build things (tracking dependencies between artifacts), only run commands.
It also comes with a shell requirement, making it hard to write portable Justfiles.
Why not $toolname
?
Here's a loose collection of reasons that I prefer werk
to other similar
tools:
ninja
: Too low-level, not nice to write by hand, very specialized for C/C++.scons
: Very clunky in my opinion, annoying Python runtime dependency.meson
: Hard to use, integrates poorly with other tools.rake
: Ruby does not work on Windows.cargo xtask
: Solves a different problem, running Rust code at build time.cargo script
: Solves a different problem.cmake
: Very hard to use correctly, extremely hard to debug.- All the Java tools (
gradle
,maven
,bazel
): Too specific to Java projects, clunky, and hard to use.
Workspace
The workspace is the directory containing the Werkfile
, minus any files and
directories mentioned by .gitignore
.
When writing build recipes, the dependencies of a build recipe may be references to files within the workspace, or they may be referencing the output of another recipe, which will exist in the output directory.
Output directory
The output directory is where files produced by werk
will be placed. The
default path is $WORKSPACE/target
(same as Cargo), but this can be overridden
in two ways:
- From within the Werkfile:
default out-dir = ".."
- From the command-like:
werk --output-dir=..
If werk
detects that an output directory is included in the workspace (i.e.,
it is not covered by .gitignore
), it will emit a hard error.
Paths
File and directory paths in Werk are not normal paths as understood by the operating system. This is because one of the primary goals of Werk is to work on all platforms, and especially equal treatment of poor, maligned Windows.
Paths in Werk are always relative to the workspace root or the output directory. Files outside of the workspace cannot be treated as inputs to or outputs of build recipes. Werk is designed to only write to the output directory.
However, invoking external commands often requires passing native OS paths.
Using the special string interpolation
syntax "<var>"
, the abstract path
stored in var
will be converted to a native absolute path within the
workspace.
Native path resolution may resolve to either an input file in the workspace or a generated file in the output directory. This check is based on existence: If the file is found in the workspace, it resolves to the file inside the workspace. Otherwise, it is assumed that a build recipe will generate the file in the output directory, and it resolves to an absolute path inside the output directory, mirroring the directory structure of the workspace.
In general, build recipes should take care to not clobber the workspace and only generate files with paths that coincide with paths in the workspace.
Logically, the workspace is an "overlay" of the output directory - it always takes precedence when a file exists, and the output directory is a "fallback".
Consider this directory structure:
c:\
workspace\
main.c
foo.c
output\
main.o
Here, c:\workspace\main.c
has previously been built and placed at
c:\workspace\output\main.o
. However, foo.c
has not yet been built.
Path resolution will then work like this:
/main.c
will resolve toc:\workspace\main.c
because it exists in the workspace./foo.c
will resolve toc:\workspace\main.c
because it exists in the workspace./main.o
will resolve toc:\workspace\output\main.o
because it does not exist in the workspace./foo.o
will resolve toc:\workspace\output\foo.o
because it does not exist in the workspace./other.c
will resolve toc:\workspace\output\other.c
because it does not exist in the workspace.
Virtual path rules
- The path separator is forward slash.
- The root path
/
refers to the workspace root, never the native filesystem root. - Path components must be valid UTF-8. Incomplete surrogate pairs on Windows or arbitrary byte sequences on Linux/macOS are not supported, and will cause an error.
- Path components must only contain "printable" Unicode characters, no control characters or newlines.
- Path components must be valid on all platforms. In particular this means that
the more restrictive rules that apply to paths on Windows also apply to path
components in abstract paths, even when
werk
is running on other operating systems. See Windows rules. - Path components cannot start or end with whitespace.
- Path components cannot end with a period
.
- the filename extension cannot be empty. - Complete paths never end in a path separator.
Illegal characters
The following characters are illegal in abstract paths paths, and it is a superset of disallowed paths on Unix-like systems and Windows:
- Shell operators:
<
and>
and|
- Quotation marks:
"
and'
- Slashes:
/
and\
- Special punctuation characters:
:
and?
and*
Windows rules
Some file names are reserved on Windows, and may not occur as file names - even when they also have a file extension, and even in lowercase, or mixed case!
To complete the madness: For the special filenames ending in numbers, the digits
1
, 2
, and 3
are considered equal to their superscript equivalents. For
example, COMĀ¹
is reserved in addition to COM1
.
CON
PRN
AUX
COM0
-COM0
LPT0
-LPT9
Werk considers these filenames invalid on all platforms, even when running on a non-Windows platform. This is to ensure the portability of Werkfiles.
.werk-cache
This is a special file created by werk
in the output
directory.
It is a TOML document containing metadata used during outdatedness
checks, including glob
results, used environment variables (env
),
used program paths (which
), the recipe
itself, manual command-line overrides (-Dkey=value
), and any global variables
used while evaluating the recipe.
In short, .werk-cache
is what enables werk
do perform very detailed
outdatedness checks.
All values stored in .werk-cache
are hashed to avoid leaking secrets from the
environment, but the hash is not cryptographically secure. It can't be: since
the hash must be stable between runs, using a random seed would defeat the
purpose.
.werk-cache
can be safely deleted by the user, but doing so may cause the next
build to rebuild more than necessary.
Task recipes
Task recipes are "housekeeping tasks" or "workflows" that you may frequently
want to run. They have the same role as .PHONY
targets (Make) and tasks in
just
.
When a task recipe has one or more run
statements, the recipe will execute
recipe commands when invoked.
Task recipes can depend on each other, and they can depend on build recipes. If a task recipe participates in any outdatedness check, it and all of its dependents is considered outdated.
A single task is only ever run once during a build (occupying a single node in the dependency graph). In other words, if multiple recipes are being executed that depend on the same task recipe, that recipe will be executed exactly once, before any of the recipes that depend on it.
Reference
task my-task {
# Define a local variable, here indicating a build recipe to run.
let my-program-target = "my-program"
# Run tasks or build recipes before this task. May be a list or a single name.
build "my-program"
# Enable forwarding the output of executed commands to the console.
capture false
# Set an environment variable for all child processes in this recipe.
env "MY_VAR" = "value"
# Remove an environment variable for all child processes in this recipe.
env-remove "MY_VAR"
# Run an external program after building this task's dependencies.
run "echo \"Hello!\""
# Can also run a block of commands.
run {
"echo \"Hello!\""
"some-other-command"
info "my-task completed!"
}
}
Build recipes
Build recipes tell werk
how to produce a file. They are the equivalent of Make
rules.
Build recipes are defined in terms of a pattern, which may just be the literal name of a file, but it can also be Make-like patterns with a substitution stem.
When a build recipe has one or more run
statements, the recipe will execute
recipe commands when invoked in order to
produce the output file.
Build recipes may depend on each other. When multiple files depend on the same recipe, that recipe is only executed exactly once (before any of its dependents are built).
When a target is outdated, it and all of its dependents will be rebuilt. See the outdatedness chapter for the detailed rules governing when targets are rebuilt.
Build recipes should always place their output in the output directory. This can
be achieved by using path interpolation
("<...>"
) when passing files as arguments to external commands.
Reference
This example builds an .o
object file from a .c
source file. See
Patterns for more information about which patterns are
supported.
build "%.o" {
# Define a local variable, here setting the name of the source file.
let source-file = "%.c"
# Define the dependencies of this recipe. May be a list or a single value.
from source-file
# Set the depfile for this recipe.
depfile "{source-file:.c=.d}"
# Disable forwarding the output of executed commands to the console.
# Default is to capture (silence) in build recipes. Note that errors and warnings
# from compilers are always forwarded.
capture true
# Set an environment variable for all child processes in this recipe.
env "MY_VAR" = "value"
# Remove an environment variable for all child processes in this recipe.
env-remove "MY_VAR"
# Run an external program to build the file.
# out is the target file of the recipe, and in is the first dependency.
run "clang -c -o <out> <in>"
}
Outdatedness
werk
has a richer idea of "outdatedness" or "staleness" than Make and similar
tools, enabling many cases that would traditionally require a full rebuild to be
selectively rebuilt instead.
This is made possible by placing a .werk-cache
file in the
project's output directory that tracks
outdatedness information between builds.
werk
tracks outdatedness in extremely high detail. Any variable or expression
used in a build recipe may contribute to its outdatedness. This enables the
--explain
option to provide very detailed information about why a build recipe
was executed, and it causes werk
to be very accurate when deciding what to
build.
Outdatedness is always transitive. If a build recipe is outdated, all of its dependents are also outdated. If a variable relies on another variable that is determined to have changed, that variable is also determined to have changed, and recipes relying on it will be outdated.
The following factors contribute to outdatedness:
-
File modification timestamps: If a build recipe depends on a file that has a newer modification timestamp than a previously built output file, the file is considered outdated.
-
Glob results: If a
glob
expression produces a new result between runs (i.e., a file is deleted that previously matched the pattern, or a new file is added matching the pattern), any recipe relying on the results of that glob expression will be outdated. -
Program paths: If the path to a program's executable changes between runs (i.e., the result of a
which
expression changed), any recipe relying on the results of that expression will be outdated. Note:werk
does not currently take file modification timestamps of found programs into account, so updating your tools may still require a manual rebuild. -
Environment variables: If the value of an environment variable changed between runs, any recipe relying on the value will be outdated.
-
Recipes: If the recipe to build a file changes in a way that would cause the file to be built in a different way, the file is considered outdated. Insignificant changes that are ignored in this check are
info
andwarn
statements and comments. -
Global variables: If the definition of a global variable changes in the Werkfile, all recipes that use that specific variable will be outdated. For example, changing the string value of a global variable will cause recipes relying on that variable to become outdated.
-
Command-line overrides: If a
-Dkey=value
option is passed towerk
to override a config variable, and it was not overridden with the same value in a previous run, all recipes depending on that variable will be considered outdated.
This means that a build recipe that has no input files can still become outdated, because its outdatedness is determined by these factors.
Note that task recipes are always "outdated" (just like .PHONY
targets), so a
build recipe that depends on a task recipe will always be outdated.
Note about globals and recipes
The outdatedness of global variables and recipes is determined by their definition in the Werkfile. This check is performed against the hash of the AST of those statements - not the source code representation. This means that modifying comments in or around those statements will not affect outdatedness.
In general, only changes that can affect the commands that are actually run by a
recipe are included in the outdatedness check, so things like modifying the
informational message of an info
statement will not cause its surrounding
recipe to become outdated.
Depfile support
werk
has limited support for depfiles (.d
) generated by other tools, such as
C/C++ compilers, shader compilers, or Cargo.
Depfiles contain Make rules which indicate the dependencies of a given source
file discovered by a compiler. werk
can parse these rules and apply them as
implicit dependencies of its own recipes.
A dependency being "implied" means that the build logic treats the dependency
normally, but the contents of the depfile are not available to the build
recipe through the in
variable.
Depfile support has two "modes", and werk
automatically detects which one is
in use (per-recipe):
- Depfile is generated during compilation of the main source file. For example,
clang
andgcc
support passing-MF
as an argument during compilation, which will generate a depfile as an additional side-effect. - Depfile is generated as a separate invocation of the compiler, without
producing the actual output of the compilation step (
-MF
without-o
).
When a depfile
statement exists in the body of a build recipe, werk
applies
the following logic:
- If there is a build recipe to specifically generate that depfile, that file is added as an implicit dependency, and its recipe will be run before the current recipe.
- If there is no build recipe to generate the depfile,
werk
assumes that the current recipe will implicitly generate the depfile. - If the depfile exists, it is parsed by
werk
, and dependencies discovered in the depfile are added as implicit dependencies of the recipe. If the depfile exists, but could not be parsed, the current recipe fails to build with a hard error. - If the depfile does not exist:
- If
werk
believes that it should be implicitly be generated by the command, consider the current recipe outdated. - If
werk
believes that another explicit recipe should have generated the depfile, but it did not, emit a hard error.
- If
- If, after building the current recipe, and the depfile should have been
implicitly generated by the compiler, but it still does not exist,
werk
emits a warning.
The parser for the depfile is a very permissive, but limited, parser of Makefile syntax to support different conventions used by different tools on different platforms. For example, some tools put prerequisites with paths containing whitespace in double-quotes, while others escape them with a backslash. Some tools escape backslashes in Windows paths, while others do not. The depfile parser cannot be used to determine if a file is valid Makefile syntax, and there is valid Makefile syntax that will be rejected by the depfile parser.
The depfile parser has been tested with the following tools:
clang
andgcc
slangc
glslc
cargo
Build configuration
Configure how werk runs
The behavior of the werk
command can be configured in two ways:
- Command-line arguments
default
statements within the Werkfile
default
statements take precedence over built-in defaults. Command-line
arguments take precedence over default
statements.
Reference:
# Set the output directory, relative to the workspace root. Default is "target".
default out-dir = "output-directory"
# Set the default recipe to run when werk is run without arguments.
default target = "recipe-name"
Customize your tasks and recipes
Build configuration variables in a Werkfile can be overridden from the
command-line using -Dkey=value
, where key
is the name of a config key = ...
statement in the global scope, and value
is a string value.
config
statements work exactly like let
statements, except that it is an
error if multiple identical config
keys exist in the Werkfile. let
statements may shadow config
statements and vice versa, but config
statements cannot shadow other config
statements.
When a config
variable is overridden from the command line, the value is
inserted during evaluation at the point where the config
statement occurs, and
subsequent statements will see the value passed on the command-line.
Consider this Werkfile:
config greeting = "Hello"
task greet {
info "{greeting}, World!"
}
Running this normally:
$ werk greet
[info] Hello, World!
[ ok ] greet
Override the greeting:
$ werk greet -Dgreeting=Goodbye
[info] Goodbye, World!
[ ok ] greet
A typical use case for this is to override the build "profile", i.e., whether to build in debug or release mode.
Example using the match expression
to
validate the argument:
config profile = "debug"
let cflags = profile | match {
"debug" => ["-O0", "-g"]
"release" => ["-O3"]
"%" => error "unknown build profile '{profile}'"
}
Running this with default arguments:
$ werk --list
Config variables:
profile = "debug"
Overriding the argument:
$ werk --list -Dprofile=release
Config variables:
profile = "release"
cflags = ["-O3"]
Overriding the argument with an invalid value:
$ werk --list -Dprofile=wrong
Error: unknown build profile 'wrong'
Watch for changes
werk
can automatically watch for changes to the workspace
and re-run a task or build recipe when a change occurs.
In any project, run werk <target> --watch
to build a target and then wait for
changes to any files in the workspace to trigger a rebuild.
Only files in the workspace are watched for changes - not changes in the output
directory, or changes to files covered by .gitignore
.
werk
inserts a small delay between detecting a file and actually starting a
rebuild, to avoid "overreacting" when many changes occur, and also because some
filesystem notifications are actually delivered before the change is visible.
This delay can be customized using --watch-delay=<ms>
.
--watch
works together with other flags, like --explain
, to provide detailed
information about the build.
Example
Using the C example, this will build and re-run the executable for every change.
$ werk run --watch --explain
[ ok ] /my-program.exe
foo() returned: 123
[ ok ] run
[werk] Watching 4 files for changes, press Ctrl-C to stop
Making a change to any of the files involved in the build will then cause a
rebuild. Let's say a change was made to foo.h
, which is included by other
files:
[0/1] rebuilding `/foo.o`
Cause: `/foo.h` was modified
[0/1] rebuilding `/main.o`
Cause: `/foo.h` was modified
[ ok ] /foo.o
[ ok ] /main.o
[0/1] rebuilding `my-program.exe`
Cause: `/foo.o` was rebuilt
Cause: `/main.o` was rebuilt
[ ok ] /my-program.exe
foo() returned: 123
[ ok ] run
[werk] Watching 4 files for changes, press Ctrl-C to stop
Color support
werk
automatically detects whether or not it is running in a terminal, and
respects conventional color support environment variables.
Since werk
captures the stdout/stderr of child processes, programs executed by
werk
cannot detect that they are running in a terminal, so the only way to get
them to produce color output is to instruct them via environment variables or
command-line arguments.
werk
automatically adjusts the color environment variables for child
processes such that child processes get a consistent idea of whether or not
color output should be enabled. For example, CLICOLOR=1
and NO_COLOR=1
will
never both be set for a child process.
Some programs do not respect these conventional environment variables, and must
manually be passed command-line arguments to enable color output. For example,
clang
must be run with -fcolor-diagnostics -fansi-escape-codes
to produce
color output when run through werk
on all platforms. The built-in global
variable COLOR
can be used to conditionally pass such
arguments to compilers when werk
itself has color output enabled.
Example
let color-cflags = COLOR | match {
"1" => ["-fcolor-diagnostics", "-fansi-escape-codes"]
"%" => []
}
let cflags = ["-O0", "-g", color-cflags]
Progress indicator
When werk
detects that it is running in a terminal, and color is not disabled
through environment variables or command-line options, it will print and update
a progress indicator (spinner or progress bar, depending on settings) to the
terminal.
Windows support
werk
only supports ANSI colors and automatically attempts to set
ENABLE_VIRTUAL_TERMINAL_PROCESSING
on Windows 10 and above. Legacy Windows
Console color is not supported, so child processes should also be instructed
to emit ANSI color codes, such as passing -fansi-escape-codes
to clang
.
Language Reference
Werkfiles are written in a very simple domain-specific language, optimized for readability and debuggability. It is a Turing-incomplete language: there are no loops or functions.
- Comments begin with
#
and go until the end of the line. Comments may appear anywhere. - Source files (Werkfile) consist of a sequence of statements.
- All statements begin with a keyword, like
let
,run
,task
,build
, etc. - Statements are separated by newlines and/or a semicolon.
- Some statements accept a
{ ... }
block containing other statements (run
blocks,task
andbuild
recipes). - Identifiers can contain any Unicode XID character, plus
-
(kebab-case is supported). - Strings and patterns are always double-quoted.
- Lists are surrounded by
[ ... ]
, and elements are comma-separated. - There are no functions or loops, but expressions can be "chained" or "piped"
through operations using the
|
operator. - All variables are immutable - there is no assignment operation.
- Local variables may shadow global variables or previously defined local variables in the same scope.
Statements
let
statement
When appearing at the root scope, this defines a global variable available to all recipes. When appearing in a recipe, this defines a local variable to that recipe. Local variables may shadow global variables.
Syntax:
let <identifier> = <expression>
Example:
let message = "Hello, World!"
config
statement
Defines a global configuration variable available to all recipes, overridable
from the command-line with -Dkey=value
.
config
statements semantically work exactly like let
statements, except that
it is an error if multiple config
statements define the same variable. In
other words, config
statements cannot shadow each other, but it is valid for
config
statements to shadow let
variables, and let
variables may shadow
config
variables.
When a command-line override -Dkey=value
is present, value
is inserted into
the evaluation at the point where the config
statement occurs, and the default
value expression is not evaluated.
Cannot appear in recipes.
Syntax:
config <identifier> = <default-value-expression>
Example:
config message = "Hello, World!"
default
statement
Sets project-level settings for the workspace, providing default values for
command-line arguments. May only appear in the global scope. Expressions are not
supported as values, and string interpolation does not happen, except for the
target
key.
Syntax:
default <key> = <value>
Example:
# Set the output directory for the workspace.
default out-dir = "path/to/output/directory"
# Set the recipe to run when `werk` is run without arguments.
default target = "build"
task
statement
Define a "workflow task", invokable from the command-line. May only appear in the global scope.
Syntax:
task <identifier> {
# Set a local variable
let <identifier> = <expression>
# Build recipe(s)
build <expression>
# Run command(s)
run <run-expression>
# Print a message
info <expression>
}
Example:
task build {
let program = "my-program{EXE_SUFFIX}"
build program
info "Build succeeded!"
}
build
statement (at global scope)
Define a recipe for building a file. May only appear in the global scope.
Syntax:
build <pattern> {
# Set a local variable
let <identifier> = <expression>
# Set the prerequisites for this recipe
from <expression>
# Set a depfile for this recipe
depfile <expression>
# Run command(s)
run <run-expression>
}
Example:
build "%.o" {
let source-file = "{%}.c"
from source-file
depfile "{%}.d"
run "clang -c -MF <depfile> -o <out> <in>"
}
Expressions
An expression is one of:
- String literal
"..."
, potentially containing interpolations. - List literal
[...]
, comma-separated. - A single identifier, referencing a variable in the local or global scope.
- An operation
<operation-name> <args>
. - An expression chain (see below).
Expression chaining / "piping"
There are no functions in werk, but expressions can be chained together, such
that one expression works as the "input" to an operation. For example, an
expression that evaluates to a list can be passed to the join ", "
operator to
produce a string where each element of the list is separated by a comma.
The chaining operator is |
, as a nod to shell piping syntax. Expression chains
can be arbitrarily long.
Syntax:
<first> | <then> | <last>
Example:
let words = ["Hello", "World"]
let message = words | join ", "
# Prints "Hello, World!"
info message
Variables
Defining a variable in Werk is a let
-statement. Values are either strings or
lists (no numbers). Lists may contain other lists. All strings are valid UTF-8.
Syntax: let identifier = expression
. The left-hand side of the =
must be a
valid identifier (Unicode is supported), and the right-hand side is any
expression. All expressions produce a value.
let my-string = "value"
let my-list = ["a", "b", "c"]
All variables are immutable, but variable names may shadow local or global variables with the same name.
let foo = "a"
let foo = "b" # valid
let bar = foo # == "b"
Global variables are public
Variables defined at the global scope (i.e., outside of any recipe) are public,
and will appear in the output of werk --list
. They can be overridden by
passing -Dkey=value
on the command-line. Comments immediately preceding a
global variable will appear in the output as documentation for that variable.
# Set the build profile.
let profile = "debug"
$ werk --list
Global variables:
profile = "debug" # Set the build profile.
Local variables
Variables defined within a recipe are local to that recipe. Recipes cannot change global variables, but they may shadow global variables in their local scope by defining a variable with the same name as a global variable.
Expressions
Expressions occur in a number of places, most often the right-hand side of a
let ... =
statement.
Every expression starts with a value:
- String literal
- List literal
- Any built-in expression that queries the system,
workspace or runtime environment, like
which
orenv
. - Identifier, referencing a previously defined variable
- Any expression chain, surrounded by parentheses
( ... )
.
There are no functions or loops in Werk, but values can be transformed or inspected by "piping" them through an expression chain, imitating shell piping. Any value can be piped through of the built-in operators to transfrom the value in some way.
Abstract syntax:
atomic-expression = string-expr
| list-expr
| builtin-expr
| identifier
| '(' expression ')'
;
expression = atomic-expression ('|' builtin-operator)*;
builtin-expr = 'which' string-expr
| 'env' string-expr
| ...
;
builtin-operator = 'match' match-body
| 'join' string-expr
| ...
;
Querying the system
The operators obtain a value from the system or runtime environment of the
werk
process, and produce a value directly. They may initiate an expression
chain.
which
Determine the native OS path of an executable, by way of the PATH
environment
variable. If the program cannot be found, this expression causes an error to be
reported, and werk
aborts.
The result of this expression participates in outdatedness checks.
Syntax:
which <string-expr>
Example:
let cc = which "clang" # e.g. "C:\Program Files\LLVM\bin\clang.EXE"
env
Read environment variable. If the variable is not set, this evaluates to the empty string.
The result of this expression participates in outdatedness checks.
Syntax:
env <string-expr>
Example:
let rust-log = env "RUST_LOG" # e.g. "trace"
glob
Glob workspace files. Given a standard globbing pattern
(e.g., **/*.txt
), produces a list of all files in the workspace that match the
pattern. All names in the result are "absolute workspace paths", i.e. they start
with /
and are relative to the workspace root.
This expression takes .gitignore
into account, and will never return a path to
a file covered by .gitignore
.
Note that standard glob pattern syntax collides with string interpolation
syntax, so capture groups must be escaped:
*.\{frag,vert,comp\}
.
The result of this expression participates in outdatedness checks.
Syntax:
glob <string-expr>
Example:
let source-files = glob "src/**/*.c" # ["src/foo.c", "src/bar.c"]
shell
Run a program during evaluation, producing its standard output. If the command
fails, evaluation fails and werk
will abort. If the command produces output
that is not valid UTF-8, this expression also fails.
Caution: In the global scope, a shell
expression will cause a program to
be run even in --dry-run
mode. The intent is that this type of expression can
be used to query information about the project or system in a non-destructive
manner, such as obtaining the git HEAD
or similar.
Note: Even though the expression is named "shell", the command is not
passed through the user's shell (like sh
, bash
, or PowerShell).
The result of this expression participates in outdatedness checks.
Syntax:
shell <run-expr>
Example:
let sha1 = shell "git rev-parse --short HEAD" # e.g., "0be81c6"
read
Read a workspace file during evaluation, producing its contents as a string. If
the file contains invalid UTF-8, this expression fails, and werk
will abort.
This will only read files in the workspace - never the output directory.
The result of this expression participates in outdatedness checks.
Syntax:
read <string-expr>
Example:
let contents = read "my-file.txt" # contents of "my-file.txt"
Expression chaining / piping
There are no functions in werk, but expressions can be followed by a chain of
operators, transforming the value in some way. For example, an expression that
evaluates to a list can be passed to the join ", "
operator to
produce a string, where each element of the list is separated by a comma.
The chaining operator is |
, as a nod to shell piping syntax. Expression chains
can be arbitrarily long.
Syntax:
<first> | <then> | <last>
Example:
let words = ["Hello", "World"]
let message = words | join ", "
# Prints "Hello, World!"
info message
Built-in operators
match
Perform string pattern-matching and substitution. The "input" to
the match operation is whatever value is piped into it, which is to say, match
can only meaningfully appear as the right-hand side of a chaining expression.
If the "input" value is a list, the pattern substitution is performed
recursively. If no patterns match the input, the string is passed through
unchanged. The catch-all pattern "%"
can be used to provide a fallback,
potentially using an error
operation to fail early.
Syntax:
match {
<pattern> => <expression>
<pattern> => <expression>
}
Example:
let source-file = "foo.c"
let object-file = source-file | match {
"%.c" => "{%}.o"
"%.cpp" => "{%}.o"
"%" => "unsupported source file extension: {}"
}
join
Given a list of values, convert the list to a string (recursively), where each element is separated by a separator.
When given a string, returns the string unmodified.
The "input" to the operation is whatever value it receives via expression piping.
Syntax:
join <separator>
Example:
let cflags = ["-O0", "-g"]
let arguments = cflags | join " " # "-O0 -g"
split
Given a string, convert it to a list by splitting it by some separator. The separator is a pattern expression. If the separator is not present in the string, returns a list with one entry containing the original string.
See also lines
.
Example:
let split = "Hello World" | split " " # ["Hello", "World]
lines
Given a string, convert it to a list by splitting it into separate lines.
This is similar to split "\n"
, except it also handles CRLF line breaks.
Example:
let split = "a\r\nb\nc" | lines # ["a", "b", "c"]
flatten
Given a list containing other lists, return a flat list containing all strings of the left-hand-side.
When given a string, returns a list with a single element containing that string
(equivalent to [string]
).
May only appear on the right-hand-side of a piping expression.
Example:
let flattened = ["a", ["b", ["c"]]] # ["a", "b", "c"]
filter
Given a list, filter elements (recursively) through a pattern, keeping only the elements that matched the pattern. This also flattens the list if it contains other lists.
Always produces a list.
Example:
let filtered = ["a.c", "b.cpp"] | filter "%.cpp" # ["b.cpp"]
filter-match
Given a list, filter elements (recursively) through a pattern, and replace each match with the right-hand side of the pattern match, keeping only the elements that matched the pattern. This also flattens the list if it contains other lists.
Always produces a list. When given a string, the string is filtered as if it was the element of a list.
This is a combination of the filter
and map
expressions,
or the match
. Compared to filter | map
, the difference
is that the mapping operation has access to pattern-match stem, capture groups,
etc., and not just the string that matched. Compared to filter | match
or
match | filter
, the difference is that the filter condition is that the
pattern failed to match.
Example:
let mapped = ["a.c", "b.cpp"]
| filter-match "%.c" => "{%}.o" # ["a.o"]
discard
Inverse of filter
: Retains only elements that do not match the
pattern(s).
Always produces a list.
Example:
let filtered = ["a.c", "b.cpp"] | detain "%.cpp" # ["a.c"]
dedup
Deduplicate strings in a list (recursively), preserving the original order. This
implies flatten
.
When given a single string, returns the string unmodified.
Example:
let deduplicated = ["a", ["a"], "b", "a"] | dedup # ["a", "b"]
map
Given a list expression, pass each element through a string expression where the "implied" value is the entry in the original list. Produces a list.
Given a string, evaluate the right-hand string expression once with the string as the implied value. Produces a string.
Example:
let mapped = ["a", "b"] | map "hello {}" # ["hello a", "hello b"]
let mapped = "a" | map "hello {}" # "hello a"
info
Print an informational message during evaluation. This can appear within an expression chain to introspect values along the way.
Syntax:
info <string-expression>
warn
Print a warning. This can appear within an expression chain.
Syntax:
warn <string-expression>
error
Unconditionally fail evaluation. If this is reached during evaluation in the global scope, the workspace will fail to initialize. If this is reached in a recipe scope, the recipe will fail to build.
In general, error
expressions only meaningfully appear within match
expressions, but syntactically they can appear anywhere. For example, it may be
useful to do "printf-style debugging" of a werkfile by unconditionally failing
early.
Syntax:
error <string-expression>
Example:
let profile = "debug"
let cflags = profile | match {
"debug" => "-O0"
"release" => "-O3"
"%" => error "Invalid profile: {profile}. Valid values are \"debug\" and \"release\"."
}
assert-eq
expression
When this appear as part of an expression chain, fail evaluation if the two sides are not equal.
Returns the value unchanged.
Syntax:
... | assert-eq <expr>
Example:
let input = ["a", "b"]
let result = input | map "{}.c" | assert-eq ["a.c", "b.c"]
assert-match
expression
When this appear as part of an expression chain, fail evaluation if the left side does not match the pattern.
When the left hand side is a list, the pattern is evaluated for all strings in the list (recursively).
Returns the value unchanged.
Syntax:
... | assert-match <pattern>
Example:
let input = ["a.c", "b.c"]
let result = input | assert-match "%.c"
String Interpolation
Strings literals behave "as expected", honoring normal character escape rules.
Additionally, string literals can contain interpolations, i.e., inserting the value of other expressions within the string. Interpolation is based on an identifier (referencing a local or global variable), along with an optional sequence of operations.
Interpolation has two "modes":
"{...}"
performs the interpolation literally - strings in the input are pasted verbatim in the string."<...>"
performs native OS path resolution before pasting the input, always producing an absolute native OS path, either in the workspace or the output directory. This is handy when passing arguments to an external process. Note that some values will already be native OS paths, such as the result of awhich
expression, so those should not be interpolated using<...>
, as it would apply path resolution twice.
Interpolation operations (i.e., operations affecting how the input is pasted)
appear after :
within the interpolation block.
Interpolation stem
Any interpolation block {...}
or <...>
consists of a "stem", and optionally
a number of interpolation operations: "{stem:...ops}"
, or "<stem:...ops>"
.
The stem is either:
- An identifier referencing a local or global variable. Example, reading the
variable
var
:"{var}"
- An integer, referring to the index of a capture group in a pattern that is in
scope. For example, when the pattern
foo.(frag|vert)
is in scope, the value of{0}
is"frag"
or"vert"
. - The pattern stem
%
:"{%}"
(braces are optional, unless there are operations). - Empty, referencing the "implied" value in a chaining operation, or the matched
string in a
match
expression. Example, copying the implicit value:"{}"
.
Note: When the interpolation stem refers to a list, and there is no join operator, the first non-empty string (recursively, depth-first) inside the list is interpolated. If the list is empty, the interpolation block produces an empty string.
Example:
let list-of-lists = [[["a"], "b"], "c"]
let string = "{list-of-lists}" # "a"
Join interpolation
When interpolating a list, the *
operator can be used to expand the list
(recursively), separated by a space. A different separator may also be provided:
{input,*}
produces a string where each element of input
is separated by a
comma.
When interpolating a string value, the join interpolation directive has no effect.
Join operations happen after any other interpolation operation has been
applied (recursively), and after native OS path resolution in <...>
interpolation blocks.
Example:
let letters = ["a", "b", "c"]
let string = "{letters,*}"
# Prints "a,b,c"
info string
Example using native OS path resolution (in this case, on Windows in a workspace located in "c:\workspace"):
let files = ["a.txt", "b.txt"]
let string = "<files*>"
# Prints "c:\workspace\a.txt c:\workspace\b.txt"
info string
Interpolation operations
Interpolation operations appear after :
in an interpolation block. Multiple
operations may appear in a single interpolation block separated by a comma, and
they are applied in order.
{...:.ext1=.ext2}
replaces file extension.ext1
with.ext2
(the period is mandatory).{...:s/regex/replacement/}
replaces occurrences matchingregex
withreplacement
. The regex is passed verbatim to theregex
crate, and the replacement string follows the normal conventions.{...:dedup}
: When interpolating a list, deduplicate entries in the list (removing duplicate entries recursively), preserving the original order.{...:dir}
: When the stem refers to an abstract path, produces the directory part of the path.{...:filename}
: When the stem refers to an abstract path, produces the file-without-directory part of the path.{...:ext}
: When the stem refers to an abstract path, produces the file extension (without the.
) of the path.<...:out-dir>
: Disambiguate native path resolution to produce a path in the output directory. Does nothing in{...}
interpolations.<...:workspace>
: Disambiguate native path resolution to produce a path in the workspace directory. Does nothing in{...}
interpolations.
String interpolation example
let input-files = ["foo.c", "main.c"]
info "{input-files ,*:.c=.o}" # Prints "foo.o, main.o"
info "<input-files*:.c=.o>" # Prints "c:\workspace\output\foo.o c:\workspace\output\main.o"
Path resolution
Werk supports translating abstract paths into native OS paths in
string interpolations, using the special "<...>"
interpolation syntax.
Normal string interpolations "{...}"
are always "verbatim" - the interpolation
is performed literally.
However, string interpolation with <...>
performs extra logic to obtain a
native OS path whenever it occurs, and this logic is sensitive to the
surroundings of the interpolation, as well as the presence of build recipe
rules.
Pathiness: A string containing a <...>
interpolation (i.e., containing a
native OS path) cannot be used in another <...>
interpolation, as this would
create nonsensical OS paths. This is transitive, so a native OS path cannot be
"smuggled" through a normal {...}
interpolation. However, certain operations
remove the "pathiness".
Consider the following Werkfile:
# c:\workspace
# target\
# dir\
# foo.txt
default out-dir = "target"
let input = "foo.txt"
let output = "bar.txt"
let dir = "dir"
let input-path = "<input>" # c:\workspace\foo.txt
let output-path = "<output>" # c:\workspace\target\bar.txt
let output-filename = "{output-path:filename}" # foo.txt
let output-path = "<output-path>" # ERROR: Double path resolution
"<input>"
resolves toc:\workspace\foo.txt
, becausefoo.txt
exists in the workspace."<output>"
resolves toc:\workspace\target\bar.txt
, becausebar.txt
does not exist in the workspace."<input:out-dir>"
resolves toc:\workspace\target\foo.txt
, because it is explicitly requested."<output:workspace>"
resolves toc:\workspace\bar.txt
, because it is explicitly requested, even though the file does not exist in the workspace."<dir>"
resolves toc:\workspace\dir
, even though it is a directory.- When an
<...>
interpolation would match a file in the workspace, but also matches a build recipe,werk
fails with an error describing the ambiguity. The path can be disambiguated by using:out-dir
or:workspace
to disambiguate path resolution. - Since they contain
<...>
interpolations,input-path
andoutput-path
are marked as "pathy", and those variables cannot be used in further<...>
interpolations. - However, the filename component of a path is not "pathy", so
output-filename
may be used in other<...>
interpolations.
Patterns and pattern-matching
Patterns are strings containing special directives. They behave similarly to Make patterns.
Special syntax in pattern strings:
%
: The "pattern stem". This matches any sequence of characters, which will be available to statements within the pattern's scope as%
or{%}
. The latter (braced) can be used if the stem is subject to interpolation operations, or when used within another pattern (without introducing a new stem).(a|b)
: Capture group matching eithera
orb
.
Patterns can contain string interpolations.
Interpolated string values are not interpreted as patterns, but will be matched
literally. For example, if an interpolated value contains %
, it will only
match the string "%".
Example, given the pattern %.(c|cpp)
:
- The string
"foo.c"
will match. The stem isfoo
, and capture group 0 isc
. - The string
"foo/bar/baz.cpp"
will match. The stem isfoo/bar/baz
, and capture group 0 iscpp
. - The string
"foo.h"
will not match, because none of the variants in the capture group apply. - The string
"abc"
will not match, because the period is missing.
When multiple patterns are participating in pattern matching (such as figuring
out which build recipe to run, or
in a match
expression), the "highest-quality"
match is chosen. Match quality is measured by the length of the stem: A match
producing a shorter stem is considered "better" than a match producing a longer
stem.
- A pattern without a
%
stem is "more specific" than a pattern that has a stem. - A pattern that matches the input with a shorter stem is "more specific" than a pattern that matches a longer stem.
- Capture groups do not affect the "specificity" of a pattern.
Important: When multiple patterns match with equal quality, the pattern
matching operation is ambiguous. In build recipes, this is a hard error. In
match
expressions, the first matching pattern will
win. Often, capture groups can be used to disambiguate two patterns by
collapsing them into a single pattern.
Example
Given the patterns %.c
, %/a.c
, foo/%/a.c
, foo/bar/a.c
, this is how
matches will be chosen based on various inputs:
"bar/b.c"
: The pattern%.c
will be chosen, because it does not match the other patterns. The stem is"bar/b"
."foo/a.c"
: The pattern%/a.c
will be chosen, because it produces the shortest stem"a"
. The stem isfoo
."foo/foo/a.c"
: The patternfoo/%/a.c
will be chosen over%.c
and%/a.c
, because it produces a shorter stem. The stem isfoo
."foo/bar/a.c"
: The patternfoo/bar/a.c
will be chosen overfoo/%/a.c
, because the pattern is literal exact match without a stem.
Conflicts: It's possible to construct patterns that are different but match
the same input with the same "specificity". For example, both patterns
foo/%/a.c
and %/foo/a.c
match the input "foo/foo/a.c"
equally. When such a
situation occurs, that's a hard error.
Recipe commands
When recipes execute, they run a series of recipe commands, defined by the run
statement in the recipe body.
The most common recipe command is to execute an external program, like a compiler, but for convenience several other operations are available, corresponding to common filesystem operations.
Recipe commands always occur as part of a run
statement in a recipe. Shell
commands are not expressions, and do not produce an output
value in the language. Instead, werk
forwards their output to the user when an
error occurs, or unconditionally if capture false
is set in the recipe.
Recipe commands are the only way to cause werk
to make modifications to the
system, and they will never execute in --dry-run
mode. (The thing that
--dry-run
disables is recipe commands.)
A recipe command is one of the following:
- A string literal, representing a command-line invocation of an external program (like a compiler).
- One of the built-in recipe commands below.
The run
statement
In any recipe, the run
statement takes one of these forms:
run "command"
: Invokecommand
as an external program directly.run { ... }
: Run each recipe command inside the braces{ ... }
, in order.run [ ... ]
: Run each external program in order.
The following three run
statements are all equivalent:
run "command"
run ["command"]
run {
shell "command"
}
Note: Recipe commands are always literal, i.e. they cannot be built from
arbitrary expressions, only strings. However, string
interpolation can be used within run
statements to build command-line
invocations from other bits.
String interpolation in run
statements
Commands executed in recipes, or as part of the shell
expression, have
additional logic in order to make it intuitive to build valid commands that can
be understood by the OS, loosely following normal shell conventions.
Commands in run
statements are always string literals, and cannot be stored in
variables.
Strings in run
statements are evaluated according to the following additional
rules:
- A command consists of segments separated by whitespace.
- The first segment is always the absolute path to an executable, such as
obtained by the
which
expression. If the first segment does not look like an absolute OS path (depending on the current platform), thewhich
operation is still performed before executing the command. - Any subsequent segment is treated as an argument to the executable, and will
be passed as-is. That is to say, when passing paths to files within the
workspace as arguments, they must be interpolated using native OS path
resolution:
<var>
. - When building a command using a string literal, the string is parsed with sensitivity to whitespace and double-quotes. Whitespace outside quotes is understood to mean a separator between arguments (and multiple whitespace characters are collapsed into a single separator). Quoted arguments are always passed literally as a single argument to the command.
- String interpolation inside quotes is pasted according to normal string interpolation rules, and passed as a single argument to the command. Note that since commands are also string expressions, quotes must be escaped.
- String interpolation outside quotes is treated as a single argument, except if the string interpolation contains a join operator that would separate the strings by a single space character (the default), in which case each string is passed as a separate argument.
- String interpolations that evaluate to strings containing quotes do not affect argument separation - the quotation characters are passed verbatim to the command. This is also the only way to pass a literal quote character as an argument to the command.
Note that shell piping syntax is not available, since the command is not run
via a shell process such as sh
or pwsh.exe
.
Examples:
let compiler = "clang"
let input = "foo.c"
let output = "foo.o"
let cflags = ["-c", "-O0", "-g"]
run "{compiler} {cflags*} -o <output> <input>"
# Command that will be run:
# "c:\path\to\clang.exe" "-c" "-O0" "-g" "-o" "c:\workspace\output\foo.o" "c:\workspace\foo.c"
Auto-which
When the first component of an external program invocation in a run
statement
is a literal string, it is automatically which
ed,
meaning that werk
automatically looks up the actual path to the program (via
the PATH
environment variable), and adds that path to outdatedness
checks for the recipe containing the run
statement.
Example:
run "clang -o <out> <in>" # automatically resolves to (e.g.) /usr/bin/clang
The reason you might want to not rely on this, and use a global variable instead, is that global variables can be overridden by the user on the command line, providing a different compiler path.
Example:
# in global scope, overridable with `-Dcc=path` on the command-line
let cc = which "clang"
# in a recipe
run "{cc} -o <out> <in>"
Built-in recipe commands
shell
Invoke an external program. In run
statements, this behaves the same as a
string literal.
Note: Within run
statements, this behaves differently from shell
expressions. The output of the command is not captured
as a string, but instead forwarded to the user, and there are no requirements
that the output is valid UTF-8.
Syntax:
shell <string-expr>
Example:
run {
shell "clang -o <out> <in>"
}
write
Write a string to a file in the output directory.
The destination path is an abstract path that will be resolved relative to the output directory. This command will never overwrite any file outside of the output directory.
If the destination path refers to a directory, this command fails.
Syntax:
write <expression> to <filename>
Example:
build "message.txt" {
let message = "Hello, World!"
run {
write message to "<out>"
}
}
copy
Copy files to a file or directory path in the output directory.
The destination path is an abstract path that will be resolved relative to the output directory. This command will never overwrite any file outside of the output directory.
The source path may refer to files or directories in the output directory or in the workspace.
Note: The source-path does not automatically participate in outdatedness
checks for the recipe. The dependency must be established
in some other way, like a from
statement in a build recipe, or the result of a
glob
expression.
Syntax:
copy <source-path> to <destination-path>
Example:
build "b.txt" {
from "a.txt"
run {
copy "a.txt" to "b.txt"
}
}
delete
Delete a file/directory or list of files/directories under the output directory, if they exist.
If the file does not exist, this silently succeeds (same as rm -rf
).
Note: This will never touch any file outside the output directory.
Syntax:
delete <paths>
Example:
task clean-objects {
let files = glob "*.c" | map "{}.o"
run {
delete files
}
}
info
Print an informational message while running commands.
Example:
run {
info "Build succeeded!"
}
# Shorthand in a recipe body:
info "Build succeeded!"
warn
Print a warning while running commands.
Example:
run {
warn "Caution!"
}
# Shorthand in a recipe body:
warn "Caution!"
Built-in variables
Variables
In build recipes:
in
, or{^}
in strings: Input files (the result of thefrom
statement)out
, or{@}
in strings: Output files (the path of the actual file being built by the recipe).depfile
: If the recipe has adepfile
statement, this is the evaluated path to the depfile.%
or{%}
in strings: The stem of the matched pattern, if a pattern is in scope and that pattern contains a%
. When defining patterns in a scope where another pattern is already present, the interpolated{%}
may be used to unambiguously refer to the stem of the "outer" pattern.
Global constants
These variables are valid in all scopes.
ROOT
: The abstract or filesystem path to the project root, which is always/
. When interpolated as<ROOT>
, this becomes the filesystem path to the directory containing the Werkfile.OS
: Lowercase name of the host operating system.- Windows:
windows
- macOS:
macos
- Linux:
linux
- FreeBSD:
freebsd
- DragonFly:
dragonfly
- OpenBSD:
openbsd
- NetBSD:
netbsd
- WebAssembly (WASIp2):
wasm-wasi
- Windows:
OS_FAMILY
: Classification of the host operating system.- Windows:
windows
- Linux, macOS, and BSDs:
unix
- WebAssembly (WASIp2):
wasm
- Windows:
ARCH
: Name of the host architecture.- x86_64 / x64:
x86_64
- x86:
x86
- ARM (64-bit):
aarch64
- ARM (32-bit):
arm
- WebAssembly:
wasm
- x86_64 / x64:
ARCH_FAMILY
: Classification of the host architecture.- x86, x86_64:
x86
- ARM:
arm
- WebAssembly:
wasm
- x86, x86_64:
EXE_SUFFIX
:- Windows:
.exe
- Other: empty
- Windows:
DYLIB_PREFIX
:- Windows: empty
- Linux and macOS:
lib
DYLIB_SUFFIX
:- Windows:
.dll
- Linux:
.so
- macOS:
.dylib
- Windows:
STATICLIB_PREFIX
:- Windows: empty
- Linux and macOS:
lib
STATICLIB_SUFFIX
:- Windows:
.lib
- Linux and macOS:
.a
- Windows:
EMPTY
: Always the empty stringCOLOR
: When color output is enabled forwerk
, this is set to"1"
. This may be used to conditionally pass command-line arguments to compilers that don't respect the conventionalCLICOLOR
environment variables.
Include
As a project grows, the size of a Werkfile
may become unwieldy, and it may be
desirable to split recipes and variables into separate files.
Werk supports the include
statement to evaluate a separate file and include
its variables and recipes in the main Werkfile
.
Included files are evaluated as-if they were a part of the file that includes them. For the purposes of expression evaluation, all included files share the same global scope.
However, default
statements may
only appear in the "main" Werkfile, as they impact how Werk runs.
include
statements take the form of include "path/in/workspace.werk"
. The
path may also be an expression, so Werkfiles can selectively include other
sources based on the value of expressions.
Example
Werkfile:
include "config.werk"
include "recipes.werk"
config.werk:
config profile = "debug"
recipes.werk:
let cflags = profile | match {
"debug" => ["-O0", "-g"]
"release" => ["-O3"]
"%" => []
}
build "%.o" {
# ...
}
Advanced example
This example includes a different set of configuration variables based on the current host platform.
Werkfile:
config profile = "debug"
include "config_{OS_FAMILY}.werk"
include "recipes.werk"
config_windows.werk:
let cc = which "cl"
let debug_cflags = []
let release_cflags = ["/O"]
config_unix.werk:
let cc = which "clang"
let debug_cflags = ["-O0", "-g"]
let release_cflags = ["-O3"]
recipes.werk:
let cflags = profile | match {
"debug" => debug_cflags
"release" => release_cflags
}
build "%.o" {
from "%.c"
run "{cc} {cflags*} -o <out> <in>"
}
Arrays / lists
Werk has first-class support for lists. Elements of lists can be accessed using
the subscript operator [index]
, where index
is either a constant integer or
an expression producing the string representation of an integer.
When array indices are negative, the result is the element from the end of the
array. For example, -1
gets the last element of the list, -2
gets the
next-to-last element, and so on.
Subscript operators may also appear within string interpolations.
Example:
let my-list = ["a", "b", "c"]
let a = my-list[0] # "a"
let my-index = "1"
let b = my-list[my-index] # "b"
let c = my-list[-1] # "c"
Array operations
These operations are specific to arrays, but arrays may also appear in other operations. See Expressions.
len
Get the number of elements in a list (as a string). When passed a string, this always returns 1.
Example:
let my-list = ["a", "b", "c"]
let len = my-list | len # "3"
first
Get the first element of a list, or the empty string if the list is empty. This
is different from array[0]
in that it does not raise an error when the list is
empty.
Example:
let my-list = ["a", "b", "c"]
let first = my-list | first # "a"
let empty = [] | first # ""
last
Get the last element of a list, or the empty string if the list is empty. This
is different from array[-1]
in that it does not raise an error when the list
is empty.
Example:
let my-list = ["a", "b", "c"]
let last = my-list | last # "c"
let empty = [] | last # ""
tail
Produce a new list with the first element removed, or an empty list if the list is empty.
Example:
let my-list = ["a", "b", "c"]
let tail = my-list | tail # ["b", "c"]
Command-line reference
Output of werk --help
:
Usage: werk [OPTIONS] [TARGET]
Arguments:
[TARGET]
The target to build
Options:
-f, --file <FILE>
The path to the Werkfile. Defaults to searching for `Werkfile` in the current working directory and its parents
-l, --list
List the available recipes
--dry-run
Dry run; do not execute any recipe commands. Note: Shell commands used in global variables are still executed!
-w, --watch
Build the target, then keep rebuilding it when the workspace changes
--watch-delay <WATCH_DELAY>
Number of milliseconds to wait after a filesystem change before rebuilding. Implies `--watch`
[default: 250]
-j, --jobs <JOBS>
Number of tasks to execute in parallel. Defaults to the number of CPU cores
--workspace-dir <WORKSPACE_DIR>
Override the workspace directory. Defaults to the directory containing Werkfile
--output-dir <OUTPUT_DIR>
Use the output directory instead of the default
-D, --define <DEFINE>
Override global variable. This takes the form `name=value`
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
Output options:
--print-commands
Print recipe commands as they are executed. Implied by `--verbose`
--print-fresh
Print recipes that were up-to-date. Implied by `--verbose`
--quiet
Silence informational output from executed commands, only printing to the terminal when a recipe fails
--loud
Print all informational output from executed commands to the terminal, even for quiet recipes. Implied by `--verbose`
--explain
For each outdated target, explain why it was outdated. Implied by `--verbose`
-v, --verbose
Shorthand for `--explain --print-commands --print-fresh --no-capture --loud`
--color <COLOR>
[default: auto]
Possible values:
- auto: Probe the current terminal and environment variables for color support
- always: Force color output, even if the command is not running in a terminal
- never: Do not use color output
--output-format <OUTPUT_FORMAT>
[default: ansi]
Possible values:
- ansi: Provide friendly user feedback assuming an ANSI terminal
- log: Emit the progress as log statements (assuming `WERK_LOG` is set to a value)
- json: Report progress as JSON to stdout. This also disables color output
--log [<LOG>]
Enable debug logging to stdout.
This takes a logging directive like `RUST_LOG`.
Shell completions
Werk supports dynamic shell completion of arguments and tasks. See below for how to enable them in your shell.
Registration
Bash
source <(COMPLETE=bash werk)
Zsh
source <(COMPLETE=zsh werk)
Fish
COMPLETE=fish werk | source
To enable completions automatically, insert the line into .config/fish/completions/werk.fish
. 1
Note that the communication between werk
and the shell is not stable, so you should not write the output of COMPLETE=<shell> werk
directly into the completion file (see clap issue #3166)
Environment variables
All environment variables are forwarded to programs being run by werk
, unless
a recipe overrides this behavior by adding/removing environment variables.
For details around color terminal output settings, see Color terminal support.
Environment variables read by Werk
WERK_LOG
: When set to a value, enables detailed logging. This takes a logging directive (RUST_LOG
conventions). Overridden by the--log
command-line option.NO_COLOR
: Disable color output. Overridden by--color=always
. Takes precedence over any other environment variables.CLICOLOR
: When non-zero, enables color output. Overridden by--color=always/never
,NO_COLOR
, andCLICOLOR_FORCE
.CLICOLOR_FORCE
: When set, force-enable color output, same as--color=always
. Overridden byNO_COLOR
and--color=never
.
Environment variables set by Werk
CLICOLOR
,CLICOLOR_FORCE
,FORCE_COLOR
: These are set for programs executed bywerk
recipes when color output is enabled (running in a terminal, or color is enabled through environment variables, or--color=always
is passed). Note that programs running through ashell
expression never have color enabled.NO_COLOR
: This is set for programs executed bywerk
when color output is disabled (not running in a terminal, or color is disabled through environment variables, or--color=never
is passed), and for all programs executed through ashell
expression
Modifying the environment in recipes
Recipes may add or remove environment variables for programs executed by that recipe. Environment variables may be set or removed for a whole recipe, or within a flow of recipe commands.
Set or override an environment variable: env "MY_VAR" = "..."
Remove an environment variable, so it becomes unavailable to the child process:
env-remove "MY_VAR"
.
Setting an environment variable in a recipe does not impact the environment variables seen by its dependencies or its dependents. Only processes executed by that specific recipe will see modifications to the environment.
Debugging
_WERK_ARTIFICIAL_DELAY
: Number of milliseconds to wait between executing recipe commands. This may be used while debuggingwerk
itself, especially rendering to the CLI.
Example: C program
This example shows a very simple project compiling a C program using werk
. It
showcases using depfiles generated implicitly by clang
.
src/foo.h:
int foo();
src/foo.c:
#include "foo.h"
int foo() { return 123; }
src/main.c:
#include "foo.h"
#include <stdio.h>
int main() {
printf("foo() returned: %d\n", foo());
return 0;
}
Werkfile:
default target = "build"
# Path to clang
let cc = which "clang"
# Path to linker
let ld = cc
# Build profile (debug or release)
let profile = "debug"
# Pick cflags based on the build profile
let cflags = profile | match {
"debug" => ["-O0", "-g"]
"release" => ["-O3"]
"%" => ""
}
# Build rule for object files
build "%.o" {
from "%.c"
depfile "%.c.d"
let include-path = "src"
let flags = [cflags, "-I<include-path>"]
# Generate depfile and object file in the same command
run "{cc} -MMD -MT <in> -MF <depfile> -c {flags*} -o <out> <in>"
}
# Build rule for the main executable
build "my-program{EXE_SUFFIX}" {
# Include all .c files in the build
from glob "src/**/*.c" | map "{:.c=.o}"
run "{ld} -o <out> <in*>"
}
task build {
build "my-program{EXE_SUFFIX}"
info "Build complete!"
}
task run {
let executable = "my-program{EXE_SUFFIX}"
build executable
run "<executable>"
}
Example: Cargo project
This example shows a simple project compiling a Cargo project using werk
. It
showcases depfiles generated by Cargo, and using the same output directory as
Cargo for build artifacts.
Cargo.toml:
[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
src/main.rs:
fn main() { println!("Hello from Rust!"); }
Werkfile:
default target = "build"
default out-dir = "target"
let cargo = which "cargo"
let profile = "debug"
let cargo-profile = profile | match {
"debug" => "dev"
"%" => "%"
}
# This rule matches the output path of Cargo.
build "{profile}/test-project{EXE_SUFFIX}" {
# This file is implicitly generated by Cargo.
depfile "{profile}/test-project.d"
run "cargo build -p test-project --profile={cargo-profile}"
}
task build {
build "{profile}/test-project{EXE_SUFFIX}"
}
Example: GLSL shaders
This example shows how to build SPIR-V shaders using glslc
. It also showcases
capture groups in pattern matching, where the same build rule is used for all
three types of shaders (fragment, vertex, compute).
Additionally, this also creates an "asset pack" containing all the shaders,
using tar
.
Werkfile:
default target = "build"
let glslc = which "glslc"
let tar = which "tar"
build "%.(frag|vert|comp).spv" {
from "%.{0}"
depfile "%.{0}.d"
run "{glslc} -MD -MF <depfile> -o <out> <in>"
}
build "shaders.tar.gz" {
# Note: Using "native" glob syntax.
from glob "*.\{frag,vert,comp\}" | map "{}.spv"
run "{tar} -zcf <out> <in*>"
}
task build {
build "shaders.tar.gz"
}
Example: Cargo + WASM + Assets
This example shows a complex use case, using parts of the other examples:
- Build a "main" binary for the host architecture using Cargo.
- Build a WASM plugin targeting
wasm32-wasip2
using Cargo. - Compile shaders using
glslc
. - Compress an
.tar.gz
"asset pack" containing compiled WASM modules, compiled shaders, and PNG images.
Due to the outdatedness rules and depfile integration,
every rule accurately captures the actual dependencies of each step. For
example, changing a .glsl
file included by one of the shaders will only cause
the relevant shaders to be rebuilt, and will cause assets.tar.gz
to be
repackaged, but it will not cause WASM modules to be rebuilt. Similarly, due to
the glob patterns, adding a .png
file to the project will cause
assets.tar.gz
to be repackaged, but nothing else will be rebuilt.
Werkfile:
default target = "build"
let cargo = which "cargo"
let glslc = which "glslc"
let wasm-tuple = "wasm32-wasip2"
let profile = "debug"
let wasm-profile = "debug"
let cargo-profile = profile | match {
"debug" => "dev"
"%" => "%"
}
let cargo-wasm-profile = wasm-profile | match {
"debug" => "dev"
"%" => "%"
}
# Rule to build a WASM target with Cargo.
build "{wasm-tuple}/{wasm-profile}/%.wasm" {
# Cargo uses dashes in package names and underscores in build artifacts, so
# use a regex to replace it.
let package-name = "{%:s/_/-/}"
depfile "{wasm-tuple}/{wasm-profile}/%.d"
run "{cargo} build
--target={wasm-tuple}
--profile={cargo-wasm-profile}
-p {package-name}"
}
# Rule to build a SPIR-V shader with glslc.
build "%.(frag|vert|comp).spv" {
from "%.{0}"
depfile "%.{0}.d"
run "{glslc} -MD -MF <depfile> -o <out> <in>"
}
let wasm-targets = ["plugin1", "plugin2"]
| map "{wasm-tuple}/{wasm-profile}/{}.wasm"
build "assets.tar.gz" {
from [
wasm-targets,
glob "assets/**/*.png",
glob "shaders/**/*.\{frag,vert,comp\}" | map "{}.spv"
]
run "tar -zcf <out> <in*>"
}
# Rule to build the main program.
build "{profile}/program{EXE_SUFFIX}" {
depfile "{profile}/program.d"
run "cargo build -p program --profile={cargo-profile}"
}
# Task to build everything.
task build {
build ["{profile}/program{EXE_SUFFIX}", "assets.tar.gz"]
}
# Task that just runs `cargo clean`. This deletes `target/`, so also removes
# compiled shaders.
task clean {
run "{cargo} clean"
}