The Development Cycle

Our calculator now has a better structure, but it still does not do much.

In this chapter, we are going to make several iterations where we add a cram test, see it fail, implement the missing feature, and repeat. This is how many OCaml projects are developed (including Dune itself).

Note

There are many ways to do this: tests can be written first or last; some prefer to promote only the fixed version.

In this tutorial, we’re going to write tests first and promote the erroneous version but as you become more familiar with Dune, you’ll be able to explore variations of this loop.

Display Errors

So far, our calculator is not doing any error handling.

We’re going to create a test with an error, see how the calculator behaves, and add a better error message.

Create a Test

Add a new test in test/calc.t with the following content. Note that we do not specify any output for the command. As before, make sure to include two spaces before the $ sign.

  $ calc -e '1+2'
  3

  $ calc -e '1+'

Next, run the tests using dune runtest. Dune will display a diff with the actual output:

   $ calc -e '1+'
+  calc: internal error, uncaught exception:
+        Calc.Parser.MenhirBasics.Error
+        
+  [125]

Run dune promote and observe that the error message is part of the test. At that point, running dune runtest will succeed.

Our goal for the rest of this section is to make this test display a nice error message.

Handle the Exception

The message points to an uncaught exception. Indeed, Parser.main can raise Parser.Error. Let’s catch this exception in eval_lb and display the location of the error in the input file.

Edit lib/cli.ml:

let eval_lb lb =
  try
    let e = Parser.main Lexer.token lb in
    Printf.printf "%d\n" (eval e)
  with Parser.Error ->
    Printf.printf "parse error near character %d" lb.lex_curr_pos

Note

This is just an excerpt from the file. Edit the eval_lb to make it look like this. The modified parts are highlighted.

Now, run the tests again with dune runtest. It displays the following diff:

   $ calc -e '1+'
-  calc: internal error, uncaught exception:
-        Calc.Parser.MenhirBasics.Error
-
-  [125]
+  parse error near character 2

Run dune promote and note that test/calc.t has changed.

Run dune runtest. Nothing is displayed, indicated that the test has passed.

Note

This is similar to what happened at the end of the previous chapter.

The first dune runtest compares the expected output (from test/calc.t, the uncaught exception message) to the actual output (our new error message) and displays the diff. So, the uncaught exception appears as deleted lines (prefixed with -) and the new error message appears as added lines (prefixed with +).

Running dune promote will copy the last actual output to calc/test.t.

Running dune runtest a second time will compare the expected output (the new message) with the actual output of the command (the new message) and find no difference.

Add Floats

At this stage, our calculator only supports integers. In this section, we are going to add support for floating-point numbers and operations.

Add a Test

First, add a test at the end of test/calc.t:

$ calc -e '1+2.5'

Run dune runtest: this displays an error.

   $ calc -e '1+2.5'
+  calc: internal error, uncaught exception:
+        Failure("lexing: empty token")
+        
+  [125]

Run dune promote to update the failing test. Our goal for the rest of this section is to change that test to print 3.5.

Add a Float constructor

We need to add a new kind of expression. Let’s extend the exp type in lib/ast.ml to add a new Float constructor.

type exp =
| Int of int
| Add of exp * exp
| Float of float

With this new constructor, we can represent the 2.5 part as Float 2.5.

Lexing and Parsing

We also need to extend our lexer to produce a new token type for floats, and a production rule in the grammar.

Let’s first add a token type in lib/parser.mly:

%token<int> Int
%token Plus
%token<float> Float

A new rule in lib/lexer.mll:

rule token = parse
    | eof { Parser.Eof }
    | space { token lexbuf }
    | '\n' { Parser.Eof }
    | '+' { Parser.Plus }
    | digit+ { Parser.Int (int_of_string (Lexing.lexeme lexbuf)) }
    | digit+ '.' digit+ { Parser.Float (float_of_string (Lexing.lexeme lexbuf)) }

And a new rule in lib/parser.mly:

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

Evaluation

Let’s run dune build.

With the new constructor, the compiler is now complaining that the pattern matching in our eval function is incomplete.

File "lib/cli.ml", line 1, characters 15-70:
1 | let rec eval = function Ast.Int n -> n | Add (a, b) -> eval a + eval b
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error (warning 8 [partial-match]): this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
Float _

To fix this, we need to tweak lib/cli.ml a bit. Instead of returning an int, let’s introduce a value type that can represent either int or float:

