Andrey Listopadov

Janet

@programming janet clojure ~10 minutes read

I decided to give Janet another look - I’ve mentioned Janet before in this blog, and I have my thoughts on it. However, I have never actually interacted with the language that much - I only read its documentation and some code.

Recently, I found this interesting post: My Kind of REPL by Ian Henry. It’s an interesting post, and I really liked the Braille plotting library they mentioned there. So I wanted it for myself.

That’s not strong enough motivation to switch to Janet from Fennel or Clojure, so I decided to port this library to Clojure. Yes, no Fennel this time. To be honest, I noticed that my feelings towards Fennel are becoming a bit rusty. Perhaps Janet’s author felt the same thing and made Janet after Fennel?

Well, after a bit of searching, I found the library they’re talking about in the post: bytemap. The name is a bit confusing, I was expecting something like braille-plot or braillemap at least. But it doesn’t matter - what does matter, is the ability to plot stuff in the REPL. And the library is neat, with cool tricks to render points to the Braille symbols using bit-ops! Kudos to Ian for implementing it!

Now, I have a lot of experience porting Clojure code to Fennel and back, and I would say that the experience is seamless enough. What I mean by that is that most of the forms can be used as is, or some simple text substitutions are required. Most of these are substituting Fennel’s module separator . with Clojure’s /, and substituting , in macros with ~. There are more differences than that, of course, but one of the goals I had with fennel-cljlib and async.fnl libraries was to allow copying code from Clojure to Fennel and back with as little changes as possible. These two libraries provide most of the important Clojure behavior, and some of Clojure syntax to Fennel.

Janet, on the other hand, says that it is not a Clojure port, and lists all differences that come with that. On the other hand, their standard library is far more rich than Fennels, and there are a lot of inspirations taken from Clojure as far as I can see. So that’s good.

I mentioned that apart from small syntactic changes, Fennel code can be ported to Clojure fairly easily. With Janet, the situation is a bit different. Say, here’s some Fennel code:

(fn [x]
  (let [a (foo)
        b (bar)
        b (baz b)]
    (qux a b x)))

Or was it Clojure code? In fact, this code will work in the same way in both languages. Now, let’s look at the same code in Janet:

(fn [x]
  (def a (foo))
  (def b (bar))
  (def b (baz b))
  (qux a b x))

Now, this code is different, however, it will work in Clojure, yet with a subtle difference. What is the difference you ask? Why, in Clojure a and b are global bindings!

Now, you might wonder, why I bring this up. Well, I find this strange, but in Janet, all of the documentation is written using def instead of let which Janet also has. And as a result, a lot of code in the wild is written like that.

That’s another mystery to me - why Janet author, who created Fennel before that, decided that using def is the way to create local variables? I mean, sure, let introduces yet another scope into the code, but is it that bad? Anyhow, you can write this code in Janet in the same way as in Clojure or Fennel, but I’m yet to see let used in Janet code in the wild.

If you’re interested in the full source code of the port, you can find it here.

Porting Janet code to Clojure

Janet uses def to establish immutable lexically-scoped bindings, and var to create mutable bindings. It’s a bit similar to Fennel, where the local (a much better name, TBH) is used for the same purpose as def in Janet, and var does the same thing. However, when porting to Clojure we should remember that we don’t have mutable bindings of any kind. So my first steps were to change all of the code to have correct Clojure syntax and replace all var definitions with the use of atom.

For example, here’s such change illustrated on the plot function:

