From c992db275494b05627248cd741adac5d7c199603 Mon Sep 17 00:00:00 2001 From: Yuuta Liang Date: Tue, 28 Nov 2023 21:00:25 -0800 Subject: Add UML generator Signed-off-by: Yuuta Liang --- README.md | 24 ++++ build.gradle | 31 +++++ settings.gradle | 2 + src/main/annotations/Assoc.java | 37 ++++++ umlgen/build.gradle | 17 +++ .../java/moe/yuuta/umlgen/AssocAnnotationParams.kt | 6 + .../java/moe/yuuta/umlgen/AssociateRelation.kt | 33 +++++ umlgen/src/main/java/moe/yuuta/umlgen/ClassItem.kt | 21 +++ .../main/java/moe/yuuta/umlgen/ExtendRelation.kt | 7 + umlgen/src/main/java/moe/yuuta/umlgen/Main.kt | 144 +++++++++++++++++++++ umlgen/src/main/java/moe/yuuta/umlgen/Parser.kt | 56 ++++++++ .../main/java/moe/yuuta/umlgen/TargetRegistry.kt | 38 ++++++ umlgen/src/main/java/moe/yuuta/umlgen/Visitor.kt | 37 ++++++ umlgen/src/main/resources/graph.ftl | 37 ++++++ 14 files changed, 490 insertions(+) create mode 100644 settings.gradle create mode 100644 src/main/annotations/Assoc.java create mode 100644 umlgen/build.gradle create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/AssocAnnotationParams.kt create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/AssociateRelation.kt create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/ClassItem.kt create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/ExtendRelation.kt create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/Main.kt create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/Parser.kt create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/TargetRegistry.kt create mode 100644 umlgen/src/main/java/moe/yuuta/umlgen/Visitor.kt create mode 100644 umlgen/src/main/resources/graph.ftl 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 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) { + if (args.size < 2) { + System.err.println("Usage: umlgen /path/to/sources/ ") + 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 = 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 = + 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 = + 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> = + getClasses().flatMap { cls -> + cls.fields.map { Pair(cls, it) } + }.distinct() + + private fun getExtends(): List = + 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 { + 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 = HashSet() + private val nodes: MutableList>> = ArrayList() + + fun register(pkg: String?, node: TypeDeclaration<*>) { + nodes.add(Pair(pkg, node)) + if (node.isClassOrInterfaceDeclaration || node.isEnumDeclaration) { + cls.add(node.nameAsString) + } + } + + fun getNodes(): List>> { + 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) : FileVisitor { + @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 = <{«${cls.tag()}»
${cls.name()}}>] + <#else> + ${cls.name()} [label = "${cls.name()}"] + + + + <#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=""] + + ${relation.from()} -> ${relation.to()} + + + + <#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()}"] + + ${assoc.from()} -> ${assoc.to()} + + +} \ No newline at end of file -- cgit v1.2.3