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!"