DEV Community

Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at bitmaybewise.substack.com

Tsonnet #33 - The arithmetic tutorial must go on

Welcome to the Tsonnet series!

If you're not following along, check out how it all started in the first post of the series.

In the previous post, we added a full set of binary operators:

We were almost done with the arithmetic tutorial. Two pieces were still missing: object merging, and division by zero error handling. Let's wrap those up.

Merging objects

The + operator is overloaded to act as an operator to merge two objects.

This sample file:

// samples/objects/merge.jsonnet
{ a: 1, b: 2 } + { b: 3, c: 4 }
Enter fullscreen mode Exit fullscreen mode

Results in:

{ "a": 1, "b": 3, "c": 4 }
Enter fullscreen mode Exit fullscreen mode

The right-hand side overrides the left-hand side fields.

The cram test looks like this:

diff --git a/test/cram/objects.t b/test/cram/objects.t
index 9c12260..bfb25bd 100644
--- a/test/cram/objects.t
+++ b/test/cram/objects.t
@@ -18,3 +18,6 @@

   $ tsonnet ../../samples/objects/toplevel_field_lookup_chain.jsonnet
   { "answer": { "value": 42 }, "answer_to_the_ultimate_question": 42 }
+
+  $ tsonnet ../../samples/objects/merge.jsonnet
+  { "a": 1, "b": 3, "c": 4 }
Enter fullscreen mode Exit fullscreen mode

I'm adding a helper function in the Ast module to merge two lists of fields:

module Object = struct
  let merge_fields fields1 fields2 =
    (* fields2 comes after and has preference over fields1 *)
    let updated = List.map
      (fun (k, v) ->
        match List.assoc_opt k fields2 with
        | Some v' -> (k, v')
        | None -> (k, v)
      )
      fields1
    in
    (* (k,v) in fields2 not present in fields1 *)
    let new_fields = List.filter
      (fun (k, _) -> not (List.mem_assoc k fields1))
      fields2
    in
    (* then we merge *)
    updated @ new_fields
end
Enter fullscreen mode Exit fullscreen mode

The type checker only needs to include the overloaded operation:

diff --git a/lib/type.ml b/lib/type.ml
index d553f5c..fbe68b1 100644
--- a/lib/type.ml
+++ b/lib/type.ml
@@ -321,6 +321,8 @@ and translate_bin_op venv pos op e1 e2 =
   | Add, _, Tstring | Add, Tstring, _ -> ok (venv'', Tstring)
   | Add, Tnumber, Tnumber -> ok (venv'', Tnumber)
   | Add, (Tarray _), (Tarray _) -> ok (venv'', Tarray Tany)
+  | Add, (Tobject _ | TruntimeObject _ | TobjectPtr _), (Tobject _ | TruntimeObject _ | TobjectPtr _) ->
+    ok (venv'', Tany)
   | Subtract, Tnumber, Tnumber -> ok (venv'', Tnumber)
   | Multiply, Tnumber, Tnumber -> ok (venv'', Tnumber)
   | Divide, Tnumber, Tnumber -> ok (venv'', Tnumber)
Enter fullscreen mode Exit fullscreen mode

The interpreter implements the new interpret_object_merge_op function to deal with that, and we pattern-match in the interpret_bin_op function to add it:

diff --git a/lib/interpreter.ml b/lib/interpreter.ml
index ddc8041..24f0595 100644
--- a/lib/interpreter.ml
+++ b/lib/interpreter.ml
@@ -66,6 +66,19 @@ and interpret_array_concat_op env e1 e2 =
   | _ ->
     error Error.Msg.interp_invalid_concat