;; Janet                                                     ;; Clojure
(defn plot [f [w h] x-scale y-scale &named axis]             (defn plot [f [w h] x-scale y-scale
  (default axis true)                                                    & {:keys [axis] :or {axis true}}]
  (def canvas (new w h))                                       (let [canvas (new w h)
  (def [w h] (bounds canvas))                                        [w h] (bounds canvas)]

  (when axis                                                     (when axis
    (for i 0 h                                                     (dotimes [i h]
      (draw-point canvas [(/ w 2) i]))                               (draw-point canvas [(/ w 2) i]))
    (for i 0 w                                                     (dotimes [i w]
      (draw-point canvas [i (/ h 2)])))                              (draw-point canvas [i (/ h 2)])))

  # we don't want to clip the extreme values off the canvas,     ;; we don't want to clip the extreme values off the canvas,
  # so we narrow the y range slightly                            ;; so we narrow the y range slightly
  (def y-scale (* y-scale (/ (+ h 1) h)))                        (let [y-scale (* y-scale (/ (+ h 1) h))

  (var current-point nil)                                              current-point (atom nil)]
  (for i 0 w                                                       (dotimes [i w]
    # x spans -0.5 to 0.5 (inclusive!)                               ;; x spans -0.5 to 0.5 (inclusive!)
    (def x (- (/ i (- w 1)) 0.5))                                    (let [x (- (/ i (- w 1)) 0.5)
    (def y (/ (f (* x 2 x-scale)) y-scale -2))                             y (/ (f (* x 2 x-scale)) y-scale -2)
    (def p [(* (+ x 0.5) (- w 1))                                          p [(* (+ x 0.5) (- w 1))
            (* (+ y 0.5) h)])                                                 (* (+ y 0.5) h)]]
    (when current-point                                                (when @current-point
      (draw-line canvas current-point p))                                (draw-line canvas @current-point p))
    (set current-point p))                                             (reset! current-point p)))
  (print-canvas canvas))                                           (print-canvas canvas))))

Here’s a quick demo:

braille-plot.core> (plot math/sin [40 10] math/PI 1)
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⢀⠤⠖⠊⠒⠒⠤⡀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠈⠢⡀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢆⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⢠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠱⡀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡷⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢄
⠹⡉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⢉⠝⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉
⠀⠘⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠊⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠈⠢⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠑⢄⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠑⢤⣀⣀⢀⣀⠤⠊⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
nil

Changes in the code so far:

  • Janet uses a default macro to establish a default value, in case one wasn’t specified. Here it works with the &named attribute in the argument list.

    In Clojure, we use rest syntax & together with the map destructuring syntax {} to achieve the same style of argument list. And to supply default values for named arguments, map destructuring supports :or modifier that itself specifies a map of names to default values.

    Hence the change from &named axis with (default axis true) to {:keys [axis] :or {axis true}}.

  • for is a list comprehension in Clojure, so it was changed to dotimes which has the closest semantic.

  • Comments were changed to use ;; instead of #

  • All inline def usage was replaced with let binding multiple symbols.

    This introduced more nesting, but it’s not a problem.

  • var usage was replaced with the use of atom.

    To be clear, I should use volatile! here, but this is a temporary change, and more people are familiar with atom. This change required changing set to use reset! as a means to update the atom.

  • I also had to replace the new function name with canvas because new is a special form in Clojure.

So even this small piece of code required a fair amount of changes. The most annoying one was converting def usage to an appropriate let usage, as it involved a lot of manual copying.

By the way, the original Janet code changes a lot of values in place. This happens all over the code, and because of that, I had to use atom in a few more places. However, most of these mutations are not necessary, and we can use things like Clojure’s update, that instead of modifying a collection, return a new one with the modifications. So the second change to this code was to make the canvas immutable,

Now all of the functions that change the canvas accept it, and return a new, modified canvas:

(defn plot
  ([f] (plot f (canvas)))
  ([f {:keys [x-scale y-scale]
       :as canvas}]
   (let [[w h] (bounds canvas)
         y-scale (* y-scale (/ (+ h 1) h))]
     (loop [[x & xs] (range w)
            current-point nil
            canvas canvas]
       (if x
         (let [x (- (/ x (- w 1)) 0.5)
               y (/ (f (* x 2 x-scale)) y-scale -2)
               p [(* (+ x 0.5) (- w 1))
                  (* (+ y 0.5) h)]]
           (recur xs p
                  (cond-> canvas
                    current-point (draw-line current-point p))))
         canvas)))))

I’ve changed plot to accept canvas as the last argument instead of passing all of its options and creating it if it wasn’t passed. So now we can just call (plot math/sin) and get a sensible result. This is another way to provide default arguments in Clojure - we create two bodies for the function, and if the function is called with less than the needed amount of arguments, it has a dedicated body that can call itself with additional arguments. Neat!

I’ve moved out the axis creation code into a separate function draw-axis because it makes more sense to create a blank canvas and draw axies on it afterward. Since we’re passing the canvas object through, gradually creating newer versions of it with each function, we can have it as a separate step.

