DEV Community

Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at open.substack.com

Tsonnet #1 - JSON literals

In the previous post, I bootstrapped the Tsonnet project and implemented the parsing of integer literals.

In this post, let's continue parsing other literal values.

Let's start with the most boring and dreaded value: null.

Parsing nulls

We need to add null to our expr type:

diff --git a/lib/ast.ml b/lib/ast.ml
index fa75c4b..c9b1836 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -1,2 +1,3 @@
 type expr =
   | Int of int
+  | Null
Enter fullscreen mode Exit fullscreen mode

The lexer:

diff --git a/lib/lexer.mll b/lib/lexer.mll
index 41f1494..982a22d 100644
--- a/lib/lexer.mll
+++ b/lib/lexer.mll
@@ -7,10 +7,12 @@ let white = [' ' '\t']+
 let newline = '\r' | '\n' | "\r\n"
 let digit = ['0'-'9']
 let int = '-'? digit+
+let null = "null"

 rule read =
   parse
   | white { read lexbuf }
   | newline { new_line lexbuf; read lexbuf }
   | int { INT (int_of_string (Lexing.lexeme lexbuf)) }
+  | null { NULL }
   | eof { EOF }
Enter fullscreen mode Exit fullscreen mode

And the parser:

diff --git a/lib/parser.mly b/lib/parser.mly
index d931e03..0440015 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -1,4 +1,5 @@
 %token <int> INT
+%token NULL
 %token EOF

 %start <Ast.expr> prog
@@ -11,4 +12,5 @@ prog:

 expr:
   | i = INT { Int i }
+  | NULL { Null }
   ;
Enter fullscreen mode Exit fullscreen mode

The OCaml compiler now complains that the pattern matching on the print function is missing the Null type. We need to fix that:

diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index c3b7bf1..6462a03 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -8,3 +8,4 @@ let parse (s: string) : expr =

 let print ast = match ast with
   | Int i -> Printf.printf "Int %d\n" i
+  | Null -> print_endline "null"
Enter fullscreen mode Exit fullscreen mode

With null implementation complete, let's move on to handling floating-point numbers.

Parsing floats

Again, adding float to the expr type:

diff --git a/lib/ast.ml b/lib/ast.ml
index c9b1836..17abdd7 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -1,3 +1,4 @@
 type expr =
   | Int of int
+  | Float of float
   | Null
Enter fullscreen mode Exit fullscreen mode

The lexer needs a little bit more of attention now:

diff --git a/lib/lexer.mll b/lib/lexer.mll
index 982a22d..b0c87ba 100644
--- a/lib/lexer.mll
+++ b/lib/lexer.mll
@@ -7,6 +7,9 @@ let white = [' ' '\t']+
 let newline = '\r' | '\n' | "\r\n"
 let digit = ['0'-'9']
 let int = '-'? digit+
+let frac = '.' digit*
+let exp = ['e' 'E']['-' '+']? digit+
+let float = digit* frac? exp?
 let null = "null"

 rule read =
@@ -14,5 +17,6 @@ rule read =
   | white { read lexbuf }
   | newline { new_line lexbuf; read lexbuf }
   | int { INT (int_of_string (Lexing.lexeme lexbuf)) }
+  | float { FLOAT (float_of_string (Lexing.lexeme lexbuf)) }
   | null { NULL }
   | eof { EOF }
Enter fullscreen mode Exit fullscreen mode

We add few patterns (frac, exp) to make it easier to add the float token definition. This will allow us to write floats in multiple formats such as 4.2, .42, 4.e2, +4.e-10, etc.

In the rule function, the lexer function call Lexing.lexeme lexbuf is going to return the value for us as a string. We need to convert it to float using the float_of_string.

The parser doesn't need to do much after that:

diff --git a/lib/parser.mly b/lib/parser.mly
index 0440015..0be9c98 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -1,4 +1,5 @@
 %token <int> INT
+%token <float> FLOAT
 %token NULL
 %token EOF

@@ -12,5 +13,6 @@ prog:

 expr:
   | i = INT { Int i }
+  | f = FLOAT { Float f }
   | NULL { Null }
   ;
Enter fullscreen mode Exit fullscreen mode

And the print function:

diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index 6462a03..2d7207b 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -8,4 +8,5 @@ let parse (s: string) : expr =

 let print ast = match ast with
   | Int i -> Printf.printf "Int %d\n" i