type value = VInt of int | VFloat of float

let value_to_string = function
  | VInt n -> string_of_int n
  | VFloat f -> Printf.sprintf "%.6g" f

And update the eval_lb function to use this printer:

let eval_lb lb =
  try
    let expr = Parser.main Lexer.token lb in
    let v = eval expr in
    Printf.printf "%s\n" (value_to_string v)
  with Parser.Error ->
    Printf.printf "parse error near character %d" lb.lex_curr_pos

Finally, for eval itself, we’ll use (+) or (+.) if both values have the same type, or convert integers to floats if needed:

let rec eval = function
  | Ast.Int n -> VInt n
  | Float f -> VFloat f
  | Add (a, b) -> (
      match (eval a, eval b) with
      | VInt na, VInt nb -> VInt (na + nb)
      | VFloat fa, VFloat fb -> VFloat (fa +. fb)
      | VInt na, VFloat fb -> VFloat (float na +. fb)
      | VFloat fa, VInt nb -> VFloat (fa +. float nb))

With this implementation done, call dune runtest and notice that it changes the output. Call dune promote to update the test.

Pi

In this section, we’re going to add named constants, and pi in particular.

Create a test

Add a new test in test/calc.t:

  $ calc -e '1+pi'

Run dune runtest and see the failure. Run dune promote to add the failure to the test file. Our goal in the rest of the section is to change the output of this test.

Add a Constructor

Let’s add an new constructor in lib/ast.ml:

type exp =
  | Int of int
  | Add of exp * exp
  | Float of float
  | Ident of string

That way, pi is going to be represented as Ident "pi".

Lexing and Parsing

We now need to parse these constants.

Let’s add a new token in lib/parser.mly:

%token Eof
%token<int> Int
%token<float> Float
%token Plus
%token<string> Ident

Produce it using a new lexing rule in lib/lexer.mll:

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

let letter = ['a'-'z']

let ident = letter+

rule token = parse
    | eof { Parser.Eof }
    | space { token lexbuf }
    | '\n' { Parser.Eof }
    | '+' { Parser.Plus }
    | digit+ { Parser.Int (int_of_string (Lexing.lexeme lexbuf)) }
    | digit+ '.' digit+ { Parser.Float (float_of_string (Lexing.lexeme lexbuf)) }
    | ident { Parser.Ident (Lexing.lexeme lexbuf) }

And handle it as a new derivation in lib/parser.mly:

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

Evaluation

Finally, we’ll have to update our evaluation function to take the new constructor into account.

OCaml does not have a value for pi, but it has a Stdlib.acos function. Since \(\cos{\frac{\pi}{2}} = 0\), we can define pi as \(2 * \arccos 0\) .

let rec eval = function
  | Ast.Int n -> VInt n
  | Float f -> VFloat f
  | Add (a, b) -> (
      match (eval a, eval b) with
      | VInt na, VInt nb -> VInt (na + nb)
      | VFloat fa, VFloat fb -> VFloat (fa +. fb)
      | VInt na, VFloat fb -> VFloat (float na +. fb)
      | VFloat fa, VInt nb -> VFloat (fa +. float nb))
  | Ident "pi" -> VFloat (2. *. Stdlib.acos 0.)
  | Ident _ -> failwith "unknown ident"

Finally, let’s run our test. dune runtest will display a diff with the new value. Run dune promote to accept it.

Multiplication

Create a Test

Let’s add a new test in test/calc.t:

  $ calc -e '1+2*3'

Run the test with dune runtest. Notice the error message.

Let’s run dune promote to add it to the file.

Now, our goal for the rest of this section is to change that to the expected result.

Add a Constructor

Let’s update the AST lib/ast.ml: we’re generalizing addition to binary operations, and create a new Mul operation (notice that we remove the line corresponding to the Add expression).

type op =
  | Add
  | Mul

type exp =
  | Int of int
  | Float of float
  | Ident of string
  | Op of op * exp * exp

Lexing and Parsing

Let’s add a new token for * in lib/parser.mly:

%token<string> Ident
%token Plus
%token Star

Then, we’ll produce it using a new rule in lib/lexer.mll:

    | space { token lexbuf }
    | '\n' { Parser.Eof }
    | '+' { Parser.Plus }
    | '*' { Parser.Star }

And use that token in a new rule in lib/parser.mly (also modifying the Add rule):

