1.1 Concepts

1.1.1 Conditions and preconditions

Condition is the basic building block of ANVL plugins and projects. In a very abstract sense, ANVL condition is a specially implemented Erlang function that ensures that state of the system satisfies certain criteria, for example:

Satisfying a condition means checking whether it already holds for the system, and running a subroutine transferring it to the desired state otherwise.

Conditions are defined using MEMO macro, which stands for “memoize”. It has the following syntax:

?MEMO(condition_name, Arg1, Arg2, ..., ArgN,
      begin
         <Body>
      end).

Body of the condition can contain arbitrary Erlang code, that has the following properties:

  1. It returns a boolean, indicating whether or not it has produced side effects changing the system.

    It returns false if the system state already satisfies the expectations, and no changes were made. Otherwise, it can run a subroutine that transfers the system into the expected state. When it succeeds, boolean true is returned, indicating presence of side effects.

  2. It throws an exception when the system cannot be transferred into the desired state for any reason.

    The recommended way to raise an exception is via ?UNSAT(FormatString, [FormatArgs...]) macro.

Conditions are memoized: ANVL guarantees that body of the condition is executed at most once for each unique set of arguments. It means, for example, that if compilation of some module is required by multiple tasks, the module will be recompiled only once.

By default, all conditions are executed concurrently.

Conditions can depend on each other. Such dependencies are called preconditions.

ANVL doesn’t require a preliminary scheduling stage to build and analyze the dependency graph. Instead, any condition can invoke any precondition at any time, making the system very flexible. Precondition dependency graph is updated dynamically when a condition calls a built-in precondition function, as demonstrated by the example below:

?MEMO(foo, Arg,
      begin
        is_foo_changed(Arg) andalso
          begin
            rebuild_foo(Arg),
            true
          end
      end).

?MEMO(bar, N,
      begin
        precondition([foo(I) || I <- lists:seq(1, N)])
      end).

Note that with an exception of ?MEMO macro used to define functions foo and bar, bodies of conditions use standard Erlang.

ANVL is optimized for the happy path: problems with the dependency graph (loops, missing dependencies) are detected asynchronously, when ANVL recognizes that the system cannot make further progress. When this happens, ANVL halts and dumps the dependency graph for debugging. Since this situation should not occur under normal circumstances, late detection a reasonable trade-off.

1.1.2 Projects

ANVL project is a directory containing anvl.erl file, also known as the project configuration file.

anvl.erl is a regular Erlang module, with one notable exception: it must not contain -module attribute (and if such exists, it is ignored). That is because ANVL can work with multiple projects simultaneously, and to achieve that, it names project configuration modules automatically. Because of that, it is not allowed to use anvl:Fun(...) or fun anvl:Fun/N syntax to refer to local functions. If needed, fun ?MODULE:Fun/N syntax can be used instead. Other than that, anvl.erl is compiled and loaded as a normal Erlang module.

Project configuration modules implement the anvl_project behavior.

Project referred to by -d CLI argument is called the root project. Project can refer to other projects, known as child projects.

1.1.3 Project Configuration

The best way to illustrate the structure of an ANVL project configuration file is to take a look at an abridged anvl.erl that ANVL uses to build itself:

-include("anvl.hrl").

%% Project configuration:
conf() ->
  EmuArgs = "-dist_listen false -escript main anvl_app",
  #{ plugins => [anvl_erlc, anvl_texinfo]
   , conditions => [install, static_checks, escript]
   , erlang =>
       #{ app_paths =>
            ["${app}", "."]
        , escript =>
            [ #{ name => anvl
               , emu_args => EmuArgs
               , apps => [lee, typerefl | apps()]
               }
            ]
        }
   , [deps, local] =>
       [#{ dir => "vendor/*"
         , kind => otp_application
         }]
   }.

%% Condition: ANVL is built and installed in the system:
?MEMO(install,
      begin
        Prefix = filename:join(os:getenv("HOME"), ".local"),
        precondition([escript()]) or
          install(Prefix, "${prefix}/bin/anvl", anvl_fn:rootdir(["anvl"]), 8#755)
      end).

%% Condition: ANVL executable is built
%% via library condition:
%%  module:
escript() ->
  anvl_erlc_escript:created(anvl_project:root(), anvl).

%% Condition: code passed static checks
?MEMO(static_checks,
      precondition(
        [ anvl_erlc_xref:passed(default)
        , anvl_erlc_dialyzer:passed(default)
        ])).

%% A regular helper function
install(Prefix, Template, Src, Mode) ->
  Dest = patsubst(Template, Src, #{prefix => Prefix}),
  newer(Src, Dest) andalso
    begin
      {ok, _} = file:copy(Src, Dest),
      ok = file:change_mode(Dest, Mode),
      true
    end.

%% List of applications and plugins included in the distribution:
apps() ->
  [anvl_core, anvl_erlc, anvl_git, anvl_texinfo, anvl_hex_pm, anvl_rebar3].

Let’s break it apart:

conf/0 callback returns project configuration as a tree of nested Erlang maps.

Note: notation #{[foo, bar, ...] => quux} is a shortcut for #{foo => #{bar => #{... => quux ...}}}.

The configuration of any project includes two standard options:

Groups of values can be repeated in the configuration, as seen in the case of [deps, local] above. In this case each configuration sub-tree appears as an element of a list. Another example could look like this:

  #{ [deps, git] =>
       [ #{ id => my_awesome_dependency
          , repo => "https://git.savannah.gnu.org/git/my_awesome_dependency.git"
          , ref => {branch, "master"}
          }
       , #{ id => another_dependency
          , repo => "https://git.savannah.gnu.org/git/another_dependency.git"
          , ref => {tag, "1.0.0"}
          }
       ]
  }