-  | Null -> print_endline "null"
+  | Float f -> Printf.printf "Float %f\n" f
+  | Null -> print_endline "Null"
Enter fullscreen mode Exit fullscreen mode

Now that we've handled numeric types, let's implement boolean values.

Parsing booleans

It's becoming repetitive and booleans are boring, you got the idea from the previous examples, so here's the entire diff:

diff --git a/lib/ast.ml b/lib/ast.ml
index 17abdd7..f92acfc 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -2,3 +2,4 @@ type expr =
   | Int of int
   | Float of float
   | Null
+  | Bool of bool
diff --git a/lib/lexer.mll b/lib/lexer.mll
index b0c87ba..a1e331e 100644
--- a/lib/lexer.mll
+++ b/lib/lexer.mll
@@ -11,6 +11,7 @@ let frac = '.' digit*
 let exp = ['e' 'E']['-' '+']? digit+
 let float = digit* frac? exp?
 let null = "null"
+let bool = "true" | "false"

 rule read =
   parse
@@ -19,4 +20,5 @@ rule read =
   | int { INT (int_of_string (Lexing.lexeme lexbuf)) }
   | float { FLOAT (float_of_string (Lexing.lexeme lexbuf)) }
   | null { NULL }
+  | bool { BOOL (bool_of_string (Lexing.lexeme lexbuf)) }
   | eof { EOF }
diff --git a/lib/parser.mly b/lib/parser.mly
index 0be9c98..0ea03a0 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -1,6 +1,7 @@
 %token <int> INT
 %token <float> FLOAT
 %token NULL
+%token <bool> BOOL
 %token EOF

 %start <Ast.expr> prog
@@ -15,4 +16,5 @@ expr:
   | i = INT { Int i }
   | f = FLOAT { Float f }
   | NULL { Null }
+  | b = BOOL { Bool b }
   ;
diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index 2d7207b..c4984cc 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -6,7 +6,8 @@ let parse (s: string) : expr =
   let ast = Parser.prog Lexer.read lexbuf in
   ast

-let print ast = match ast with
+let print = function
   | Int i -> Printf.printf "Int %d\n" i
   | Float f -> Printf.printf "Float %f\n" f
   | Null -> print_endline "Null"
+  | Bool b -> Printf.printf "Bool %b\n" b
Enter fullscreen mode Exit fullscreen mode

With primitive types (numbers and booleans) in place, it's time to tackle string parsing.

Parsing strings

diff --git a/lib/ast.ml b/lib/ast.ml
index f92acfc..9a9358c 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -3,3 +3,4 @@ type expr =
   | Float of float
   | Null
   | Bool of bool
+  | String of string
Enter fullscreen mode Exit fullscreen mode

Strings need to be handled with extra care when tokenizing them:

diff --git a/lib/lexer.mll b/lib/lexer.mll
index a1e331e..b2eb1b4 100644
--- a/lib/lexer.mll
+++ b/lib/lexer.mll
@@ -1,6 +1,7 @@
 {
   open Lexing
   open Parser
+  exception SyntaxError of string
 }

 let white = [' ' '\t']+
@@ -21,4 +22,22 @@ rule read =
   | float { FLOAT (float_of_string (Lexing.lexeme lexbuf)) }
   | null { NULL }
   | bool { BOOL (bool_of_string (Lexing.lexeme lexbuf)) }
+  | '"' { read_string (Buffer.create 16) lexbuf }
+  | _ { raise (SyntaxError ("Unexpected char: " ^ Lexing.lexeme lexbuf)) }
   | eof { EOF }
+and read_string buf =
+  parse
+  | '"' { STRING (Buffer.contents buf) }
+  | '\\' '/'  { Buffer.add_char buf '/'; read_string buf lexbuf }
+  | '\\' '\\' { Buffer.add_char buf '\\'; read_string buf lexbuf }
+  | '\\' 'b'  { Buffer.add_char buf '\b'; read_string buf lexbuf }
+  | '\\' 'f'  { Buffer.add_char buf '\012'; read_string buf lexbuf }
+  | '\\' 'n'  { Buffer.add_char buf '\n'; read_string buf lexbuf }
+  | '\\' 'r'  { Buffer.add_char buf '\r'; read_string buf lexbuf }
+  | '\\' 't'  { Buffer.add_char buf '\t'; read_string buf lexbuf }
+  | [^ '"' '\\']+
+    { Buffer.add_string buf (Lexing.lexeme lexbuf);
+      read_string buf lexbuf
+    }
+  | _ { raise (SyntaxError ("Illegal string character: " ^ Lexing.lexeme lexbuf)) }
+  | eof { raise (SyntaxError ("String is not terminated")) }
Enter fullscreen mode Exit fullscreen mode

