Search code examples
clojureclojure.spec

How to check the resolvability of Clojure specs?


clojure.spec.alpha allows one to use non-resolvable specs when defining a new one:

(s/def :foo/bar (s/or :nope :foo/foo))

Here, :foo/foo can’t be resolved and so using :foo/bar will raise an exception on usage:

(s/valid? :foo/bar 42)
;; Exception Unable to resolve spec: :foo/foo  clojure.spec.alpha/reg-resolve! (alpha.clj:69)

This is something that happens in my code when I make typos like :my-ns/my-spec instead of ::my-ns/my-spec. I’d like to catch those with unit-tests.

Diving in the clojure.spec.alpha source code I found I can get all specs with (keys (s/registry)) so my test look like this:

(ns my-ns.spec-test
  (:require [clojure.test :refer :all]
            [clojure.spec.alpha :as s]
            ;; :require all the relevant namespaces to populate the
            ;; global spec registry.
            [my-ns.spec1]
            [my-ns.spec2]))

(deftest resolvable-specs
  (doseq [spec (keys (s/registry))]
    (is (resolvable? spec))))
    ;;   ^^^^^^^^^^^ placeholder; that’s the function I want

Unfortunately there’s not such thing as s/resolvable? in clojure.spec.alpha. The only solution I’ve found so far is to call (s/valid? spec 42) and assume it not raising an exception means it’s resolvable but it doesn’t check all branches:

(s/def :int/int int?)
(s/def :bool/bool bool?)

(s/def :my/spec (s/or :int :int/int
                      :other (s/or :bool bool/bool
                                   :nope :idont/exist)))

(s/valid? :my/spec 1) ; <- matches the :int branch
;; => true

(s/valid? :my/spec :foo)
;; Exception Unable to resolve spec: :idont/exist  clojure.spec.alpha/reg-resolve! (alpha.clj:69)

I checked the exception stacktrace as well as the source code to see if I could find any function to fully resolve a spec without using a test value like 42 or :foo above but couldn’t find any.

Is there a way check that, for a given spec, all specs it refers to in all its branches do exist?


Solution

  • I was able to do the following:

    (ns my-ns.utils
      (:require [clojure.spec.alpha :as s]))
    
    (defn- unresolvable-spec
      [spec]
      (try
        (do (s/describe spec) nil)
        (catch Exception e
          (if-let [[_ ns* name*] (re-matches #"Unable to resolve spec: :([^/]+)/(.+)$" (.getMessage e))]
            (keyword ns* name*)
            (throw e)))))
    
    (defn unresolvable?
      "Test if a spec is unresolvable, and if so return a sequence
       of the unresolvable specs it refers to."
      [spec]
      (cond
        (symbol? spec)
          nil
    
        (keyword? spec)
          (if-let [unresolvable (unresolvable-spec spec)]
            [unresolvable]
            (not-empty (distinct (unresolvable? (s/describe spec)))))
    
        (seq? spec)
          (case (first spec)
            or (->> spec (take-nth 2) rest (mapcat unresolvable?))
            and (->> spec rest (mapcat unresolvable?))
    
            ;; undecidable
            nil)
    
        :default (unresolvable-spec spec)))
    
    (def resolvable? (complement unresolvable?))
    

    It works on s/and's and s/or's, which was my minimal use-case:

    (u/resolvable? :int/int) ;; => true
    (u/resolvable? :my/spec) ;; => false
    (u/unresolvable? :my/spec) ;; => (:idont/exist)
    

    But it has some flaws:

    • It reinvents the wheel; I think those spec-walking functions already exist somewhere in clojure.spec.alpha
    • It relies on catching an exception then parsing its message, because (1) clojure.spec.alpha doesn’t have a function that doesn’t raise an exception and (2) the functions that raise one don’t use anything more specific than Exception

    I’d be happy to accept any other answer if someone has something more robust.