Scripting fud2 with Rhai

You can add functionality to fud2 with functionality written in Rhai. Rhai is a scripting language designed to work well with Rust.

All functionality included with fud2 is written in Rhai. They can be found here. These provide a good example of how to add states and operations with Rhai.

Loading Scripts

You can tell fud2 to load a script by including a plugins key in your fud2.toml file.

plugins = ["/my/fancy/plugin.rhai"]

[calyx]
base = "..."

Example Script in High Level Rhai

We'll walk through how to write a script that adds support for using the calyx compiler.

First, we need to define some states:

export const calyx_state = state("calyx", ["futil"]);
export const verilog_state = state("verilog", ["sv", "v"]);

These two lines define a calyx state and a verilog state. The export prefix means that these variables will be accessible to other scripts that import "calyx".

Now we will define an operation taking the calyx state to the verilog state. These operations define functions whose arguments are input files and return values are output values. Their bodies consist of commands that will transform those inputs to those outputs.

// define an op called "calyx_to_verilog" taking a "calyx_state" to a "verilog_state".
defop calyx_to_verilog(c: calyx_state) >> v: verilog_state {
    // retrieve a variable from the fud2.toml config
    let calyx_base = config("calyx.base");
    // retrieve a variable from the config, or a default value
    let calyx_exe = config_or("calyx.exe", "${calyx_base}/target/debug/calyx");
    let args = config_or("calyx.args", "");

    // specify a shell command to turn a calyx file "c" into a verilog file "v"
    shell("${calyx_exe} -l ${calyx_base} -b verilog ${args} ${c} > ${v});
}

Counterintuitively, c, v, calyx_base, calyx_exe, and args do not contain the actual variable values. They contain identifiers which are replaced by the values at runtime. For example, print(args) would print a $args instead of the value assigned by the config. An op cannot take different code paths based on config values or different input/output file names.

This example shows off nearly all of the features available for defining ops. Scripts can reuse functionality by exploiting the tools of Rhai scripting. For example, if we wanted to create a second, similar op calyx_noverify, we could factor the contents of calyx_to_verilog into a new function and call that function in both ops.

// a function constructing a shell command to take a calyx in_file to a verilog out_file
// this function adds `add_args` as extra arguments to it's call to the calyx compiler
fn calyx_cmd(in_file, out_file, add_args) {
    let calyx_base = config("calyx.base");
    let calyx_exe = config_or("calyx.exe", "${calyx_base}/target/debug/calyx");
    let args = config_or("calyx.args", "");

    shell("${calyx_exe} -l ${calyx_base} -b verilog ${args} ${add_args} ${in_file} > ${out_file});
}

// define an op called "calyx_to_verilog" taking a "calyx_state" to a "verilog_state".
defop calyx_to_verilog(c: calyx_state) >> v: verilog_state {
    calyx_cmd(c, v, "");
}

// define an op called "calyx_noverify" taking a "calyx_state" to a "verilog_state".
defop calyx_to_verilog(c: calyx_state) >> v: verilog_state {
    calyx_cmd(c, v, "--disable-verify");
}

Example Script in Low Level Rhai

fud2 generates Ninja build files. Low level Rhai gives more control over what generated build files look like.

We'll walk through how to write a script that adds support for using the calyx compiler.

Like before, we need to define some states:

export const calyx_state = state("calyx", ["futil"]);
export const verilog_state = state("verilog", ["sv", "v"]);

These two lines define a calyx state and a verilog state. The export prefix means that these variables will be accessible to other scripts that import "calyx".

Next we'll define a setup procedure to define some rules that will be useful.

// allows calyx_setup to be used in other scripts
export const calyx_setup = calyx_setup;

// a setup function is just a normal Rhai function that takes in an emitter
// we can use the emitter in the same way that we use it from rust
fn calyx_setup(e) {
   // define a Ninja var from the fud2.toml config
   e.config_var("calyx-base", "calyx.base");
   // define a Ninja var from either the config, or a default derived from calyx-base
   e.config_var_or("calyx-exe", "calyx.exe", "$calyx-base/target/debug/calyx");
   // define a Ninja var from cli options, or with a default
   e.config_var_or("args", "calyx.args", "");
   // define a rule to run the Calyx compiler
   e.rule("calyx", "$calyx-exe -l $calyx-base -b $backend $args $in > $out");
}

And now we can define the actual operation that will transform calyx files into verilog files.

op(
  "calyx-to-verilog",      // operation name
  [calyx_setup],           // required setup functions
  calyx_state,             // input state
  verilog_state,           // output state
  |e, input, output| {     // function to construct Ninja build command
    e.build_cmd([output], "calyx", [input], []) ;
    e.arg("backend", "verilog");
  }
);

Rhai Specifics

String Templates

Rhai has a string templating feature, similar to the format! macro in rust. Templated strings are marked with backticks (`path/${some_var}.ext`) and variables are included with $. You can include expressions that will be evaluated by using brackets: ${1 + 2}.

String Functions

Rhai includes standard string operations. They are described in the documentation. These are useful for constructing more complicated paths.

Export Rules

In Rhai, all top-level variable declarations are private by default. If you want them to be available from other files, you need to export them explicitly.

All functions are exported by default. However, they are only exported in a callable format. If you want to use the function as a variable (when passing them as a setup function or build function), you need to export them explicitly as well.

This is how that looks:

export const my_fancy_setup = my_fancy_setup;
fn my_fancy_setup(e) {
   ...
}

Imports

You can import another Rhai script file like so:

import "calyx" as c;

All exported symbols defined in calyx.rhai will be available under c.

print(c::calyx_state);
print(c::calyx_setup);

The name for an import is always just the basename of the script file, without any extension.

API

High Level Rhai

defop

defop <op name>(<input1>: <input1 state>, <input2>: <input2 state> ...) >> <target1>: <target1 state>, <target2>, <target2 state> ... {
    <statements>
}

Defines an op with <op name> which runs <statements> to generate target states from input states.

The name, inputs, and targets are called the signature of the op. <statements> is called the body of the op.

shell

shell(<string>)

When called in the body of an op, that op will run <string> as a shell command to generate its targets. It is an error to call shell outside of the body of an op. Additionally, it is an error to call shell in the body of an op in which shell_deps was called prior.

In the generated Ninja code, shell will create both a rule wrapping the shell command and a build command that invokes that rule. When a defop contains multiple shell commands, fud2 automatically generates Ninja dependencies among the build command to ensure they run in order.

shell_deps

shell_deps(<string>, [<dep1>, <dep2>, ...], [<target1>, <target2>, ..])

When called in the body of an op, that op will run <string> as a shell command if it needs to generate <target1>, <target2>, ... from <dep1>, <dep2>, .... It is an error to call shell_deps outside of the body of an op. Additionally, it is an error to call shell_deps in the body of an op in which shell was called prior.

Targets and deps are either strings, such as "file1", or identifiers, such as if c: calyx existed in the signature of an op then c could be used as a target or dep.

A call to shell_deps corresponds directly to a Ninja rule in the Ninja file generated by fud2.

Low Level Rhai

Currently, the Rhai API is almost identical to the Rust API. However Emitter::add_file is not currently supported. And Emitter::var is renamed to _var because var is a reserved keyword in Rhai.

Adding to the API

If there is something that is hard to do in Rhai, it is straightforward to register a Rust function so that it is available from Rhai.

Rust functions are registered in ScriptRunner::new. Refer to ScriptRunner::reg_get_state to see a simple example of how to register a function.