├── bar.png ├── foo.png ├── doc └── intro.md ├── .gitignore ├── test └── specviz │ └── core_test.clj ├── deps.edn ├── project.clj ├── src └── specviz │ ├── guide.clj │ ├── util.cljc │ ├── example.cljc │ ├── spec.cljc │ ├── html.cljc │ ├── graphviz.cljc │ └── core.clj ├── LICENSE ├── README.md ├── bar.dot └── foo.dot /bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jebberjeb/specviz/HEAD/bar.png -------------------------------------------------------------------------------- /foo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jebberjeb/specviz/HEAD/foo.png -------------------------------------------------------------------------------- /doc/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction to specviz 2 | 3 | TODO: write [great documentation](http://jacobian.org/writing/what-to-write/) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .hgignore 11 | .hg/ 12 | -------------------------------------------------------------------------------- /test/specviz/core_test.clj: -------------------------------------------------------------------------------- 1 | (ns specviz.core-test 2 | (:require [clojure.test :refer :all] 3 | [specviz.core :refer :all])) 4 | 5 | (deftest a-test 6 | (testing "FIXME, I fail." 7 | (is (= 0 1)))) 8 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {hiccup/hiccup {:mvn/version "1.0.5"} 3 | crate/crate {:mvn/version "0.2.4"} 4 | viz-cljc/viz-cljc {:mvn/version "0.1.3" :exclusions [org.clojure/clojurescript org.clojure/clojure]}} 5 | 6 | :aliases {:dev {:extra-deps {org.clojure/tools.trace {:mvn/version "0.7.10"}}}}} 7 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject specviz "0.2.5-SNAPSHOT" 2 | :description "Generate Graphviz images from Clojure specs" 3 | :url "https://github.com/jebberjeb/specviz" 4 | :license {:name "MIT" 5 | :url "https://opensource.org/licenses/MIT"} 6 | 7 | :plugins [[lein-tools-deps "0.4.5"]] 8 | :middleware [lein-tools-deps.plugin/resolve-dependencies-with-deps-edn]) 9 | -------------------------------------------------------------------------------- /src/specviz/guide.clj: -------------------------------------------------------------------------------- 1 | (ns specviz.guide 2 | "Slurp spec code from the spec guide, load it in this ns" 3 | (:require 4 | [clojure.pprint :refer [pprint]] 5 | [clojure.string :as string] 6 | [specviz.util :as util])) 7 | 8 | (def guide-url "https://raw.githubusercontent.com/clojure/clojure-site/master/content/guides/spec.adoc") 9 | 10 | ;; Use of non-greedy *? here to avoid SOE. 11 | (def code (->> (re-seq #"(?:clojure\]\n----\n)((?:.|\s)*?)(?:\s----\s)" 12 | (slurp guide-url)) 13 | (map second) 14 | (mapcat string/split-lines) 15 | (remove #(#{\: \= \[} (first %))) 16 | (string/join "\n"))) 17 | 18 | (load-string code) 19 | -------------------------------------------------------------------------------- /src/specviz/util.cljc: -------------------------------------------------------------------------------- 1 | (ns specviz.util 2 | "General utilities." 3 | (:require 4 | [clojure.spec.alpha :as s] 5 | [clojure.string :as string])) 6 | 7 | (defn add-line-no 8 | "Add line numbers to strings, for debugging." 9 | [s] 10 | (->> s 11 | string/split-lines 12 | (map-indexed (fn [idx line] (str idx ": " line))) 13 | (string/join "\n"))) 14 | 15 | (def concatv (comp vec concat)) 16 | 17 | (defn first* 18 | "Returns the first item in x, if x is sequential, else x." 19 | [x] 20 | (if (sequential? x) (first x) x)) 21 | 22 | (defn escape-quotes 23 | [s] (string/replace s "\"" "\\\"")) 24 | 25 | (defn strip-core 26 | "Remove 'clojure.core/' prefix from a string." 27 | [s] 28 | (string/replace s "clojure.core/" "")) 29 | -------------------------------------------------------------------------------- /src/specviz/example.cljc: -------------------------------------------------------------------------------- 1 | (ns specviz.example 2 | "A place for example specs." 3 | (:require 4 | [clojure.spec.alpha :as s])) 5 | 6 | (s/def ::ident keyword?) 7 | (s/def ::eid int?) 8 | (s/def ::lookup-ref (s/tuple keyword? any?)) 9 | (s/def ::identifier 10 | (s/or :ident ::ident 11 | :eid ::eid 12 | :lookup-ref ::lookup-ref)) 13 | 14 | (s/def ::shape #{::square ::circle ::triangle}) 15 | 16 | (s/def ::test-map {:foo 1 17 | :bar 2 18 | :baz 3}) 19 | 20 | (s/def ::test-or 21 | (s/or :foo keyword? 22 | :bar ::eid 23 | :baz (s/or :pos pos? 24 | :neg neg? 25 | :zero zero?) 26 | :qux (s/tuple keyword? string?) 27 | :qul (s/keys :req [::shape ::foo]))) 28 | 29 | (s/def ::test-and 30 | (s/and ::shape 31 | string?)) 32 | 33 | (s/def ::tuple-w-nested-spec 34 | (s/tuple keyword? 35 | (s/or :pos pos? 36 | :even even?) 37 | ::ident)) 38 | 39 | (s/def ::test-map-of 40 | (s/map-of ::shape ::test-or)) 41 | 42 | (s/def ::test-coll-of 43 | (s/coll-of ::shape)) 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jeb Beich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/specviz/spec.cljc: -------------------------------------------------------------------------------- 1 | (ns specviz.spec 2 | "Analyze clojure.spec specs." 3 | (:require 4 | [clojure.spec.alpha :as s])) 5 | 6 | (defn registered? 7 | "Returns true if `x` is the keyword of a registered spec?" 8 | [x] 9 | (some? (s/get-spec x))) 10 | 11 | (defn depends-on* 12 | [names spec-form] 13 | (cond (coll? spec-form) 14 | (doseq [s spec-form] (depends-on* names s)) 15 | 16 | (and (registered? spec-form) 17 | (not (contains? @names spec-form))) 18 | (do (swap! names conj spec-form) 19 | (depends-on* names (s/form (s/get-spec spec-form)))))) 20 | 21 | (defn depends-on 22 | "Returns a collection of the qualified-keywords of all specs referenced 23 | by the spec-form, transatively." 24 | [spec-name] 25 | (let [names (atom #{spec-name})] 26 | (depends-on* names (s/form (s/get-spec spec-name))) 27 | @names)) 28 | 29 | (defn conform-or-throw 30 | [spec x] 31 | "Return the result of conforming `x` using `spec`. If `x` does not conform, 32 | throw an exception." 33 | (when-let [reason (s/explain-data spec x)] 34 | (throw (ex-info "invalid spec" {:reason reason}))) 35 | (s/conform spec x)) 36 | 37 | (defn literal? 38 | "Returns true if `x` is a spec literal, ex: `(clojure.spec/coll-of int?)`." 39 | [x] 40 | (when (coll? x) 41 | (= (namespace (first x)) 42 | "clojure.spec"))) 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # specviz 2 | 3 | Generate [Graphviz](https://www.graphviz.org) images from [clojure.spec](https://clojure.org/about/spec). 4 | 5 | ## Release 6 | 7 | ### Leiningen dependency 8 | 9 | ``` 10 | [specviz "0.2.3"] 11 | ``` 12 | 13 | ## Usage 14 | 15 | Create a diagram of all specs in the `specviz.example` namespace, and their 16 | dependencies. The file will be exported to `foo.png`. 17 | 18 | ``` 19 | user=> (require '[specviz.core :as specviz]) 20 | nil 21 | user=> (specviz/diagram 'specviz.example nil "foo") 22 | ``` 23 | 24 | ![Image](foo.png) 25 | 26 | Create a diagram for the `:specviz.graphviz/drawable` spec. 27 | 28 | ``` 29 | user=> (specviz/diagram :specviz.graphviz/drawable nil "bar") 30 | ``` 31 | 32 | ![Image](bar.png) 33 | 34 | ## Status 35 | 36 | The following spec types are supported. 37 | 38 | * keys 39 | * and 40 | * or 41 | * every 42 | * tuple 43 | 44 | Regex specs are currently in development. 45 | 46 | ## Change Log 47 | 48 | ### 0.2.0 49 | 50 | * Fix SOE due to recursive specs 51 | * Render `s/nilable?` using `s/or :nil nil? :not-nil ...` 52 | * Render map, set, vector literals using a table 53 | * Always filter out `clojure.core` specs 54 | 55 | ## License 56 | 57 | The MIT License (MIT) 58 | 59 | Copyright (c) 2016 Jeb Beich 60 | 61 | Permission is hereby granted, free of charge, to any person obtaining 62 | a copy of this software and associated documentation files (the 63 | "Software"), to deal in the Software without restriction, including 64 | without limitation the rights to use, copy, modify, merge, publish, 65 | distribute, sublicense, and/or sell copies of the Software, and to 66 | permit persons to whom the Software is furnished to do so, subject to 67 | the following conditions: 68 | 69 | The above copyright notice and this permission notice shall be 70 | included in all copies or substantial portions of the Software. 71 | 72 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 73 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 74 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 75 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 76 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 77 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 78 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 79 | -------------------------------------------------------------------------------- /src/specviz/html.cljc: -------------------------------------------------------------------------------- 1 | (ns specviz.html 2 | "Functions to work with hiccup data." 3 | (:require 4 | [clojure.string :as string] 5 | #?(:clj [hiccup.core :as html] 6 | :cljs [crate.core :as html]))) 7 | 8 | (def h1-color "#CCCCCC") 9 | (def h2-color "#EEEEEE") 10 | 11 | (defn port 12 | "Returns a string representing the 'port' id, for a cell in a graphviz table." 13 | [index] 14 | (str "f" index)) 15 | 16 | (defn decorate-row 17 | "Adds the prefix to the value of the row's first cell, and the suffix to 18 | the value of the row's last cell." 19 | [row prefix suffix] 20 | (-> row 21 | (update-in [1 2] #(str prefix %)) 22 | (update-in [(dec (count row)) 2] #(str % suffix)))) 23 | 24 | (defn header-row 25 | "Returns a header row with the `title`." 26 | [title rows] 27 | [:tr [:td {:colspan (dec (count (first rows))) 28 | :bgcolor h1-color} title]]) 29 | 30 | (defn ellipses-row 31 | "Returns a row with the same number of cells as the rows in `rows`, where 32 | each cell's value is '...'" 33 | [rows] 34 | `[:tr ~@(repeat (dec (count (first rows))) [:td "..."])]) 35 | 36 | (defn escape 37 | [s] 38 | (-> s 39 | (string/replace ">" ">"))) 40 | 41 | (defn row 42 | "Returns a single row with one cell for each spec. One small detail of 43 | graphviz has leaked through here -- the port attribute, which is used 44 | for connectiions. 45 | 46 | `cell-attributes` a map of the properties to be used by each cell 47 | ex `{:bgcolor \"#cccccc\" :height 10 :width 10}` 48 | " 49 | [values cell-attributes] 50 | `[:tr ~@(map-indexed 51 | (fn [i v] [:td 52 | (merge {:port (port i)} 53 | cell-attributes) 54 | (escape (str v))]) 55 | values)]) 56 | 57 | (defn col 58 | "Like `row`, but creates a row for each value with one cell." 59 | [values cell-attributes] 60 | (vec (map-indexed 61 | (fn [i v] [:tr [:td 62 | (merge {:port (port i)} cell-attributes) 63 | (escape (str v))]]) 64 | values))) 65 | 66 | (defn table 67 | "Returns a table with the `rows` and `table-opts`." 68 | [title rows table-opts] 69 | `[:table 70 | {:border ~(or (:border table-opts) 0) 71 | :cellspacing ~(or (:cellspacing table-opts) 0) 72 | :cellpadding 3 73 | :cellborder ~(or (:cellborder table-opts) 1)} 74 | ~(when title (header-row title rows)) 75 | ~@rows]) 76 | 77 | (defn html 78 | "Generate the html string for the table data." 79 | [table] 80 | (html/html table)) 81 | -------------------------------------------------------------------------------- /bar.dot: -------------------------------------------------------------------------------- 1 | 0: specvizgraphvizdrawable [shape=box,label=":specviz.graphviz/drawable",style=filled,fillcolor="#CCCCCC", height=null,width=null 2 | 1: ]; 3 | 2: specvizgraphvizdrawable->node22 [label=""style=null,constraint=true,dir=null]; 4 | 3: node22 [shape=diamond,label="",style=null,fillcolor="null", height=null,width=null 5 | 4: ]; 6 | 5: node22->specvizgraphvizconnection [label=":connection"style=null,constraint=true,dir=null]; 7 | 6: node22->specvizgraphviznode [label=":node"style=null,constraint=true,dir=null]; 8 | 7: specvizgraphvizconnection [shape=plaintext,label=<
:specviz.graphviz/connection
:req
:specviz.graphviz/to
:specviz.graphviz/from
:opt
:specviz.graphviz/label
:specviz.graphviz/line-style
:specviz.graphviz/constraint
:specviz.graphviz/line-direction
>,style=null,fillcolor="null", height=null,width=null 9 | 8: ]; 10 | 9: specvizgraphvizconnection:f5->specvizgraphvizlinestyle [label=""style=null,constraint=true,dir=null]; 11 | 10: specvizgraphvizconnection:f7->specvizgraphvizlinedirection [label=""style=null,constraint=true,dir=null]; 12 | 11: specvizgraphvizlinestyle [shape=box,label=":specviz.graphviz/line-style",style=filled,fillcolor="#CCCCCC", height=null,width=null 13 | 12: ]; 14 | 13: specvizgraphvizlinestyle->node23 [label=""style=null,constraint=true,dir=null]; 15 | 14: node23 [shape=oval,label="#{:solid :dotted}",style=null,fillcolor="null", height=null,width=null 16 | 15: ]; 17 | 16: specvizgraphvizlinedirection [shape=box,label=":specviz.graphviz/line-direction",style=filled,fillcolor="#CCCCCC", height=null,width=null 18 | 17: ]; 19 | 18: specvizgraphvizlinedirection->node24 [label=""style=null,constraint=true,dir=null]; 20 | 19: node24 [shape=oval,label="#{:forward :back :both :none}",style=null,fillcolor="null", height=null,width=null 21 | 20: ]; 22 | 21: specvizgraphviznode [shape=plaintext,label=<
:specviz.graphviz/node
:req
:specviz.graphviz/name
:specviz.graphviz/label
:opt
:specviz.graphviz/shape
:specviz.graphviz/fillcolor
:specviz.graphviz/style
:specviz.graphviz/height
:specviz.graphviz/width
>,style=null,fillcolor="null", height=null,width=null 23 | 22: ]; 24 | 23: specvizgraphviznode:f4->specvizgraphvizshape [label=""style=null,constraint=true,dir=null]; 25 | 24: specvizgraphvizshape [shape=box,label=":specviz.graphviz/shape",style=filled,fillcolor="#CCCCCC", height=null,width=null 26 | 25: ]; 27 | 26: specvizgraphvizshape->node25 [label=""style=null,constraint=true,dir=null]; 28 | 27: node25 [shape=oval,label="#{diamond record oval box plaintext circle}",style=null,fillcolor="null", height=null,width=null 29 | 28: ]; -------------------------------------------------------------------------------- /src/specviz/graphviz.cljc: -------------------------------------------------------------------------------- 1 | (ns specviz.graphviz 2 | "Tools to work with graphviz data. 3 | 4 | Graphviz data consists of two primary elements, nodes and connections. A 5 | graphviz document consists of a sequence of these elements in which order 6 | can be important. 7 | 8 | This namespace contains the following: 9 | 10 | - specs for graphviz data 11 | - functions to convert graphviz data into a graphviz dot string 12 | - a function to render a graphviz dot string into a png image 13 | " 14 | (:require 15 | [clojure.spec.alpha :as s] 16 | [clojure.string :as string] 17 | [specviz.spec :as spec] 18 | [specviz.util :as util] 19 | [viz.core :as viz])) 20 | 21 | ;; *** Graphviz specs *** 22 | 23 | (s/def ::shape #{"record" "box" "oval" "plaintext" "circle" "diamond" 24 | "trapezium" "square" "folder" "doublecircle" "point"}) 25 | (s/def ::connection (s/keys :req [::to ::from] 26 | :opt [::label ::line-style ::constraint 27 | ::line-direction])) 28 | (s/def ::node (s/keys :req [::name] 29 | :opt [::label ::shape ::fillcolor ::style 30 | ::height ::width])) 31 | (s/def ::drawable (s/or :connection ::connection 32 | :node ::node)) 33 | (s/def ::line-style #{:dotted :solid}) 34 | (s/def ::line-direction #{:none :both :forward :back}) 35 | 36 | ; *** graphviz data -> dot *** 37 | 38 | ;; `render-graphviz` generates the graphviz dot string for a graphviz element 39 | (defmulti render-graphviz first) 40 | 41 | (defmulti render-graphviz-node ::shape) 42 | 43 | #?(:cljs 44 | (defn format 45 | [format-str & args] 46 | (reduce (fn [string arg] 47 | (string/replace-first string "%s" arg)) 48 | format-str 49 | args))) 50 | 51 | (defn render-graphviz-node* 52 | [{:keys [::name ::shape ::label ::style ::fillcolor ::height ::width]}] 53 | (format "%s [shape=%s,label=%s,style=%s,fillcolor=\"%s\", height=%s,width=%s 54 | ];\n" 55 | name 56 | shape 57 | label 58 | style 59 | fillcolor 60 | height 61 | width)) 62 | 63 | (defmethod render-graphviz-node "plaintext" 64 | [node] 65 | (render-graphviz-node* node)) 66 | 67 | (defmethod render-graphviz-node :default 68 | [node] 69 | (render-graphviz-node* (update node ::label #(format "\"%s\"" 70 | (or % (::name node)))))) 71 | 72 | (defmethod render-graphviz :node 73 | [[_ node]] 74 | (render-graphviz-node node)) 75 | 76 | (defmethod render-graphviz :connection 77 | [[_ {:keys [::to ::from ::line-style ::constraint ::line-direction ::label]}]] 78 | (format "%s->%s [label=\"%s\",style=%s,constraint=%s,dir=%s];\n" 79 | from 80 | to 81 | (or label "") 82 | (when line-style (name line-style)) 83 | (if (nil? constraint) true constraint) 84 | (when line-direction (name line-direction)))) 85 | 86 | (defn dot-string 87 | "Generate the graphviz dot string for a sequence of graphviz element 88 | (connection & node) maps." 89 | [elements] 90 | (->> elements 91 | (map (partial spec/conform-or-throw ::drawable)) 92 | (map render-graphviz) 93 | (apply str))) 94 | 95 | ;; *** graphviz utility functions *** 96 | 97 | (let [id (atom 0)] 98 | (defn- next-id 99 | [] 100 | (swap! id inc) 101 | @id)) 102 | 103 | (defn next-name 104 | "Returns a unique name for use with a graphviz node." 105 | [] 106 | (str "node" (next-id))) 107 | 108 | (defn clean-name 109 | "Turn the qualified keyword into a graphviz friendly name" 110 | [qkw] 111 | (when qkw 112 | (-> (apply str (namespace qkw) (name qkw)) 113 | (string/replace ">" "") 114 | (string/replace "." "") 115 | (string/replace ":" "") 116 | (string/replace "-" "") 117 | (string/replace "?" "")))) 118 | 119 | (defn get-root-name 120 | "Gets the name of the tree's root graphviz node." 121 | [nodes] 122 | (::name (util/first* nodes))) 123 | 124 | (defn connect 125 | "Make a connection from one node to another node. 126 | 127 | `from` the origin node (map) 128 | 129 | `from-port` (optional) if `from-node` is a table, the id (string) of a port 130 | (cell) of the node, from which the connection should originate 131 | 132 | `to` the destination of the connection, can be a node (map), sequence of 133 | nodes, or the name of a node (string) 134 | 135 | `label` the connection's label" 136 | [& {:keys [from from-port to label]}] 137 | (assert (and from to)) 138 | (let [from-str (if from-port 139 | (str (::name from) ":" from-port) 140 | (::name from))] 141 | {::from from-str 142 | ::label label 143 | ::to (cond (string? to) to 144 | (coll? to) (get-root-name to))})) 145 | 146 | ;; *** dot executable wrapper functions *** 147 | 148 | (defn generate-image! 149 | "Generates two files (1) .dot containing the dot string, and 150 | .png containing the graphviz rendering as a png file, using the 151 | `dot` executable binary." 152 | [dot-string filename] 153 | (let [dot-string' (str "digraph {\nrankdir=LR;\n" dot-string "\n}")] 154 | 155 | #?(:clj 156 | (do 157 | (spit (str filename ".dot") (util/add-line-no dot-string')) 158 | (spit (str filename ".svg") 159 | (viz/image (string/replace dot-string' "\n" ""))))) 160 | 161 | #?(:cljs 162 | (viz/image (string/replace dot-string' "\n" ""))))) 163 | 164 | (comment 165 | (let [data [{::name "foo" 166 | ::label "Foo" 167 | ::shape "box"} 168 | {::name "bar" 169 | ::label "Bar" 170 | ::shape "diamond"} 171 | {::from "foo" 172 | ::to "bar" 173 | ::label "baz"}]] 174 | (generate-image! (dot-string data) "baz"))) 175 | -------------------------------------------------------------------------------- /foo.dot: -------------------------------------------------------------------------------- 1 | 0: specvizexamplelookupref [shape=plaintext,label=<
:specviz.example/lookup-ref
( clojure.core/keyword?clojure.core/any? )
>,style=null,fillcolor="null", height=null,width=null 2 | 1: ]; 3 | 2: specvizexampletestcollof [shape=plaintext,label=<
:specviz.example/test-coll-of (...)
:specviz.example/shape
...
>,style=null,fillcolor="null", height=null,width=null 4 | 3: ]; 5 | 4: specvizexampletestcollof:f0->specvizexampleshape [label=""style=null,constraint=true,dir=null]; 6 | 5: specvizexampleshape [shape=box,label=":specviz.example/shape",style=filled,fillcolor="#CCCCCC", height=null,width=null 7 | 6: ]; 8 | 7: specvizexampleshape->node1 [label=""style=null,constraint=true,dir=null]; 9 | 8: node1 [shape=oval,label="#{:specviz.example/circle :specviz.example/square :specviz.example/triangle}",style=null,fillcolor="null", height=null,width=null 10 | 9: ]; 11 | 10: specvizexampleidentifier [shape=box,label=":specviz.example/identifier",style=filled,fillcolor="#CCCCCC", height=null,width=null 12 | 11: ]; 13 | 12: specvizexampleidentifier->node2 [label=""style=null,constraint=true,dir=null]; 14 | 13: node2 [shape=diamond,label="",style=null,fillcolor="null", height=null,width=null 15 | 14: ]; 16 | 15: node2->specvizexampleident [label=":ident"style=null,constraint=true,dir=null]; 17 | 16: node2->specvizexampleeid [label=":eid"style=null,constraint=true,dir=null]; 18 | 17: node2->specvizexamplelookupref [label=":lookup-ref"style=null,constraint=true,dir=null]; 19 | 18: specvizexampleident [shape=box,label=":specviz.example/ident",style=filled,fillcolor="#CCCCCC", height=null,width=null 20 | 19: ]; 21 | 20: specvizexampleident->node3 [label=""style=null,constraint=true,dir=null]; 22 | 21: node3 [shape=oval,label="clojure.core/keyword?",style=null,fillcolor="null", height=null,width=null 23 | 22: ]; 24 | 23: specvizexampleeid [shape=box,label=":specviz.example/eid",style=filled,fillcolor="#CCCCCC", height=null,width=null 25 | 24: ]; 26 | 25: specvizexampleeid->node4 [label=""style=null,constraint=true,dir=null]; 27 | 26: node4 [shape=oval,label="clojure.core/int?",style=null,fillcolor="null", height=null,width=null 28 | 27: ]; 29 | 28: specvizexampletuplewnestedspec [shape=plaintext,label=<
:specviz.example/tuple-w-nested-spec
( clojure.core/keyword?clojure.spec/or:specviz.example/ident )
>,style=null,fillcolor="null", height=null,width=null 30 | 29: ]; 31 | 30: specvizexampletuplewnestedspec:f1->node5 [label=""style=null,constraint=true,dir=null]; 32 | 31: node5 [shape=diamond,label="",style=null,fillcolor="null", height=null,width=null 33 | 32: ]; 34 | 33: node5->node7 [label=":pos"style=null,constraint=true,dir=null]; 35 | 34: node7 [shape=oval,label="clojure.core/pos?",style=null,fillcolor="null", height=null,width=null 36 | 35: ]; 37 | 36: node5->node8 [label=":even"style=null,constraint=true,dir=null]; 38 | 37: node8 [shape=oval,label="clojure.core/even?",style=null,fillcolor="null", height=null,width=null 39 | 38: ]; 40 | 39: specvizexampletuplewnestedspec:f2->specvizexampleident [label=""style=null,constraint=true,dir=null]; 41 | 40: specvizexampletestand [shape=box,label=":specviz.example/test-and",style=filled,fillcolor="#CCCCCC", height=null,width=null 42 | 41: ]; 43 | 42: specvizexampletestand->node6 [label=""style=null,constraint=true,dir=null]; 44 | 43: node6 [shape=plaintext,label=<
>,style=null,fillcolor="null", height=null,width=0.25 45 | 44: ]; 46 | 45: node6:f0->specvizexampleshape [label=""style=null,constraint=true,dir=null]; 47 | 46: node6:f1->node9 [label=""style=null,constraint=true,dir=null]; 48 | 47: node9 [shape=oval,label="clojure.core/string?",style=null,fillcolor="null", height=null,width=null 49 | 48: ]; 50 | 49: specvizexampletestmapof [shape=plaintext,label=<
:specviz.example/test-map-of {...}
:specviz.example/shape:specviz.example/test-or
......
>,style=null,fillcolor="null", height=null,width=null 51 | 50: ]; 52 | 51: specvizexampletestmapof:f0->specvizexampleshape [label=""style=null,constraint=true,dir=null]; 53 | 52: specvizexampletestmapof:f1->specvizexampletestor [label=""style=null,constraint=true,dir=null]; 54 | 53: specvizexampletestor [shape=box,label=":specviz.example/test-or",style=filled,fillcolor="#CCCCCC", height=null,width=null 55 | 54: ]; 56 | 55: specvizexampletestor->node10 [label=""style=null,constraint=true,dir=null]; 57 | 56: node10 [shape=diamond,label="",style=null,fillcolor="null", height=null,width=null 58 | 57: ]; 59 | 58: node10->node11 [label=":foo"style=null,constraint=true,dir=null]; 60 | 59: node11 [shape=oval,label="clojure.core/keyword?",style=null,fillcolor="null", height=null,width=null 61 | 60: ]; 62 | 61: node10->specvizexampleeid [label=":bar"style=null,constraint=true,dir=null]; 63 | 62: node10->node12 [label=":baz"style=null,constraint=true,dir=null]; 64 | 63: node12 [shape=diamond,label="",style=null,fillcolor="null", height=null,width=null 65 | 64: ]; 66 | 65: node12->node14 [label=":pos"style=null,constraint=true,dir=null]; 67 | 66: node14 [shape=oval,label="clojure.core/pos?",style=null,fillcolor="null", height=null,width=null 68 | 67: ]; 69 | 68: node12->node15 [label=":neg"style=null,constraint=true,dir=null]; 70 | 69: node15 [shape=oval,label="clojure.core/neg?",style=null,fillcolor="null", height=null,width=null 71 | 70: ]; 72 | 71: node12->node16 [label=":zero"style=null,constraint=true,dir=null]; 73 | 72: node16 [shape=oval,label="clojure.core/zero?",style=null,fillcolor="null", height=null,width=null 74 | 73: ]; 75 | 74: node10->node13 [label=":qux"style=null,constraint=true,dir=null]; 76 | 75: node13 [shape=plaintext,label=<
( clojure.core/keyword?clojure.core/string? )
>,style=null,fillcolor="null", height=null,width=null 77 | 76: ]; 78 | 77: node10->node17 [label=":qul"style=null,constraint=true,dir=null]; 79 | 78: node17 [shape=plaintext,label=<
:req
:specviz.example/shape
:specviz.example/foo
>,style=null,fillcolor="null", height=null,width=null 80 | 79: ]; 81 | 80: node17:f1->specvizexampleshape [label=""style=null,constraint=true,dir=null]; -------------------------------------------------------------------------------- /src/specviz/core.clj: -------------------------------------------------------------------------------- 1 | (ns specviz.core 2 | "Generate diagrams from specs." 3 | (:require 4 | [clojure.spec.alpha :as s] 5 | [clojure.string :as string] 6 | [specviz.html :as html :refer [h1-color h2-color]] 7 | [specviz.graphviz 8 | :as graphviz 9 | :refer [next-name clean-name render-graphviz]] 10 | [specviz.spec :as spec] 11 | [specviz.util :as util])) 12 | 13 | ;; *** Table Nodes *** 14 | ;; Use `specviz.html` to construct table representations of spec data, and 15 | ;; render it a an html string in order to be used as the label of a graphviz 16 | ;; plaintext node. 17 | 18 | (defn- specs->rows 19 | "Returns a single html row with one cell for each spec." 20 | [specs row-prefix row-suffix] 21 | (-> (map (comp util/strip-core 22 | util/first*) specs) 23 | (html/row nil) 24 | (html/decorate-row row-prefix row-suffix) 25 | vector)) 26 | 27 | (defn- specs->col 28 | "Like `specs-row`, but one row for each spec with a single cell." 29 | [specs row-prefix row-suffix] 30 | (html/col (map util/strip-core specs) nil)) 31 | 32 | (defn- table->graphviz-label 33 | "Generate the (slightly modified) html to be used as a label value for a 34 | graphviz plaintext node." 35 | [table] 36 | (format "<%s>" (html/html table))) 37 | 38 | (defn- specs->graphviz-table-node 39 | "Turn the spec parts into row data for rendering a graphviz table." 40 | [specs spec-keyword node-name {:keys [table-opts ellipses-row title-suffix 41 | row-prefix row-suffix vertical? 42 | hide-title?]}] 43 | (let [title (when-not hide-title? 44 | (str (or spec-keyword " ") " " (or title-suffix " "))) 45 | 46 | ;; Create html rows from the specs, and format them. 47 | rows ((if vertical? specs->col specs->rows) 48 | specs row-prefix row-suffix) 49 | 50 | ;; Create an html table for the row, and potentially ellipses row. 51 | table (html/table 52 | title 53 | (concat rows (when ellipses-row [(html/ellipses-row rows)])) 54 | table-opts)] 55 | 56 | ;; Create a graphviz plaintext node who's label is the html string 57 | ;; generated using the table created above. 58 | {::graphviz/name node-name 59 | ::graphviz/shape "plaintext" 60 | ::graphviz/label (table->graphviz-label table)})) 61 | 62 | ;; *** spec -> graphviz *** 63 | ;; Core spec -> graphviz conversion code. 64 | 65 | (defn- with-name-graphviz-node 66 | "If the spec-keyword is not nil, add a graphviz-node indicating the spec's name." 67 | [spec-keyword nodes] 68 | (concat 69 | (when spec-keyword 70 | (let [node {::graphviz/name (clean-name spec-keyword) 71 | ::graphviz/label spec-keyword 72 | ::graphviz/shape "box" 73 | ::graphviz/style "filled" 74 | ::graphviz/fillcolor h1-color}] 75 | [node 76 | {::graphviz/from (::graphviz/name node) 77 | ::graphviz/to (::graphviz/name (first nodes))}])) 78 | nodes)) 79 | 80 | 81 | (declare spec->graphviz-elements) 82 | 83 | ;; The `spec->graphviz-elements*` multimethod is implemented for each of the 84 | ;; supported spec expression types. `spec->graphviz-elements*` returns a 85 | ;; collection of graphviz drawables (nodes & connections). In most cases, it 86 | ;; behaves recursively. 87 | 88 | (defmulti spec->graphviz-elements* (fn [spec-form spec-keyword] 89 | (when (sequential? spec-form) 90 | (first spec-form)))) 91 | 92 | (s/def ::keys (s/cat 93 | :keys #{'clojure.spec.alpha/keys} 94 | :parts (s/* (s/cat :type #{:req :opt :req-un :opt-un} 95 | :kws (s/every keyword? :kind vector?))))) 96 | 97 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/keys 98 | [spec-form spec-keyword] 99 | (let [node-name (or (clean-name spec-keyword) (next-name)) 100 | parts (:parts (s/conform ::keys spec-form)) 101 | 102 | types-and-kws (mapcat (fn [{:keys [:type :kws]}] 103 | (cons type kws)) 104 | parts) 105 | 106 | table-node (specs->graphviz-table-node 107 | ;; Combine the :type & :kws attributes for now, into one 108 | ;; list. In the future, we'll need h2-colored 109 | ;; ::graphviz/bgcolor for the type rows. 110 | types-and-kws 111 | spec-keyword 112 | node-name 113 | {:vertical? true}) 114 | 115 | edges (map-indexed 116 | ;; Similarly, when we introduce h2-colored formatting above, 117 | ;; we may need to adjust this so that port indexes line up. 118 | (fn [i kw] (when (spec/registered? kw) 119 | (graphviz/connect :from table-node 120 | :from-port (html/port i) 121 | :to (clean-name kw)))) 122 | types-and-kws)] 123 | (conj edges table-node))) 124 | 125 | (defn- and-graphviz-node 126 | "Create the vertical 'fork' node, used to represent `s/and` specs." 127 | [node-name fork-count] 128 | (let [height (/ 150 fork-count)] 129 | {::graphviz/name node-name 130 | ::graphviz/width 0.25 ;; HACK, reduce the outer shape's width to fit. 131 | ::graphviz/label (table->graphviz-label 132 | (html/table 133 | nil 134 | (html/col (repeat fork-count "") 135 | {:height height 136 | :width 12 137 | :bgcolor "#666666"}) 138 | nil)) 139 | ::graphviz/shape "plaintext"})) 140 | 141 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/and 142 | [spec-form spec-keyword] 143 | (let [and-node (and-graphviz-node (next-name) (count (rest spec-form))) 144 | branches (map-indexed 145 | (fn [i spec-form] 146 | (if (qualified-keyword? spec-form) 147 | (graphviz/connect :from and-node 148 | :from-port (str "f" i) 149 | :to (clean-name spec-form)) 150 | (let [nodes (spec->graphviz-elements spec-form)] 151 | (conj nodes (graphviz/connect :from and-node 152 | :from-port (str "f" i) 153 | :to nodes))))) 154 | (rest spec-form))] 155 | (with-name-graphviz-node spec-keyword (cons and-node branches)))) 156 | 157 | (defn- or-graphviz-node 158 | "Create a diamond node, used to represent `s/or` specs." 159 | [] 160 | {::graphviz/name (next-name) 161 | ::graphviz/label "" 162 | ::graphviz/shape "diamond"}) 163 | 164 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/or 165 | [spec-form spec-keyword] 166 | (let [or-node (or-graphviz-node) 167 | or-pairs (->> spec-form rest (partition 2)) 168 | or-branches (map (fn [[label-kw spec-form]] 169 | (if (qualified-keyword? spec-form) 170 | (graphviz/connect :from or-node 171 | :to (clean-name spec-form) 172 | :label label-kw) 173 | (let [nodes (spec->graphviz-elements spec-form)] 174 | (conj nodes (graphviz/connect :from or-node 175 | :to nodes 176 | :label label-kw))))) 177 | or-pairs)] 178 | (with-name-graphviz-node spec-keyword (cons or-node or-branches)))) 179 | 180 | (defn- tabular-spec->graphviz-elements* 181 | "Returns a collection of graphviz nodes used to display a tabular entity. 182 | 183 | `spec-parts` are the pieces of a tabular spec. For example, given the spec 184 | `(s/coll-of int? :foo/bar)` `spec-parts` would have a value of 185 | `[int? :foo/bar]`. 186 | 187 | `spec-keyword` the registered keyword of the spec, if it has one. 188 | 189 | `recursive?` if true, generate the nodes for the spec recursively. 190 | 191 | `table-opts` optionally includes :table-opts, :ellipses-row, :title-suffix, 192 | :row-prefix, :row-suffix :vertica? :hide-title?" 193 | [spec-parts spec-keyword recursive? & table-opts] 194 | (let [node-name (or (clean-name spec-keyword) (next-name)) 195 | table-node (specs->graphviz-table-node 196 | spec-parts spec-keyword node-name table-opts) 197 | nodes (when recursive? 198 | (mapcat (fn [spec i] 199 | (let [port (html/port i)] 200 | (cond 201 | ;; For a literal, generate the nodes & connection 202 | ;; node. 203 | (spec/literal? spec) 204 | (let [part-nodes (spec->graphviz-elements spec)] 205 | (conj part-nodes 206 | (graphviz/connect :from table-node 207 | :from-port port 208 | :to part-nodes))) 209 | 210 | ;; For an existing spec keyword, only generate the 211 | ;; connection node. 212 | (spec/registered? spec) 213 | [(graphviz/connect :from table-node 214 | :from-port port 215 | :to (clean-name spec))] 216 | 217 | ;; Otherwise, if this is a predicate function, or a 218 | ;; set for example, don't generate any nodes. We'll 219 | ;; display it using the table. 220 | ))) 221 | spec-parts 222 | (range)))] 223 | (conj nodes table-node))) 224 | 225 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/every 226 | [spec-form spec-keyword] 227 | (if (not (coll? (second spec-form))) 228 | ;; TODO - factor this! 229 | (tabular-spec->graphviz-elements* [(second spec-form)] 230 | spec-keyword 231 | true 232 | :table-opts {:cellspacing 0} 233 | :ellipses-row true 234 | :title-suffix "(...)") 235 | (tabular-spec->graphviz-elements* (rest (second spec-form)) 236 | spec-keyword 237 | true 238 | :table-opts {:cellspacing 0} 239 | :ellipses-row true 240 | :title-suffix "{...}"))) 241 | 242 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/tuple 243 | [spec-form spec-keyword] 244 | (tabular-spec->graphviz-elements* (rest spec-form) 245 | spec-keyword 246 | true 247 | :row-suffix " )" 248 | :row-prefix "( ")) 249 | 250 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/nilable 251 | [spec-form spec-keyword] 252 | (spec->graphviz-elements `(s/or :nil nil? 253 | :not-nil ~@(rest spec-form)) 254 | spec-keyword)) 255 | 256 | ;; Unsupported 257 | 258 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/multi-spec 259 | [spec-form spec-keyword] 260 | nil) 261 | 262 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/fspec 263 | [spec-form spec-keyword] 264 | nil) 265 | 266 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/* 267 | [spec-form spec-keyword] 268 | nil) 269 | 270 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/& 271 | [spec-form spec-keyword] 272 | nil) 273 | 274 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/cat 275 | [spec-form spec-keyword] 276 | nil) 277 | 278 | (defmethod spec->graphviz-elements* 'clojure.spec.alpha/merge 279 | [spec-form spec-keyword] 280 | nil) 281 | 282 | (defn other-spec->graphviz-elements 283 | [spec-form spec-keyword] 284 | (with-name-graphviz-node 285 | spec-keyword 286 | [{::graphviz/name (next-name) 287 | ::graphviz/label (-> spec-form 288 | pr-str 289 | util/strip-core) 290 | ::graphviz/shape "oval"}])) 291 | 292 | (defn coll-spec->graphviz-elements 293 | [first-row spec-form spec-keyword] 294 | (with-name-graphviz-node 295 | spec-keyword 296 | (tabular-spec->graphviz-elements* 297 | (cons first-row (map pr-str spec-form)) 298 | nil 299 | false 300 | :table-opts {:cellspacing 0 301 | :cellborder 0 302 | :border 1} 303 | :vertical? true 304 | :hide-title? true))) 305 | 306 | (defmethod spec->graphviz-elements* :default 307 | [spec-form spec-keyword] 308 | ((cond (vector? spec-form) 309 | (partial coll-spec->graphviz-elements "[...]") 310 | (set? spec-form) 311 | (partial coll-spec->graphviz-elements "#{...}") 312 | (map? spec-form) 313 | (partial coll-spec->graphviz-elements "{...}") 314 | :else 315 | other-spec->graphviz-elements) spec-form spec-keyword)) 316 | 317 | (defn- spec->graphviz-elements 318 | "Return a sequence of graphviz elements used to render the spec." 319 | ([spec-form] (spec->graphviz-elements spec-form nil)) 320 | ([spec-form spec-keyword] 321 | (spec->graphviz-elements* spec-form spec-keyword))) 322 | 323 | ;; *** Diagram *** 324 | 325 | (defn diagram 326 | "Generate a diagram of the specs. 327 | 328 | `filename` only the name of the file. Both .png, and .dot 329 | files will be generated. 330 | 331 | `root` can be a keyword, naming a spec in the registry, or a namespace symbol 332 | from which to load all specs. From these starting points, all 333 | dependent specs are included, recursively. 334 | ex: `:specviz.graphviz/shape`, `'specviz.graphviz` 335 | 336 | `excluded-namespaces` collection of strings representing namespaces, or 337 | partial namespaces, which should be excluded from the 338 | diagram. ex: `[\"clojure.core\" \"string\"]`" 339 | [root excluded-namespaces filename] 340 | 341 | ;; Make sure the specs are loaded (into the registry). 342 | (require (if (qualified-keyword? root) 343 | (symbol (namespace root)) 344 | root)) 345 | 346 | (let [;; Start with either the specified root spec, or all of the specs in 347 | ;; the root namespace. 348 | starting-specs (if (qualified-keyword? root) 349 | [root] 350 | (->> (s/registry) 351 | (map key) 352 | (filter #(= root (symbol (namespace %)))))) 353 | 354 | ;; Get nested specs for all of the starting specs. 355 | nested-specs (distinct (mapcat spec/depends-on starting-specs)) 356 | 357 | ;; Filter the excluded specs. 358 | filtered-specs (remove (fn [spec] (some 359 | #(.contains (namespace spec) %) 360 | (conj excluded-namespaces 361 | "clojure.core"))) 362 | nested-specs) 363 | 364 | ;; Generate the graphviz dot string. 365 | dot (->> filtered-specs 366 | (map (fn [spec-kw] (spec->graphviz-elements 367 | (s/form (s/get-spec spec-kw)) spec-kw))) 368 | flatten 369 | (remove nil?) 370 | graphviz/dot-string)] 371 | 372 | (graphviz/generate-image! dot filename))) 373 | 374 | (comment 375 | (specviz.core/diagram :specviz.graphviz/drawable nil "bar") 376 | (specviz.core/diagram 'specviz.example nil "foo1")) 377 | --------------------------------------------------------------------------------