Skip to content

Instantly share code, notes, and snippets.

@plexus
Created June 9, 2016 08:25
Show Gist options
  • Save plexus/0f105f590e6bbfa3332e7b4580eeaedb to your computer and use it in GitHub Desktop.
Save plexus/0f105f590e6bbfa3332e7b4580eeaedb to your computer and use it in GitHub Desktop.

clojure.spec

Available in Clojure 1.9 (now in alpha)

(defproject robochef "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.9.0-alpha5"]])

In the namespace clojure.spec

(ns robochef.core
  (:require [clojure.spec :as s]))

Basic example

(s/def ::ingredients (s/* (s/cat :amount number?
                                 :unit   keyword?
                                 :name   string?)))

(s/describe ::ingredients)
;;=> (* (cat :amount number? :unit keyword? :name string?))

(s/valid? ::ingredients [5 :g "tea"])
;;=> true

(s/conform ::ingredients [5 :g "tea"])
;;=> [{:amount 5, :unit :g, :name "tea"}]

(s/explain-str ::ingredients ["10" :g "tea"])
;;=> "In: [0] val: \"10\" fails spec: :robochef.core/ingredients at:
;;      [:amount] predicate: number?\n"

(s/exercise ::ingredients 2)
;;=> ([() []]
;;    [(0 :Hi "0") [{:amount 0, :unit :Hi, :name "0"}]])

Namespace qualified symbols

(ns robochef.core
  (:require [clojure.spec :as s]))

(= ::foo   :robochef.core/foo)
(= ::s/foo :clojure.spec/foo)

;; UPCOMING FEATURES

;; CLJ-1910 Namespaced maps
{:person/first "Han" :person/last "Solo" :person/ship
  {:ship/name "Millenium Falcon" :ship/model "YT-1300f light freighter"}}
;;-->
#:person{:first "Han" :last "Solo" :ship
  #:ship{:name "Millenium Falcon" :model "YT-1300f light freighter"}}


;; CLJ-1919 Destructuring support for namespaced keys and syms
(let [{:keys [person/first person/last person/email]} m]
  (format "%s %s - %s" first last email))

Spec’ing sequences

Five “Regex” operators: three quantifiers (*, +, ?) + cat + alt

(s/conform (s/* keyword?) [])        ;;=> []
(s/conform (s/* keyword?) [:a])      ;;=> [:a]
(s/conform (s/* keyword?) [:a :b])   ;;=> [:a :b]

(s/conform (s/+ keyword?) [])        ;;=> :clojure.spec/invalid
(s/conform (s/+ keyword?) [:a])      ;;=> [:a]
(s/conform (s/+ keyword?) [:a :b])   ;;=> [:a :b]

(s/conform (s/? keyword?) [])        ;;=> nil
(s/conform (s/? keyword?) [:a])      ;;=> :a
(s/conform (s/? keyword?) [:a :b])   ;;=> :clojure.spec/invalid

Spec’ing sequences

cat: concatentate specs

(s/conform (s/cat :num number?, :key keyword?) [5 :b])   ;;=> {:num 5, :key :b}

Works well with destructuring!

alt: match alternatives

(s/conform (s/alt :num number?,:key keyword?) [5])       ;;=> [:num 5]

(s/conform (s/alt :num number?,:key keyword?) [:b])      ;;=> [:key :b]

Works well with core.match!

Alt vs Or

Clojure spec also has s/or which is similar to s/alt

(s/conform (s/or :num number?, :str string?) 5)               ;;=> [:num 5]
(s/conform (s/alt :num number?, :str string?) [5])            ;;=> [:num 5]

In fact you might wonder why you need both

(s/conform (s/cat :x (s/or :num number?, :str string?)) [5])  ;;=> {:x [:num 5]}
(s/conform (s/cat :x (s/alt :num number?, :str string?)) [5]) ;;=> {:x [:num 5]}

Alt vs Or

But alt partakes in the current regex, or starts a new spec.

(s/def ::cat-alt (s/cat :1 (s/alt :nums (s/+ number?)
                                  :strs (s/+ string?))
                        :2 (s/alt :keys (s/+ keyword?)
                                  :syms (s/+ symbol?))))

(s/conform ::cat-alt [1 2 3 :a :b :c])   ;;=> {:1 [:nums [1 2 3]],   :2 [:keys [:a :b :c]]}
(s/conform ::cat-alt ["a" "b" 'x 'y])    ;;=> {:1 [:strs ["a" "b"]], :2 [:syms [x y]]}
(s/conform ::cat-alt [1 2 :a 'b])        ;;=> :clojure.spec/invalid


(s/def ::cat-or (s/cat :1 (s/or :nums (s/+ number?)
                                :strs (s/+ string?))
                       :2 (s/or :keys (s/+ keyword?)
                                :syms (s/+ symbol?))))

(s/conform ::cat-or [[1 2 3] [:a :b :c]])  ;;=> {:1 [:nums [1 2 3]],   :2 [:keys [:a :b :c]]}
(s/conform ::cat-or [["a" "b"] ['x 'y]])   ;;=> {:1 [:strs ["a" "b"]], :2 [:syms [x y]]}

Spec’ing sequences

Suppose we want a spec for the arguments or case

(comment (case x
           :foo 7
           :bar 42
           "else"))

(s/def ::case-args (s/cat :form    :clojure.spec/any
                          :clauses (s/* (s/cat :test   :clojure.spec/any
                                               :result :clojure.spec/any))
                          :else    (s/? :clojure.spec/any)))

(s/conform ::case-args '(x :foo 7 :bar 42 "else"))
;;=> {:form x, :clauses [{:test :foo, :result 7} {:test :bar, :result 42}], :else "else"}

Notice how great destructuring works on this conformed data

(let [conformed (s/conform ::case-args '(x :foo 7 :bar 42 "else"))
      {:keys [form clauses else]} conformed]
  (doseq [{:keys [test result]} clauses]
    ,,,))

Parsing sequences

Putting it all together

(s/def ::hiccup (s/or :string  string?
                      :element (s/cat :tag keyword?
                                      :attrs (s/? map?)
                                      :content (s/* ::hiccup))))
(s/conform ::hiccup "foo")
(s/conform ::hiccup [:div])
(s/conform ::hiccup [:div {:class "klz"}])
(s/conform ::hiccup [:div {:class "klz"} "hello"])
(s/conform ::hiccup [:div {:class "klz"} [:p "hello"] [:img {:src "/parrot.gif"}]])
(s/conform ::hiccup [:div [:p "hello"] [:img {:src "/parrot.gif"}]])

Spec types

a predicate

map?

name of a registered spec

::ingredient

a regex spec

(s/* keyword?)

a spec object

(s/coll-of number? [])

Spec’ing collections

(s/map-of <key-spec> <value-spec>)

(s/valid? (s/map-of keyword? number) {:x 3})

(s/coll-of <item-spec> <seed-collection>)

(s/valid? (s/coll-of string? []) ["foo"])

Spec’ing collections

Most powerful: keys

Keyword used both for lookup in the map, and to find a spec in the registry

(s/def ::recipe (s/keys :req [::ingredients]
                        :opt [::steps]))

(s/def ::ingredients (s/* ::ingredient))
(s/def ::ingredient (s/cat :amount number?
                           :unit   keyword?
                           :name   string?))

(s/def ::steps (s/* string?))

(def recipe
  {::ingredients [1 :kg "aubergine"
                  20 :ml "soy sauce"]
   ::steps ["fry the aubergines"
            "add soy sauce"]})

(s/valid? ::recipe recipe) ;;=> true

Spec’ing collections

Naturally extensible

(s/def ::recipe (s/keys :req [::ingredients]
                        :opt [::steps]))

(def recipe
  {::ingredients [1 :kg "aubergine"
                  20 :ml "soy sauce"]
   ::steps ["fry the aubergines"
            "add soy sauce"]
   ::feeds "4"})

(s/valid? ::recipe recipe) ;;=> true

(s/def ::feeds string?)

(s/valid? ::recipe recipe) ;;=> false

Instrumenting functions

fdef lets you set specs on the arguments, return value, and the relationship between them.

(defn cook! [recipe]
  (let [{i ::ingredients} (s/conform ::recipe recipe)]
    (reduce (fn [acc {:keys [amount]}]
              (+ acc amount))
            0
            i)))

(s/fdef cook!
  :args (s/cat :recipe ::recipe)
  :ret  number?
  :fn   #(> (:ret %) (count (-> % :args :recipe ::ingredients))))

(s/instrument #'cook!)
(s/unstrument #'cook!)

(cook! recipe)
(cook! {})

This also works for macros!

Some more specs

and let’s you chain multiple specs. It’s like -> or .., each spec gets the conformed value of the previous spec

(s/def ::contrived-and (s/and vector?
                              #(> (count %) 3)
                              (s/cat :sym keyword?
                                     :rest (s/* ::s/any))
                              #(#{:x :y :z} (:sym %))))

(s/conform ::contrived-and [:x 1 2 3]) ;;=> {:sym :x, :rest [1 2 3]}
(s/conform ::contrived-and [:a 1 2 3]) ;;=> :clojure.spec/invalid

Test.check

(require '[clojure.test.check :as tc])
(require '[clojure.test.check.generators :as gen])
(require '[clojure.test.check.properties :as prop])

(def positive-recipe-prop
  (prop/for-all [r (s/gen ::recipe)]
    (>= (cook! r) 0)))

(tc/quick-check 100 positive-recipe-prop)
;;=> {:result false, :seed 1465408304404, :failing-size 2, :num-tests 3, :fail [{:robochef.core/ingredients (-2.0 :+.j/l*4 "")}], :shrunk {:total-nodes-visited 26, :depth 8, :result false, :smallest [{:robochef.core/ingredients (-1.0 :A "")}]}}

(cook! {:robochef.core/ingredients [-1.0 :A ""]})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment