Introduction

Werk is early alpha software. Use at your own risk. It may eat your files, so run 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

  1. git clone https://github.com/simonask/werk
  2. cd werk
  3. 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 per werk 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 the globset crate, which comes from ripgrep.

  • 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 a depfile dependency, it is automatically built and included when evaluating the dependencies of that recipe.

  • .gitignore support: The ignore crate is used to hide files from werk.

  • 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 (like sh or bash), 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 use match expressions to generate the correct compiler arguments based on the captured pattern {0}, which will evaluate to either "c" or "cpp".
  • 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 generated parser.h file, and add another recipe for the parser.c file that has no commands, but just depends on parser.h.
  • 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 all Werkfiles 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 to c:\workspace\main.c because it exists in the workspace.
  • /foo.c will resolve to c:\workspace\main.c because it exists in the workspace.
  • /main.o will resolve to c:\workspace\output\main.o because it does not exist in the workspace.
  • /foo.o will resolve to c:\workspace\output\foo.o because it does not exist in the workspace.
  • /other.c will resolve to c:\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 and warn 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 to werk 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 and gcc 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, 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 and gcc
  • slangc
  • glslc
  • cargo

Build configuration

Configure how werk runs

The behavior of the werk command can be configured in two ways:

  1. Command-line arguments
  2. 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 and build 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:

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 a which 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 matching regex with replacement. The regex is passed verbatim to the regex 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 to c:\workspace\foo.txt, because foo.txt exists in the workspace.
  • "<output>" resolves to c:\workspace\target\bar.txt, because bar.txt does not exist in the workspace.
  • "<input:out-dir>" resolves to c:\workspace\target\foo.txt, because it is explicitly requested.
  • "<output:workspace>" resolves to c:\workspace\bar.txt, because it is explicitly requested, even though the file does not exist in the workspace.
  • "<dir>" resolves to c:\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 and output-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 either a or b.

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 is foo, and capture group 0 is c.
  • The string "foo/bar/baz.cpp" will match. The stem is foo/bar/baz, and capture group 0 is cpp.
  • 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 is foo.
  • "foo/foo/a.c": The pattern foo/%/a.c will be chosen over %.c and %/a.c, because it produces a shorter stem. The stem is foo.
  • "foo/bar/a.c": The pattern foo/bar/a.c will be chosen over foo/%/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": Invoke command 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), the which 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 whiched, 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 the from statement)
  • out, or {@} in strings: Output files (the path of the actual file being built by the recipe).
  • depfile: If the recipe has a depfile 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
  • OS_FAMILY: Classification of the host operating system.
    • Windows: windows
    • Linux, macOS, and BSDs: unix
    • WebAssembly (WASIp2): wasm
  • ARCH: Name of the host architecture.
    • x86_64 / x64: x86_64
    • x86: x86
    • ARM (64-bit): aarch64
    • ARM (32-bit): arm
    • WebAssembly: wasm
  • ARCH_FAMILY: Classification of the host architecture.
    • x86, x86_64: x86
    • ARM: arm
    • WebAssembly: wasm
  • EXE_SUFFIX:
    • Windows: .exe
    • Other: empty
  • DYLIB_PREFIX:
    • Windows: empty
    • Linux and macOS: lib
  • DYLIB_SUFFIX:
    • Windows: .dll
    • Linux: .so
    • macOS: .dylib
  • STATICLIB_PREFIX:
    • Windows: empty
    • Linux and macOS: lib
  • STATICLIB_SUFFIX:
    • Windows: .lib
    • Linux and macOS: .a
  • EMPTY: Always the empty string
  • COLOR: When color output is enabled for werk, this is set to "1". This may be used to conditionally pass command-line arguments to compilers that don't respect the conventional CLICOLOR 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

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, and CLICOLOR_FORCE.
  • CLICOLOR_FORCE: When set, force-enable color output, same as --color=always. Overridden by NO_COLOR and --color=never.

Environment variables set by Werk

  • CLICOLOR, CLICOLOR_FORCE, FORCE_COLOR: These are set for programs executed by werk 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 a shell expression never have color enabled.
  • NO_COLOR: This is set for programs executed by werk 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 a shell 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 debugging werk 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"
}