今回のお題:
・入力となるソースコードをコンパイルする。
・メインクラスの ClassNode を取得する。
・取得した ClassNode のツリー構造を画像データにレンダリングする。
次のようなステップで処理を行う。
(1) groovy スクリプトのファイルを読み出してコンパイルし AST を取り出す。
(2) メインクラスの ClassNode を取得し、その配下の全ノードをトラバースして対応する XML ツリーを作成する。
(3) XML から Graphviz の DOT 形式に変換する。
(4) DOT 形式から Graphviz*1 の dot コマンドで PNG 画像を出力する。
いきなり (2) で DOT にすればいいじゃん!無駄じゃん!と思われるかもしれないが、AST を一度 XML にしておけば、色んな加工方法が考えられるので「あえて」XML にしている。
それぞれのステップを見ていく。
(1) groovy スクリプトのファイルを読み出してコンパイルし AST を取り出す。
ModuleNode src2ast (File infile, CompilePhase compilePhase) { def name = infile.name.substring(0,infile.name.indexOf(".groovy")) def source = infile.text def sourceUnit = SourceUnit.create(name, source) def compUnit = new CompilationUnit() compUnit.addSource(sourceUnit) compUnit.compile(compilePhase.phaseNumber) sourceUnit.getAST() }
前回みつけた、ソースコードをコンパイルして AST を取り出すコード。
SourceUnitの名前にはスクリプトのファイル名を使用。
(2) メインクラスの ClassNode を取得し、その配下の全ノードをトラバースして対応する XML ツリーを作成する。
String ast2xml (ModuleNode mn) { def sb = new StringBuffer() def visitor = new MyVisitor(sb) sb << "<ast>\n" sb << "<class>\n" visitor.visitClass(pickupMainClassNode(mn)) sb << "</class>\n" sb << "</ast>\n" sb.toString() }
GroovyClassVisitor と GroovyCodeVisitor を実装した MyVisitor クラスでメインクラスの ClassNode 配下をトラバースする。MyVisitor クラスはややボリュームがあるのでこのエントリの末尾に掲載しておく。
(3) XML から Graphviz の DOT 形式に変換する。
String xml2dot (GPathResult n) { def sb = new StringBuffer() sb << "digraph ast {\n" sb << "node [shape = record, fontname = \"Helvetica\", fontsize = 10];\n" node(n.class.node[0], sb) sb << "}\n" sb.toString() }
node メソッドは再帰的に XMLの Elementツリーをトラバースし、対応する DOT 形式を出力する。
(4) DOT 形式から Graphviz の dot コマンドで PNG 画像を出力する。
boolean dot2png (File dotfile, File pngfile) { "$dot_exe -T png -o ${pngfile} ${dotfile}".execute().waitFor() pngfile.exists() }
Graphviz の dot.exe コマンドを実行。String への execute メソッドの追加はコードを簡潔に記述できて便利。
それでは、以下、2つの Groovy スクリプトの AST のツリー構造を見てみよう。
// test1.groovy class test1 { public static void main (String[] args) { println "Hello, world!" } }
// test2.groovy println "Hello, world!"
test1 は予想通りだったが、test2 は予想を超えていて驚いた。謎の public Object run () メソッドの中に test2 のコードが挿入されている。
てっきり、main メソッドの中に挿入されるものと予想していた。しかし、run メソッドか~。
クラス定義を行わずに書いたコードは、補完されたクラスのインスタンスオブジェクトのコンテキストで実行されるわけだ。これは意識しておいた方がよさそう。
とりあえず、AST の可視化はわかったので、次は AST変換に取り組んでみることにしよう。つまり、ASTTransformation の実装である。
次回はどんな AST変換を作るか、ネタを整理してみる。
最後に今回作成したコードの全体を示す。
// ast2png.groovy import groovy.transform.* import org.codehaus.groovy.control.* import org.codehaus.groovy.ast.* import groovy.util.slurpersupport.* import bunji.MyVisitor def debug=false def compilePhase = CompilePhase.CONVERSION println "${this.class}: compile phase = ${compilePhase}" @Field def dot_exe="C:\\opt\\graphviz\\bin\\dot.exe" for (def x in [dot_exe]) { def f = new File(x) if (! f.exists()) { println "${this.class}: ${f.path} is not found!" return } } ModuleNode src2ast (File infile, CompilePhase compilePhase) { def name = infile.name.substring(0,infile.name.indexOf(".groovy")) def source = infile.text def sourceUnit = SourceUnit.create(name, source) def compUnit = new CompilationUnit() compUnit.addSource(sourceUnit) compUnit.compile(compilePhase.phaseNumber) sourceUnit.getAST() } ClassNode pickupMainClassNode (ModuleNode mn) { def mainClassName = mn.mainClassName if (mn.classes != null) { for (ClassNode cn in mn.classes) { if (cn.name == mainClassName) { return cn } } } return null } String ast2xml (ModuleNode mn) { def sb = new StringBuffer() def visitor = new MyVisitor(sb) sb << "<ast>\n" sb << "<class>\n" visitor.visitClass(pickupMainClassNode(mn)) sb << "</class>\n" sb << "</ast>\n" sb.toString() } @Field int ctr=0 String escape (String x) { x.replaceAll(~/&/, '&') .replaceAll(~/</, '<') .replaceAll(~/>/, '>') } int node(GPathResult n, StringBuffer sb) { if (n.name() == "node") { def t = [] def params = [:] n.param?.each { m -> def fc = m.children()[0] if (fc.name() == "value") { if (fc.@annotation != "") { t << "${m.@name}=\\\"${escape(fc.text())}\\\" (${fc.@annotation})" } else { t << "${m.@name}=\\\"${escape(fc.text())}\\\"" } } else { params[m.@name] = node(fc, sb) } } int c = ctr++ sb << "N$c [label = \"{(N$c)${n.@name}" if (t.size()>0) { sb << "|{" sb << t.join("|") sb << "}" } if (params.size()>0) { sb << "|{" sb << params.collect {k,v -> "<$k> $k" }.join("|") sb << "}}\"];\n" params.each {k,v -> sb << "N$c:<$k> -> N$v;\n" } } else { sb << "}\"];\n" } return c } if (n.name() == "value") { int c = ctr++ sb << "N$c [label = \"(N$c)${n.text()}\"];\n" return c } if (n.name () == "array") { def a=[] n.children().each { m -> a << node(m, sb) } int c = ctr++ sb << "N$c [label = \"{array(${n.@size})" if (n.@size != "0") { sb << "|{" int i=0 sb << a.collect { m -> "<$i> ${i++}" }.join("|") sb << "}" } sb << "}\"];\n" a.eachWithIndex { m,i -> sb << "N$c:<$i> -> N$m;\n" } return c } if (n.name () == "param") { return node(n.children()[0], sb) } int c = ctr++ sb << "N$c [label = \"(N$c)${n.name()}\"];\n" return c } String xml2dot (GPathResult n) { def sb = new StringBuffer() sb << "digraph ast {\n" sb << "node [shape = record, fontname = \"Helvetica\", fontsize = 10];\n" node(n.class.node[0], sb) sb << "}\n" sb.toString() } boolean dot2png (File dotfile, File pngfile) { "$dot_exe -T png -o ${pngfile} ${dotfile}".execute().waitFor() pngfile.exists() } def infile = new File(args[0]) def ast = src2ast (infile, compilePhase) def xml = ast2xml(ast) if (debug) { def xmlfile = new File(infile.path + ".xml") xmlfile.text = xml println "${this.class}: internal xml is saved in ${xmlfile.path}." } def n = new XmlSlurper().parseText(xml) def dot = xml2dot(n) def dotfile = new File(infile.path + ".dot") dotfile.text = dot if (debug) { println "${this.class}: internal dot is saved in ${dotfile.path}." } def pngfile = new File(infile.path + ".png") if (pngfile.exists()) { if (pngfile.delete()) { println "${this.class}: old png is deleted." } } println "Rendering ${dotfile.name} to ${pngfile.name} ..." if (dot2png(dotfile, pngfile)) { println "${this.class}: png is saved in ${pngfile.path}" } else { println "${this.class}: failed." }
bunji.MyVisitor は実装するメソッドが多くなかなか骨が折れたが、結果としてノード系とコード系すべてのクラスの JavaDoc に眼を通すことになり、勉強になった。
package bunji import org.codehaus.groovy.ast.* import org.codehaus.groovy.ast.expr.* import org.codehaus.groovy.ast.stmt.* import org.codehaus.groovy.classgen.BytecodeExpression import org.codehaus.groovy.syntax.Token import org.codehaus.groovy.syntax.Types public class MyVisitor implements GroovyClassVisitor,GroovyCodeVisitor { static def rev_Types = [:] private StringBuffer sb; String escape (x) { (x == null)?x: x.toString().replaceAll(~/&/, '&') .replaceAll(~/</, '<') .replaceAll(~/>/, '>') } public MyVisitor(StringBuffer sb) { this.sb = sb for (field in Types.declaredFields) { def k = field.name def v = Types[field.name] if (v instanceof Integer) { rev_Types[v]=k } } } String modifier2str (int modifier) { def m = [] if (modifier & 1) { m << "public" } if (modifier & 2) { m << "private" } if (modifier & 4) { m << "protected" } if (modifier & 8) { m << "static" } if (modifier & 16) { m << "final" } if (modifier & 32) { m << "synchronized" } if (modifier & 64) { m << "volatile" } if (modifier & 128) { m << "transient" } if (modifier & 256) { m << "native" } if (modifier & 1024) { m << "abstract" } return m.join(",") } void nb(n) { // Node Begin sb << "<node name=\"$n\">\n" } void ne() { // Node End sb << "</node>" } void p(n) { // Param begin/end sb << "<param name=\"$n\"/>\n" } void pb(n) { // Param Begin sb << "<param name=\"$n\">\n" } void pe() { // Param End sb << "</param>\n" } void p(n,t) { // Param begin/end with value sb << "<param name=\"$n\">\n" sb << "<value>${escape(t)}</value>\n" sb << "</param>\n" } void p(n,t,type) { // Param begin/end with value sb << "<param name=\"$n\">\n" sb << "<value type=\"$type\">${escape(t)}</value>\n" sb << "</param>\n" } void p(n,t,type,annotation) { // Param begin/end with value sb << "<param name=\"$n\">\n" sb << "<value type=\"$type\" annotation=\"$annotation\">${escape(t)}</value>\n" sb << "</param>\n" } void pbl(n,s) { // Param Begin with Array Begin sb << "<param name=\"$n\">\n<array size=\"${s}\">\n" } void pel() { // Param End with Array End sb << "</array>\n</param>\n" } void pl(n) { // Param begin/end with Array begin/end sb << "<param name=\"$n\"><array size=\"0\"/></param>\n" } void ab(s) { // Array Begin sb << "<array size=\"${s}\">\n" } void ae() { // Array End sb << "</array>\n" } void a() { sb << "<array size=\"0\"/>\n" } String trimType(x) { if (x!=null && x.toString().startsWith("class ")) { x.toString().substring(6) } else { x } } void visitToken(Token t) { def op = escape(t.text) nb("Token") p("type", t.type, "int", rev_Types[t.type]) p("text", op) ne() } void visitParameter(Parameter pa) { nb("Parameter") p("name", pa.name) p("type", trimType(pa.type)) ne() } void visitProperty(PropertyNode n) { nb("PropertyNode") p("name", n.name) pb "initialExpression" n.initialExpression?.visit(this) pe() if (n.field != null) { pb "field" visitField n.field // n.field.visit(this) pe() } p("modifiers", n.modifiers, "int", modifier2str(n.modifiers)) pb "getterBlock" n.getterBlock?.visit(this) pe() pb "setterBlock" n.setterBlock?.visit(this) pe() ne() } void visitField(FieldNode n) { nb("FieldNode") p("name", n.name) p("type", n.type.name) p("owner", n.owner.name) pb "initialExpression" n.initialExpression?.visit(this) pe() p("modifiers", n.modifiers, "int", modifier2str(n.modifiers)) ne() } void visitConstructor(ConstructorNode n) { nb "ConstructorNode" p("modifiers", n.modifiers, "int", modifier2str(n.modifiers)) if (n.parameters?.size() > 0) { pbl("parameters",n.parameters?.size()) n.parameters.each { visitParameter(it) } pel() } else { pl("parameters") } if (n.exceptions?.size() > 0) { pbl("exceptions",n.exceptions?.size()) n.exceptions.each { it.visit(this) } pel() } else { pl("exceptions") } pb("code") n.code.visit(this) pe() ne() } void visitClass(ClassNode n) { nb("ClassNode") p("name", n.name) p("modifiers", n.modifiers, "int", modifier2str(n.modifiers)) p("superClass", n.superClass.name) if (n.fields?.size() > 0) { pbl "fields",n.fields?.size() n.fields?.each{ visitField(it) // it.visit(this) } pel() } else { pl "fields" } if (n.methods?.size() > 0) { pbl "methods",n.methods?.size() n.methods?.each{ visitMethod(it) } pel() } else { pl "methods" } if (n.properties?.size() > 0) { pbl "properties",n.properties?.size() n.properties?.each{ visitProperty(it) } pel() } else { pl "properties" } if (n.declaredConstructors?.size()) { pbl "declaredConstructors",n.declaredConstructors?.size() n.declaredConstructors?.each{ visitConstructor(it) } pel() } else { pl "declaredConstructors" } if (n.objectInitializerStatements?.size()) { pbl "objectInitializerStatements",n.objectInitializerStatements.size() n.dobjectInitializerStatements.each{ it.visit(this) } pel() } else { pl "objectInitializerStatements" } ne() } void visitMethod(MethodNode n) { nb("MethodNode") p("name", n.name) p("modifiers", n.modifiers, "int", modifier2str(n.modifiers)) p("returnType", trimType(n.returnType)) if (n.parameters?.size() > 0) { pbl("parameters",n.parameters?.size()) n.parameters.each {visitParameter(it)} pel() } else { pl("parameters") } pb("code") n.code.visit(this) pe() ne() } void visitArgumentlistExpression(ArgumentListExpression expression) { nb "ArgumentListExpression" if (expression?.expressions?.size() > 0) { pbl "expressions",expression?.expressions?.size() expression?.expressions?.each { it.visit(this) } pel () } else { pl "expressions" } ne () } void visitArrayExpression(ArrayExpression expression) { nb "ArrayExpression" if (expression.expressions?.size() >0) { pbl "expressions",expression.expressions?.size() expression.expressions.each { it.visit(this) } pel() } else { pl "expressions" } ne() } void visitAssertStatement(AssertStatement statement) { nb "AssertStatement" pb "booleanExpression" statement.booleanExpression.visit(this) pe() pb "messageExpression" statement.messageExpression.visit(this) pe() ne() } void visitAttributeExpression(AttributeExpression attributeExpression) { nb "AttributeExpression" pb "objectExpression" attributeExpression.objectExpression.visit(this) pe () pb "property" attributeExpression.property.visit(this) pe () ne () } void visitBinaryExpression(BinaryExpression expression) { nb "BinaryExpression" pb "leftExpression" expression.leftExpression.visit(this) pe() pb "operation" visitToken(expression.operation) pe() pb "rightExpression" expression.rightExpression.visit(this) pe() ne() } void visitBitwiseNegationExpression(BitwiseNegationExpression expression) { pb "leftExpression" expression.expression.visit(this) pe() nb "BitwiseNegationExpression" ne() } void visitBlockStatement(BlockStatement statement) { nb "BlockStatement" if (statement.statements?.size() > 0) { pbl "statements",statement.statements?.size() statement.statements.each { it.visit(this) } pel() } else { pl "statements" } ne() } void visitBooleanExpression(BooleanExpression expression) { nb "BooleanExpression" pb "expression" expression.expression.visit(this) pe() ne() } void visitBreakStatement(BreakStatement statement) { nb "BreakStatement" ne() } void visitBytecodeExpression(BytecodeExpression expression) { nb "BytecodeExpression" ne() } void visitCaseStatement(CaseStatement statement) { nb "CaseStatement" pb "expression" statement.expression.visit(this) pe() pb "code" statement.code.visit(this) pe() ne() } void visitCastExpression(CastExpression expression) { nb "CastExpression" p "type", trimType(expression.type) pb "expression" expression.expression.visit(this) pe() ne() } void visitCatchStatement(CatchStatement statement) { nb "CatchStatement" p "variable",statement.variable pb "code" statement.code.visit(this) pe() ne() } void visitClassExpression(ClassExpression expression) { nb "ClassExpression" p "type",trimType(expression.type) ne() } void visitClosureExpression(ClosureExpression expression) { nb "ClosureExpression" if (expression.parameters.size()>0) { pbl "parameters",expression.parameters.size() expression.parameters.each { visitParameter(it) } pel() } else { pl "parameters" } pb "code" expression.code.visit(this) pe() ne() } void visitClosureListExpression(ClosureListExpression closureListExpression) { nb "ClosureListExpression" if (closureListExpression.expressions?.size() > 0) { pbl "expressions",closureListExpression.expressions?.size() closureListExpression.expressions.each { it.visit(this) } pel() } else { pl "expressions" } ne() } void visitConstantExpression(ConstantExpression expression) { nb "ConstantExpression" p "value",expression.value p "type",trimType(expression?.value?.class) ne() } void visitConstructorCallExpression(ConstructorCallExpression expression) { nb "ConstructorCallExpression" p "type",trimType(expression.type) p "methodAsString",expression.methodAsString p "specialCall",expression.isSpecialCall() p "superCall",expression.isSuperCall() p "thisCall",expression.isThisCall() p "usingAnonymousInnerClass",expression.isUsingAnonymousInnerClass() pb "arguments" expression.arguments.visit(this) pe() ne() } void visitContinueStatement(ContinueStatement statement) { nb "ContinueStatement" ne() } void visitDeclarationExpression(DeclarationExpression expression) { nb "DeclarationExpression" pb "leftExpression" expression.leftExpression.visit(this) pe() pb "operation" visitToken(expression.operation) pe() pb "rightExpression" expression.rightExpression.visit(this) pe() ne() } void visitDoWhileLoop(DoWhileStatement loop) { nb "DoWhileStatement" pb "booleanExpression" loop.booleanExpression.visit(this) pe() pb "loopBlock" loop.loopBlock.visit(this) pe() ne() } void visitExpressionStatement(ExpressionStatement statement) { nb "ExpressionStatement" pb "expression" statement.expression.visit(this) pe() ne() } void visitFieldExpression(FieldExpression expression) { nb "FieldExpression" p "fieldName",expression.fieldName ne() } void visitForLoop(ForStatement forLoop) { nb "ForStatement" pb "variable" visitParameter(forLoop.variable) pe() pb "collectionExpression" forLoop.collectionExpression.visit(this) pe() pb "loopBlock" forLoop.loopBlock.visit(this) pe() ne() } void visitGStringExpression(GStringExpression expression) { nb "GStringExpression" if (expression.strings?.size() > 0) { pbl "strings",expression.strings?.size() expression.strings.each { it.visit(this) } pel() } else { pl "strings" } if (expression.values?.size() > 0) { pbl "values",expression.values?.size() expression.values.each { it.visit(this) } pel() } else { pl "values" } ne() } void visitIfElse(IfStatement ifElse) { nb "IfStatement" pb "booleanExpression" ifElse.booleanExpression.visit(this) pe() pb "ifBlock" ifElse.ifBlock.visit(this) pe() pb "elseBlock" if (! ifElse.elseBlock.isEmpty()) { ifElse.elseBlock.visit(this) } else { nb "EmptyStatement" ne() } pe() ne() } void visitListExpression(ListExpression expression) { nb "ListExpression" if (expression.expressions?.size() > 0) { pbl "expressions",expression.expressions?.size() expression.expressions.each { it.visit(this) } pel() } else { pl "expressions" } ne() } void visitMapEntryExpression(MapEntryExpression expression) { nb "MapEntryExpression" pb "keyExpression" expression.keyExpression.visit(this) pe() pb "valueExpression" expression.valueExpression.visit(this) pe() ne() } void visitMapExpression(MapExpression expression) { nb "MapExpression" if (expression.mapEntryExpressions.size() > 0) { pbl "mapEntryExpressions",expression.mapEntryExpressions.size() expression.mapEntryExpressions.each { it.visit(this) } pel() } else { pl "mapEntryExpressions" } ne() } void visitMethodCallExpression(MethodCallExpression call) { nb "MethodCallExpression" pb "objectExpression" call.objectExpression.visit(this) pe() pb "method" call.method.visit(this) pe() pb "arguments" call.arguments.visit(this) pe() ne() } void visitMethodPointerExpression(MethodPointerExpression expression) { nb "MethodPointerExpression" pb "expression" expression.expression.visit(this) pe() pb "methodName" expression.methodName.visit(this) pe() ne() } void visitNotExpression(NotExpression expression) { nb "NotExpression" pb "expression" expression.expression.visit(this) pe() ne() } void visitPostfixExpression(PostfixExpression expression) { nb "PostfixExpression" pb "expression" expression.expression.visit(this) pe() pb "operation" visitToken(expression.operation) pe() ne() } void visitPrefixExpression(PrefixExpression expression) { nb "PrefixExpression" pb "expression" expression.expression.visit(this) pe() pb "operation" visitToken(expression.operation) pe() ne() } void visitPropertyExpression(PropertyExpression expression) { nb "PropertyExpression" pb "objectExpression" expression.objectExpression.visit(this) pe() pb "property" expression.property.visit(this) pe() ne() } void visitRangeExpression(RangeExpression expression) { nb "RangeExpression" p "inclusive",expression.inclusive pb "from" expression.from.visit(this) pe() pb "to" expression.to.visit(this) pe() ne() } void visitReturnStatement(ReturnStatement statement) { nb "ReturnStatement" pb "expression" statement.expression.visit(this) pe() ne() } void visitShortTernaryExpression(ElvisOperatorExpression expression) { nb "ElvisOperatorExpression" pb "booleanExpression" expression.booleanExpression.visit(this) pe() pb "trueExpression" expression.trueExpression.visit(this) pe() pb "falseExpression" expression.falseExpression.visit(this) pe() ne() } void visitSpreadExpression(SpreadExpression expression) { nb "SpreadExpression" pb "expression" expression.expression.visit(this) pe() ne() } void visitSpreadMapExpression(SpreadMapExpression expression) { nb "SpreadMapExpression" pb "expression" expression.expression.visit(this) pe() ne() } void visitStaticMethodCallExpression(StaticMethodCallExpression expression) { nb "StaticMethodCallExpression" p "type",trimType(expression.type) p "method",expression.method pb "arguments" call.arguments.visit(this) pe() ne() } void visitSwitch(SwitchStatement statement) { nb "SwitchStatement" pb "expression" statement.expression.visit(this) pe() if (statement.caseStatements.size() > 0) { pbl "caseStatements",statement.caseStatements.size() statement.caseStatements.each { it.visit(this) } pel() } else { pl "caseStatements" } pb "defaultStatement" statement.defaultStatement.visit(this) pe() ne() } void visitSynchronizedStatement(SynchronizedStatement statement) { nb "SynchronizedStatement" pb "expression" expression.expression.visit(this) pe() pb "code" expression.code.visit(this) pe() ne() } void visitTernaryExpression(TernaryExpression expression) { nb "TernaryExpression" pb "booleanExpression" expression.booleanExpression.visit(this) pe() pb "trueExpression" expression.trueExpression.visit(this) pe() pb "falseExpression" expression.falseExpression.visit(this) pe() ne() } void visitThrowStatement(ThrowStatement statement) { nb "ThrowStatement" pb "expression" expression.expression.visit(this) pe() ne() } void visitTryCatchFinally(TryCatchStatement finally1) { nb "TryCatchStatement" pb "tryStatement" finally1.tryStatement.visit(this) pe() if (finally1.catchStatements.size() > 0) { pbl "catchStatement",finally1.catchStatements.size() finally1.catchStatements.each { it.visit(this) } pel() } else { pl "catchStatement" } if (finally1.finallyStatement) { pb "finallyStatement" finally1.finallyStatement.visit(this) pe() } ne() } void visitTupleExpression(TupleExpression expression) { nb "TupleExpression" if (expression.expressions?.size()>0) { pbl "expressions",expression.expressions?.size() expression.expressions.each { it.visit(this) } pel() } else { pl "expressions" } ne() } void visitUnaryMinusExpression(UnaryMinusExpression expression) { nb "UnaryMinusExpression" pb "expression" expression.expression.visit(this) pe() ne() } void visitUnaryPlusExpression(UnaryPlusExpression expression) { nb "UnaryPlusExpression" pb "expression" expression.expression.visit(this) pe() ne() } void visitVariableExpression(VariableExpression expression) { nb "VariableExpression" p "name",expression.name ne() } void visitWhileLoop(WhileStatement loop) { nb "WhileStatement" pb "booleanExpression" loop.booleanExpression.visit(this) pe() pb "loopBlock" loop.loopBlock.visit(this) pe() ne() } }
*1:http://www.graphviz.org/