Add XML persistence code generation tool.
Test: m xmlpersistence_cli && xmlpersistence_cli
Change-Id: Ia54739e99f52c3ab7125f21b1bc306a474f6bf68
diff --git a/tools/xmlpersistence/Android.bp b/tools/xmlpersistence/Android.bp
new file mode 100644
index 0000000..d58d0dc
--- /dev/null
+++ b/tools/xmlpersistence/Android.bp
@@ -0,0 +1,11 @@
+java_binary_host {
+ name: "xmlpersistence_cli",
+ manifest: "manifest.txt",
+ srcs: [
+ "src/**/*.kt",
+ ],
+ static_libs: [
+ "javaparser-symbol-solver",
+ "javapoet",
+ ],
+}
diff --git a/tools/xmlpersistence/OWNERS b/tools/xmlpersistence/OWNERS
new file mode 100644
index 0000000..4f4d06a
--- /dev/null
+++ b/tools/xmlpersistence/OWNERS
@@ -0,0 +1 @@
+zhanghai@google.com
diff --git a/tools/xmlpersistence/manifest.txt b/tools/xmlpersistence/manifest.txt
new file mode 100644
index 0000000..6d97719
--- /dev/null
+++ b/tools/xmlpersistence/manifest.txt
@@ -0,0 +1 @@
+Main-class: MainKt
diff --git a/tools/xmlpersistence/src/main/kotlin/Generator.kt b/tools/xmlpersistence/src/main/kotlin/Generator.kt
new file mode 100644
index 0000000..28467b7
--- /dev/null
+++ b/tools/xmlpersistence/src/main/kotlin/Generator.kt
@@ -0,0 +1,578 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import com.squareup.javapoet.ClassName
+import com.squareup.javapoet.FieldSpec
+import com.squareup.javapoet.JavaFile
+import com.squareup.javapoet.MethodSpec
+import com.squareup.javapoet.NameAllocator
+import com.squareup.javapoet.ParameterSpec
+import com.squareup.javapoet.TypeSpec
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import java.time.Year
+import java.util.Objects
+import javax.lang.model.element.Modifier
+
+// JavaPoet only supports line comments, and can't add a newline after file level comments.
+val FILE_HEADER = """
+ /*
+ * Copyright (C) ${Year.now().value} The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+ // Generated by xmlpersistence. DO NOT MODIFY!
+ // CHECKSTYLE:OFF
+ // @formatter:off
+""".trimIndent() + "\n\n"
+
+private val atomicFileType = ClassName.get("android.util", "AtomicFile")
+
+fun generate(persistence: PersistenceInfo): JavaFile {
+ val distinctClassFields = persistence.root.allClassFields.distinctBy { it.type }
+ val type = TypeSpec.classBuilder(persistence.name)
+ .addJavadoc(
+ """
+ Generated class implementing XML persistence for${'$'}W{@link $1T}.
+ <p>
+ This class provides atomicity for persistence via {@link $2T}, however it does not provide
+ thread safety, so please bring your own synchronization mechanism.
+ """.trimIndent(), persistence.root.type, atomicFileType
+ )
+ .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
+ .addField(generateFileField())
+ .addMethod(generateConstructor())
+ .addMethod(generateReadMethod(persistence.root))
+ .addMethod(generateParseMethod(persistence.root))
+ .addMethods(distinctClassFields.map { generateParseClassMethod(it) })
+ .addMethod(generateWriteMethod(persistence.root))
+ .addMethod(generateSerializeMethod(persistence.root))
+ .addMethods(distinctClassFields.map { generateSerializeClassMethod(it) })
+ .addMethod(generateDeleteMethod())
+ .build()
+ return JavaFile.builder(persistence.root.type.packageName(), type)
+ .skipJavaLangImports(true)
+ .indent(" ")
+ .build()
+}
+
+private val nonNullType = ClassName.get("android.annotation", "NonNull")
+
+private fun generateFileField(): FieldSpec =
+ FieldSpec.builder(atomicFileType, "mFile", Modifier.PRIVATE, Modifier.FINAL)
+ .addAnnotation(nonNullType)
+ .build()
+
+private fun generateConstructor(): MethodSpec =
+ MethodSpec.constructorBuilder()
+ .addJavadoc(
+ """
+ Create an instance of this class.
+
+ @param file the XML file for persistence
+ """.trimIndent()
+ )
+ .addModifiers(Modifier.PUBLIC)
+ .addParameter(
+ ParameterSpec.builder(File::class.java, "file").addAnnotation(nonNullType).build()
+ )
+ .addStatement("mFile = new \$1T(file)", atomicFileType)
+ .build()
+
+private val nullableType = ClassName.get("android.annotation", "Nullable")
+
+private val xmlPullParserType = ClassName.get("org.xmlpull.v1", "XmlPullParser")
+
+private val xmlType = ClassName.get("android.util", "Xml")
+
+private val xmlPullParserExceptionType = ClassName.get("org.xmlpull.v1", "XmlPullParserException")
+
+private fun generateReadMethod(rootField: ClassFieldInfo): MethodSpec =
+ MethodSpec.methodBuilder("read")
+ .addJavadoc(
+ """
+ Read${'$'}W{@link $1T}${'$'}Wfrom${'$'}Wthe${'$'}WXML${'$'}Wfile.
+
+ @return the persisted${'$'}W{@link $1T},${'$'}Wor${'$'}W{@code null}${'$'}Wif${'$'}Wthe${'$'}WXML${'$'}Wfile${'$'}Wdoesn't${'$'}Wexist
+ @throws IllegalArgumentException if an error occurred while reading
+ """.trimIndent(), rootField.type
+ )
+ .addAnnotation(nullableType)
+ .addModifiers(Modifier.PUBLIC)
+ .returns(rootField.type)
+ .addControlFlow(
+ "try (final \$1T inputStream = mFile.openRead())", FileInputStream::class.java
+ ) {
+ addStatement("final \$1T parser = \$2T.newPullParser()", xmlPullParserType, xmlType)
+ addStatement("parser.setInput(inputStream, null)")
+ addStatement("return parse(parser)")
+ nextControlFlow("catch (\$1T e)", FileNotFoundException::class.java)
+ addStatement("return null")
+ nextControlFlow(
+ "catch (\$1T | \$2T e)", IOException::class.java, xmlPullParserExceptionType
+ )
+ addStatement("throw new IllegalArgumentException(e)")
+ }
+ .build()
+
+private val ClassFieldInfo.allClassFields: List<ClassFieldInfo>
+ get() =
+ mutableListOf<ClassFieldInfo>().apply {
+ this += this@allClassFields
+ for (field in fields) {
+ when (field) {
+ is ClassFieldInfo -> this += field.allClassFields
+ is ListFieldInfo -> this += field.element.allClassFields
+ }
+ }
+ }
+
+private fun generateParseMethod(rootField: ClassFieldInfo): MethodSpec =
+ MethodSpec.methodBuilder("parse")
+ .addAnnotation(nonNullType)
+ .addModifiers(Modifier.PRIVATE, Modifier.STATIC)
+ .returns(rootField.type)
+ .addParameter(
+ ParameterSpec.builder(xmlPullParserType, "parser").addAnnotation(nonNullType).build()
+ )
+ .addExceptions(listOf(ClassName.get(IOException::class.java), xmlPullParserExceptionType))
+ .apply {
+ addStatement("int type")
+ addStatement("int depth")
+ addStatement("int innerDepth = parser.getDepth() + 1")
+ addControlFlow(
+ "while ((type = parser.next()) != \$1T.END_DOCUMENT\$W"
+ + "&& ((depth = parser.getDepth()) >= innerDepth || type != \$1T.END_TAG))",
+ xmlPullParserType
+ ) {
+ addControlFlow(
+ "if (depth > innerDepth || type != \$1T.START_TAG)", xmlPullParserType
+ ) {
+ addStatement("continue")
+ }
+ addControlFlow(
+ "if (\$1T.equals(parser.getName(),\$W\$2S))", Objects::class.java,
+ rootField.tagName
+ ) {
+ addStatement("return \$1L(parser)", rootField.parseMethodName)
+ }
+ }
+ addStatement(
+ "throw new IllegalArgumentException(\$1S)",
+ "Missing root tag <${rootField.tagName}>"
+ )
+ }
+ .build()
+
+private fun generateParseClassMethod(classField: ClassFieldInfo): MethodSpec =
+ MethodSpec.methodBuilder(classField.parseMethodName)
+ .addAnnotation(nonNullType)
+ .addModifiers(Modifier.PRIVATE, Modifier.STATIC)
+ .returns(classField.type)
+ .addParameter(
+ ParameterSpec.builder(xmlPullParserType, "parser").addAnnotation(nonNullType).build()
+ )
+ .apply {
+ val (attributeFields, tagFields) = classField.fields
+ .partition { it is PrimitiveFieldInfo || it is StringFieldInfo }
+ if (tagFields.isNotEmpty()) {
+ addExceptions(
+ listOf(ClassName.get(IOException::class.java), xmlPullParserExceptionType)
+ )
+ }
+ val nameAllocator = NameAllocator().apply {
+ newName("parser")
+ newName("type")
+ newName("depth")
+ newName("innerDepth")
+ }
+ for (field in attributeFields) {
+ val variableName = nameAllocator.newName(field.variableName, field)
+ when (field) {
+ is PrimitiveFieldInfo -> {
+ val stringVariableName =
+ nameAllocator.newName("${field.variableName}String")
+ addStatement(
+ "final String \$1L =\$Wparser.getAttributeValue(null,\$W\$2S)",
+ stringVariableName, field.attributeName
+ )
+ if (field.isRequired) {
+ addControlFlow("if (\$1L == null)", stringVariableName) {
+ addStatement(
+ "throw new IllegalArgumentException(\$1S)",
+ "Missing attribute \"${field.attributeName}\""
+ )
+ }
+ }
+ val boxedType = field.type.box()
+ val parseTypeMethodName = if (field.type.isPrimitive) {
+ "parse${field.type.toString().capitalize()}"
+ } else {
+ "valueOf"
+ }
+ if (field.isRequired) {
+ addStatement(
+ "final \$1T \$2L =\$W\$3T.\$4L($5L)", field.type, variableName,
+ boxedType, parseTypeMethodName, stringVariableName
+ )
+ } else {
+ addStatement(
+ "final \$1T \$2L =\$W$3L != null ?\$W\$4T.\$5L($3L)\$W: null",
+ field.type, variableName, stringVariableName, boxedType,
+ parseTypeMethodName
+ )
+ }
+ }
+ is StringFieldInfo ->
+ addStatement(
+ "final String \$1L =\$Wparser.getAttributeValue(null,\$W\$2S)",
+ variableName, field.attributeName
+ )
+ else -> error(field)
+ }
+ }
+ if (tagFields.isNotEmpty()) {
+ for (field in tagFields) {
+ val variableName = nameAllocator.newName(field.variableName, field)
+ when (field) {
+ is ClassFieldInfo ->
+ addStatement("\$1T \$2L =\$Wnull", field.type, variableName)
+ is ListFieldInfo ->
+ addStatement(
+ "final \$1T \$2L =\$Wnew \$3T<>()", field.type, variableName,
+ ArrayList::class.java
+ )
+ else -> error(field)
+ }
+ }
+ addStatement("int type")
+ addStatement("int depth")
+ addStatement("int innerDepth = parser.getDepth() + 1")
+ addControlFlow(
+ "while ((type = parser.next()) != \$1T.END_DOCUMENT\$W"
+ + "&& ((depth = parser.getDepth()) >= innerDepth || type != \$1T.END_TAG))",
+ xmlPullParserType
+ ) {
+ addControlFlow(
+ "if (depth > innerDepth || type != \$1T.START_TAG)", xmlPullParserType
+ ) {
+ addStatement("continue")
+ }
+ addControlFlow("switch (parser.getName())") {
+ for (field in tagFields) {
+ addControlFlow("case \$1S:", field.tagName) {
+ val variableName = nameAllocator.get(field)
+ when (field) {
+ is ClassFieldInfo -> {
+ addControlFlow("if (\$1L != null)", variableName) {
+ addStatement(
+ "throw new IllegalArgumentException(\$1S)",
+ "Duplicate tag \"${field.tagName}\""
+ )
+ }
+ addStatement(
+ "\$1L =\$W\$2L(parser)", variableName,
+ field.parseMethodName
+ )
+ addStatement("break")
+ }
+ is ListFieldInfo -> {
+ val elementNameAllocator = nameAllocator.clone()
+ val elementVariableName = elementNameAllocator.newName(
+ field.element.xmlName!!.toLowerCamelCase()
+ )
+ addStatement(
+ "final \$1T \$2L =\$W\$3L(parser)", field.element.type,
+ elementVariableName, field.element.parseMethodName
+ )
+ addStatement(
+ "\$1L.add(\$2L)", variableName, elementVariableName
+ )
+ addStatement("break")
+ }
+ else -> error(field)
+ }
+ }
+ }
+ }
+ }
+ }
+ for (field in tagFields.filter { it is ClassFieldInfo && it.isRequired }) {
+ addControlFlow("if ($1L == null)", nameAllocator.get(field)) {
+ addStatement(
+ "throw new IllegalArgumentException(\$1S)", "Missing tag <${field.tagName}>"
+ )
+ }
+ }
+ addStatement(
+ classField.fields.joinToString(",\$W", "return new \$1T(", ")") {
+ nameAllocator.get(it)
+ }, classField.type
+ )
+ }
+ .build()
+
+private val ClassFieldInfo.parseMethodName: String
+ get() = "parse${type.simpleName().toUpperCamelCase()}"
+
+private val xmlSerializerType = ClassName.get("org.xmlpull.v1", "XmlSerializer")
+
+private fun generateWriteMethod(rootField: ClassFieldInfo): MethodSpec =
+ MethodSpec.methodBuilder("write")
+ .apply {
+ val nameAllocator = NameAllocator().apply {
+ newName("outputStream")
+ newName("serializer")
+ }
+ val parameterName = nameAllocator.newName(rootField.variableName)
+ addJavadoc(
+ """
+ Write${'$'}W{@link $1T}${'$'}Wto${'$'}Wthe${'$'}WXML${'$'}Wfile.
+
+ @param $2L the${'$'}W{@link ${'$'}1T}${'$'}Wto${'$'}Wpersist
+ """.trimIndent(), rootField.type, parameterName
+ )
+ addAnnotation(nullableType)
+ addModifiers(Modifier.PUBLIC)
+ addParameter(
+ ParameterSpec.builder(rootField.type, parameterName)
+ .addAnnotation(nonNullType)
+ .build()
+ )
+ addStatement("\$1T outputStream = null", FileOutputStream::class.java)
+ addControlFlow("try") {
+ addStatement("outputStream = mFile.startWrite()")
+ addStatement(
+ "final \$1T serializer =\$W\$2T.newSerializer()", xmlSerializerType, xmlType
+ )
+ addStatement(
+ "serializer.setOutput(outputStream, \$1T.UTF_8.name())",
+ StandardCharsets::class.java
+ )
+ addStatement(
+ "serializer.setFeature(\$1S, true)",
+ "http://xmlpull.org/v1/doc/features.html#indent-output"
+ )
+ addStatement("serializer.startDocument(null, true)")
+ addStatement("serialize(serializer,\$W\$1L)", parameterName)
+ addStatement("serializer.endDocument()")
+ addStatement("mFile.finishWrite(outputStream)")
+ nextControlFlow("catch (Exception e)")
+ addStatement("e.printStackTrace()")
+ addStatement("mFile.failWrite(outputStream)")
+ }
+ }
+ .build()
+
+private fun generateSerializeMethod(rootField: ClassFieldInfo): MethodSpec =
+ MethodSpec.methodBuilder("serialize")
+ .addModifiers(Modifier.PRIVATE, Modifier.STATIC)
+ .addParameter(
+ ParameterSpec.builder(xmlSerializerType, "serializer")
+ .addAnnotation(nonNullType)
+ .build()
+ )
+ .apply {
+ val nameAllocator = NameAllocator().apply { newName("serializer") }
+ val parameterName = nameAllocator.newName(rootField.variableName)
+ addParameter(
+ ParameterSpec.builder(rootField.type, parameterName)
+ .addAnnotation(nonNullType)
+ .build()
+ )
+ addException(IOException::class.java)
+ addStatement("serializer.startTag(null, \$1S)", rootField.tagName)
+ addStatement("\$1L(serializer, \$2L)", rootField.serializeMethodName, parameterName)
+ addStatement("serializer.endTag(null, \$1S)", rootField.tagName)
+ }
+ .build()
+
+private fun generateSerializeClassMethod(classField: ClassFieldInfo): MethodSpec =
+ MethodSpec.methodBuilder(classField.serializeMethodName)
+ .addModifiers(Modifier.PRIVATE, Modifier.STATIC)
+ .addParameter(
+ ParameterSpec.builder(xmlSerializerType, "serializer")
+ .addAnnotation(nonNullType)
+ .build()
+ )
+ .apply {
+ val nameAllocator = NameAllocator().apply {
+ newName("serializer")
+ newName("i")
+ }
+ val parameterName = nameAllocator.newName(classField.serializeParameterName)
+ addParameter(
+ ParameterSpec.builder(classField.type, parameterName)
+ .addAnnotation(nonNullType)
+ .build()
+ )
+ addException(IOException::class.java)
+ val (attributeFields, tagFields) = classField.fields
+ .partition { it is PrimitiveFieldInfo || it is StringFieldInfo }
+ for (field in attributeFields) {
+ val variableName = "$parameterName.${field.name}"
+ if (!field.isRequired) {
+ beginControlFlow("if (\$1L != null)", variableName)
+ }
+ when (field) {
+ is PrimitiveFieldInfo -> {
+ if (field.isRequired && !field.type.isPrimitive) {
+ addControlFlow("if (\$1L == null)", variableName) {
+ addStatement(
+ "throw new IllegalArgumentException(\$1S)",
+ "Field \"${field.name}\" is null"
+ )
+ }
+ }
+ val stringVariableName =
+ nameAllocator.newName("${field.variableName}String")
+ addStatement(
+ "final String \$1L =\$WString.valueOf(\$2L)", stringVariableName,
+ variableName
+ )
+ addStatement(
+ "serializer.attribute(null, \$1S, \$2L)", field.attributeName,
+ stringVariableName
+ )
+ }
+ is StringFieldInfo -> {
+ if (field.isRequired) {
+ addControlFlow("if (\$1L == null)", variableName) {
+ addStatement(
+ "throw new IllegalArgumentException(\$1S)",
+ "Field \"${field.name}\" is null"
+ )
+ }
+ }
+ addStatement(
+ "serializer.attribute(null, \$1S, \$2L)", field.attributeName,
+ variableName
+ )
+ }
+ else -> error(field)
+ }
+ if (!field.isRequired) {
+ endControlFlow()
+ }
+ }
+ for (field in tagFields) {
+ val variableName = "$parameterName.${field.name}"
+ if (field.isRequired) {
+ addControlFlow("if (\$1L == null)", variableName) {
+ addStatement(
+ "throw new IllegalArgumentException(\$1S)",
+ "Field \"${field.name}\" is null"
+ )
+ }
+ }
+ when (field) {
+ is ClassFieldInfo -> {
+ addStatement("serializer.startTag(null, \$1S)", field.tagName)
+ addStatement(
+ "\$1L(serializer, \$2L)", field.serializeMethodName, variableName
+ )
+ addStatement("serializer.endTag(null, \$1S)", field.tagName)
+ }
+ is ListFieldInfo -> {
+ val sizeVariableName = nameAllocator.newName("${field.variableName}Size")
+ addStatement(
+ "final int \$1L =\$W\$2L.size()", sizeVariableName, variableName
+ )
+ addControlFlow("for (int i = 0;\$Wi < \$1L;\$Wi++)", sizeVariableName) {
+ val elementNameAllocator = nameAllocator.clone()
+ val elementVariableName = elementNameAllocator.newName(
+ field.element.xmlName!!.toLowerCamelCase()
+ )
+ addStatement(
+ "final \$1T \$2L =\$W\$3L.get(i)", field.element.type,
+ elementVariableName, variableName
+ )
+ addControlFlow("if (\$1L == null)", elementVariableName) {
+ addStatement(
+ "throw new IllegalArgumentException(\$1S\$W+ i\$W+ \$2S)",
+ "Field element \"${field.name}[", "]\" is null"
+ )
+ }
+ addStatement("serializer.startTag(null, \$1S)", field.element.tagName)
+ addStatement(
+ "\$1L(serializer,\$W\$2L)", field.element.serializeMethodName,
+ elementVariableName
+ )
+ addStatement("serializer.endTag(null, \$1S)", field.element.tagName)
+ }
+ }
+ else -> error(field)
+ }
+ }
+ }
+ .build()
+
+private val ClassFieldInfo.serializeMethodName: String
+ get() = "serialize${type.simpleName().toUpperCamelCase()}"
+
+private val ClassFieldInfo.serializeParameterName: String
+ get() = type.simpleName().toLowerCamelCase()
+
+private val FieldInfo.variableName: String
+ get() = name.toLowerCamelCase()
+
+private val FieldInfo.attributeName: String
+ get() {
+ check(this is PrimitiveFieldInfo || this is StringFieldInfo)
+ return xmlNameOrName.toLowerCamelCase()
+ }
+
+private val FieldInfo.tagName: String
+ get() {
+ check(this is ClassFieldInfo || this is ListFieldInfo)
+ return xmlNameOrName.toLowerKebabCase()
+ }
+
+private val FieldInfo.xmlNameOrName: String
+ get() = xmlName ?: name
+
+private fun generateDeleteMethod(): MethodSpec =
+ MethodSpec.methodBuilder("delete")
+ .addJavadoc("Delete the XML file, if any.")
+ .addModifiers(Modifier.PUBLIC)
+ .addStatement("mFile.delete()")
+ .build()
+
+private inline fun MethodSpec.Builder.addControlFlow(
+ controlFlow: String,
+ vararg args: Any,
+ block: MethodSpec.Builder.() -> Unit
+): MethodSpec.Builder {
+ beginControlFlow(controlFlow, *args)
+ block()
+ endControlFlow()
+ return this
+}
diff --git a/tools/xmlpersistence/src/main/kotlin/Main.kt b/tools/xmlpersistence/src/main/kotlin/Main.kt
new file mode 100644
index 0000000..e271f8c
--- /dev/null
+++ b/tools/xmlpersistence/src/main/kotlin/Main.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.io.File
+import java.nio.file.Files
+
+fun main(args: Array<String>) {
+ val showUsage = args.isEmpty() || when (args.singleOrNull()) {
+ "-h", "--help" -> true
+ else -> false
+ }
+ if (showUsage) {
+ usage()
+ return
+ }
+
+ val files = args.flatMap {
+ File(it).walk().filter { it.isFile && it.extension == "java" }.map { it.toPath() }
+ }
+ val persistences = parse(files)
+ for (persistence in persistences) {
+ val file = generate(persistence)
+ Files.newBufferedWriter(persistence.path).use {
+ it.write(FILE_HEADER)
+ file.writeTo(it)
+ }
+ }
+}
+
+private fun usage() {
+ println("Usage: xmlpersistence <FILES>")
+}
diff --git a/tools/xmlpersistence/src/main/kotlin/Parser.kt b/tools/xmlpersistence/src/main/kotlin/Parser.kt
new file mode 100644
index 0000000..3ea12a9
--- /dev/null
+++ b/tools/xmlpersistence/src/main/kotlin/Parser.kt
@@ -0,0 +1,248 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import com.github.javaparser.JavaParser
+import com.github.javaparser.ParseProblemException
+import com.github.javaparser.ParseResult
+import com.github.javaparser.ParserConfiguration
+import com.github.javaparser.ast.Node
+import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration
+import com.github.javaparser.ast.body.FieldDeclaration
+import com.github.javaparser.ast.body.TypeDeclaration
+import com.github.javaparser.ast.expr.AnnotationExpr
+import com.github.javaparser.ast.expr.Expression
+import com.github.javaparser.ast.expr.NormalAnnotationExpr
+import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr
+import com.github.javaparser.ast.expr.StringLiteralExpr
+import com.github.javaparser.resolution.declarations.ResolvedReferenceTypeDeclaration
+import com.github.javaparser.resolution.types.ResolvedPrimitiveType
+import com.github.javaparser.resolution.types.ResolvedReferenceType
+import com.github.javaparser.symbolsolver.JavaSymbolSolver
+import com.github.javaparser.symbolsolver.javaparsermodel.declarations.JavaParserClassDeclaration
+import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver
+import com.github.javaparser.symbolsolver.resolution.typesolvers.MemoryTypeSolver
+import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver
+import com.squareup.javapoet.ClassName
+import com.squareup.javapoet.ParameterizedTypeName
+import com.squareup.javapoet.TypeName
+import java.nio.file.Path
+import java.util.Optional
+
+class PersistenceInfo(
+ val name: String,
+ val root: ClassFieldInfo,
+ val path: Path
+)
+
+sealed class FieldInfo {
+ abstract val name: String
+ abstract val xmlName: String?
+ abstract val type: TypeName
+ abstract val isRequired: Boolean
+}
+
+class PrimitiveFieldInfo(
+ override val name: String,
+ override val xmlName: String?,
+ override val type: TypeName,
+ override val isRequired: Boolean
+) : FieldInfo()
+
+class StringFieldInfo(
+ override val name: String,
+ override val xmlName: String?,
+ override val isRequired: Boolean
+) : FieldInfo() {
+ override val type: TypeName = ClassName.get(String::class.java)
+}
+
+class ClassFieldInfo(
+ override val name: String,
+ override val xmlName: String?,
+ override val type: ClassName,
+ override val isRequired: Boolean,
+ val fields: List<FieldInfo>
+) : FieldInfo()
+
+class ListFieldInfo(
+ override val name: String,
+ override val xmlName: String?,
+ override val type: ParameterizedTypeName,
+ val element: ClassFieldInfo
+) : FieldInfo() {
+ override val isRequired: Boolean = true
+}
+
+fun parse(files: List<Path>): List<PersistenceInfo> {
+ val typeSolver = CombinedTypeSolver().apply { add(ReflectionTypeSolver()) }
+ val javaParser = JavaParser(ParserConfiguration()
+ .setSymbolResolver(JavaSymbolSolver(typeSolver)))
+ val compilationUnits = files.map { javaParser.parse(it).getOrThrow() }
+ val memoryTypeSolver = MemoryTypeSolver().apply {
+ for (compilationUnit in compilationUnits) {
+ for (typeDeclaration in compilationUnit.getNodesByClass<TypeDeclaration<*>>()) {
+ val name = typeDeclaration.fullyQualifiedName.getOrNull() ?: continue
+ addDeclaration(name, typeDeclaration.resolve())
+ }
+ }
+ }
+ typeSolver.add(memoryTypeSolver)
+ return mutableListOf<PersistenceInfo>().apply {
+ for (compilationUnit in compilationUnits) {
+ val classDeclarations = compilationUnit
+ .getNodesByClass<ClassOrInterfaceDeclaration>()
+ .filter { !it.isInterface && (!it.isNestedType || it.isStatic) }
+ this += classDeclarations.mapNotNull { parsePersistenceInfo(it) }
+ }
+ }
+}
+
+private fun parsePersistenceInfo(classDeclaration: ClassOrInterfaceDeclaration): PersistenceInfo? {
+ val annotation = classDeclaration.getAnnotationByName("XmlPersistence").getOrNull()
+ ?: return null
+ val rootClassName = classDeclaration.nameAsString
+ val name = annotation.getMemberValue("value")?.stringLiteralValue
+ ?: "${rootClassName}Persistence"
+ val rootXmlName = classDeclaration.getAnnotationByName("XmlName").getOrNull()
+ ?.getMemberValue("value")?.stringLiteralValue
+ val root = parseClassFieldInfo(
+ rootXmlName ?: rootClassName, rootXmlName, true, classDeclaration
+ )
+ val path = classDeclaration.findCompilationUnit().get().storage.get().path
+ .resolveSibling("$name.java")
+ return PersistenceInfo(name, root, path)
+}
+
+private fun parseClassFieldInfo(
+ name: String,
+ xmlName: String?,
+ isRequired: Boolean,
+ classDeclaration: ClassOrInterfaceDeclaration
+): ClassFieldInfo {
+ val fields = classDeclaration.fields.filterNot { it.isStatic }.map { parseFieldInfo(it) }
+ val type = classDeclaration.resolve().typeName
+ return ClassFieldInfo(name, xmlName, type, isRequired, fields)
+}
+
+private fun parseFieldInfo(field: FieldDeclaration): FieldInfo {
+ require(field.isPublic && field.isFinal)
+ val variable = field.variables.single()
+ val name = variable.nameAsString
+ val annotations = field.annotations + variable.type.annotations
+ val annotation = annotations.getByName("XmlName")
+ val xmlName = annotation?.getMemberValue("value")?.stringLiteralValue
+ val isRequired = annotations.getByName("NonNull") != null
+ return when (val type = variable.type.resolve()) {
+ is ResolvedPrimitiveType -> {
+ val primitiveType = type.typeName
+ PrimitiveFieldInfo(name, xmlName, primitiveType, true)
+ }
+ is ResolvedReferenceType -> {
+ when (type.qualifiedName) {
+ Boolean::class.javaObjectType.name, Byte::class.javaObjectType.name,
+ Short::class.javaObjectType.name, Char::class.javaObjectType.name,
+ Integer::class.javaObjectType.name, Long::class.javaObjectType.name,
+ Float::class.javaObjectType.name, Double::class.javaObjectType.name ->
+ PrimitiveFieldInfo(name, xmlName, type.typeName, isRequired)
+ String::class.java.name -> StringFieldInfo(name, xmlName, isRequired)
+ List::class.java.name -> {
+ requireNotNull(xmlName)
+ val elementType = type.typeParametersValues().single()
+ require(elementType is ResolvedReferenceType)
+ val listType = ParameterizedTypeName.get(
+ ClassName.get(List::class.java), elementType.typeName
+ )
+ val element = parseClassFieldInfo(
+ "(element)", xmlName, true, elementType.classDeclaration
+ )
+ ListFieldInfo(name, xmlName, listType, element)
+ }
+ else -> parseClassFieldInfo(name, xmlName, isRequired, type.classDeclaration)
+ }
+ }
+ else -> error(type)
+ }
+}
+
+private fun <T> ParseResult<T>.getOrThrow(): T =
+ if (isSuccessful) {
+ result.get()
+ } else {
+ throw ParseProblemException(problems)
+ }
+
+private inline fun <reified T : Node> Node.getNodesByClass(): List<T> =
+ getNodesByClass(T::class.java)
+
+private fun <T : Node> Node.getNodesByClass(klass: Class<T>): List<T> = mutableListOf<T>().apply {
+ if (klass.isInstance(this@getNodesByClass)) {
+ this += klass.cast(this@getNodesByClass)
+ }
+ for (childNode in childNodes) {
+ this += childNode.getNodesByClass(klass)
+ }
+}
+
+private fun <T> Optional<T>.getOrNull(): T? = orElse(null)
+
+private fun List<AnnotationExpr>.getByName(name: String): AnnotationExpr? =
+ find { it.name.identifier == name }
+
+private fun AnnotationExpr.getMemberValue(name: String): Expression? =
+ when (this) {
+ is NormalAnnotationExpr -> pairs.find { it.nameAsString == name }?.value
+ is SingleMemberAnnotationExpr -> if (name == "value") memberValue else null
+ else -> null
+ }
+
+private val Expression.stringLiteralValue: String
+ get() {
+ require(this is StringLiteralExpr)
+ return value
+ }
+
+private val ResolvedReferenceType.classDeclaration: ClassOrInterfaceDeclaration
+ get() {
+ val resolvedClassDeclaration = typeDeclaration
+ require(resolvedClassDeclaration is JavaParserClassDeclaration)
+ return resolvedClassDeclaration.wrappedNode
+ }
+
+private val ResolvedPrimitiveType.typeName: TypeName
+ get() =
+ when (this) {
+ ResolvedPrimitiveType.BOOLEAN -> TypeName.BOOLEAN
+ ResolvedPrimitiveType.BYTE -> TypeName.BYTE
+ ResolvedPrimitiveType.SHORT -> TypeName.SHORT
+ ResolvedPrimitiveType.CHAR -> TypeName.CHAR
+ ResolvedPrimitiveType.INT -> TypeName.INT
+ ResolvedPrimitiveType.LONG -> TypeName.LONG
+ ResolvedPrimitiveType.FLOAT -> TypeName.FLOAT
+ ResolvedPrimitiveType.DOUBLE -> TypeName.DOUBLE
+ }
+
+// This doesn't support type parameters.
+private val ResolvedReferenceType.typeName: TypeName
+ get() = typeDeclaration.typeName
+
+private val ResolvedReferenceTypeDeclaration.typeName: ClassName
+ get() {
+ val packageName = packageName
+ val classNames = className.split(".")
+ val topLevelClassName = classNames.first()
+ val nestedClassNames = classNames.drop(1)
+ return ClassName.get(packageName, topLevelClassName, *nestedClassNames.toTypedArray())
+ }
diff --git a/tools/xmlpersistence/src/main/kotlin/StringCaseExtensions.kt b/tools/xmlpersistence/src/main/kotlin/StringCaseExtensions.kt
new file mode 100644
index 0000000..b4bdbba
--- /dev/null
+++ b/tools/xmlpersistence/src/main/kotlin/StringCaseExtensions.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.util.Locale
+
+private val camelHumpBoundary = Regex(
+ "-"
+ + "|_"
+ + "|(?<=[0-9])(?=[^0-9])"
+ + "|(?<=[A-Z])(?=[^A-Za-z]|[A-Z][a-z])"
+ + "|(?<=[a-z])(?=[^a-z])"
+)
+
+private fun String.toCamelHumps(): List<String> = split(camelHumpBoundary)
+
+fun String.toUpperCamelCase(): String =
+ toCamelHumps().joinToString("") { it.toLowerCase(Locale.ROOT).capitalize(Locale.ROOT) }
+
+fun String.toLowerCamelCase(): String = toUpperCamelCase().decapitalize(Locale.ROOT)
+
+fun String.toUpperKebabCase(): String =
+ toCamelHumps().joinToString("-") { it.toUpperCase(Locale.ROOT) }
+
+fun String.toLowerKebabCase(): String =
+ toCamelHumps().joinToString("-") { it.toLowerCase(Locale.ROOT) }
+
+fun String.toUpperSnakeCase(): String =
+ toCamelHumps().joinToString("_") { it.toUpperCase(Locale.ROOT) }
+
+fun String.toLowerSnakeCase(): String =
+ toCamelHumps().joinToString("_") { it.toLowerCase(Locale.ROOT) }