package asai

  1. Overview
  2. Docs

Quickstart Tutorial

This tutorial is for an implementer (you!) to adopt this library as quickly as possible. We will assume you are already familiar with OCaml and are using a typical OCaml package structure.

Define the Message Type

The first step is to create a file Reporter.ml with the following template:

module Message =
struct
  (** The type of all messages used in your application. *)
  type t =
    | (* ... *)
    | (* ... *)
    | (* ... *)

  (** The default severity level of diagnostics with a particular message. *)
  let default_severity : t -> Asai.Diagnostic.severity =
    function
    | (* ... *) -> Bug
    | (* ... *) -> Error
    | (* ... *) -> Warning

  (** A short, concise, ideally Google-able string representation for each message. *)
  let short_code : t -> string =
    function
    | (* ... *) -> "E0001"
    | (* ... *) -> "E0002"
    | (* ... *) -> "E0003"
end

(** Include all the goodies from the asai library. *)
include Asai.Reporter.Make(Message)

The most important step is to define the type of messages. It should be a meaningful classification of all the diagnostics you want to send to the end user. For example, UndefinedSymbol could be a reasonable message about failing to find the definition of a symbol. TypeError could be another reasonable message about ill-typed terms. Don't worry about missing details in the message type---you can attach free-form text, location information, and additional remarks to a message. Once you have defined the type of all messages, you will have to define two functions default_severity and short_code:

  1. default_severity: Severity levels describe how serious the end user should take your message (is it an error or a warning?). It seems diagnostics with the same message usually come with the same severity level, so we want you to define a default severity level for each message. You can then save some typing later when sending a diagnostic.
  2. short_code: This function is to show a message as short code to the end user. Ideally, the short code should be a Google-able string representation for the end user to find more explanations. Please do not use long descriptions such as "scope-error: undefined symbols" The library will give you plenty of opportunities to add as many details as you want to a message, but not here. The short code should be unambiguous, easily recognizable, and "machine-readable without ChatGPT."

Once you have filled out the template, run dune build or other tools to check that everything compiles. If so, you are ready for the next step.

Start Sending Diagnostics

Now, go to the places where you want to send a message to the end user, be it a warning or an error. If you want to print a message and continue the execution, you can emit a string:

Reporter.emit Greeting "hello";
(* continue doing other things *)

where Greeting is the message and "Hello!" is the free-form text that explains the message. The fancier version is emitf, which formats the text like printf and sends it:

Reporter.emitf TypeError "@[<2>this term doesn't look right:@ %a@]" Syntax.pp term;
(* continue doing other things *)

There is an important limitation of emitf though: you should not include any control character (for example the newline character \n) anywhere when using emitf. Use break hints (such as @, and @ ) and boxes instead. See Stdlib.Format for more information on boxes and break hints.

If you wish to terminate your program after sending a message instead of continuing the execution, use fatal instead of emit. There's also a fancier fatalf that works in the same way as emitf.

Choose a Diagnostic Handler

Now that your program is generating lots of messages, you need a handler to deal with them. The library comes with a stock handler to display those messages in a terminal. Suppose your entry point module looks like this:

let () =
  (* your application code *)

You can use the terminal handler as follows:

module Term = Asai.Tty.Make(Reporter.Message)

let () =
  Reporter.run ~emit:Term.display ~fatal:Term.display @@ fun () ->
  (* your application code *)

A handler is actually just a function that takes a diagnostic. Here, any function of type Reporter.Code.t Diagnostic.t -> unit would have worked.

Add Backtraces

Great messages come with meaningful backtraces. To add backtraces, you will have to "annotate" your code to generate meaningful stack frames. Suppose this is one of the functions whose invocation should be noted in user-facing backtraces:

let f x y =
  (* very important code *)

Add trace to add a frame to the current backtrace:

let f x y =
  Reporter.trace "when calling f" @@ fun () ->
  (* very important code *)

Similar to emitf, there is also tracef which allows you to format texts:

let f x y =
  Reporter.tracef "when calling f on %d and %d" x y @@ fun () ->
  (* very important code *)

We do not recommend adding trace to every single function. Remember they should make sense to the end user!

PS: We have a GitHub issue on spewing debugging information for developers (you!), not the end user. Your comments will help us complete the design.

Add Location Information

Good diagnostics also help the end user locate the issues in their program or proof. Here, a location is a range of text from a file or a string. Many functions in your Reporter take an optional location argument loc, including trace, which should be a range highlighting the most relevant part of the text. For example, maybe the term which does not type check should be highlighted. The asai library will take the location information and draw fancy Unicode art on the screen to highlight the text. Here is one snippet showing the usage:

Reporter.emit ~loc Greeting "hello again";
(* continue doing other things *)

You can use Range.make to create such a range manually. However, if you are using ocamllex and Menhir, you certainly want to use provided helper functions. One of them is Range.locate; you can add these lines in your Menhir grammar to generated a node annotated with its location:

%inline
locate(X):
  | e = X
    { Asai.Range.locate_lex $loc e }

The annotated node will have type data Range.located where data is the output type of X. Another one is Range.of_lexbuf, which comes in handy when reporting a parsing error:

try Grammar.start Lex.token lexbuf with
| Lex.SyntaxError token ->
  Reporter.fatalf ~loc:(Range.of_lexbuf lexbuf) ParsingError
    "unrecognized token `%s'" (String.escaped token)
| Grammar.Error ->
  Reporter.fatal ~loc:(Range.of_lexbuf lexbuf) ParsingError
    "failed to parse the code"

