Welcome to the Tsonnet series!
If you're just joining us, you can check out how it all started in the first post of the series.
In the previous post, we replaced exception handling with monadic error handling:
Now that we've replaced exceptions with the result type, let's improve our error handling to deal with malformed strings.
Handling unterminated string
Here's a Jsonnet sample containing an unterminated string that we'll use to test Tsonnet:
diff --git a/samples/errors/malformed_string.jsonnet b/samples/errors/malformed_string.jsonnet
new file mode 100644
index 0000000..bac036e
--- /dev/null
+++ b/samples/errors/malformed_string.jsonnet
@@ -0,0 +1 @@
+"oops... no end quote
Feeding it to Jsonnet get us the following output:
$ jsonnet samples/errors/malformed_string.jsonnet
samples/errors/malformed_string.jsonnet:1:1 Unterminated String
"oops... no end quote
$ echo $?
1
We want a similar output and the same exit error code.
The lexer already has code to catch malformed strings, but I forgot to add a test case for it before.
Let's add a new cram test to cover it:
diff --git a/test/cram/errors.t b/test/cram/errors.t
new file mode 100644
index 0000000..75db079
--- /dev/null
+++ b/test/cram/errors.t
@@ -0,0 +1,3 @@
+ $ tsonnet ../../samples/errors/malformed_string.jsonnet
+ String is not terminated
+ [1]
The error message differs slightly from Jsonnet's output, but that's acceptable -- we don't need an exact match. While we're missing the file location, line number, and column information in the error trace, we'll implement those in a future update.
The exit code can be expressed in a cram test by putting the exit code between square brackets after the returned error message. This is the pattern used by dune to assert the exit code.
In order to catch the exceptions raised by the lexer, we need to expose it to the other modules. In OCaml we do that by writing the public signatures of functions and definitions in a .mli
file -- similar to C header files, but OCaml:
diff --git a/lib/lexer.mli b/lib/lexer.mli
new file mode 100644
index 0000000..453d61a
--- /dev/null
+++ b/lib/lexer.mli
@@ -0,0 +1,7 @@
+open Lexing
+open Parser
+
+exception SyntaxError of string
+
+val read : lexbuf -> token
+val read_string : Buffer.t -> lexbuf -> token
We could potentially replace the exceptions here by result types too. I want to explore this way of designing the lexer and parser, but I will refrain myself of doing it right now to avoid losing the focus on the purpose of this change -- I'll come back to it later.
And now we can catch the SyntaxError
exception in the parse
function in the Tsonnet module:
diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index f443742..0e525e2 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -2,12 +2,13 @@ open Ast
open Result
let (let*) = Result.bind
+let (>>=) = Result.bind
(** [parse s] parses [s] into an AST. *)
-let parse (s: string) : expr =
+let parse (s: string) =
let lexbuf = Lexing.from_string s in
- let ast = Parser.prog Lexer.read lexbuf in
- ast
+ try ok (Parser.prog Lexer.read lexbuf)
+ with | Lexer.SyntaxError err_msg -> error err_msg
let interpret_bin_op (op: bin_op) (n1: number) (n2: number) : expr =
match op, n1, n2 with
@@ -42,6 +43,4 @@ let rec interpret (e: expr) : (expr, string) result =
| _ -> error "invalid binary operation"
let run (s: string) : (string, string) result =
- let expr = parse s in
- let* expr = interpret expr in
- Json.expr_to_string expr
+ parse s >>= interpret >>= Json.expr_to_string
I took the liberty of using >>=
as an alias for chaining function calls returning Result. After you get used to it, this monadic style feels quite natural -- similar to the pipe operator in bash scripts! The bind
function unwraps the value from a Result and passes it to the next function, but only continues the chain if there's no failure.
Turns out Tsonnet is neither outputting a clean error message nor the correct exit code:
$ dune exec -- tsonnet samples/errors/malformed_string.jsonnet
Fatal error: exception Failure("String is not terminated")
$ echo $?
2
This is due to failwith
-- it raises an exception and halts the execution with exit code 2, which is not semantically correct. We need to explicitly print the message to stderr
and exit with code 1:
diff --git a/bin/main.ml b/bin/main.ml
index 5747a00..cab8f16 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -9,7 +9,8 @@ let run_parser filename =
close_in input_channel;
match Tsonnet.run content with
| Ok stringified_json -> print_endline stringified_json
- | Error err -> failwith err
+ | Error err -> prerr_endline err; exit 1
+
let () =
Arg.parse spec_list anonymous_fun usage_msg;
And now it does:
$ dune exec -- tsonnet samples/errors/malformed_string.jsonnet
String is not terminated
$ echo $?
1
Conclusion
With these changes, we've improved our error handling to properly report malformed strings, direct errors to stderr, and return semantically correct exit codes. These small but important details make Tsonnet more robust and user-friendly.
Missing a closing quote? Don't leave our Tsonnet series hanging! Subscribe to Bit Maybe Wise for more -- I promise all posts end properly 😉
Top comments (0)