Skip to content

Latest commit

 

History

History
454 lines (434 loc) · 14.2 KB

s-graphviz.org

File metadata and controls

454 lines (434 loc) · 14.2 KB

an S-expression presentation of GraphViz DOT language

Table of Contents

Introduction

S-Graphviz is an S-Expression syntax for the input language of the ‘Dot’ graph drawing tool from the AT&T GraphViz suite. With S-Graphviz, you can render ‘Dot’ graphs from within Common Lisp. The idea of this package is from S-DOT.

Preparation

Let’s create an individual package for this module.

(defpackage :s-graphviz
  (:nicknames :graphviz)
  (:use :common-lisp :iter)
  (:export :render-graph :format-graph))
(in-package :s-graphviz)

Language presentation

We will use a lisp style to present the DOT language.

graph	:	[ strict ] (graph | digraph) [ ID ] '{' stmt_list '}'
stmt_list	:	[ stmt [ ';' ] stmt_list ]
stmt	:	node_stmt
|	edge_stmt
|	attr_stmt
|	ID '=' ID
|	subgraph
attr_stmt	:	(graph | node | edge) attr_list
attr_list	:	'[' [ a_list ] ']' [ attr_list ]
a_list	:	ID '=' ID [ (';' | ',') ] [ a_list ]
edge_stmt	:	(node_id | subgraph) edgeRHS [ attr_list ]
edgeRHS	:	edgeop (node_id | subgraph) [ edgeRHS ]
node_stmt	:	node_id [ attr_list ]
node_id	:	ID [ port ]
port	:	':' ID [ ':' compass_pt ]
|	':' compass_pt
subgraph	:	[ subgraph [ ID ] ] '{' stmt_list '}'
compass_pt	:	(n | ne | e | se | s | sw | w | nw | c | _)

global variables

the encoded stream

The encoded DOT expressions will print to this stream.

(defvar *dot-stream*)

the indent level

How many white spaces will be written before printing current DOT expression.

(defvar *indent-spaces* 0)

the default size for indent tab

(defvar *indent-tab-size* 2)

how to render an S-GRAPHVIZ S-expression

Renders a s-graphviz graph into a graphic file. file-name should be a path name. If the file-name is /foo/bar.png, the DOT file /foo/bar.dot is created and then rendered. Format should be one out of http://www.graphviz.org/doc/info/output.html, for example svg, ps, gif, png, or jpg.

(defun render-graph (file-name s-expression &key
                               (format (pathname-type file-name))
                               (dot-exe "dot")
                               (dot-options "")
                               (dot-output-format-switch "-T"))
  (let ((dot-file-name (make-pathname :directory (pathname-directory file-name)
                                      :name (pathname-name file-name) :type "dot")))
    (with-open-file (stream dot-file-name :direction :output :if-exists :supersede
                            :if-does-not-exist :create)
      (format stream "// This file is generated automatically by S-GRAPHVIZ.~%~%")
      (format-graph s-expression :stream stream))
    (uiop:run-program (format nil "~a -o ~a ~a~a ~a ~a"
                              dot-exe file-name dot-output-format-switch
                              format dot-options dot-file-name)
                      :ignore-error-status t)))

We will provide a helpful routine to render an S-GRAPHVIZ S-expression and open it in Emacs.

