Clojure 1.10 error messages

One of the things I spent the most time working on for Clojure 1.10 (along with Rich and Stu) is updated error messages. The thing we spent the most time working through was identifying the different phases of execution, how to distinguish them, and what to say when we found them. Some examples are :read-source, :macro-syntax-check, etc. See the whole list in the docs.

For each of these we looked more closely at what was being reported and what would ideally be more useful. We also tried to make the different phase messages both semantically useful but also follow some similar patterns for reporting the phase and location in the first line and original root cause in the second line.

Reader errors

The first phase of running Clojure is to invoke the reader which reads text and produces Clojure data structures (pre-evaluation). If you use an invalid token, you’ll get a reader error (here Clojure 1.10):

user=> :::5
Syntax error reading source at (REPL:2:0).
Invalid token: :::5

The first line indicates the phase “reading source” and the location “REPL:2:0”. Reader errors at the REPL will report the line read from the input stream, here 2, and the column of the invalid token. If the characters were being read from a source file, this would instead be the file name and the line and column in the file. The second line is the root cause message coming from the reader. Additionally, all exceptions occurring during read, macroexpansion, or compilation that are syntax-related are noted as “syntax” errors, indicating that something is wrong with the user’s code, not an error during execution.

We can compare this to the equivalent exception in Clojure 1.9:

user=> :::5
RuntimeException Invalid token: :::5  clojure.lang.Util.runtimeException (Util.java:221)

This is the same error, but it both shows things that aren’t useful (RuntimeException) and omits things that are (the location in the source of the syntax error, showing instead the location in the error handling implementation, not the user’s source).

Macroexpansion errors

Macroexpansion is also an area where the former exceptions typically indicated the location in the macro, rather than the syntax error in the user’s code. Macroexpansion syntax errors also include the class of macro spec errors.

For example, in 1.9:

user=> (cond 1)
IllegalArgumentException cond requires an even number of forms  clojure.core/cond (core.clj:600)

Based on a review of clojure.core and many open source macros, it was determined that the vast majority of explicit syntax checks in macro implementations were throwing IllegalArgumentException, IllegalStateException, or ExceptionInfo. These exception types, when throw from a macro, will be treated as :macro-syntax-check phase. All other exceptions thrown during macroexpansion are treated as :macroexpansion phase.

The same error in 1.10 will look like follows:

user=> (cond 1)
Syntax error macroexpanding cond at (REPL:1:1).
cond requires an even number of forms

Again, this error better classifies the phase, identifies it as a syntax error in the user’s code, and identifies the location in the user’s source, rather than the location in the macro.

Previously, all of this location data was stored only embedded in the error message string. Now, the exception being thrown from the compiler contains that information as ex-data for use by tools:

user=> (ex-data *e)
#:clojure.error{
  :phase :macro-syntax-check, :line 1, :column 1, 
  :source "NO_SOURCE_PATH", :symbol cond}

As I mentioned above, this phase also includes spec macro syntax errors. We spent some effort reworking how spec errors reports as well. I won’t go into the full details of that, but here’s an example:

user=> (let [x])
Syntax error macroexpanding clojure.core/let at (REPL:1:1).
[x] - failed: even-number-of-forms? at: [:bindings] spec: :clojure.core.specs.alpha/bindings

Compilation errors have a similar treatment - identifying the phase, whether it’s a syntax error, and the location in source (although this was done in 1.9 as well).

Execution errors

Seems like we should look at everyone’s favorite example error, division by 0! :)

In 1.10, execution errors get the same general treatment of identifying phase, location, and cause. We spent some extra time on filtering unnecessary stack frames at the top and demunging the Java frame to report the top Clojure function in the stack, which for something at the repl is going to be the top-level eval, but is often going to be a call in your own code that’s more informative:

user=> (/ 1 0)
Execution error (ArithmeticException) at user/eval144 (REPL:1).
Divide by zero

In 1.9 this error reported a location inside the Clojure implementation instead.

Tool support

All of the messages produced in Clojure 1.10 are supported by two functions that are also available for REPLs and other tools outside the main Clojure REPL - ex-triage and ex-str which in the Clojure repl are applied in a pipeline like this:

(-> *e Throwable->map clojure.main/ex-triage clojure.main/ex-str)

Tools can tap into that pipeline to inject or modify data or customize the final output.

This is still only scratching the surface of all the changes that were made, but hopefully it gives an idea of the kind of changes we’ve been working on.

Written on December 17, 2018