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.
(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 op =
| Add
| Mul
| Div
type exp =
| Int of int
| Float of float
| Ident of string
| Op of op * exp * exp
| Call of string * exp
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
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) }
%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 }
%%
(cram
(deps %{bin:calc}))
$ 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