Here we define a new exception SyntaxError to handle typos and unexpected syntax--we were missing it before. The error handling deserves careful attention. Nobody likes to use a programming language with cryptic error messages. But we are not there yet, so I'm postponing the friendlier error messages to a future iteration.

The rule read as soon as it matches a double-quote opening will call the supplementary rule read_string and pass a Buffer of size 16--the buffer size increases automatically if the string needs to allocate more than that.

The read_string rule will call itself recursively, handling escapes sequences, until it finds a closing double-quote, signalling the end of the string. In case of a illegal char or the lexer reaches the end of the file before the closing quote, an exception is raised.

Following to the parser and print function, the implementation is straightforward:

diff --git a/lib/parser.mly b/lib/parser.mly
index 0ea03a0..2e1adc4 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -2,6 +2,7 @@
 %token <float> FLOAT
 %token NULL
 %token <bool> BOOL
+%token <string> STRING
 %token EOF

 %start <Ast.expr> prog
@@ -17,4 +18,5 @@ expr:
   | f = FLOAT { Float f }
   | NULL { Null }
   | b = BOOL { Bool b }
+  | s = STRING { String s }
   ;
diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index c4984cc..301f3a9 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -11,3 +11,4 @@ let print = function
   | Float f -> Printf.printf "Float %f\n" f
   | Null -> print_endline "Null"
   | Bool b -> Printf.printf "Bool %b\n" b
+  | String s -> Printf.printf "\"%s\"\n" s
Enter fullscreen mode Exit fullscreen mode

Disclaimer: it's worth noting that ocamllex is not unicode friendly. I want Tsonnet to support unicode, but I'm postponing this complexity for later. Perhaps using the sedlex package for that.

Having implemented all the basic JSON types, we can now move on to composite types, starting with arrays. Arrays present our first challenge in handling collections of values, and they'll require us to make our expression type recursive to support nested structures.

 Parsing arrays

Arrays deserve a bit more of attention. During parsing, each value will be an expression. Fortunately, OCaml's type system is expressive enough to allow recursive type definitions, so we can self reference the expr type and since we want Tsonnet arrays to be immutable, let's use a list of expr:

diff --git a/lib/ast.ml b/lib/ast.ml
index 9a9358c..734e487 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -4,3 +4,4 @@ type expr =
   | Null
   | Bool of bool
   | String of string
+  | Array of expr list
Enter fullscreen mode Exit fullscreen mode

The lexer already has code to tokenize the expressions, but we are missing the square brackets and comma tokens:

diff --git a/lib/lexer.mll b/lib/lexer.mll
index b2eb1b4..9ff73b9 100644
--- a/lib/lexer.mll
+++ b/lib/lexer.mll
@@ -23,6 +23,9 @@ rule read =
   | null { NULL }
   | bool { BOOL (bool_of_string (Lexing.lexeme lexbuf)) }
   | '"' { read_string (Buffer.create 16) lexbuf }
+  | '[' { LEFT_SQR_BRACKET }
+  | ']' { RIGHT_SQR_BRACKET }
+  | ',' { COMMA }
   | _ { raise (SyntaxError ("Unexpected char: " ^ Lexing.lexeme lexbuf)) }
   | eof { EOF }
 and read_string buf =
Enter fullscreen mode Exit fullscreen mode

Menhir has the helper function separated_list to facilitate parsing values separated by a token. It fits like a glove for arrays!

diff --git a/lib/parser.mly b/lib/parser.mly
index 2e1adc4..979f43d 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -3,6 +3,9 @@
 %token NULL
 %token <bool> BOOL
 %token <string> STRING
+%token LEFT_SQR_BRACKET
+%token RIGHT_SQR_BRACKET
+%token COMMA
 %token EOF

 %start <Ast.expr> prog
@@ -19,4 +22,8 @@ expr:
   | NULL { Null }
   | b = BOOL { Bool b }
   | s = STRING { String s }
