Creating a CLI with Reason native

Posted 12.02.2019 ยท 8 min read

Recently 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 pesy
mkdir calc && cd calc
pesy

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.opam
dune
dune-project
package.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.