loading...

Native CLI Apps in ReasonML: Part 1

citizen428 profile image Michael Kohl Originally published at citizen428.net on ・5 min read

ReasonML — Reason for short — is an alternative syntax and toolchain for OCaml, a mature functional programming language. It was originally developed by Jordan Walke at Facebook and can target both JavaScript (via BuckleScript) and native code (via Reason Native). Its goal is to make OCaml more accessible to JS developers, both in syntactically but also when it comes to tooling. In this post we'll explore how to write native CLI apps in Reason.

App Scaffolding

One of the easiest ways to create new Reason/OCaml projects is the scaffolding tool Spin (full disclosure, I'm a contributor), which provides templates for various use cases. Let's create a new project from the cli template:

$ spin new native dev-cli

This command created the following structure for us:

$ tree -L 2
.
├── LICENSE
├── README.md
├── _esy
│   └── default
├── bin
│   ├── dev_cli_app.re
│   └── dune
├── dev-cli.install
├── dev-cli.opam
├── dune-project
├── esy.lock
│   ├── index.json
│   ├── opam
│   └── overrides
├── lib
│   ├── dune
│   ├── utils.re
│   └── utils.rei
├── node_modules
├── package.json
├── test
│   ├── _snapshots
│   ├── dune
│   ├── support
│   └── utils_test.re
└── test_runner
    ├── dune
    └── test_runner_app.re

12 directories, 16 files

Observant readers will have noticed the package.json file and corresponding node_modules folder. These are used by esy, a package manager for native Reason/OCaml projects. The template also includes the Jest-inspired Rely testing tool, which offers a familiar unit testing experience for JS developers.

The main entry point to our application is in bin/dev_cli_app.re:

open Dev_cli;

/** Main entry point for our application. */

let () = Stdio.Out_channel.print_endline @@ Utils.hello();

This just calls Utils.hello, prints its result to the screen and then exits. Note that @@ is the application operator provided by Jane Street's Core standard library. It's a common idiom to reduce nesting of expressions, so the above is equivalent to

let () = Stdio.Out_channel.print_endline(Utils.hello());

Utils.hello lives in lib/utils.re and returns the customary "Hello World" greeting:

let hello = () => "Hello World";

We can run this with esy start, which will compile and execute our program:

$ esy start
Hello World

This is really just a shortcut for esy x dev-cli.exe, defined in the scripts section of our package.json file. Inspecting the build artifact shows that it is indeed a native macOS application:

$ file _esy/default/build/default/bin/dev_cli_app.exe
_esy/default/build/default/bin/dev_cli_app.exe: Mach-O 64-bit executable x86_64

Adding Some Color

Given that Jordan Walke is not only the creator of Reason but also of React, it may not come as a complete surprise that the former also has a built-in JSX syntax. This is used by Pastel, a handy text formatting library that feels like React for command line applications. We'll add it to our project now, and while we're at it we'll also add Console, a logging library that's modelled after the browser console's API:

esy add @reason-native/console
esy add @reason-native/pastel

Now we have to add them to the Dune configuration file in lib/dune. In this file we'll change the libraries stanza from

(libraries base)

to

(libraries base console.lib pastel.lib)

With this in place we can now update the Utils.hello function:

open Pastel;

let hello = () =>
  <Pastel bold=true color=Green>
    "Hello, "
    <Pastel italic=true color=Red> "World!" </Pastel>
    " 👋"
  </Pastel>;

Starting the program now will display the following message:

cli output

Adding a Test

As previously mentioned our project template includes the Rely test framework, so let's put it to use by adding the following test to test/utils_test.re:

open Test_framework;
open Dev_cli;

/** Test suite for the Utils module. */
describe("Utils", ({test, _}) =>
  test("Utils.hello() returns a greeting", ({expect}) => {
    expect.string(Utils.hello()).toMatch("👋")
  })
);

The syntax is pretty straight-forward and should feel familiar to anyone who used Jest or a similar framework before. Note though that matching ANSI escape sequences is not my understanding of fun, so instead of checking the full message we're just making sure that it contains the waving hand emoji. We can run our test suite via esy test, which is short for esy x test-runner.exe. Everything works as expected:

$ esy test
Running 1 test suite
 PASS  hello world

Test Suites: 0 failed, 1 passed, 1 total
Tests:       0 failed, 1 passed, 1 total
Time:        < 1ms

Argument Parsing

No command line app would be complete without argument parsing. A popular solution to this problem in OCaml is Cmdliner, which we'll now add to our project:

esy add @opam/cmdliner

We now have to add it to the build configuration of our binary, located in bin/dune:

-(libraries base stdio dev-cli.lib)
+(libraries base cmdliner stdio dev-cli.lib)

The next step is defining a "term" for our options. Let's update bin/dev_cli_app.re:

open Cmdliner;

let cmd = {
  let doc = "Simple CLI built in Reason";

  let who = {
    let doc = "Who do you want to greet";
    Arg.(
      required
      & pos(0, some(string), None)
      & info([], ~docv="WHO", ~doc)
    );
  };

  let run = who => {
    Console.log @@ Utils.hello(who);
  };

  Term.(const(run) $ who, info("dev-cli", ~doc));
};

let () = Term.exit @@ Term.eval(cmd);

Going into all Cmdliner options goes well beyond the scope of this post, but the project has a fairly comprehensive guide which explains them in more detail. In summary we define a command and its documentation, then describe its options. This program only has a single positional argument named who, which is of type string. Evaluating our command with Term.eval will pass who to its run function, which uses it in the call to Utils.hello. We'll also update the unit test accordingly:

describe("hello world", ({test, _}) =>
  test("Utils.hello()", ({expect}) => {
    expect.string(Utils.hello("Test")).toMatch("Test")
  })
);

Note that this will not work, until we change the function's definition in lib/utils.rei (or just delete the interface file):

-let hello: unit => string;
+let hello: string => string;

With all these changes in place, executing the command with an argument now works as expected:

$ esy start Readers
Hello, Readers 👋

However, if we forget to provide one, we'll be presented with a helpful error message:

dev-cli: required argument WHO is missing
Usage: dev-cli [OPTION]... WHO
Try `dev-cli --help' for more information.

Cmdliner also generated a comprehensive help screen for us:

$ esy start --help

DEV-CLI(1)                      Dev-cli Manual                      DEV-CLI(1)



NAME
       dev-cli - Simple CLI built in Reason

SYNOPSIS
       dev-cli [OPTION]... WHO

ARGUMENTS
       WHO (required)
           Who do you want to greet

OPTIONS
       --help[=FMT] (default=auto)
           Show this help in format FMT. The value FMT must be one of `auto',
           `pager', `groff' or `plain'. With `auto', the format is `pager` or
           `plain' whenever the TERM env var is `dumb' or undefined.



Dev-cli                                                             DEV-CLI(1)

Summary

While Reason itself is relatively new, it benefits greatly from the OCaml ecosystem and its battle-tested libraries, in this case Cmdliner. On the other hand Reason adds some modern tooling to the mix, and especially JS developers should have no problems picking up esy, Rely or Pastel. In the next post we'll add some actual functionality to our command line app, until then you can play around with the source code from this post.

Posted on by:

citizen428 profile

Michael Kohl

@citizen428

I dev @ DEV. Your friendly neighborhood anarcho-cynicalist. ¯\_(ツ)_/¯ and (╯°□°)╯︵ ┻━┻) are my two natural states. Tag mod for #ruby, #fsharp, #ocaml

Discussion

markdown guide