DEV Community

Cover image for Tsonnet #5 - Pretty-printing JSON
Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at open.substack.com

Tsonnet #5 - Pretty-printing JSON

Welcome to the Tsonnet series!

In the previous post, we refactored numerical types:

Today, I'd like to tackle the presentation layer: pretty-printing the JSON output.

The print function is not actually printing "JSON", just raw value representations.

Yojson is a pretty common choice for parsing JSON in OCaml.

Let's install it with opam:

$ opam install yojson
Enter fullscreen mode Exit fullscreen mode

And add it to our dependencies in dune-project:

diff --git a/dune-project b/dune-project
index 383bbef..775351b 100644
--- a/dune-project
+++ b/dune-project
@@ -33,7 +33,9 @@
   (bisect_ppx
    (and
     :with-test
-    (>= 2.8.3))))
+    (>= 2.8.3)))
+  (yojson
+   (>= 2.2.2)))
  (tags
   (jsonnet interpreter compiler)))
diff --git a/tsonnet.opam b/tsonnet.opam
index 31c792c..16783c3 100644
--- a/tsonnet.opam
+++ b/tsonnet.opam
@@ -15,6 +15,7 @@ depends: [
   "menhir" {= "20240715"}
   "alcotest" {with-test & >= "1.8.0"}
   "bisect_ppx" {with-test & >= "2.8.3"}
+  "yojson" {>= "2.2.2"}
   "odoc" {with-doc}
 ]
 build: [
Enter fullscreen mode Exit fullscreen mode

The opam file is automatically updated by dune.

Before using the library, we need to specify that the lib config will depend on yojson:

diff --git a/lib/dune b/lib/dune
index 3452131..05c4dc3 100644
--- a/lib/dune
+++ b/lib/dune
@@ -1,7 +1,8 @@
 (library
  (name tsonnet)
  (instrumentation
-  (backend bisect_ppx)))
+  (backend bisect_ppx))
+ (libraries yojson))

 (menhir
  (modules parser))
Enter fullscreen mode Exit fullscreen mode

To get rid of the printfs, we need a way of converting a expr to JSON.

Let's encapsulate the expression conversion in a new module, dedicated to transforming expressions into JSON representation:

diff --git a/lib/json.ml b/lib/json.ml
new file mode 100644
index 0000000..886d2bd
--- /dev/null
+++ b/lib/json.ml
@@ -0,0 +1,17 @@
+open Ast
+
+let rec expr_to_yojson : (expr -> Yojson.t) = function
+  | Number n ->
+    (match n with
+    | Int i -> `Int i
+    | Float f -> `Float f)
+  | Null -> `Null
+  | Bool b -> `Bool b
+  | String s -> `String s
+  | Array values -> `List (List.map expr_to_yojson values)
+  | Object attrs ->
+    let eval' = fun (k, v) -> (k, expr_to_yojson v)
+    in `Assoc (List.map eval' attrs)
+  | _ -> failwith "value type not representable as JSON"
+
+let expr_to_string expr = Yojson.pretty_to_string (expr_to_yojson expr)
Enter fullscreen mode Exit fullscreen mode

The new Json module will implement two functions. The function expr_to_yojson basically maps our expr types to Yojson.t. The function expr_to_string will use the previous function to convert from Yojson.t to string.

If eventually we decide to change the way we want to render JSON, like using a more performant library, for example, we refactor this module and there's no need to update anywhere else -- maybe the tests.

With that, we can remove our messy hand-written print function:

diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index 832b56c..a528488 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -6,24 +6,6 @@ let parse (s: string) : expr =
   let ast = Parser.prog Lexer.read lexbuf in
   ast

-let rec print = function
-  | Number n ->
-    (match n with
-    | Int i -> Printf.sprintf "%d" i
-    | Float f -> Printf.sprintf "%f" f)
-  | Null -> Printf.sprintf "null"
-  | Bool b -> Printf.sprintf "%b" b
-  | String s -> Printf.sprintf "\"%s\"" s
-  | Array values -> Printf.sprintf "[%s]" (String.concat ", " (List.map print values))
-  | Object attrs ->
-    let print_key_val = function
-      | (k, v) -> Printf.sprintf "\"%s\": %s" k (print v)
-    in
-    Printf.sprintf "{%s}" (
-      String.concat ", " (List.map print_key_val attrs)
-    )
-  | _ -> failwith "not implemented"
-
 let interpret_bin_op (op: bin_op) (n1: number) (n2: number) : expr =
   match op, n1, n2 with
   | Add, (Int a), (Int b) -> Number (Int (a + b))
@@ -52,7 +34,7 @@ let rec interpret (e: expr) : expr =
     | (Number v1), (Number v2) -> interpret_bin_op op v1 v2
     | _ -> failwith "invalid binary operation"

-let run (s: string) : expr =
+let run (s: string) =
   let ast = parse s in
   let evaluated_ast = interpret ast in
-  evaluated_ast
+  Json.expr_to_string evaluated_ast
Enter fullscreen mode Exit fullscreen mode

The Tsonnet library module looks much cleaner!

As we are making the run function return a string, we must update main.ml accordingly:

diff --git a/bin/main.ml b/bin/main.ml
index 78e9bb8..723b106 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -7,8 +7,8 @@ let run_parser filename =
   let input_channel = open_in filename in
   let content = really_input_string input_channel (in_channel_length input_channel) in
   close_in input_channel;
-  let expr = Tsonnet.run content in
-  print_endline (Tsonnet.print expr)
+  let result = Tsonnet.run content in
+  print_endline result

 let () =
   Arg.parse spec_list anonymous_fun usage_msg;
Enter fullscreen mode Exit fullscreen mode

And the cram tests need to reflect the output as well:

diff --git a/bin/usage.t b/bin/usage.t
index a8fc82f..b488efe 100644
--- a/bin/usage.t
+++ b/bin/usage.t
@@ -7,7 +7,14 @@ Using the Tsonnet program:
   "Hello, world!"

   $ tsonnet ../samples/literals/object.jsonnet
-  {"int_attr": 1, "float_attr": 4.200000, "string_attr": "Hello, world!", "null_attr": null, "array_attr": [1, false, {}], "obj_attr": {"a": true, "b": false, "c": {"d": [42]}}}
+  {
+    "int_attr": 1,
+    "float_attr": 4.2,
+    "string_attr": "Hello, world!",
+    "null_attr": null,
+    "array_attr": [ 1, false, {} ],
+    "obj_attr": { "a": true, "b": false, "c": { "d": [ 42 ] } }
+  }

   $ tsonnet ../samples/binary_operations.jsonnet
-  44.700000
+  44.7 
Enter fullscreen mode Exit fullscreen mode

One trick that makes our lives super easy is the fact that dune has a feature called promote.

What does it do? If the tests run and the output is correct, but the diff is conflicting, you have the option to promote it, which means that it will apply the diff for you with a single command:

$ dune runtest --auto-promote
Enter fullscreen mode Exit fullscreen mode

The command above will re-run the tests and apply the diff of the output to the respective files.

This is super handy! I won't go into details here, but I really recommend reading the Diffing and Promotion documentation.

Beware, this can make you grumpy against your current building tool -- approach with caution. It feels like magic, really! 😜

It feels much better having a pretty-printed output, isn't it?!


Thanks for reading Bit Maybe Wise! Subscribe to receive new posts about Tsonnet.

Photo by Pete Godfrey on Unsplash

Top comments (0)