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 typeCmdliner.Cmd.info
) which contains metadata for the program (used in help, etc)a
term
value (of typeunit Cmdliner.Term.t
) which sets up arguments and callseval_lb
with the rightlexbuf
a
cmd
value (of typeunit Cmdliner.Cmd.t
) groupinginfo
andterm
togethera
main
function of typeunit -> 'a
to runcmd
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.
(lang dune 3.0)
(using menhir 2.1)
(package (name calc))
(executable
(public_name calc)
(libraries calc))
let () = Calc.Cli.main ()
(library
(name calc)
(libraries cmdliner))
(ocamllex lexer)
(menhir
(modules parser))
type exp =
| Int of int
| Add of exp * exp
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
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)) }
%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) }
%%
(cram
(deps %{bin:calc}))
$ calc -e '1+2'
3