Project configuration is statically checked against the plugins’ schema. ANVL reports readable errors to the user when necessary configuration parameters are missing or wrongly typed.

Another feature of the ANVL configuration file is the ability to define custom logic in place, as seen with conditions like static_checks/0 and helper functions like install/4.

1.1.4 Project Configuration Overrides

conf_override/1 callback of the root project module allows to override configuration of any child project.

The return value is a configuration patch: a list of operations applied to the child configuration. There are two operations: {set, Key, Value}, that replaces the value associated with the key, and {rm, Key}, that resets the value associated with the key to the default or deletes it.

conf_override(ChildProject) ->
  case filename:basename(anvl_project:dir(ChildProject)) of
    "some_project" ->
      %% If project is located in a directory ending with "some_project",
      %% apply an override:
      [ {set, [erlang, bdeps], [some_app]}
      , {rm, [erlang, escript, {some_escript}]}
      , ...
      ];
    _ ->
      []
  end.

1.1.5 Plugin

A plugin is an Erlang/OTP application containing a module that implements the anvl_plugin behavior.

ANVL comes with a number of built-in plugins, sufficient for bootstrapping other plugins:

anvl_core application provides functionality that allows projects to load the plugins on demand, deals with configuration, CLI interface, documentation, etc. It also contains a number of utility functions to aid plugin development.

Plugin configuration is split into two parts:

  1. Tool configuration, that is controlled by the user running anvl command, and can be set via CLI arguments and environment variables. Tool configuration is site-specific and global.
  2. Project Configuration, that is set by ANVL projects via anvl_project:conf/0 callback. ANVL keeps such configurations separate for each project. The root project can override configuration of its dependencies.

Note: since ANVL is implemented in Erlang, most built-in plugins are in some way related to it. But that does not mean that ANVL’s purpose is limited to building Erlang code, or building code. We’ll illustrate it in the next section:

1.1.6 Example: building C code

Let us demonstrate that this rather abstract approach is sufficient to replace a traditional build system. In this example we’ll define an ANVL project compiling C code into an executable.

First, let’s declare a condition source_compiled, that is satisfied when the object file exists and is newer than the source file or after compiling the source file with GCC.

-include("anvl.hrl").

?MEMO(source_compiled, Src, Obj,
      begin
        newer(Src, Obj) andalso
          anvl_lib:exec("gcc", ["-c", "-o", Obj, Src])
      end).

ANVL framework does not assume anything about the nature of conditions, it only concerns with the presence of side effects. Here, newer is a library function that compares modification times of the files (similar to make), but the user is free to implement any change detection, for example using hash comparison.

Next, let’s define a condition that ensures that the target executable is built and up-to-date:

?MEMO(executable_built,
      begin
        Executable = "build/hello",
        Sources = filelib:wildcard("c_src/*.c"),
        Objs = lists:map(fun obj_name/1, Sources),
        precondition([source_compiled(Src, obj_name(Src)) || Src <- Sources]) or
          newer(Objs, Executable) andalso
          anvl_lib:exec("gcc", ["-o", Executable | Objs])
      end).

obj_name(Src) ->
  patsubst("build/${basename}.o", Src).

Finally, setting conditions project configuration will let ANVL framework know that executable_built/0 function is a condition that can be invoked by the user:

conf() ->
  #{ conditions => [executable_built]
   }.

Now running anvl executable_built command in the project directory will satisfy the condition with the same name. (Running anvl without arguments will satisfy the first condition in the list.)

While this may look verbose at first, it opens up some possibilities. Using a full-fledged programming language to define conditions gives access to proper data structures and algorithms, and allows to encapsulate build rules into reusable modules with well-defined interfaces.

1.1.7 ANVL versus traditional build systems

A traditional build system is a program that automatically solves the following problems:

Typical build systems solve this problem using concepts of source, target and rule. Source and target are (usually) file names, and rule is a subroutine that takes the source files and produces the target file(s). Most build systems have a standard way of checking whether the target has to be rebuilt, based on comparison of file modification times or hashes. Dependencies between the targets are encoded in the build recipe as a graph. This way of doing things works very well as long as build system only works with files, but falls short when it’s no longer the case.

ANVL’s conditions, in contrast, are not tied to the file system or anything concrete.

Additionally, traditional build systems usually mix several concepts together:

In contrast, ANVL presents these as independent library primitives.


JavaScript license information