Improving Structure

Our calculator is fairly monolithic at this stage. Instead of a single executable, we’re going to extract a library and create some tests. For now, this is just a cram test that will call the executable, but this structure will later allow adding unit tests for the library.

Extract a Library

Create folders bin, lib and test, for binary, library, and tests, respectively.

Run the following command to install cmdliner:

opam install cmdliner.1.3.0

Let’s create a library. Create lib/dune with the (ocamllex) and (menhir) stanzas from the original dune file and a new (library) stanza:

(library
 (name calc)
 (libraries cmdliner))

(ocamllex lexer)

(menhir
 (modules parser))

Note

This is the whole contents of the file. The (library) part is highlighted to show that it’s the part that we’ve just added.

Note

We’re defining a library that depends on the cmdliner library.

Libraries can either be defined in your project, or provided by an opam package. In the case of cmdliner, this is the latter, since we’ve installed it just before.

The OCaml Ecosystem covers the difference between packages, libraries, and modules.

Move ast.ml, lexer.mll, and parser.mly to the lib directory.

Now we’re going to move calc.ml to lib/cli.ml and replace it by the following:

let rec eval = function Ast.Int n -> n | Add (a, b) -> eval a + eval b

let info = Cmdliner.Cmd.info "calc"

let eval_lb lb =
  let e = Parser.main Lexer.token lb in
  Printf.printf "%d\n" (eval e)

let repl () =
  while true do
    Printf.printf ">> %!";
    let lb = Lexing.from_channel Stdlib.stdin in
    eval_lb lb
  done

let term =
  let open Cmdliner.Term.Syntax in
  let+ expr_opt =
    let open Cmdliner.Arg in
    value & opt (some string) None & info [ "e" ]
  in
  match expr_opt with
  | Some s -> eval_lb (Lexing.from_string s)
  | None -> repl ()

let cmd = Cmdliner.Cmd.v info term
let main () = Cmdliner.Cmd.eval cmd |> Stdlib.exit

Note

Two things are happening here.

We are adding a second code path to evaluate a string directly, so we extract an eval_lb function that operates on a lexbuf (the “source” a lexer can read from).

We are also moving to cmdliner for command-line parsing. This consists in:

  • an info value (of type Cmdliner.Cmd.info) which contains metadata for the program (used in help, etc)

  • a term value (of type unit Cmdliner.Term.t) which sets up arguments and calls eval_lb with the right lexbuf

  • a cmd value (of type unit Cmdliner.Cmd.t) grouping info and term together

  • a main function of type unit -> 'a to run cmd

Extract an Executable

Let’s create an executable in bin. To do so, create a bin/dune file with the following contents:

(executable
 (public_name calc)
 (libraries calc))

And bin/calc.ml with a single function call:

let () = Calc.Cli.main ()

Delete the dune at the root.

Create a Test

Create test/calc.t with the following contents.

Important

In cram tests, commands start with two spaces, a dollar sign, and a space.

Make sure to include two spaces at the beginning of the line.

  $ calc -e '1+2'

Now create test/dune to inform Dune that cram tests will use our calc executable and need to be executed again when it changes:

(cram
 (deps %{bin:calc}))

At this stage, we’re ready to run our test.

Let’s do this with dune runtest.

It’s displaying a diff:

   $ calc -e '1+2'
+  3

Now, run dune promote. The contents of test/calc.t have changed. Most editors will pick this up automatically, but it might be necessary to reload the file to see the change.

Finally, run dune runtest. Nothing happens.

Now, run the calculator by running dune exec calc to confirm that the interactive mode still works.

Note

What happened here? This Dune feature, where some tests can edit the source file, is called promotion.

Cram tests contain both commands and their expected input. We did not include any output in the initial cram test. When running dune runtest for the first time, Dune executes the commands, and calls diff between the expected output (in test/calc.t: no output at all) and the actual output (from running the command: the line “3”), which will display added lines with a + sign and deleted lines with a - sign.

Running dune promote replaces the input file (test/calc.t) with the last actual output. So this includes the line with “3”.

Running dune runtest again will execute the test again and compare the expected output (test/calc.t with the “3” line in it) with the actual output and finds no difference. This means that the test passes.

Checkpoint

This is how the project looks like at the end of this chapter.

dune-project (unchanged)
(lang dune 3.0)
(using menhir 2.1)
(package (name calc))
bin/dune
(executable
 (public_name calc)
 (libraries calc))
bin/calc.ml
let () = Calc.Cli.main ()
lib/dune
(library
 (name calc)
 (libraries cmdliner))

(ocamllex lexer)

(menhir
 (modules parser))
lib/ast.ml (unchanged)
type exp =
  | Int of int
  | Add of exp * exp
lib/cli.ml
let rec eval = function Ast.Int n -> n | Add (a, b) -> eval a + eval b

let info = Cmdliner.Cmd.info "calc"

let eval_lb lb =
  let e = Parser.main Lexer.token lb in
  Printf.printf "%d\n" (eval e)

let repl () =
  while true do
    Printf.printf ">> %!";
    let lb = Lexing.from_channel Stdlib.stdin in
    eval_lb lb
  done

let term =
  let open Cmdliner.Term.Syntax in
  let+ expr_opt =
    let open Cmdliner.Arg in
    value & opt (some string) None & info [ "e" ]
  in
  match expr_opt with
  | Some s -> eval_lb (Lexing.from_string s)
  | None -> repl ()

let cmd = Cmdliner.Cmd.v info term
let main () = Cmdliner.Cmd.eval cmd |> Stdlib.exit
lib/lexer.mll (unchanged)
let space = [' ']+

let digit = ['0'-'9']

rule token = parse
    | eof { Parser.Eof }
    | space { token lexbuf }
    | '\n' { Parser.Eof }
    | '+' { Parser.Plus }
    | digit+ { Parser.Int (int_of_string (Lexing.lexeme lexbuf)) }
lib/parser.mly (unchanged)
%token Eof
%token<int> Int
%token Plus
%start<Ast.exp> main

%left Plus

%{ open Ast %}

%%

main: expr Eof { $1 }

expr:
| Int { Int $1 }
| expr Plus expr { Add ($1, $3) }

%%
test/dune
(cram
 (deps %{bin:calc}))
test/calc.t
  $ calc -e '1+2'
  3