+and interpret_object_merge_op env pos e1 e2 =
+  let eval_object = function
+    | EvaluatedObject _ as obj -> ok (env, obj)
+    | RuntimeObject _ as obj -> interpret env obj
+    | _ -> error Error.Msg.invalid_binary_op
+  in
+  let* (_, e1') = eval_object e1 in
+  let* (_, e2') = eval_object e2 in
+  match e1', e2' with
+  | EvaluatedObject (_, fields1), EvaluatedObject (_, fields2) ->
+    ok (env, EvaluatedObject (pos, Object.merge_fields fields1 fields2))
+  | _ -> error Error.Msg.invalid_binary_op
+
 and interpret_array env (pos, exprs) =
   let* (env', evaluated_exprs) = List.fold_left
     (fun result expr ->
@@ -234,6 +247,8 @@ and interpret_bin_op env (pos, op, e1, e2) =
    | Add, (Array _ as v1), (Array _ as v2)  ->
      interpret_array_concat_op env2 v1 v2
+   | Add, (EvaluatedObject _ | RuntimeObject _ as v1), (EvaluatedObject _ | RuntimeObject _ as v2) ->
+     interpret_object_merge_op env2 pos v1 v2
    | In, (String _ | Ident _ as field), (EvaluatedObject _ | RuntimeObject (_, _, _) as obj) ->
      interpret_in_op env2 pos field obj
    | _, v1, v2 ->
Enter fullscreen mode Exit fullscreen mode

Done!

dune exec -- tsonnet samples/objects/merge.jsonnet
{ "a": 1, "b": 3, "c": 4 }
Enter fullscreen mode Exit fullscreen mode

Division by zero

Next we should deal with the division by zero error.

Here's how Jsonnet handles it:

$ jsonnet samples/errors/divide_by_zero.jsonnet
RUNTIME ERROR: Division by zero.
    samples/errors/divide_by_zero.jsonnet:1:1-6 $
    During evaluation
Enter fullscreen mode Exit fullscreen mode

And here are the sample files and how I want it to look in Tsonnet:

diff --git a/samples/errors/divide_by_zero.jsonnet b/samples/errors/divide_by_zero.jsonnet
new file mode 100644
index 0000000..59a5d52
--- /dev/null
+++ b/samples/errors/divide_by_zero.jsonnet
@@ -0,0 +1 @@
+5 / 0
diff --git a/samples/errors/divide_by_zero_float.jsonnet b/samples/errors/divide_by_zero_float.jsonnet
new file mode 100644
index 0000000..20f3764
--- /dev/null
+++ b/samples/errors/divide_by_zero_float.jsonnet
@@ -0,0 +1 @@
+5 / 0.0
diff --git a/samples/errors/modulo_by_zero.jsonnet b/samples/errors/modulo_by_zero.jsonnet
new file mode 100644
index 0000000..be4046b
--- /dev/null
+++ b/samples/errors/modulo_by_zero.jsonnet
@@ -0,0 +1 @@
+5 % 0
diff --git a/samples/errors/modulo_by_zero_float.jsonnet b/samples/errors/modulo_by_zero_float.jsonnet
new file mode 100644
index 0000000..8f666b8
--- /dev/null
+++ b/samples/errors/modulo_by_zero_float.jsonnet
@@ -0,0 +1 @@
+5 % 0.0
diff --git a/test/cram/errors.t b/test/cram/errors.t
index 7c117b8..dfcac7e 100644
--- a/test/cram/errors.t
+++ b/test/cram/errors.t
@@ -93,9 +105,43 @@
   $ tsonnet ../../samples/errors/object_outer_most_ref_out_of_scope.jsonnet
   ../../samples/errors/object_outer_most_ref_out_of_scope.jsonnet:2:13 No top-level object found

   2: local _two = $.one + 1;
      ^^^^^^^^^^^^^^^^^^^^^^^
   [1]
+
+
+  $ tsonnet ../../samples/errors/divide_by_zero.jsonnet
+  ../../samples/errors/divide_by_zero.jsonnet:1:0 Division by zero
+  
+  1: 5 / 0
+     ^^^^^
+  [1]
+
+
+  $ tsonnet ../../samples/errors/divide_by_zero_float.jsonnet
+  ../../samples/errors/divide_by_zero_float.jsonnet:1:0 Division by zero
+  
+  1: 5 / 0.0
+     ^^^^^^^
+  [1]
+
+
+  $ tsonnet ../../samples/errors/modulo_by_zero.jsonnet
+  ../../samples/errors/modulo_by_zero.jsonnet:1:0 Division by zero
+  
+  1: 5 % 0
+     ^^^^^
+  [1]
+
+
+  $ tsonnet ../../samples/errors/modulo_by_zero_float.jsonnet
+  ../../samples/errors/modulo_by_zero_float.jsonnet:1:0 Division by zero
+  
+  1: 5 % 0.0
+     ^^^^^^^
+  [1]
+
Enter fullscreen mode Exit fullscreen mode

Not a huge difference from the reference implementation, but I like seeing the faulty operation highlighted in the source — much easier to spot and fix.

Let's add the error message:

diff --git a/lib/error.ml b/lib/error.ml
index 8c6b53f..ac8ae48 100644
--- a/lib/error.ml
+++ b/lib/error.ml
@@ -29,6 +29,7 @@ module Msg = struct
   let type_invalid_lookup_key expr = "Invalid object lookup key: " ^ expr

   (* Interpreter messages *)
+  let interp_division_by_zero = "Division by zero"
   let interp_invalid_concat = "Invalid concatenation operation"
   let interp_invalid_lookup = "Invalid object lookup"
   let interp_cannot_interpret expr = Printf.sprintf "Expression %s cannot be interpreted" expr
diff --git a/lib/error.mli b/lib/error.mli
index 58d7e8e..0bdd708 100644
--- a/lib/error.mli
+++ b/lib/error.mli
@@ -26,6 +26,7 @@ module Msg : sig
   val type_invalid_lookup_key : string -> string

   (* Interpreter messages *)
+  val interp_division_by_zero : string
   val interp_invalid_concat : string
   val interp_invalid_lookup : string
   val interp_cannot_interpret : string -> string
Enter fullscreen mode Exit fullscreen mode

The interpret_arith_op function only needs to pattern-match on Int and Float zero before the division — pretty simple. Have I mentioned how much I enjoy pattern matching?

diff --git a/lib/interpreter.ml b/lib/interpreter.ml
index 24f0595..fe248b9 100644
--- a/lib/interpreter.ml
+++ b/lib/interpreter.ml
@@ -280,6 +280,8 @@ and interpret_arith_op env (pos, bin_op, n1, n2) =
     ok (env, Number (pos, Float ((float_of_int a) *. b)))
   | Multiply, Number (_, Float a), Number (_, Float b) ->
     ok (env, Number (pos, Float (a *. b)))
+  | Divide, _, Number (_, n) when n = Int 0 || n = Float 0.0 ->
+    Error.error_at pos Error.Msg.interp_division_by_zero
   | Divide, Number (_, Int a), Number (_, Int b) ->
     ok (env, Number (pos, Float ((float_of_int a) /. (float_of_int b))))
   | Divide, Number (_, Float a), Number (_, Int b) ->
@@ -288,6 +290,8 @@ and interpret_arith_op env (pos, bin_op, n1, n2) =
     ok (env, Number (pos, Float ((float_of_int a) /. b)))
   | Divide, Number (_, Float a), Number (_, Float b) ->
     ok (env, Number (pos, Float (a /. b)))
+  | Modulo, _, Number (_, n) when n = Int 0 || n = Float 0.0 ->
+    Error.error_at pos Error.Msg.interp_division_by_zero
   | Modulo, Number (_, Int a), Number (_, Int b) ->
     ok (env, Number (pos, Int (a mod b)))
   | Modulo, Number (_, Float a), Number (_, Int b) ->
Enter fullscreen mode Exit fullscreen mode

The guard clauses (when n = Int 0 || n = Float 0.0) catch both integer and float zero before the division cases get a chance to run.

Completing the tutorial

With those two additions in place, samples/tutorials/arith.jsonnet is now fully supported -- minus the string formatting bits I'm deliberately setting aside for later. Here's the file with those commented out:

{
  concat_array: [1, 2, 3] + [4],
  concat_string: '123' + 4,
  equality1: 1 == '1',
  equality2: [{}, { x: 3 - 1 }]
             == [{}, { x: 2 }],
  ex1: 1 + 2 * 3 / (4 + 5),
  // Bitwise operations first cast to int.
  ex2: self.ex1 | 3,
  // Modulo operator.
  ex3: self.ex1 % 2,
  // Boolean logic
  ex4: (4 > 3) && (1 <= 3) || false,
  // Mixing objects together
  obj: { a: 1, b: 2 } + { b: 3, c: 4 },
  // Test if a field is in an object
  obj_member: 'foo' in { foo: 1 },
//   // String formatting
//   str1: 'The value of self.ex2 is '
//         + self.ex2 + '.',
//   str2: 'The value of self.ex2 is %g.'
//         % self.ex2,
//   str3: 'ex1=%0.2f, ex2=%0.2f'
//         % [self.ex1, self.ex2],
//   // By passing self, we allow ex1 and ex2 to
//   // be extracted internally.
//   str4: 'ex1=%(ex1)0.2f, ex2=%(ex2)0.2f'
//         % self,
//   // Do textual templating of entire files:
//   str5: |||
//     ex1=%(ex1)0.2f
//     ex2=%(ex2)0.2f
//   ||| % self,
}
Enter fullscreen mode Exit fullscreen mode

Running it:

dune exec -- tsonnet samples/tutorials/arith.jsonnet
{
  "concat_array": [ 1, 2, 3, 4 ],
  "concat_string": "1234",
  "equality1": false,
  "equality2": true,
  "ex1": 1.6666666666666665,
  "ex2": 3,
  "ex3": 1.6666666666666665,
  "ex4": true,
  "obj": { "a": 1, "b": 3, "c": 4 },
  "obj_member": true
}
Enter fullscreen mode Exit fullscreen mode

String formatting is next on the list -- but there are more interesting features to tackle first, so I'm parking it for now.

Conclusion

Two small additions -- object merging and division by zero -- and the arithmetic tutorial is effectively done. The pattern-matching guard clauses made the zero-division check almost embarrassingly straightforward, and the merge_fields helper slotted in without touching anything else.

You can check the entire diff here.


If you, too, believe dividing by zero should be an error and not a NaN, subscribe and let's keep each other honest.

Photo by Lance Grandahl on Unsplash

Top comments (0)