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 --- .../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 ++++++ 8 files changed, 342 insertions(+) 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 (limited to 'umlgen/src/main/java') 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 + } +} -- cgit v1.2.3