Hemlock language insights

A String of Closures


We recently added a creature comfort to formatted printing that transforms strings like "%u=(^x^)" to "x=42", and after additional reflection we realized we were tantalizingly close to having a general stringification mechanism. What did we do about it? We added generalized stringification syntax, of course! (|...|) delimits a stringified closure; read on for details.

Hemlock doesn’t have a preprocessor like cpp for C/C++, nor does it have metaprogramming facilities like Camlp4 or ppx for OCaml. This is an intentional design decision that limits tooling complexity, especially as related to integrated development environments (IDEs), but the tradeoff is that various boilerplate code is challenging or impossible to avoid. Our experience with first-class modules suggests that truly onerous boilerplate will not proliferate in Hemlock projects, and we are willing to manually implement boilerplate as complex as comparators, pretty-printers, and [de]serializers if it means avoiding brittle metaprogramming facilities. Nonetheless, metaprogramming can be extremely useful, and our reservations are entirely complexity-based rather than utility-based.

Hemlock already needed a stringification mechanism in order to support assert, and in fact this required that assert be a keyword so that the compiler could stringify its parameter.

let x = 13
assert (x = 42)
# -> halt "Failed assertion: x = 42"

We can do better now.

type capture 'a >e: capture a e =
    code: string
    closure: unit >e-> a

# val assert >e: capture bool e >hlt-> unit
let assert {code; closure} =
    match closure () with
    | true -> ()
    | false -> halt "Failed assertion: %s(^code^)"

let x = 13
assert (|x = 42|)
# -> halt "Failed assertion: x = 42"


It is worth mentioning that we initially planned to make capture 'a >e a richer record which also provided module, path, line, and col (column), but we simplified the design to avoid cascading cache invalidation during incremental compilation. We realized that if assertions appeared to depend on location within the file, innocuous edits could invalidate memoized compiler artifacts. This commits us to relying on runtime debug metadata when reporting source locations, but we already planned to routinely generate halt backtraces, so this is merely an application API consideration, rather than pushing a hard problem elsewhere.

Other uses

We prefer the C-style assert which stringifies the expression, but other languages/frameworks instead use a signature more like bool -> string -> unit, where the string is a description of the assertion. Of course developers can implement assert alternatives that match their desires, but Hemlock’s assert can be (ab)used to achieve the same effect on the odd occasion.

let x = 13
assert {(|x = 42|) with code="What is the question?"}
assert {closure=(x = 42); code="What is the question?"}

Perhaps more significantly, we plan to provide tracing facilities which take advantage of concealed effects. It will be quite convenient to stringify expressions and report their results in traces. Spitballing:

(|...|) |> Trace.eval ~level:7

We will soon be living in a brave new future wherein we stringify all the things, yet neither our tools nor our minds suffer complexity overload.