Please take a look at Asai.Range to learn all kinds of ways to create a range!

Note that Reporter will remember and reuse the innermost specified location, and thus you do not have to explicitly pass it. For example, in the following code

Reporter.trace ~loc "when checking this code" @@ fun () ->
(* ... *)
Reporter.emit "wow" (* using the location [loc] from above *)
(* ... *)

the inner message "wow" will inherit the location loc from the outer trace function call! You can also use merge_loc to "remember" a location for later use, which is helpful when you want to remember a location but not to leave a trace:

Reporter.merge_loc (Some loc) @@ fun () ->
(* ... *)
Reporter.emit "wow" (* using the location [loc] from above *)
(* ... *)

Of course, you can always pass a new location to overwrite the remembered one:

Reporter.merge_loc (Some loc) @@ fun () ->
(* ... *)
Reporter.emit ~loc:real_loc "wow" (* using [real_loc] instead  *)
(* ... *)

Use a Library that Uses asai

Suppose you wanted to use a cool OCaml library which is also using asai (which is probably why it is cool), how should you display the diagnostics from the library as if they are yours? Let's assume the library exposes a module CoolLibrary, and the library authors also followed this tutorial to create a module called CoolLibrary.Reporter. You want to painlessly incorporate the library.

Extend Your Reporter

The first step is to extend your message type so that it can embed all messages from the library. Open up your Reporter.ml and update the type and functions as follows.

module Message =
struct
  (** The type of all messages used in your application. *)
  type t =
    (* ... *)
    | Cool of CoolLibrary.Reporter.t (** Embedding all messages from [CoolLibrary]. *)

  (** The default severity level of diagnostics with a particular message. *)
  let default_severity : t -> Asai.Diagnostic.severity =
    function
    (* ... *)
    | Cool _ -> assert false (* You probably should not create new diagnostics using the cool library's messages. *)

  (** A short, concise, ideally Google-able string representation for each message. *)
  let short_code : t -> string =
    function
    (* ... *)
    | Cool c -> CoolLibrary.Reporter.short_code c

  (** It is recommended to add a helper function (such as [cool]) to save typing,
      and this tutorial will assume you have done that. *)
  let cool c = Cool c
end

After updating the module, move to the end of the Reporter.ml and add the following line:

let lift_cool f = adopt (Asai.Diagnostic.map Message.cool) CoolLibrary.Reporter.run f

Remember to run dune build or your development tool to check that everything still compiles. Now you are ready to call any function in the cool library!

PS: If you know Haskell, yes, the name lift was inspired by the monadic lifting from Haskell.

Use the Lifting

Whenever you want to use the cool library, wrap the code under Reporter.lift_cool---it will take care of backtraces, locations, effects, etc.

Reporter.lift_cool @@ fun () ->
CoolLibrary.cool_function "argument" 123

That's it!

No Need to Wrap Errors

It is tempting to consider wrapping errors (e.g., advocated in Go). However, it seems good backtraces make error wrapping obsolete. To see why one might wish to wrap errors, consider the following code:

Reporter.trace "when loading settings" @@ fun () ->
let content = Reporter.lift_cool @@ fun () ->
  CoolLibrary.read "/path/to/some/file.json"
in
(* ... *)

When the file does not exist, the cool library might output the message that the file does not exist. Together with the trace, the terminal handler will output

 → error[E123]
 ꭍ ○ when loading settings
 ○ file `/path/to/some/file.json' does not exist

This message is not bad, but suboptimal. There's a disconnection between "settings" and /path/to/some/file.json---how exactly is this file relevant? It is tempting to directly edit the text in the diagnostic to include such information, that is, wrapping the error. However, we suggest improving the trace instead:

let file_path = "/path/to/some/file.json" in
Reporter.trace "when loading settings from file `%s'" file_path @@ fun () ->
let content = Reporter.lift_cool @@ fun () ->
  CoolLibrary.read file_path
in
(* ... *)

so that the output is

 → error[E123]
 ꭍ ○ when loading settings from file `/path/to/some/file.json'
 ○ file `/path/to/some/file.json' does not exist

There's no conceptual gap in the message anymore!

Treat All Diagnostics as Errors

If you want to turn everything into an error, add the following lines to the end of your Reporter.ml:

let all_as_errors f = map_diagnostic (fun d -> {d with severity = Error}) f

And then use Reporter.all_as_errors to turn all diagnostics into errors:

Reporter.all_as_errors @@ fun () -> (* any diagnostic sent here will be an error *)

Note that turning a diagnostic into an error does not abort the computation. all_as_errors only makes diagnostics look scarier and it will not affect the control flow. If you instead wish to abort the program the moment any diagnostic is sent, no matter whether it is a warning or an error, do this:

let abort_at_any f = map_diagnostic fatal_diagnostic f

Within abort_at_any, every diagnostic will become fatal:

Reporter.abort_at_any @@ fun () -> (* any diagnostic will abort the program *)

Recover from Fatal Diagnostics

Just like the usual try ... with in OCaml, you can use Reporter.try_with to intercept fatal diagnostics. However, unlike diagnostics sent via emit, there is no way to resume the aborted computation (as what you can expect from OCaml exceptions). Therefore, you have to provide a new value as a replacement. For example,

Reporter.try_with ~fatal:(fun _ -> 42) @@ fun () -> Reporter.fatal Abort "abort"

will give you the number 42 in the end. It intercepts the fatal diagnostic and gives 42 instead.

There are More!

We are still expanding this tutorial, but in the meanwhile, you can also check out our 📔 API reference.