├── 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 | 
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 | 
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 |
--------------------------------------------------------------------------------