Part 2 - Plugin Anatomy
Today we’ll be looking at a sample plugin implementation, covering the various callbacks we might want to expose and looking at some of the rebar internals in the process.
The accompanying source code can be found in the plugin-anatomy-local branch of the main git repository.
Introducing the foo-bar
plugin
In this post, we’ll develop an illustrative plugin that exports two new
commands to rebar, foo
and bar
. To keep things simple, the commands won’t
do much apart from printing to the console and maybe writing to the file
system. We’ll also keep the packaging simple, by storing the plugin in the root
directory of the sample project.
For any project to be built by rebar, it needs an .app.src
file, so we’ll
generate one using rebar’s create-app
command:
t4@malachi:rebar-plugin-tutorial $ rebar create-app appid=plugin_anatomy_local
==> rebar-plugin-tutorial (create-app)
Writing src/plugin_anatomy_local.app.src
Writing src/plugin_anatomy_local_app.erl
Writing src/plugin_anatomy_local_sup.erl
t4@malachi:rebar-plugin-tutorial $ rm src/plugin_anatomy_local_*
t4@malachi:rebar-plugin-tutorial $
Once the initial project structure is in place - note that we removed any extraneous modules from the source directory - we can test out our plugin:
t4@malachi:rebar-plugin-tutorial $ echo "-module(foobar_plugin)." >> foobar_plugin.erl
t4@malachi:rebar-plugin-tutorial $ echo "-compile(export_all)." >> foobar_plugin.erl
t4@malachi:rebar-plugin-tutorial $ echo 'foo(_, _) -> io:format("foo!~n").' >> foobar_plugin.erl
t4@malachi:rebar-plugin-tutorial $ echo "{plugins, [foobar_plugin]}." >> rebar.config
t4@malachi:rebar-plugin-tutorial $ rebar foo -v
DEBUG: Rebar location: "/Users/t4/bin/rebar"
DEBUG: Load global config file "/Users/t4/.rebar/config"
DEBUG: Consult config file "/Users/t4/.rebar/config"
DEBUG: Consult config file "/Users/t4/work/hyperthunk/rebar-plugin-tutorial/rebar.config"
DEBUG: Entering /Users/t4/work/hyperthunk/rebar-plugin-tutorial
DEBUG: Available deps: []
DEBUG: Missing deps : []
INFO: Loading plugin foobar_plugin from /Users/t4/work/hyperthunk/rebar-plugin-tutorial/foobar_plugin.erl
DEBUG: Predirs: []
==> rebar-plugin-tutorial (foo)
foo!
DEBUG: Postdirs: []
t4@malachi:rebar-plugin-tutorial $
By running the foo
command with -v
, we’re treated to a slew of (often quite
useful) debugging information.
It’s clear from this that our plugin was indeed loaded from the file system,
as we expected. The astute reader may have also noticed that the foo! text’s
appearance in the output is lacking the logging prefixes (such as DEBUG:
,
INFO:
and so on) present in many of the other log lines. If you want
generate log messages such as these, rather than just writing to stdout, you’ll
need to use the rebar_log
module instead. Let’s alter our code to do just
that.
1 2 3 4 5 |
|
Now let’s try it out on the command line:
t4@malachi:rebar-plugin-tutorial $ rebar foo
==> rebar-plugin-tutorial (foo)
t4@malachi:rebar-plugin-tutorial $
And there’s nothing there. Because rebar’s default log level is set to error
in the rebar.app
configuration file, nothing below that level is printed out
unless the -v
switch is given:
t4@malachi:rebar-plugin-tutorial $ rebar foo -v
DEBUG: Rebar location: "/Users/t4/bin/rebar"
DEBUG: Load global config file "/Users/t4/.rebar/config"
DEBUG: Consult config file "/Users/t4/.rebar/config"
DEBUG: Consult config file "/Users/t4/work/hyperthunk/rebar-plugin-tutorial/rebar.config"
DEBUG: Entering /Users/t4/work/hyperthunk/rebar-plugin-tutorial
DEBUG: Available deps: []
DEBUG: Missing deps : []
INFO: Loading plugin foobar_plugin from /Users/t4/work/hyperthunk/rebar-plugin-tutorial/foobar_plugin.erl
DEBUG: Predirs: []
==> rebar-plugin-tutorial (foo)
WARN: foo!
DEBUG: Postdirs: []
t4@malachi:rebar-plugin-tutorial $
Our call to rebar_log
takes the desired log level as an argument; We can
pass one of debug, info, warn or error. Log messages at error
level always
appear. If you want to change the default log level, you’ll need to rebuild
your rebar binary with a slight modification to the rebar.app
config file.
We’ll be talking about managing custom rebar forks later on in the series.
Handling Errors
Now that we’ve implemented the foo
command, let’s take a look at implementing
bar
. This command will simply keep track of how many times it has been run.
To do this, we’ll keep the count in a file stored in a subdirectory of the
project: /build/foo.count
. Assuming the file exists, we read the count out
of the file and increment it by one, overwriting the file to store the result.
1 2 3 4 5 6 7 8 |
|
When we run this, it goes bang very nicely…
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
ERROR: bar failed while processing /Users/t4/work/hyperthunk/rebar-plugin-tutorial: {'EXIT',,
[{foobar_plugin,bar,2},
{rebar_core,run_modules,4},
{rebar_core,execute,4},
{rebar_core,process_dir0,6},
{rebar_core,process_commands,2},
{rebar,main,1},
{escript,run,2},
{escript,start,1}]}}
t4@malachi:rebar-plugin-tutorial $
Our deliberate oversight illustrates an important point: rebar does not handle exceptions apart from wrapping them up into a formatted error message. If your code crashes while it’s executing, the error will be printed to the console and the vm will subsequently exit (with a well behaved non-zero exit status).
We don’t see a line number in the exception (using R14) but {error, enoent}
is a common return value in the file
module, indicating that something (in
this case, a file we’re trying to open) is not present. It was fairly obvious
the we need to check whether the file exists before attempting to open it, so
let’s try again.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
And now we get the desired result:
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
Bar command has been run 1 times
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
Bar command has been run 2 times
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
Bar command has been run 3 times
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
Bar command has been run 4 times
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
Bar command has been run 5 times
t4@malachi:rebar-plugin-tutorial $
Notice the call to rebar_utils:ensure_dir/1
, which ensures the directory
exists prior to writing the file. Another thing to take note of is that plugin
commands need to return the atom ok
if they have succeeded, otherwise rebar
will stop executing the current command and halt the vm with an error report.
The relevant code (in rebar_core
) looks something like this:
1 2 3 4 5 6 7 |
|
The call to file:write_file/3
returns ok
on success and {error, Reason}
on error, so we don’t need to handle the return value explicitly at all. In
general though, you need to keep this in mind when implementing new commands.
Everybody wants to go to the party. Nobody wants to stay and clean up.
Let’s implement a mechanism to reset the count for our command. We could create
a new reset-bar
command, but hooking into the existing clean
command is
probably more intuitive. All we need do is delete the bar.count
file:
1 2 3 4 5 6 7 8 9 |
|
This works fine at first, but fails if we try to run clean
twice:
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
Bar command has been run 2 times
t4@malachi:rebar-plugin-tutorial $ rebar clean
==> rebar-plugin-tutorial (clean)
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> rebar-plugin-tutorial (bar)
Bar command has been run 1 times
t4@malachi:rebar-plugin-tutorial $ rebar clean
==> rebar-plugin-tutorial (clean)
t4@malachi:rebar-plugin-tutorial $ rebar clean
==> rebar-plugin-tutorial (clean)
ERROR: clean failed while processing /Users/t4/work/hyperthunk/rebar-plugin-tutorial in module foobar_plugin: {error,enoent}
t4@malachi:rebar-plugin-tutorial $
Once again, the requirement for command return values to be ok
has bitten us.
Our call to file:delete/1
is returning an error tuple because the file isn’t
there after running clean
. The file won’t be there the first time clean
is
run either, unless bar
has been run first. We’ll tidy this up by delegating
to a rebar utility module, which handles the whole thing.
1 2 3 4 |
|
t4@malachi:rebar-plugin-tutorial $ rebar clean
==> rebar-plugin-tutorial (clean)
t4@malachi:rebar-plugin-tutorial $ rebar clean
==> rebar-plugin-tutorial (clean)
t4@malachi:rebar-plugin-tutorial $ rebar clean
==> rebar-plugin-tutorial (clean)
t4@malachi:rebar-plugin-tutorial $
Much better.
Running in base_dir
Let’s see what happens when we throw in some dependencies to our
rebar.config
.
1 2 3 4 5 |
|
t4@malachi:rebar-plugin-tutorial $ rebar get-deps
==> rebar-plugin-tutorial (get-deps)
Pulling hamcrest from {git,"git@github.com:hyperthunk/hamcrest-erlang.git"}
Initialized empty Git repository in /Users/t4/work/hyperthunk/rebar-plugin-tutorial/deps/hamcrest/.git/
Failed to add the RSA host key for IP address '207.97.227.239' to the list of known hosts (/Users/t4/.ssh/known_hosts).
==> hamcrest (get-deps)
Pulling proper from {git,"http://github.com/manopapad/proper.git","master"}
Initialized empty Git repository in /Users/t4/work/hyperthunk/rebar-plugin-tutorial/deps/proper/.git/
==> proper (get-deps)
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> proper (bar)
Bar command has been run 1 times
==> hamcrest (bar)
Bar command has been run 1 times
==> rebar-plugin-tutorial (bar)
Bar command has been run 1 times
t4@malachi:rebar-plugin-tutorial $
Our bar
command will be run for each of the dependencies as well as the
root project. Whilst this isn’t particularly problematic for this trivial
command, more complex plugins may break (or do bad things to the dependencies)
when executed in this manner. The
nodewatch project for example, has a
folder named build
as part of it’s existing directory structure, so the
removal of this directory during clean
will effectively break the build for
that dependency.
You can avoid running commands in dependency dirs by passing skip_deps=true
on the command line, but this can make trivial build commands far more complex
for the user, forcing them to build your code in various steps, some with
skip_deps=true
and some without. The ultimate evidence of this problem is the
presence of a Makefile wrapper around your use of rebar.
It is generally better to make sure that plugins only run when and where
they’re supposed to and we can achieve this for our plugin using rebar’s
base_dir
global configuration property, which points to the top level
directory - i.e., the first directory in which the program was executed.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
After making this change, we should see rebar still entering the dependency
directories and executing the command. What we’re doing here is simply skipping
the processing (in our code), so the user doesn’t have to think about
skip_deps
and the like.
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> proper (bar)
==> hamcrest (bar)
==> rebar-plugin-tutorial (bar)
Bar command has been run 1 times
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> proper (bar)
==> hamcrest (bar)
==> rebar-plugin-tutorial (bar)
Bar command has been run 2 times
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> proper (bar)
==> hamcrest (bar)
==> rebar-plugin-tutorial (bar)
Bar command has been run 3 times
t4@malachi:rebar-plugin-tutorial $ rebar clean
==> proper (clean)
==> hamcrest (clean)
==> rebar-plugin-tutorial (clean)
t4@malachi:rebar-plugin-tutorial $ rebar bar
==> proper (bar)
==> hamcrest (bar)
==> rebar-plugin-tutorial (bar)
Bar command has been run 1 times
t4@malachi:rebar-plugin-tutorial $
Next timeā¦
We’ll continue next time by looking at plugin pre and post command hooks as well as how to package and reuse external plugins.