aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuuta Liang <yuutaw@student.cs.ubc.ca>2023-11-28 21:00:25 -0800
committerYuuta Liang <yuutaw@student.cs.ubc.ca>2023-11-28 21:00:25 -0800
commitc992db275494b05627248cd741adac5d7c199603 (patch)
treef259bb943512007cbc9b50ddf8013d6e3f8b9008
parent1073af21305360bd33903c533cdac57e9f936294 (diff)
downloadjca-c992db275494b05627248cd741adac5d7c199603.tar
jca-c992db275494b05627248cd741adac5d7c199603.tar.gz
jca-c992db275494b05627248cd741adac5d7c199603.tar.bz2
jca-c992db275494b05627248cd741adac5d7c199603.zip
Add UML generator
Signed-off-by: Yuuta Liang <yuutaw@student.cs.ubc.ca>
-rw-r--r--README.md24
-rw-r--r--build.gradle31
-rw-r--r--settings.gradle2
-rw-r--r--src/main/annotations/Assoc.java37
-rw-r--r--umlgen/build.gradle17
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/AssocAnnotationParams.kt6
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/AssociateRelation.kt33
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/ClassItem.kt21
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/ExtendRelation.kt7
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/Main.kt144
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/Parser.kt56
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/TargetRegistry.kt38
-rw-r--r--umlgen/src/main/java/moe/yuuta/umlgen/Visitor.kt37
-rw-r--r--umlgen/src/main/resources/graph.ftl37
14 files changed, 490 insertions, 0 deletions
diff --git a/README.md b/README.md
index 2be62b6..36cc6e4 100644
--- a/README.md
+++ b/README.md
@@ -101,6 +101,30 @@ Tue Nov 28 11:53:01 PST 2023 - yuuta: Certificate 1 is revoked with reason KEY_C
Tue Nov 28 11:53:06 PST 2023 - yuuta: Signed CRL with 1 revoked certs.
```
+## Phase 4: Task 3
+
+[View complete UML diagram (may be too complex to read)](img/uml.all.pdf) |
+[View type hierarchy only](img/uml.type.pdf) |
+[View associations only](img/uml.assoc.pdf)
+
+## A note on UML
+
+The above UML diagrams are generated using a simple tool called UMLGen, and its source is located in the `umlgen/`
+directory. I am the author of the tool. The tool uses JavaParser to parse Java syntax into AST, and it generates Graphviz
+dot files using Apache Freemarker. The dot files are then rendered into the above PDFs using Graphviz. The whole process
+is driven by Gradle.
+
+To generate the files again, run the following command: (`dot(1)` and `gradle(1)` must present in PATH)
+
+```shell
+gradle uml
+```
+
+Please note that both codes in `umlgen/` and Gradle build scripts `*.gradle` are NOT part of the project submission.
+They do not subject to project requirements as they are not part of the project at all, either tests or UI. They are only
+local build tools. The project (both tests and UI) runs perfectly without any of these files. To run the project, clone it
+in IntelliJ Idea and press the green triangle in `Main`.
+
## Author
Yuuta Liang <yuutaw@student.ubc.ca>
diff --git a/build.gradle b/build.gradle
index 60876c8..1d42616 100644
--- a/build.gradle
+++ b/build.gradle
@@ -75,3 +75,34 @@ tasks.withType(Checkstyle) {
configFile file('checkstyle.xml')
}
}
+
+tasks.register('uml') {
+ doFirst {
+ renderUML("all")
+ renderUML("type")
+ renderUML("assoc")
+ }
+
+ dependsOn project(':umlgen').build
+}
+
+ext.renderUML = { scope ->
+ def output = new ByteArrayOutputStream()
+ javaexec {
+ classpath = project(':umlgen').sourceSets.main.runtimeClasspath
+ mainClass = 'moe.yuuta.umlgen.Main'
+ standardOutput = output
+ args "${sourceSets.main.java.srcDirs[1]}", scope
+ }
+ def dot = output.toByteArray()
+ out.close()
+ def input = new ByteArrayInputStream(dot)
+ output = new FileOutputStream("${rootDir}/arts/uml.${scope}.pdf")
+ exec {
+ commandLine 'dot', '-Tpdf'
+ standardInput = input
+ standardOutput = output
+ }
+ input.close()
+ output.close()
+} \ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..83f1ef1
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,2 @@
+rootProject.name = 'JCA'
+include 'umlgen'
diff --git a/src/main/annotations/Assoc.java b/src/main/annotations/Assoc.java
new file mode 100644
index 0000000..71ec5f5
--- /dev/null
+++ b/src/main/annotations/Assoc.java
@@ -0,0 +1,37 @@
+package annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Denotes the association relationship of a field.
+ * Used only for the UML static analyzer.
+ * Fields without that annotation will be treated as:
+ * 1. For non-list and non-array field: Single association relationship (--->)
+ * 2. For list and array: Multiple relationship (---> 0..*)
+ * Use this annotation to specify bonds and part-of relationships.
+ */
+@Retention(RetentionPolicy.CLASS)
+@Target(ElementType.FIELD)
+public @interface Assoc {
+ /**
+ * EFFECTS: Return the lower bond of the list or variable. Return 0 for nullable variables.
+ * Defaults to 1 (single variable, means nonnull) or 0 (list, means empty-able).
+ */
+ int lowerBond() default 1;
+
+ /**
+ * EFFECTS: Return the upper bond of the list.
+ * Defaults to 1 (single variable) or -1 (list, means *)
+ * REQUIRES: For a single variable, it must return 1.
+ */
+ int upperBond() default -1;
+
+ /**
+ * EFFECTS: Returns whether the field is in a part-of relationship.
+ * Defaults to false.
+ */
+ boolean partOf() default false;
+}
diff --git a/umlgen/build.gradle b/umlgen/build.gradle
new file mode 100644
index 0000000..63e9e84
--- /dev/null
+++ b/umlgen/build.gradle
@@ -0,0 +1,17 @@
+plugins {
+ id 'application'
+ id "org.jetbrains.kotlin.jvm" version "1.9.21"
+}
+
+application {
+ mainClass = 'moe.yuuta.umlgen.Main'
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation 'com.github.javaparser:javaparser-core:3.25.6'
+ implementation 'org.freemarker:freemarker:2.3.14'
+}
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/AssocAnnotationParams.kt b/umlgen/src/main/java/moe/yuuta/umlgen/AssocAnnotationParams.kt
new file mode 100644
index 0000000..9bc2702
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/AssocAnnotationParams.kt
@@ -0,0 +1,6 @@
+package moe.yuuta.umlgen
+
+/**
+ * Represent the parsed params for @Assoc annotation.
+ */
+data class AssocAnnotationParams(val lower: Int, val upper: Int, val partOf: Boolean)
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/AssociateRelation.kt b/umlgen/src/main/java/moe/yuuta/umlgen/AssociateRelation.kt
new file mode 100644
index 0000000..31b5fd4
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/AssociateRelation.kt
@@ -0,0 +1,33 @@
+package moe.yuuta.umlgen
+
+/**
+ * Indicate an associate relationship, from one class to another, with lower / upper bonds and whether it is a partOf
+ * relation.
+ */
+@JvmRecord
+data class AssociateRelation(val from: String,
+ val to: String,
+ val lower: Int,
+ val upper: Int,
+ val partOf: Boolean) {
+ /**
+ * Destruct the params.
+ */
+ constructor(from: String, to: String, params: AssocAnnotationParams):
+ this(from, to, params.lower, params.upper, params.partOf)
+
+ /**
+ * Return the label to be put at arrow head.
+ * For lower == upper == 1, just returns "" because it's the default association.
+ * For upper == infinity (-1), return lower..*
+ * Otherwise, return lower..upper.
+ */
+ fun head(): String =
+ if (lower == upper && upper == 1) {
+ ""
+ } else if (upper == -1) {
+ "${lower}..*"
+ } else {
+ "${lower}..${upper}"
+ }
+}
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/ClassItem.kt b/umlgen/src/main/java/moe/yuuta/umlgen/ClassItem.kt
new file mode 100644
index 0000000..245f0e0
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/ClassItem.kt
@@ -0,0 +1,21 @@
+package moe.yuuta.umlgen
+
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration
+import com.github.javaparser.ast.body.EnumDeclaration
+
+/**
+ * Represent a class with abstract / interface tag, or no tag.
+ */
+@JvmRecord
+data class ClassItem(val name: String, val tag: String?) {
+ /**
+ * Automatically determine if the class is abstract, interface, or else, and set the tag.
+ */
+ constructor(cls: ClassOrInterfaceDeclaration): this(cls.nameAsString,
+ if (cls.isAbstract) "abstract" else if (cls.isInterface) "interface" else null)
+
+ /**
+ * For enum, use null as tag.
+ */
+ constructor(cls: EnumDeclaration): this(cls.nameAsString, null)
+}
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/ExtendRelation.kt b/umlgen/src/main/java/moe/yuuta/umlgen/ExtendRelation.kt
new file mode 100644
index 0000000..86dec54
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/ExtendRelation.kt
@@ -0,0 +1,7 @@
+package moe.yuuta.umlgen
+
+/**
+ * Represents an extend / implemention relationship from one class to another.
+ */
+@JvmRecord
+data class ExtendRelation(val from: String, val to: String, val impl: Boolean)
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/Main.kt b/umlgen/src/main/java/moe/yuuta/umlgen/Main.kt
new file mode 100644
index 0000000..f7482a7
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/Main.kt
@@ -0,0 +1,144 @@
+package moe.yuuta.umlgen
+
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration
+import com.github.javaparser.ast.body.EnumDeclaration
+import com.github.javaparser.ast.body.FieldDeclaration
+import com.github.javaparser.ast.type.Type
+import freemarker.template.Configuration
+import freemarker.template.TemplateException
+import java.io.IOException
+import java.io.OutputStreamWriter
+import java.nio.file.Files
+import java.nio.file.Paths
+
+object Main {
+ @Throws(IOException::class, TemplateException::class)
+ @JvmStatic
+ fun main(args: Array<String>) {
+ if (args.size < 2) {
+ System.err.println("Usage: umlgen /path/to/sources/ <all|type|assoc>")
+ System.exit(64)
+ return
+ }
+ if (args[1] != "all" && args[1] != "type" && args[1] != "assoc") {
+ System.err.println("Invalid selection: ${args[1]}")
+ System.exit(64)
+ return
+ }
+ val parser = Parser()
+ val visitor = Visitor(parser)
+ val path = Paths.get(args[0])
+ Files.walkFileTree(path, visitor)
+ val cfg = Configuration()
+ cfg.setClassForTemplateLoading(Main::class.java, "/")
+ val input: MutableMap<String, Any> = HashMap()
+ input["classes"] = getClasses().map { ClassItem(it) } + getEnums().map { ClassItem(it) }
+ input["classes"]
+ if (args[1] == "all" || args[1] == "type") {
+ input["extends"] = getExtends()
+ }
+ if (args[1] == "all" || args[1] == "assoc") {
+ val singles = getAssociations()
+ .filter { (_, f) -> f.getCommonType().isClassOrInterfaceType && !isMap(f) && !isList(f) }
+ .filter { (_, f) -> isSelfType(f.commonType) }
+ .map { (cls, f) ->
+ val params = parseAssocAnnotation(f, AssocAnnotationParams(1, 1, false))
+ if (params.upper != 1 && params.upper != -1) {
+ System.err.printf("Expr '%s' contains invalid upperBond settings. Resetting to 1.\n", f)
+ }
+ AssociateRelation(cls.nameAsString, f.getCommonType().asClassOrInterfaceType().nameAsString, params)
+ }
+ val arr = getAssociations()
+ .filter { (_, f) -> f.getCommonType().isArrayType && f.getElementType().isClassOrInterfaceType }
+ .filter { (_, f) -> isSelfType(f.elementType) }
+ .map { (cls, f) ->
+ val params = parseAssocAnnotation(f, AssocAnnotationParams(0, -1, false))
+ AssociateRelation(cls.nameAsString, f.getElementType().asClassOrInterfaceType().nameAsString, params)
+ }
+ val lst = getAssociations()
+ .filter { (_, f) -> isList(f) }
+ .map { (cls, f) -> Triple(cls, f, f.commonType.asClassOrInterfaceType().typeArguments.get()[0]) }
+ .filter { (_, _, f) -> isSelfType(f) }
+ .map { (cls, f0, f) ->
+ val params = parseAssocAnnotation(f0, AssocAnnotationParams(0, -1, false))
+ AssociateRelation(cls.nameAsString, f.asClassOrInterfaceType().nameAsString, params)
+ }
+
+ input["assocs"] =
+ (singles + arr + lst).groupBy { Pair(it.from, it.to) }.values.map { it.maxBy { it.upper } }
+ }
+ val template = cfg.getTemplate("graph.ftl")
+ template.process(input, OutputStreamWriter(System.out))
+ }
+
+ private fun filterThrowable(c: ClassOrInterfaceDeclaration): Boolean {
+ // Naive detection ... should use recursion.
+ return if (c.implementedTypes.isEmpty() && c.extendedTypes.isEmpty()) {
+ true
+ } else !c.nameAsString.endsWith("Exception")
+ }
+
+ private fun getClasses(): List<ClassOrInterfaceDeclaration> =
+ TargetRegistry.getInstance().getNodes()
+ .filter { (_, node) -> node.isClassOrInterfaceDeclaration }
+ .filter { (pkg, _) -> pkg != null && !pkg.startsWith("ui.tui") }
+ .map { (_, node) -> node.asClassOrInterfaceDeclaration() }
+ .filter { filterThrowable(it) }
+
+ private fun getEnums(): List<EnumDeclaration> =
+ TargetRegistry.getInstance().getNodes()
+ .filter { (_, node) -> node.isEnumDeclaration }
+ .map { (_, node) -> node.asEnumDeclaration() }
+
+ private fun parseAssocAnnotation(field: FieldDeclaration,
+ def: AssocAnnotationParams): AssocAnnotationParams {
+ var params = def
+ if (field.getAnnotationByName("Assoc").isPresent) {
+ val expr = field.getAnnotationByName("Assoc").get()
+ val annotationArgs = expr.asNormalAnnotationExpr().pairs
+ .associateBy({ it.nameAsString }, { it.value })
+ if (annotationArgs.containsKey("lowerBond")) {
+ params = AssocAnnotationParams(annotationArgs["lowerBond"]!!.asIntegerLiteralExpr().asNumber().toInt(),
+ params.upper,
+ params.partOf)
+ }
+ if (annotationArgs.containsKey("upperBond")) {
+ params = AssocAnnotationParams(params.lower,
+ annotationArgs["upperBond"]!!.asIntegerLiteralExpr().asNumber().toInt(),
+ params.partOf)
+ }
+ if (annotationArgs.containsKey("partOf")) {
+ params = AssocAnnotationParams(params.lower, params.upper,
+ annotationArgs["partOf"]!!.isBooleanLiteralExpr)
+ }
+ }
+ return params
+ }
+
+ private fun getAssociations(): List<Pair<ClassOrInterfaceDeclaration, FieldDeclaration>> =
+ getClasses().flatMap { cls ->
+ cls.fields.map { Pair(cls, it) }
+ }.distinct()
+
+ private fun getExtends(): List<ExtendRelation> =
+ getClasses().flatMap {
+ it.extendedTypes.map { superType -> ExtendRelation(it.nameAsString, superType.nameAsString, false) } +
+ it.implementedTypes.map { superType -> ExtendRelation(it.nameAsString, superType.nameAsString, true) }
+ }.filter { n: ExtendRelation -> TargetRegistry.getInstance().hasClass(n.to) }.distinct()
+
+ private fun isMap(f: FieldDeclaration): Boolean =
+ f.getCommonType().isClassOrInterfaceType &&
+ f.getCommonType().asClassOrInterfaceType().nameAsString.endsWith("Map") &&
+ f.getCommonType().asClassOrInterfaceType().typeArguments.isPresent &&
+ f.getCommonType().asClassOrInterfaceType().typeArguments.get().size == 2
+
+ private fun isList(f: FieldDeclaration): Boolean =
+ f.getCommonType().isClassOrInterfaceType &&
+ (f.getCommonType().asClassOrInterfaceType().nameAsString.endsWith("List") ||
+ f.getCommonType().asClassOrInterfaceType().nameAsString.equals("Collection")) &&
+ f.getCommonType().asClassOrInterfaceType().typeArguments.isPresent &&
+ f.getCommonType().asClassOrInterfaceType().typeArguments.get().size == 1
+
+ private fun isSelfType(type: Type): Boolean =
+ type.isClassOrInterfaceType && TargetRegistry.getInstance().hasClass(type.asClassOrInterfaceType().nameAsString)
+}
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/Parser.kt b/umlgen/src/main/java/moe/yuuta/umlgen/Parser.kt
new file mode 100644
index 0000000..9c4fae5
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/Parser.kt
@@ -0,0 +1,56 @@
+package moe.yuuta.umlgen
+
+import com.github.javaparser.JavaParser
+import com.github.javaparser.Problem
+import com.github.javaparser.ast.CompilationUnit
+import com.github.javaparser.ast.body.TypeDeclaration
+import moe.yuuta.umlgen.TargetRegistry.Companion.getInstance
+import java.nio.file.Path
+import java.util.function.BiFunction
+import java.util.function.Consumer
+
+/**
+ * Accepts path and buffer. Parse the Java source code. Append all parsed nodes into the global registry.
+ */
+class Parser : BiFunction<Path, String, Void?> {
+ private val parser: JavaParser
+
+ /**
+ * Init with a default [JavaParser].
+ */
+ init {
+ parser = JavaParser()
+ }
+
+ /**
+ * Parse the buf. Prompt for error if fails. Add all parsed nodes into the global registry.
+ * @param path Path of the buf.
+ * @param buf A UTF-8 buffer that contains the Java source.
+ * @return null
+ */
+ override fun apply(path: Path, buf: String): Void? {
+ val res = parser.parse(buf)
+ if (!res.isSuccessful || res.result.isEmpty) {
+ System.err.println("Cannot parse $path")
+ res.problems.forEach(Consumer { x: Problem? -> System.err.println(x) })
+ return null
+ }
+ val r = res.result.get()
+ val pkg = parsePackage(r)
+ r.types.forEach(Consumer { t: TypeDeclaration<*>? -> getInstance().register(pkg, t!!) })
+ return null
+ }
+
+ /**
+ * Read the package declaration from the CompilationUnit, optional.
+ * @param r non-null
+ * @return The package name or null.
+ */
+ private fun parsePackage(r: CompilationUnit): String? {
+ return if (r.packageDeclaration.isPresent) {
+ r.packageDeclaration.get().nameAsString
+ } else {
+ null
+ }
+ }
+}
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/TargetRegistry.kt b/umlgen/src/main/java/moe/yuuta/umlgen/TargetRegistry.kt
new file mode 100644
index 0000000..7c193bd
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/TargetRegistry.kt
@@ -0,0 +1,38 @@
+package moe.yuuta.umlgen
+
+import com.github.javaparser.ast.body.TypeDeclaration
+import java.util.*
+
+/**
+ * A global registry for all parsed nodes.
+ */
+class TargetRegistry {
+ private val cls: MutableSet<String> = HashSet()
+ private val nodes: MutableList<Pair<String?, TypeDeclaration<*>>> = ArrayList()
+
+ fun register(pkg: String?, node: TypeDeclaration<*>) {
+ nodes.add(Pair(pkg, node))
+ if (node.isClassOrInterfaceDeclaration || node.isEnumDeclaration) {
+ cls.add(node.nameAsString)
+ }
+ }
+
+ fun getNodes(): List<Pair<String?, TypeDeclaration<*>>> {
+ return nodes
+ }
+
+ fun hasClass(cls: String): Boolean {
+ return this.cls.contains(cls)
+ }
+
+ companion object {
+ private var sInstance: TargetRegistry? = null
+ @JvmStatic
+ fun getInstance(): TargetRegistry {
+ if (sInstance == null) {
+ sInstance = TargetRegistry()
+ }
+ return sInstance!!
+ }
+ }
+}
diff --git a/umlgen/src/main/java/moe/yuuta/umlgen/Visitor.kt b/umlgen/src/main/java/moe/yuuta/umlgen/Visitor.kt
new file mode 100644
index 0000000..ea99b99
--- /dev/null
+++ b/umlgen/src/main/java/moe/yuuta/umlgen/Visitor.kt
@@ -0,0 +1,37 @@
+package moe.yuuta.umlgen
+
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import java.nio.file.*
+import java.nio.file.attribute.BasicFileAttributes
+import java.util.function.BiFunction
+
+class Visitor(private val consumer: BiFunction<Path, String, Void?>) : FileVisitor<Path> {
+ @Throws(IOException::class)
+ override fun preVisitDirectory(path: Path, basicFileAttributes: BasicFileAttributes): FileVisitResult {
+ return FileVisitResult.CONTINUE
+ }
+
+ @Throws(IOException::class)
+ override fun visitFileFailed(path: Path, e: IOException?): FileVisitResult {
+ System.err.printf("Open %s: %s\n", path.toFile(), e?.message)
+ return FileVisitResult.CONTINUE
+ }
+
+ @Throws(IOException::class)
+ override fun postVisitDirectory(path: Path, e: IOException?): FileVisitResult {
+ return FileVisitResult.CONTINUE
+ }
+
+ @Throws(IOException::class)
+ override fun visitFile(path: Path, basicFileAttributes: BasicFileAttributes?): FileVisitResult {
+ if (path.fileName.endsWith(".java")) {
+ return FileVisitResult.CONTINUE
+ }
+ val fd = Files.newInputStream(path, StandardOpenOption.READ)
+ val buf = String(fd.readAllBytes(), StandardCharsets.UTF_8)
+ fd.close()
+ consumer.apply(path, buf)
+ return FileVisitResult.CONTINUE
+ }
+}
diff --git a/umlgen/src/main/resources/graph.ftl b/umlgen/src/main/resources/graph.ftl
new file mode 100644
index 0000000..759fc44
--- /dev/null
+++ b/umlgen/src/main/resources/graph.ftl
@@ -0,0 +1,37 @@
+digraph {
+ node [
+ shape=record
+ style=filled
+ fillcolor=gray95
+ ]
+
+ <#list classes as cls>
+ <#if cls.tag()??>
+ ${cls.name()} [label = <{<b>«${cls.tag()}»</b><br />${cls.name()}}>]
+ <#else>
+ ${cls.name()} [label = "${cls.name()}"]
+ </#if>
+ </#list>
+
+ <#if extends??>
+ <#list extends as relation>
+ <#if relation.impl()>
+ edge [dir=forward arrowtail=empty arrowhead=empty style="dashed"]
+ <#else>
+ edge [dir=forward arrowtail=empty arrowhead=empty style=""]
+ </#if>
+ ${relation.from()} -> ${relation.to()}
+ </#list>
+ </#if>
+
+ <#if assocs??>
+ <#list assocs as assoc>
+ <#if assoc.partOf()>
+ edge [dir=both arrowtail=odiamond arrowhead=vee style="" headlabel="${assoc.head()}"]
+ <#else>
+ edge [dir=forward arrowtail=empty arrowhead=vee style="" headlabel="${assoc.head()}"]
+ </#if>
+ ${assoc.from()} -> ${assoc.to()}
+ </#list>
+ </#if>
+} \ No newline at end of file