Clojure Namespace Shenanigans

ยท 385 words ยท 2 minute read

Juxtaposing Functions in a Namespace ๐Ÿ”—

If you had a Clojure namespace with a bunch of functions that followed a particular naming convention like plugin-foo, plugin-bar, etc. and wanted to apply them all to an input, you could do the following.

(defn plugin-foo [arg] ...)
(defn plugin-bar [arg] ...)
(defn plugin-baz [arg] ...)

(defmacro juxt-all
  "Create a juxt function using all functions found by the provided prefix in
   the specified namespace."
  [ns prefix]
  `(fn [& args#]
     (let [fs# (keep (fn [[k# v#]]
                       (when (clojure.string/starts-with k# ~prefix)
                         v#))
                     (ns-publics ~ns))]
       (reduce (fn [acc# f#]
                 (into acc# (apply f# args#)))
               []
               fs#))))

(def ^:const juxt-plugins
  (juxt-all (symbol (namespace ::this))
            "plugin-"))

(juxt-plugins {:key "value"})

NOTE: ns-publics doesn’t guarantee any particular order.

(def foo "hi")
#'user/foo
(ns-publics (symbol (namespace ::this)))
{foo #'user/foo}
(def bar "world")
#'user/bar
(ns-publics (symbol (namespace ::this)))
{bar #'user/bar, foo #'user/foo}

Sequentially running a series of functions in the current namespace ๐Ÿ”—

We run a custom function that parses the source file and extracts the list of functions sought in order as again ns-publics does not preserve order.

(defn ns-publics-in-order
  "This is a hack. `ns-publics` does not return functions in source-order.
   By loading the source text and filtering for function definitions ourselves,
   we can provide that alternative."
  [ns]
  (some->> ns
           ns-publics
           vals
           first
           meta
           :file
           (.getResourceAsStream (RT/baseLoader))
           clojure.java.io/reader
           line-seq
           (filter #(and (clojure.string/starts-with? % "(defn")
                         (clojure.string/includes? % "-hook")))
           ;; Assumption is that all function definitions have a leading `defn-`
           ;; followed by the name and possible a spec.
           ;;
           ;; (defn `name`
           ;;   ...)
           ;;
           ;; (defn-spec `name` `spec`
           ;;   ...)
           (map #(clojure.string/split % #" "))
           (map second)
           (map symbol)
           (map (fn [x] [x (ns-resolve ns x)]))
           (into (flatland.ordered.map/ordered-map))))

(defmacro reduce-all
  "Sequentially run a series of functions found by the provided suffix in the
   current namespace."
  [ns suffix]
  (let [publics (ns-publics-in-order (eval ns))]
    `(fn [state# short-circuit-fn#]
       (let [fs# (keep (fn [[k# v#]]
                         (when (clojure.string/ends-with? k# ~suffix)
                           v#))
                       ~publics)]
         (reduce (fn [acc# f#]
                   (if (short-circuit-fn# acc#)
                     acc#
                     (f# acc#)))
                 state#
                 fs#)))))

(defn foo-hook [state] ...)
(defn bar-hook [state] ...)
(defn baz-hook [state] ...)

;; NB: It is really important that this is a macro with an eval within.
;;     It captures and analyzes the source at compile time since the source
;;     might not be available from within an Uberjar context.
(defmacro reduce-hooks
  []
  (eval '(til.core/reduce-all (symbol (namespace ::this)) "-hook")))