A few words from Agical

Keeping the :arglists of Clojure functions DRY

The problem: function-a takes the same arguments as function-b. In fact, function-a calls function-b. Without too much synchronized updating of the function signatures, I want (doc function-a) to show me the same argument lists as (doc function-b).

The solution: Use :arglists in the metadata of the function.

(defn function-b [{:keys [a b c]}]
  (println a b c))

(defn function-a
  {:arglists '([{:keys [a b c]}])}
  [args]
  (function-b args))

That’s the TL;DR. Read on for some rationale, and for some nerdy diving into the worlds of static and dynamic analysis.

I had a case where I had several “pairs” of functions that shared their interface. The functions return SVG and the interface is a configuration map. One of the functions in a pair returns Hiccup, because that’s what I need for the components rendering the SVG. The other returns the rendered string, because that’s what I need for a JavaScript library I use that also renders the SVG.

Here’s a contrived example with the hiccup-producing part of the pair: traffic-light-symbol-hiccup:

(ns nrepl-lsp-arglists.svg-example
  (:require [hiccup2.core :as h]))

(def kind->color {:stop :red
                  :wait :yellow
                  :go :green})

(defn- styled-circle [{:keys [size style]}]
  [:svg {:width size
         :height size
         :viewBox [0 0 512 512]
         :fill :none
         :xmlns "http://www.w3.org/2000/svg"}
   [:circle (merge {:cx 256
                    :cy 256
                    :r 200}
                   style)]])

(defn traffic-light-symbol-hiccup
  "Returns SVG for a traffic light symbol as an SVG circle.
   Args:
   * size: The size (radius) of the circle.
   * kind: The kind of the traffic light, one of :stop, :wait, :go.
   * bells?: Whether the circle should have bells.
   * whistles?: Whether the circle should have whistles."
  [{:keys [size kind bells? whistles?]}]
  (styled-circle {:size size
                  :style (merge {:fill (kind->color kind :black)
                                 :stroke :darkgrey}
                                (when-not bells? {:fill-opacity 0.25})
                                (when whistles? {:stroke-width 5
                                                 :stroke-dasharray [10 10]}))}))

Now, the string producing function is as simple as:

(defn traffic-light-symbol-string [props]
  (h/html (traffic-light-symbol-hiccup props)))

The problem with this is that my Clojure development environment (Calva) will not be very helpful with looking up information about the traffic-light-symbol-string function. No docs and the argument list of props will be displayed.

Calva also has parameter hints that I can pop up while typing out the params, and they will also simply say props.

Well, sure there are better alternatives. One a bit more verbose:

(defn traffic-light-symbol-string-2 [{:keys [size kind bells? whistles?]}]
  (h/html (traffic-light-symbol-hiccup {:size size
                                        :kind kind
                                        :bells? bells?
                                        :whistles? whistles?})))

The bigger problem here is that I need to re-assemble the configuration map. That we can fix using:

(defn traffic-light-symbol-string-3 [{:keys [size kind bells? whistles?] :as props}]
  (h/html (traffic-light-symbol-hiccup props)))

But now the linter (Calva is clj-kondo powered) complains about unused variables keys, kind, bells?, and whistles?. Suppress unused-binding diagnostics to the rescue:

#_{:clj-kondo/ignore [:unused-binding]}
(defn traffic-light-symbol-string-3 [{:keys [size kind bells? whistles?] :as props}]
  (h/html (traffic-light-symbol-hiccup props)))

However, in my real project some of the -string functions have let, and other local binding-boxes, and I do want clj-kondo to help with those… So, I took this to the Clojurians Slack and asked for advice which way to go, and I also asked if there was some way I was missing.

There is a way.

I got help from several Clojure and Clojure tooling experts, so it turned into a super enlightening discussion for me. And there is icing on the cake! Let’s repeat it.

The solution: Use :arglists in the metadata of the function.

(defn traffic-light-symbol-string-arglists
  {:arglists '[[{:keys [size type bells? whistles?]}]]}
  [props]
  (h/html (traffic-light-symbol-hiccup props)))

Calva has two providers of help with the documentation and arguments list of functions: nREPL and clojure-lsp. nREPL is dynamic and will tell me the truth about the argument list from what is evaluated/compiled into the running program. clojure-lsp is static and will tell me the truth about the argument list from what is typed into the file. Both of these providers honor the :arglists metadata.

In most development sessions (at least for me) both clojure-lsp and nREPL are running providing power to Calva. If the function is compiled into the program, nREPL’s answer will be used, otherwise clojure-lsp’s answer. In practice there is only a short period of time between the function text being updated and the code being compiled when I work, so both answers will be the same when I need to look them up.

Caveat. You may be tempted to provide the metadata like so:

(defn ^{:arglists '[[{:keys [size type bells? whistles?]}]]}
  traffic-light-symbol-string-arglists 
  [props]
  (h/html (traffic-light-symbol-hiccup props)))

This should be the same, and for clojure-lsp it doesn’t matter. However, the Clojure compiler will overwrite the :arglists entry of the metadata, or rather remove it, since it is not present in the attr-map. So doing it like that will render the nREPL answer the less useful [props] again (and Calva will not know that this has happened and choose this more boring answer). Now you know. I recommend you check that Slack thread out!

But what about the doc string? I have to admit I haven’t asked the experts about this yet, but here is what I currently do:

(defn traffic-light-symbol-string-arglists-w-doc
  "Returns a string for a traffic light symbol as an SVG circle.
   See `traffic-light-symbol` for more details."
  {:arglists '[[{:keys [size type bells? whistles?]}]]
   :doc (:doc (meta #'traffic-light-symbol-hiccup))}
  [props]
  (h/html (traffic-light-symbol-hiccup props)))

The reason I have both a regular doc string and a :doc entry in the attr-map is that the attr map is populated dynamically, so only nREPL will see it. The shorter docstring is a backup for clojure-lsp to show:

When the function is compiled, nREPL (and thus Calva) will provide the following answer::

I can only conclude my learnings from this inquiry with: The attr-map FTW!


Addendum. Wouldn’t it be nice if the REPL-version worked in clojure-lsp too? And wouldn’t it be nice if it worked for both the argument lists and for the doc string? Like so:

(defn function-b
  "Prints `:a`, `:b`, and `:c` from the argment map.
   * :a The a of the map
   * :b The b of the map
   * :c The c of the map"
  [{:keys [a b c]}]
  (println a b c))

(defn function-a
  {:arglists (:arglists (meta #'function-b))
   :doc (:doc (meta #'function-b))}
  [args]
  (function-b args))

There’s an issue for that! https://github.com/clojure-lsp/clojure-lsp/issues/1811