The final change here is the use of the loop instead of the dotimes. This change is motivated by the fact that we need to change some state between iterations, but we don’t want to use mutable constructs such as atom. So instead, we create a binding current-point and change it with each call to recur. And the same thing happens to the canvas binding. This is literally a recursion, except Clojure has to do it this way because JVM can’t optimize tail calls.

And, it works! We can now pass the canvas through multiple plot calls:

braille-plot.core> (->> (canvas :width 42 :height 20)
                        (plot #(math/sqrt (abs (- 1 (math/pow % 2)))))
                        (plot #(- (math/sqrt (abs (- 1 (math/pow % 2))))))
                        (plot #(/ (math/sqrt (abs (- 1 (math/pow % 2)))) 2))
                        (plot #(/ (- (math/sqrt (abs (- 1 (math/pow % 2))))) 2))
                        draw-axies
                        print-canvas)
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⠤⠔⠒⠒⠒⠊⡗⠒⠒⠒⠢⠤⢄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠴⠒⠉⠁⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠉⠒⠤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⢀⠴⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⠤⡀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠢⡀⠀⠀⠀⠀
⠀⠀⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢄⠀⠀⠀
⠀⠀⡔⠁⠀⠀⠀⠀⠀⢀⣀⣠⠤⠔⠒⠒⠒⠊⠉⠉⠉⡏⠉⠉⠑⠒⠒⠒⠢⠤⢄⣀⡀⠀⠀⠀⠀⠀⠈⢢⠀⠀
⠀⡰⠁⠀⠀⣀⠤⠒⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠒⠤⣀⠀⠀⠈⢆⠀
⢠⠃⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠢⡀⠘⡄
⢸⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠢⡇
⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿
⣏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⣹
⢹⢆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⡏
⠸⡀⠑⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⢀⠇
⠀⢣⠀⠀⠉⠒⠤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠒⠉⠀⠀⡜⠀
⠀⠈⢢⠀⠀⠀⠀⠀⠉⠑⠒⠲⠤⢄⣀⣀⣀⡀⠀⠀⠀⡇⠀⠀⢀⣀⣀⣀⡠⠤⠔⠒⠊⠉⠀⠀⠀⠀⠀⡔⠁⠀
⠀⠀⠀⠱⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠉⠉⡏⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀⠀
⠀⠀⠀⠀⠘⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠑⢤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠈⠒⢤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠓⠲⠤⢄⣀⣀⣀⡀⣇⣀⣀⣀⡠⠤⠔⠒⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
nil
Code Snippet 1: (the width of the outer unit circle depends on your Braille font)

The rest of the code was converted similarly. Uses of def were converted to let, and in-place mutations were replaced with the use of immutable data structures API.

ClojureScript came with its oddities. Clojure 1.11.0 introduced the clojure.math namespace that allows more portable code between implementations. However, the clojure.string namespace is still quite sparse. For instance, here’s the function that generates the Braille symbols:

(defn- braille [byte]
  (let [bytes (encode (+ 0x2800 byte))]
    #?(:clj
       (String. (byte-array bytes))
       :cljs
       ;; TODO: maybe there's a better way?
       (let [bytes (new js/Uint8Array (clj->js bytes))]
         (.decode (new js/TextDecoder "utf-8") (clj->js bytes))))))

It is pretty straightforward for Clojure, but in ClojureScript we have some shenanigans. I program in ClojureScript quite infrequently, so this code is, perhaps, completely terrible, so reach me out if you know a better way of doing this.

Closing thoughts

Well, Janet still feels weird as a language. I mean, it feels like its syntax, with regards to special characters, went through the Caesar cipher: # for comments, ; for splicing, @ for table mutability, ~ for quasiquote ` for long strings, | for short function notation. Its use of def is, well, unconventional, and code style, in general, is not lispy enough. I mean, Fennel isn’t a proper Lisp either, yet thanks to the runtime, it feels pretty natural, and its syntax doesn’t deviate too far from established conventions. It feels more mature than Fennel, but quirky at the same time.

In the future, though, I’d like to take a closer look at Janet’s virtual machine. Perhaps it can be used as a target for a language that I will personally like more.