Creating a CLI with Reason native
Posted 12.02.2019 ยท 8 min readRecently I have started to look into the native compilation of Reason. There are many use cases for compiling to native, e.g. fast CLIs, react based ui development without a browser runtime like electron or using Reason for the backend of a project. I am writing this to gather some getting started info as I go and will try to keep things up to date as I learn more.
The goal of this blogpost is to create a simple CLI for basic calculation. Not the most useful CLI tool, but a small thing to get started. The post might make more sense if you have a little bit prior knowledge about Reason. However, don't be afraid to try setting this up, but the post does not explain Reason syntax. The reason documentation and the egghead.io course getting started with reason are good resources.
We are going to use a few different tools.
- Esy helps us manage dependencies and package our code based on package.json.
- Pesy is a tool that has two useful features that we will leverage: creating esy projects and configuring dune.
- Dune is a build system for ocaml and reason. It will take care of compiling and running our code in development.
- reason native is a collection of libraries for creating natively compiled applications.
Setting up the project
Firstly, we need to setup the project with some basic build tools.
npm install -g esy pesymkdir calc && cd calcpesy
This will create a file structure like the list below. There will be more
files, however, these are the most relevant ones. The folders executable
,
library
and test
are different source directories. dune-project
and
dune
are config files for dune. Those are automatically maintained by pesy
so we do not need to edit those manually. The calc.opam
is the config
file for the ocaml package manager, kind of like package.json is for npm. At
last this project also have a package.json, which is used to specify
dependencies for esy and the build configuration for pesy.
executable/library/test/calc.opamdunedune-projectpackage.json
If we look into the package.json it has some configuration for both esy and pesy:
{ "esy": { "build": "pesy", "release": { "releasedBinaries": ["CalcApp.exe"] } }, "buildDirs": { "test": { "require": ["Calc.lib"], "main": "TestCalc", "name": "TestCalc.exe" }, "library": { "name": "Calc.lib", "namespace": "Calc" }, "executable": { "require": ["Calc.lib"], "main": "CalcApp", "name": "CalcApp.exe" } }}
The easy configuration needs to know how we build things and what we want to
export out of our package since it is handling packaging. Pesy is using the
buildDirs
setting for its config. Each directory that we are using in for
code in the project needs to have a set of settings. Mainly name and
dependencies,
full list of supported config can be found in the pesy readme.
The buildDirs
config in packages.json is the baseline for the config written
into dune files. Thus, every time we update the buildDirs
config we need to run
esy pesy
to update the dune config files. Dune is the tool building the project and
running the ocaml compiler. We run that with esy build
and after building the project
we can run the executable with esy x CalcApp.exe
.
In order to build a release binary of the CLI we can run esy npm-release
, which will
create a folder _release
. It will contain a bin folder with binary and a package.json
for npm publishing and a postinstall script provided by pesy. In order to publish to npm
run esy npm-release
and then npm publish
from the _release folder.
Implementing the CLI
Now that the project build tools are set up and configured we are ready to start the implmentation. Firstly, we need to add some dependencies by running.
esy add @reason-native/pastel @opam/cmdliner
This will install reason native pastel from npm and cmdliner from opam, the
ocaml package manager. Pastel is helpful changing the font-style and colors on the
terminal output. Cmdliner is a tool to parse command line arguments and generate
documentation for command line tools. Furthermore, we need to add these to the libs
sections of the executable package in the buildDirs
config. The result should be:
"executable": { "require": [ "cmdliner", "pastel.lib", "calc.lib" ], "main": "CalcApp", "name": "CalcApp.exe"}
The first thing we will add is a default command and run eval with cmdliner. This
code should be in ./executable/CalcApp.re
. The
following code will make the default command list the help screen generated by
cmdliner.
open Cmdliner;let version = "1.0.0"
let default = ( Term.(ret(const(_ => `Help((`Pager, None))) $ const())), Term.info("calc", ~version),);
let _ = Term.eval_choice(default, []) |> Term.exit;
The empty list in eval_choice
is where the sub commands should be added.
let add = { let x = Arg.(value & pos(0, int, 0) & info([], ~docv="number", ~doc="First number")); let y = Arg.(value & pos(1, int, 0) & info([], ~docv="number", ~doc="Second number")); ( Term.(const((x, y) => print_result(x + y)) $ x $ y), Term.info("add", ~version), );};
In this part we define a new command add
which takes two positional
arguments. Two numbers that we will add together. Let us break down the
different parts of the argument definition. In
Arg.(value & pos(0, int, 0) & info([], ~docv="number", ~doc="First number"))
there is three main parts. value
tells that we want to extract the value, not
just whether it is there(flag
). pos(0, int, 0)
is to instruct that this is
the positional argument at position 0 parsed as an integer with 0 as the default
value. info([], ~docv="number", ~doc="First number"))
is to generate the
documentation about the option. The empty list would contain the name if this was
an option, docv
is for the usage string while doc
is for the man page.
Furthermore, in the creation of the command we have the statement.
Term.(const((x, y) => print_result(x + y)) $ x $ y)
which
defines that the command should run the function
(x, y) => print_result(x + y)
when called. It knows about
the arguments from the $ x $ y
which applies the argument definition to
the command. If you try to remove $ y
you will get a compile error
complaining that at some place unit
was expected but int => unit
was
received. That is because the function in the command takes in both x
and y
, but only the argument definition for x
is applied.
In the command above we have used a function print_result
that does
not exist. This is a nice use case for pastel, which
is a library to handle styling of printed text in the terminal. Below is
an implementation of the print_result
function.
let print_result = result => Pastel.( <Pastel> <Pastel color=BlackBright> "Result: " </Pastel> <Pastel bold=true> {string_of_int(result)} </Pastel> </Pastel> ) |> print_endline;
It will first create a string with the correct formatting, in other words the
<Pastel></Pastel>
evaluates to a string. The string is then passed along to
print_endline
from ocaml.
In order to use the add command, it has to be added to the command list so
change the eval_choice
line to:
let _ = Term.eval_choice(default, [add]) |> Term.exit;
In the code above the addition implementation is within the task, for a simple
CLI one might argue that it is fine. However, if it was more than a one line it
probably should be moved out. A nice side effect of moving it is that it becomes
easier to test. In the buildDirs
config in package.json there is this library
folder, which is a dependency of the executable part. Create a Calculator.re
in that folder.
In Calculator.re
will contain the calculation logic. In this example this will
be a small module only to demonstrate the relation between library and executable
folder in the default pesy layout. Add the following function to the `Calculator.re``
file:
let add = (x, y) => x + y
It is possible to use library modules inside the executable folder by referring
to the namespace in the package.json config for the library, that was named
Calc
. If we want to use the add function we added we can use Calc.Calculator.add
or do open Calc
and then Calculator.add
. This means that the task definition can
be changed to the following:
open Calc;let add = { let x = Arg.(value & pos(0, int, 0) & info([], ~docv="number", ~doc="First number")); let y = Arg.(value & pos(1, int, 0) & info([], ~docv="number", ~doc="Second number")); ( Term.(const((x, y) => print_result(Calculator.add(x, y))) $ x $ y), Term.info("add", ~version), );};
Try to run esy x CalcApp.exe add 2 3
it should print the result and it should
be something like the output below. ๐
Result: 5
Congratulations on making a CLI with Reason. Next steps might be to look at rely to create some tests for the library code. I have made a companion repository for this post on github.com/relekang/reason-calculator if you get stuck on something you can have a look there or if you just want to have a look at the code.