| Int { Int $1 }
| Float { Float $1 }
| Ident { Ident $1 }
| expr Plus expr { Op (Add, $1, $3) }
| expr Star expr { Op (Mul, $1, $3) }

We have a last edit to do here, which is to add a precedence annotation:

%left Plus
%left Star

Evaluation

Now, we can update our evaluation function in lib/cli.ml:

let eval_number_op f_int f_float va vb =
  match (va, vb) with
  | VInt na, VInt nb -> VInt (f_int na nb)
  | VFloat fa, VFloat fb -> VFloat (f_float fa fb)
  | VInt na, VFloat fb -> VFloat (f_float (float_of_int na) fb)
  | VFloat fa, VInt nb -> VFloat (f_float fa (float_of_int nb))

let rec eval = function
  | Ast.Int n -> VInt n
  | Float f -> VFloat f
  | Ident "pi" -> VFloat (2. *. Stdlib.acos 0.)
  | Ident _ -> failwith "unknown ident"
  | Op (Add, a, b) -> eval_number_op ( + ) ( +. ) (eval a) (eval b)
  | Op (Mul, a, b) -> eval_number_op ( * ) ( *. ) (eval a) (eval b)

With these updates done, let’s run dune runtest. It displays that the result is 7. Call dune promote to update the test.

Division

This section is going to be very similar to the previous one. Instead we’re adding the division operator.

Create a Test

Add a test in test/calc.t:

  $ calc -e '4/2'

Call dune runtest, note the error, call dune promote.

Add a Constructor

Add a constructor in lib/ast.ml:

type op =
  | Add
  | Mul
  | Div

Lexing and Parsing

Update lib/parser.mly:

%token<string> Ident;
%token Plus
%token Star
%token Slash

Then add the right precedence:

 %left Plus
+%left Star Slash

And the corresponding rule:

| Ident { Ident $1 }
| expr Plus expr { Op (Add, $1, $3) }
| expr Star expr { Op (Mul, $1, $3) }
| expr Slash expr { Op (Div, $1, $3) }

Finally, add a lexing rule in lib/lexer.mll:

    | '+' { Parser.Plus }
    | '*' { Parser.Star }
    | '/' { Parser.Slash }

Evaluation

Add a new case in lib/cli.ml:

let rec eval = function
  | Ast.Int n -> VInt n
  | Float f -> VFloat f
  | Ident "pi" -> VFloat (2. *. Stdlib.acos 0.)
  | Ident _ -> failwith "unknown ident"
  | Op (Add, a, b) -> eval_number_op ( + ) ( +. ) (eval a) (eval b)
  | Op (Mul, a, b) -> eval_number_op ( * ) ( *. ) (eval a) (eval b)
  | Op (Div, a, b) -> eval_number_op ( / ) ( /. ) (eval a) (eval b)

Now, running dune runtest should display the right result. Run dune promote to accept it.

Sine

Create a Test

Create a new test in test/calc.t:

  $ calc -e 'sin (pi / 6)'

Call dune runtest, note the error, call dune promote.

Add a Constructor

Add a constructor in lib/ast.ml:

type exp =
  | Int of int
  | Float of float
  | Ident of string
  | Op of op * exp * exp
  | Call of string * exp

Lexing and Parsing

Update lib/parser.mly by defining new tokens for parentheses:

%token<string> Ident;
%token Plus
%token Star
%token Slash
%token Lpar Rpar

And a production for calls:

| Ident { Ident $1 }
| expr Plus expr { Op (Add, $1, $3) }
| expr Star expr { Op (Mul, $1, $3) }
| expr Slash expr { Op (Div, $1, $3) }
| Ident Lpar expr Rpar { Call ($1, $3) }

Update lib/lexer.mll:

    | '+' { Parser.Plus }
    | '*' { Parser.Star }
    | '/' { Parser.Slash }
    | '(' { Parser.Lpar }
    | ')' { Parser.Rpar }

Evaluation

We’ll need a float to pass to Stdlib.sin, so let’s introduce a conversion functin. We’ll also add the corresponding cases in lib/cli.ml:

let as_float = function
  | VInt n -> float_of_int n
  | VFloat f -> f