(defmacro render-and-open-s-graphviz (file-name &rest left-args)
  (let ((real-file-name (gensym "file-name")))
    `(let ((,real-file-name (namestring ,file-name)))
       (render-graph ,real-file-name ,@left-args)
       #+slynk
       (slynk:eval-in-emacs `(find-file ,,real-file-name) t)
       #+swank
       (swank:eval-in-emacs `(find-file ,,real-file-name) t))))

graph

  • The top expression must begin with the graph type:
    • :graph or
    • :digraph, or
    • (:strict :graph) or
    • (:strict :digraph)
(defvar *valid-graph-predicates* '(:graph :digraph (:strict :graph) (:strict :digraph)))
  • the second item in top expression will be the graph ID if not null.
  • the left items in top expression are the stmt_list for this graph.
(defun format-graph (s-expression &key stream)
  (let ((graph-type (car s-expression))
        (id (second s-expression))
        (stmt-list (cddr s-expression))
        (*indent-spaces* 0)
        (*dot-stream* (or stream
                          (make-string-output-stream))))
    ;; check graph type.
    (unless (find graph-type *valid-graph-predicates* :test #'equal)
      (error "The dot graph must be a 'graph' or a 'digraph'!"))
    ;; write out graph type.
    (if (atom graph-type)
      (format *dot-stream* "~(~a~) " graph-type)
      (format *dot-stream* "~{~(~a ~)~}" graph-type))
    ;; write out optional graph ID
    (when id
      (format-id id))

    (format-stmt-list stmt-list)
    (when (null stream)
      (get-output-stream-string *dot-stream*))))

stmt-list

a graph is established by a stmt_list. We can also apply some global configuration in a stmt_list to limit their affection scope, for example:

(render-and-open-s-graphviz
 (merge-pathnames
  #p"images/statements.png"
  (asdf:component-pathname (asdf:find-system :s-graphviz)))
 '(:digraph nil
   (= :rankdir "LR")
   (:-> nil a b c)
   (:-> nil d e f)
   (:-> nil b d)
   (:{}
    (= :rank :same)
    (b)
    (d)))
 )
(defun format-stmt-list (stmt-list)
  (format *dot-stream* "{~%")
  (incf *indent-spaces* *indent-tab-size*)
  ;; write out statements
  (iter (for stmt in stmt-list)
        (iter (repeat *indent-spaces*)
              (write-char #\Space *dot-stream*))
        (format-stmt stmt)
        (format *dot-stream* ";~%"))
  (decf *indent-spaces* *indent-tab-size*)
  (iter (repeat *indent-spaces*)
        (write-char #\Space *dot-stream*))
  (format *dot-stream* "}")
  )

stmt

There are many kinds of statements, let’s recognize them one by one.

(defun format-stmt (stmt)
  (case (car stmt)
    ((:-> :--) (format-edge-stmt stmt))
    ((:graph :node :edge) (format-attr-stmt stmt))
    ((:= =) (format-attribute stmt))
    (({ :{ :{}) (format-stmt-list (cdr stmt)))
    (:subgraph (format-subgraph-stmt stmt))
    (otherwise (format-node-stmt stmt))))

node statement

If a statement starts without a known keyword, then it’s a node statement, for example

(render-and-open-s-graphviz
 (merge-pathnames
  #p"images/node1.png"
  (asdf:component-pathname (asdf:find-system :s-graphviz)))
 '(:digraph nil
   (node1 (:label "nice node") (:shape :box) (:fontname "Arial") (:fontcolor "#AA0000"))))

./images/node1.png

(defun format-node-stmt (stmt)
  (let ((id-port (first stmt))
        (attr-list (cdr stmt)))
    (format-node-id id-port)
    (format-attr-list attr-list)))

node id

node_id	:	ID [ port ]

In an S-expression, it can be a single id or a list contains both id and port.

(defun format-node-id (id-port)
  (if (atom id-port)
    (format-id id-port)
    (let ((id (first id-port))
          (port (second id-port)))
      (format-id id)
      (when port
        (format-port port)))))

edge statement

edge_stmt	:	(node_id | subgraph) edgeRHS [ attr_list ]
edgeRHS	:	edgeop (node_id | subgraph) [ edgeRHS ]

In an S-expression, it’s a list that starts with an edgeop and an attr_list and the rest are a list of node id, for example

(render-and-open-s-graphviz
 (merge-pathnames
  #p"images/edge1.png"
  (asdf:component-pathname (asdf:find-system :s-graphviz)))
 '(:digraph nil
   (:-> ((arrowhead :diamond)
         (color "#FF0000")
         (headlabel "head\\nlabel")
         (label "red edge")
         (labelfontname "Arial")
         (fontname "courier")
         (arrowsize 2))
    node1
    node2
    node3)))

./images/edge1.png

(defun format-edge-stmt (stmt)
  (let ((edge-op (first stmt))
        (attr-list (second stmt))
        (list-of-node-id (cddr stmt)))
    (iter (initially (format-node-id (first list-of-node-id)))
          (for left-nodes on (cdr list-of-node-id))
          (format *dot-stream* " ~a " edge-op)
          (format-node-id (car left-nodes)))
    (format-attr-list attr-list)))

attribute statement

attr_stmt	:	(graph | node | edge) attr_list

In an S-GRAPHVIZ S-expression, it starts with keyword :graph, :node, :edge , and the rest items in the list is the ~attr_list~(ref:attr-list),for example:

(render-and-open-s-graphviz
 (merge-pathnames
  #p"images/node2.png"
  (asdf:component-pathname (asdf:find-system :s-graphviz)))
 '(:digraph nil
   (:node
    (:fillcolor "#AAFFAA")
    (:shape :circle)
    (:color "#0000AA")
    (:style :filled)
    (:fontsize 16))
   (node1 (:id "a")(:label :test))))

./images/node2.png

(defun format-attr-stmt (stmt)
  (format-id (car stmt))
  (format-attr-list (cdr stmt)))

attribute

a single attribute can be applied to global environment in a statement, in an S-GRAPHVIZ S-expression, it looks like this:

(render-and-open-s-graphviz
 (merge-pathnames
  #p"images/attr.png"
  (asdf:component-pathname (asdf:find-system :s-graphviz)))
 '(:digraph nil
   (= :compound t)
   (node1 (:id :test)(:label :test))))

./images/attr.png

(defun format-attribute (stmt)
  (let ((key (second stmt))
        (value (third stmt)))
    (format-id key)
    (write-string " = " *dot-stream*)
    (format-id value)))

subgraph

It a subgraph’s name starts with “cluster”, then it has a special meaning. in an S-GRAPHVIZ S-expression, it looks like this:

(render-and-open-s-graphviz
 (merge-pathnames
  #p"images/cluster1.png"
  (asdf:component-pathname (asdf:find-system :s-graphviz)))
 '(:digraph nil
   (:subgraph cluster_1
    (node1 (:id :test)(:label :test))
    (node2 (:id :test2)(:label :test2))
    (:-> nil node1 node2))))

./images/cluster1.png

(defun format-subgraph-stmt (stmt)
  (let ((id (second stmt))
        (stmt-list (cddr stmt)))
    (write-string "subgraph " *dot-stream*)
    (when id
      (format-id id))
    (format-stmt-list stmt-list)))

id

(defun format-id (id)
  (typecase id
    (string
     ;; To a string, we have to format it in `~a' to avoid escape special characters twice.
     (write-char #\" *dot-stream*)
     (format *dot-stream* "~a" id)
     (write-char #\" *dot-stream*))
    (t (cond ((eq id t)
              (format *dot-stream* "true"))
             ((eq id nil)
              (format *dot-stream* "false"))
             (t
              (format *dot-stream* "~(~a~)" id))))))

port

Its BNF syntax is:

port	:	':' ID [ ':' compass_pt ]
       |	':' compass_pt
compass_pt	:	(n | ne | e | se | s | sw | w | nw | c | _)

In an S-GRAPHVIZ S-expression, it looks like this:

(render-and-open-s-graphviz
 (merge-pathnames
  #p"images/port.png"
  (asdf:component-pathname (asdf:find-system :s-graphviz)))
 '(:digraph nil
   (:-> nil (node1 :e) (node2 :s))))

./images/port.png

(defun format-port (port)
  (write-string " : " *dot-stream*)
  (cond ((atom port)
         (format-id port))
        (t
         (let ((id (first port))
               (compass-pt (second port)))
           (format-id id)
           (write-string " : " *dot-stream*)
           (format-id compass-pt)))))

attr list

label:attr-list

attr_list	:	'[' [ a_list ] ']' [ attr_list ]
a_list	:	ID '=' ID [ (';' | ',') ] [ a_list ]

In an S-expression, it is an association list like this:

((:label "a label") (:shape :box))
(defun format-attr-list (attr-list)
  (when attr-list
    (iter (initially (format *dot-stream* " ["))
          (for (key value) in attr-list)
          (unless (first-iteration-p)
            (write-string ", " *dot-stream*))
          (format-id key)
          (write-string " = " *dot-stream*)
          (format-id value)
          (finally (format *dot-stream* "]")))))

Tests

Preparation

Now it’s time to validate some functions. The FiveAM library is used to test.

(eval-when (:compile-toplevel :load-toplevel :execute)
  (unless (find-package :fiveam)
    #+quicklisp (ql:quickload :fiveam)
    #-quicklisp (asdf:load-system :fiveam)))
(5am:def-suite s-graphviz-suite :description "The test suite of S-GRAPHVIZ.")
(5am:in-suite s-graphviz-suite)

node statement

label:test-of-node-statement

(5am:test node-stmt
  (5am:is (equal "digraph {
  node1 [label = \"nice node\", shape = box, fontname = \"Arial\", fontcolor = \"#AA0000\"];
}"
                 (format-graph '(:digraph nil
                                 (node1
                                  (:label "nice node")
                                  (:shape :box)
                                  (:fontname "Arial")
                                  (:fontcolor "#AA0000"))))))
  )