+  | LEFT_SQR_BRACKET; values = list_fields; RIGHT_SQR_BRACKET { Array values }
   ;
+
+list_fields:
+  vl = separated_list(COMMA, expr) { vl };
Enter fullscreen mode Exit fullscreen mode

The print function now needs to be converted to a recursive function to handle arrays:

diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index 301f3a9..7eae3c8 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -6,9 +6,10 @@ let parse (s: string) : expr =
   let ast = Parser.prog Lexer.read lexbuf in
   ast

-let print = function
-  | Int i -> Printf.printf "Int %d\n" i
-  | Float f -> Printf.printf "Float %f\n" f
-  | Null -> print_endline "Null"
-  | Bool b -> Printf.printf "Bool %b\n" b
-  | String s -> Printf.printf "\"%s\"\n" s
+let rec print = function
+  | 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))
Enter fullscreen mode Exit fullscreen mode

Building on our array implementation, we can now handle objects - the most complex JSON type. Objects are similar to arrays in that they're collections, but they introduce the additional complexity of key-value pairs and require careful consideration of lookup performance.

 Parsing objects

An object is a collection of string keys containing expressions as values. I'm keeping it as a list containing tuples (key, expr) for now, for the sake of simplicity:

diff --git a/lib/ast.ml b/lib/ast.ml
index 734e487..4ceb13d 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -5,3 +5,4 @@ type expr =
   | Bool of bool
   | String of string
   | Array of expr list
+  | Object of (string * expr) list
Enter fullscreen mode Exit fullscreen mode

Later on, it might be worth using a Hashtbl to improve performance lookups for attributes and methods. Right now, we don't have this need yet. I'm going to refactor the object definition when the time comes.

The lexer and parser need only to be updated to add tokens for the curly brackets and colon symbols and the print function is almost the same as for arrays:

diff --git a/lib/lexer.mll b/lib/lexer.mll
index 9ff73b9..a581fcd 100644
--- a/lib/lexer.mll
+++ b/lib/lexer.mll
@@ -25,7 +25,10 @@ rule read =
   | '"' { read_string (Buffer.create 16) lexbuf }
   | '[' { LEFT_SQR_BRACKET }
   | ']' { RIGHT_SQR_BRACKET }
+  | '{' { LEFT_CURLY_BRACKET }
+  | '}' { RIGHT_CURLY_BRACKET }
   | ',' { COMMA }
+  | ':' { COLON }
   | _ { raise (SyntaxError ("Unexpected char: " ^ Lexing.lexeme lexbuf)) }
   | eof { EOF }
 and read_string buf =
diff --git a/lib/parser.mly b/lib/parser.mly
index 979f43d..6872459 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -6,6 +6,9 @@
 %token LEFT_SQR_BRACKET
 %token RIGHT_SQR_BRACKET
 %token COMMA
+%token LEFT_CURLY_BRACKET
+%token RIGHT_CURLY_BRACKET
+%token COLON
 %token EOF

 %start <Ast.expr> prog
@@ -23,7 +26,14 @@ expr:
   | b = BOOL { Bool b }
   | s = STRING { String s }
   | LEFT_SQR_BRACKET; values = list_fields; RIGHT_SQR_BRACKET { Array values }
+  | LEFT_CURLY_BRACKET; attrs = obj_fields; RIGHT_CURLY_BRACKET { Object attrs }
   ;

 list_fields:
   vl = separated_list(COMMA, expr) { vl };
+
+obj_field:
+  k = STRING; COLON; v = expr { (k, v) };
+
+obj_fields:
+    obj = separated_list(COMMA, obj_field) { obj };
diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index 7eae3c8..98ac680 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -13,3 +13,10 @@ let rec print = function
   | 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)
+    )
Enter fullscreen mode Exit fullscreen mode

Formatting the JSON output with Printf is becoming annoying and doesn't scale properly, but we'll come back to it soon.

 Concluding

$ dune exec -- tsonnet samples/literals/object.jsonnet
{
  "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 ] } }
}
Enter fullscreen mode Exit fullscreen mode

So far, so good! We can now handle plain JSON.

I've been ignoring an important part, super relevant to guarantee the language spec in meeting its requirements: tests. We can't proceed without tests. I'm excited to share how I'm using cram tests to write automated tests.

Until then!


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

Top comments (0)