let rec eval = function
  | Ast.Int n -> VInt n
  | Float f -> VFloat f
  | Ident "pi" -> VFloat (2. *. Stdlib.acos 0.)
  | Ident _ -> failwith "unknown ident"
  | Op (Add, a, b) -> eval_number_op ( + ) ( +. ) (eval a) (eval b)
  | Op (Mul, a, b) -> eval_number_op ( * ) ( *. ) (eval a) (eval b)
  | Op (Div, a, b) -> eval_number_op ( / ) ( /. ) (eval a) (eval b)
  | Call ("sin", e) -> VFloat (Stdlib.sin (as_float (eval e)))
  | Call _ -> failwith "unknown function"

Now, run the tests using dune runtest. Accept the correction with dune promote.

Conclusion

We’ve added several features to our calculator, and added tests in the meantime. To do so, we’ve used dune runtest and dune promote, two of the most useful Dune commands.

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 (unchanged)
(executable
 (public_name calc)
 (libraries calc))
bin/calc.ml (unchanged)
let () = Calc.Cli.main ()
lib/dune (unchanged)
(library
 (name calc)
 (libraries cmdliner))

(ocamllex lexer)

(menhir
 (modules parser))
lib/ast.ml
type op =
  | Add
  | Mul
  | Div

type exp =
  | Int of int
  | Float of float
  | Ident of string
  | Op of op * exp * exp
  | Call of string * exp
lib/cli.ml
type value = VInt of int | VFloat of float

let value_to_string = function
  | VInt n -> string_of_int n
  | VFloat f -> Printf.sprintf "%.6g" f

let eval_number_op f_int f_float va vb =
  match (va, vb) with
  | VInt na, VInt nb -> VInt (f_int na nb)
  | VFloat fa, VFloat fb -> VFloat (f_float fa fb)
  | VInt na, VFloat fb -> VFloat (f_float (float_of_int na) fb)
  | VFloat fa, VInt nb -> VFloat (f_float fa (float_of_int nb))

let as_float = function
  | VInt n -> float_of_int n
  | VFloat f -> f

let rec eval = function
  | Ast.Int n -> VInt n
  | Float f -> VFloat f
  | Ident "pi" -> VFloat (2. *. Stdlib.acos 0.)
  | Ident _ -> failwith "unknown ident"
  | Op (Add, a, b) -> eval_number_op ( + ) ( +. ) (eval a) (eval b)
  | Op (Mul, a, b) -> eval_number_op ( * ) ( *. ) (eval a) (eval b)
  | Op (Div, a, b) -> eval_number_op ( / ) ( /. ) (eval a) (eval b)
  | Call ("sin", e) -> VFloat (Stdlib.sin (as_float (eval e)))
  | Call _ -> failwith "unknown function"

let info = Cmdliner.Cmd.info "calc"

let eval_lb lb =
  try
    let expr = Parser.main Lexer.token lb in
    let v = eval expr in
    Printf.printf "%s\n" (value_to_string v)
  with Parser.Error ->
    Printf.printf "parse error near character %d" lb.lex_curr_pos

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
let space = [' ']+

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

let letter = ['a'-'z']

let ident = letter+

rule token = parse
    | eof { Parser.Eof }
    | space { token lexbuf }
    | '\n' { Parser.Eof }
    | '+' { Parser.Plus }
    | '*' { Parser.Star }
    | '/' { Parser.Slash }
    | '(' { Parser.Lpar }
    | ')' { Parser.Rpar }
    | digit+ { Parser.Int (int_of_string (Lexing.lexeme lexbuf)) }
    | digit+ '.' digit+ { Parser.Float (float_of_string (Lexing.lexeme lexbuf)) }
    | ident { Parser.Ident (Lexing.lexeme lexbuf) }
lib/parser.mly
%token Eof
%token<int> Int
%token Plus
%token Star
%token Slash
%token Lpar Rpar
%token<float> Float
%token<string> Ident
%start<Ast.exp> main

%left Plus
%left Star Slash

%{ open Ast %}

%%

main: expr Eof { $1 }

expr:
| Int { Int $1 }
| expr Plus expr { Op (Add, $1, $3) }
| expr Star expr { Op (Mul, $1, $3) }
| expr Slash expr { Op (Div, $1, $3) }
| Ident Lpar expr Rpar { Call ($1, $3) }
| Float { Float $1 }
| Ident { Ident $1 }

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

  $ calc -e '1+'
  parse error near character 2

  $ calc -e '1+2.5'
  3.5

  $ calc -e '1+pi'
  4.14159

  $ calc -e '1+2*3'
  7

  $ calc -e '4/2'
  2

  $ calc -e 'sin (pi / 6)'
  0.5