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 Error Codes

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

module Code =
struct
  (** All message codes used in your application. *)
  type t = (* ... *)

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

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

(** Include all the goodies from the asai library. *)
include Asai.Logger.Make(Code)

The most important step is to decide the message codes. It should be a meaningful classification of all the messages that could be sent to the end users. For example, UndefinedSymbol could be a reasonable code for a message about failing to find the definition of a symbol. Once you define the type of all message codes, you will have to define two functions default_severity and to_string:

  1. default_severity: Severity means how serious an end user should take your message (is it an error or a warning?), and this can be overwritten when a message is sent. It seems messages with the same message code usually come with the same severity, so we want you to define a default severity value for each message code. You can then save some typing later when sending a message.
  2. to_string: This function is to show the message code to the user. Ideally, it should give a short, Google-able string representation. 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 message 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 Messages

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

Logger.emit Greeting "Hello!";
(* continue doing other things *)

where Greeting is the message code of this message. The fancier version is emitf, which formats a message like printf and sends it:

Logger.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 Backend

Now your program is generating lots of messages, and you have to choose a backend to handle them. We will show how to display those messages in a terminal. Suppose your entry point module looks like this:

let () = (* your application code *)

You can use the terminal backend as follows:

module Term = Asai.Tty.Make (Logger.Code)

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

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 =
  Logger.trace "When calling f..." @@ fun () ->
  (* very important code *)

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

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

Note that, by default, the terminal backend will not show backtraces. You have to enable it as follows in your entry-point module:

module Term = Asai.Tty.Make (Logger.Code)

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

We do not recommend adding trace to every single function. Remember that they have to make sense to end users!

Add Location Information

Good messages also help end users locate the issues in their program or proof. Here, a location is a range of text from a file, which we call span. Many functions in your Logger take an optional location argument loc, including trace, which should be a span highlighting the most relevant 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:

Logger.emit ~loc Greeting "Hello again!";
(* continue doing other things *)

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

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

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

try Grammar.start Lex.token lexbuf with
| Lex.SyntaxError token ->
  Logger.fatalf ~loc:(Span.of_lexbuf lexbuf) ParsingError
    {|Unrecognized token "%s"|} String.escaped token
| Grammar.Error ->
  Logger.fatal ~loc:(Span.of_lexbuf lexbuf) ParsingError
    "Could not parse the program"

Please take a look at Asai.Span to learn all kinds of ways to create a span!

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

Logger.trace ~loc "When checking this code..." @@ fun () ->
(* ... *)
Logger.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:

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

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

Logger.merge_loc (Some loc) @@ fun () ->
(* ... *)
Logger.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 messages 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.Logger. You want to painlessly incorporate the library.

Extend Your Code Module

The first step is to extend your message code type so that it can embed all message codes from the library. Open up your Logger.ml and update the type and functions as follows. It is recommended to add a helper function (such as cool) to save typing, and this tutorial will assume you have done that.

module Code =
struct
  (** All message codes used in your application. *)
  type t = (* ... *)
    | Cool of CoolLibrary.Logger.t (* Embed all message codes from [CoolLibrary]. *)

  (** The default severity of messages with a particular message code. *)
  let default_severity : t -> Asai.Diagnostic.severity =
    function
    (* ... *)
    | Cool c -> CoolLibrary.Logger.default_severity c

  (** A short, concise, ideally Google-able string representation for each message code. *)
  let to_string : t -> Asai.Diagnostic.severity =
    function
    (* ... *)
    | Cool c -> CoolLibrary.Logger.to_string c

  (** It is recommended to add a helper function to save typing. *)
  let cool c = Cool c
end

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

let lift_cool f = adopt (Asai.Diagnostic.map Code.cool) CoolLibrary.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 is inspired by the monadic lifting from Haskell.

Use the Lifting

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

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

That's it!

Customize the Lifting

Suppose you want to modify the messages from the cool library before sending them to end users. Open your Logger.ml again and replace (Asai.Diagnostic.map Code.cool) within lift_cool

let lift_cool f = adopt (Asai.Diagnostic.map Code.cool) CoolLibrary.Logger.run f

with any function of type CoolLibrary.Code.t Asai.Diagnostic.t -> Code.t Asai.Diagnostic.t. Say you have defined your own message transformation as function embed_cool. The new lifting is:

let lift_cool f = adopt embed_cool CoolLibrary.run f

Treat All Messages as Errors

🚧 Note: we might come up with a new, cooler API to simplify this section.

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

let all_as_errors f =
  try_with
    ~emit:(fun d -> emit_diagnostic {d with severity = Error})
    ~fatal:(fun d -> fatal_diagnostic {d with severity = Error})
    f

And then use Logger.all_as_errors to turn all messages into errors:

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

Note that turning a message into an error does not abort the computation. all_as_errors only makes the message look scarier and it will not affect the control flow. If you wish to also abort the program the moment any message is sent, replace emit_diagnostic with fatal_diagnostic:

let abort_at_any f =
  try_with
    ~emit:(fun d -> fatal_diagnostic {d with severity = Error})
    ~fatal:(fun d -> fatal_diagnostic {d with severity = Error})
    f

Within abort_at_any, every message will become a fatal error:

Logger.abort_at_any @@ fun () -> (* any message will be an error AND abort the program *)

Recover from Fatal Messages

Just like the usual try ... with in OCaml, you can use Logger.try_with to intercept fatal messages. However, unlike messages sent via emit, there is no way to resume the aborted computation (as what you can do with an OCaml exception). Therefore, you have to provide a new value as a replacement. For example, the code:

Logger.try_with ~fatal:(fun _ -> 42) @@ fun () -> Logger.fatal "abort!"

will give you the number 42 in the end. It intercepts the fatal message 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.

OCaml

Innovation. Community. Security.