From 4dd5a49bc4363ce38f74e21993aaa1729e47a5b3 Mon Sep 17 00:00:00 2001 From: Martin Nonnenmacher Date: Mon, 26 Aug 2024 23:48:51 +0200 Subject: [PATCH] refactor(plugins-api): Separate plugin analysis from code generation Introduce an intermediary `PluginSpec` model that is used as input for the code generation and move the code to create a `PluginSpec` to the new class `PluginSpecFactory`. This provides a better separation of concerns and makes it easier to generate other output files based on the `PluginSpec`. Signed-off-by: Martin Nonnenmacher --- plugins/api/build.gradle.kts | 1 + .../src/main/kotlin/PluginFactoryGenerator.kt | 266 ++++-------------- .../api/src/main/kotlin/PluginProcessor.kt | 21 +- .../main/kotlin/PluginProcessorProvider.kt | 3 +- plugins/api/src/main/kotlin/PluginSpec.kt | 52 ++++ .../api/src/main/kotlin/PluginSpecFactory.kt | 192 +++++++++++++ 6 files changed, 309 insertions(+), 226 deletions(-) create mode 100644 plugins/api/src/main/kotlin/PluginSpec.kt create mode 100644 plugins/api/src/main/kotlin/PluginSpecFactory.kt diff --git a/plugins/api/build.gradle.kts b/plugins/api/build.gradle.kts index 06c1138072cc6..ac73fdb752faa 100644 --- a/plugins/api/build.gradle.kts +++ b/plugins/api/build.gradle.kts @@ -27,5 +27,6 @@ dependencies { implementation(libs.kotlinpoet) implementation(libs.kotlinpoet.ksp) + implementation(libs.kotlinx.serialization.json) implementation(libs.ksp) } diff --git a/plugins/api/src/main/kotlin/PluginFactoryGenerator.kt b/plugins/api/src/main/kotlin/PluginFactoryGenerator.kt index 1bb7900b142d1..601bda43f46d8 100644 --- a/plugins/api/src/main/kotlin/PluginFactoryGenerator.kt +++ b/plugins/api/src/main/kotlin/PluginFactoryGenerator.kt @@ -19,15 +19,8 @@ package org.ossreviewtoolkit.plugins.api -import com.google.devtools.ksp.KspExperimental -import com.google.devtools.ksp.getAnnotationsByType -import com.google.devtools.ksp.getConstructors import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.processing.Dependencies -import com.google.devtools.ksp.symbol.KSClassDeclaration -import com.google.devtools.ksp.symbol.KSFunctionDeclaration -import com.google.devtools.ksp.symbol.KSType -import com.google.devtools.ksp.symbol.Modifier import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -37,42 +30,25 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.ksp.toTypeName import com.squareup.kotlinpoet.ksp.writeTo class PluginFactoryGenerator(private val codeGenerator: CodeGenerator) { - fun generate(ortPlugin: OrtPlugin, pluginClass: KSClassDeclaration, pluginFactoryClass: KSClassDeclaration) { - val generatedFactory = generateFactoryClass(ortPlugin, pluginClass, pluginFactoryClass) - generateServiceLoaderFile(pluginClass, pluginFactoryClass, generatedFactory) + fun generate(pluginSpec: PluginSpec) { + val generatedFactory = generateFactoryClass(pluginSpec) + generateServiceLoaderFile(pluginSpec, generatedFactory) } /** - * Generate a factory class for the [ortPlugin] of type [pluginClass] that implements the [pluginFactoryClass] - * interface. + * Generate a factory class for the [pluginSpec]. */ - private fun generateFactoryClass( - ortPlugin: OrtPlugin, - pluginClass: KSClassDeclaration, - pluginFactoryClass: KSClassDeclaration - ): TypeSpec { - val pluginType = pluginClass.asType(emptyList()).toTypeName() - val pluginFactoryType = pluginFactoryClass.asType(emptyList()).toTypeName() - - val constructor = getPluginConstructor(pluginClass) - val (configClass, configType) = if (constructor.parameters.size == 2) { - val type = constructor.parameters[1].type - type.resolve().declaration as KSClassDeclaration to type.toTypeName() - } else { - null to null - } - - val pluginOptions = configClass?.getPluginOptions().orEmpty() - + private fun generateFactoryClass(pluginSpec: PluginSpec): TypeSpec { // Create the initializer for the plugin config object. - val configInitializer = configType?.let { getConfigInitializer(it, pluginOptions) } + val configInitializer = pluginSpec.configClass?.let { + getConfigInitializer(it.typeName, pluginSpec.descriptor.options) + } // Create the plugin descriptor property. - val descriptorInitializer = getDescriptorInitializer(ortPlugin, pluginClass, pluginOptions) + val descriptorInitializer = getDescriptorInitializer(pluginSpec.descriptor) val descriptorProperty = PropertySpec.builder("descriptor", PluginDescriptor::class, KModifier.OVERRIDE) .initializer(descriptorInitializer) .build() @@ -84,27 +60,27 @@ class PluginFactoryGenerator(private val codeGenerator: CodeGenerator) { if (configInitializer != null) { addCode(configInitializer) - addCode("return %T(%N, configObject)", pluginType, descriptorProperty) + addCode("return %T(%N, configObject)", pluginSpec.typeName, descriptorProperty) } else { - addCode("return %T(%N)", pluginType, descriptorProperty) + addCode("return %T(%N)", pluginSpec.typeName, descriptorProperty) } - returns(pluginType) + returns(pluginSpec.typeName) }.build() // Create the factory class. - val className = "${pluginClass.simpleName.asString()}Factory" + val className = "${pluginSpec.descriptor.className}Factory" val classSpec = TypeSpec.classBuilder(className) - .addSuperinterface(pluginFactoryType) + .addSuperinterface(pluginSpec.factory.typeName) .addProperty(descriptorProperty) .addFunction(createFunction) .build() // Write the factory class to a file. - FileSpec.builder(ClassName(pluginClass.packageName.asString(), className)) + FileSpec.builder(ClassName(pluginSpec.packageName, "${pluginSpec.descriptor.className}Factory")) .addType(classSpec) .build() - .writeTo(codeGenerator, aggregating = true, originatingKSFiles = listOfNotNull(pluginClass.containingFile)) + .writeTo(codeGenerator, aggregating = true, originatingKSFiles = listOfNotNull(pluginSpec.containingFile)) return classSpec } @@ -161,191 +137,63 @@ class PluginFactoryGenerator(private val codeGenerator: CodeGenerator) { /** * Generate the code block to initialize the [PluginDescriptor] for the plugin. */ - private fun getDescriptorInitializer( - ortPlugin: OrtPlugin, - pluginClass: KSClassDeclaration, - pluginOptions: List - ) = CodeBlock.builder().apply { - add( - """ - PluginDescriptor( - name = %S, - className = %S, - description = %S, - options = listOf( - - """.trimIndent(), - ortPlugin.name, - pluginClass.simpleName.asString(), - ortPlugin.description - ) - - pluginOptions.forEach { + private fun getDescriptorInitializer(descriptor: PluginDescriptor) = + CodeBlock.builder().apply { add( """ - | %T( - | name = %S, - | description = %S, - | type = %T.%L, - | defaultValue = %S, - | isRequired = %L - | ), - | - """.trimMargin(), - PluginOption::class, - it.name, - it.description, - PluginOptionType::class, - it.type.name, - it.defaultValue, - it.isRequired + PluginDescriptor( + name = %S, + className = %S, + description = %S, + options = listOf( + + """.trimIndent(), + descriptor.name, + descriptor.className, + descriptor.description ) - } - add( - """ + descriptor.options.forEach { + add( + """ + | %T( + | name = %S, + | description = %S, + | type = %T.%L, + | defaultValue = %S, + | isRequired = %L + | ), + | + """.trimMargin(), + PluginOption::class, + it.name, + it.description, + PluginOptionType::class, + it.type.name, + it.defaultValue, + it.isRequired ) - ) - """.trimIndent() - ) - }.build() - - /** - * Get the constructor of the plugin class that has a [PluginDescriptor] and a config argument. Throw an - * [IllegalArgumentException] if more than one or no such constructor exists. - */ - private fun getPluginConstructor(pluginClass: KSClassDeclaration): KSFunctionDeclaration { - // TODO: Consider adding an @OrtPluginConstructor annotation to mark the constructor to use. This could be - // useful if a plugin needs multiple constructors for different purposes like testing. - val constructors = pluginClass.getConstructors().filterTo(mutableListOf()) { - if (it.parameters.size < 1 || it.parameters.size > 2) { - return@filterTo false - } - - val firstArgumentIsDescriptor = it.parameters[0].name?.asString() == "descriptor" && - it.parameters[0].type.resolve().declaration.qualifiedName?.asString() == - "org.ossreviewtoolkit.plugins.api.PluginDescriptor" - - val optionalSecondArgumentIsCalledConfig = - it.parameters.size == 1 || it.parameters[1].name?.asString() == "config" - - firstArgumentIsDescriptor && optionalSecondArgumentIsCalledConfig - } - - require(constructors.size == 1) { - "Plugin class $pluginClass must have exactly one constructor with a PluginDescriptor and a config " + - "argument." - } - - return constructors.first() - } - - /** - * Get the plugin options from the config class by mapping its properties to [PluginOption] instances. - */ - @OptIn(KspExperimental::class) - private fun KSClassDeclaration.getPluginOptions(): List { - require(Modifier.DATA in modifiers) { - "Config class $this must be a data class." - } - - require(getConstructors().toList().size == 1) { - "Config class $this must have exactly one constructor." - } - - val constructor = getConstructors().single() - - return constructor.parameters.map { param -> - val paramType = param.type.resolve() - val paramTypeString = getQualifiedNameWithTypeArguments(paramType) - val paramName = param.name?.asString() - - requireNotNull(paramName) { - "Config class constructor parameter has no name." - } - - require(param.isVal) { - "Config class constructor parameter $paramName must be a val." } - require(!param.hasDefault) { - "Config class constructor parameter $paramName must not have a default value. Default values must be " + - "set via the @OrtPluginOption annotation." - } - - val prop = getAllProperties().find { it.simpleName.asString() == paramName } - - requireNotNull(prop) { - "Config class must have a property with the name $paramName." - } - - val annotations = prop.getAnnotationsByType(OrtPluginOption::class).toList() - - require(annotations.size <= 1) { - "Config class constructor parameter $paramName must have at most one @OrtPluginOption annotation." - } - - val annotation = annotations.firstOrNull() - - val type = when (paramTypeString) { - "kotlin.Boolean" -> PluginOptionType.BOOLEAN - "kotlin.Int" -> PluginOptionType.INTEGER - "kotlin.Long" -> PluginOptionType.LONG - "org.ossreviewtoolkit.plugins.api.Secret" -> PluginOptionType.SECRET - "kotlin.String" -> PluginOptionType.STRING - "kotlin.collections.List" -> PluginOptionType.STRING_LIST - - else -> throw IllegalArgumentException( - "Config class constructor parameter ${param.name?.asString()} has unsupported type " + - "$paramTypeString." + add( + """ + ) ) - } - - val defaultValue = annotation?.defaultValue - - PluginOption( - name = param.name?.asString().orEmpty(), - description = prop.docString?.trim().orEmpty(), - type = type, - defaultValue = defaultValue, - isRequired = !paramType.isMarkedNullable && defaultValue == null + """.trimIndent() ) - } - } - - /** - * Get the qualified name of a [type] with its type arguments, for example, - * `kotlin.collections.List`. - */ - private fun getQualifiedNameWithTypeArguments(type: KSType): String = - buildString { - append(type.declaration.qualifiedName?.asString()) - if (type.arguments.isNotEmpty()) { - append("<") - append( - type.arguments.joinToString(", ") { argument -> - argument.type?.resolve()?.let { getQualifiedNameWithTypeArguments(it) } ?: "Unknown" - } - ) - append(">") - } - } + }.build() /** - * Generate a service loader file for the plugin factory. + * Generate a service loader file for the [generatedFactory]. */ - private fun generateServiceLoaderFile( - pluginClass: KSClassDeclaration, - pluginFactoryClass: KSClassDeclaration, - generatedFactory: TypeSpec - ) { + private fun generateServiceLoaderFile(pluginSpec: PluginSpec, generatedFactory: TypeSpec) { codeGenerator.createNewFileByPath( - dependencies = Dependencies(aggregating = false, *listOfNotNull(pluginClass.containingFile).toTypedArray()), - path = "META-INF/services/${pluginFactoryClass.qualifiedName?.asString()}", + dependencies = Dependencies(aggregating = true, *listOfNotNull(pluginSpec.containingFile).toTypedArray()), + path = "META-INF/services/${pluginSpec.factory.qualifiedName}", extensionName = "" ).use { output -> output.writer().use { writer -> - writer.write("${pluginClass.packageName.asString()}.${generatedFactory.name}\n") + writer.write("${pluginSpec.packageName}.${generatedFactory.name}\n") } } } diff --git a/plugins/api/src/main/kotlin/PluginProcessor.kt b/plugins/api/src/main/kotlin/PluginProcessor.kt index df1efda0391b0..363b493905890 100644 --- a/plugins/api/src/main/kotlin/PluginProcessor.kt +++ b/plugins/api/src/main/kotlin/PluginProcessor.kt @@ -24,18 +24,20 @@ import com.google.devtools.ksp.getAllSuperTypes import com.google.devtools.ksp.getAnnotationsByType import com.google.devtools.ksp.getClassDeclarationByName import com.google.devtools.ksp.processing.CodeGenerator -import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSClassDeclaration -class PluginProcessor(private val codeGenerator: CodeGenerator, private val logger: KSPLogger) : SymbolProcessor { +class PluginProcessor(codeGenerator: CodeGenerator) : SymbolProcessor { /** * True, if the processor has been invoked in a previous run. */ private var invoked = false + private val specFactory = PluginSpecFactory() + private val factoryGenerator = PluginFactoryGenerator(codeGenerator) + /** * Process all classes annotated with [OrtPlugin] to generate plugin factories for them. */ @@ -62,7 +64,8 @@ class PluginProcessor(private val codeGenerator: CodeGenerator, private val logg val pluginParentClass = getPluginParentClass(pluginFactoryClass) checkExtendsPluginClass(pluginClass, pluginParentClass) - createPluginFactory(pluginAnnotation, pluginClass, pluginFactoryClass) + val pluginSpec = specFactory.create(pluginAnnotation, pluginClass, pluginFactoryClass) + factoryGenerator.generate(pluginSpec) } invoked = true @@ -129,16 +132,4 @@ class PluginProcessor(private val codeGenerator: CodeGenerator, private val logg "Plugin class $pluginClass does not extend the required super type $pluginBaseClass." } } - - /** - * Create the plugin factory for the given [ortPlugin]. - */ - private fun createPluginFactory( - ortPlugin: OrtPlugin, - pluginClass: KSClassDeclaration, - pluginFactoryClass: KSClassDeclaration - ) { - val generator = PluginFactoryGenerator(codeGenerator) - generator.generate(ortPlugin, pluginClass, pluginFactoryClass) - } } diff --git a/plugins/api/src/main/kotlin/PluginProcessorProvider.kt b/plugins/api/src/main/kotlin/PluginProcessorProvider.kt index 7e8a029bfbda6..de482cab096bb 100644 --- a/plugins/api/src/main/kotlin/PluginProcessorProvider.kt +++ b/plugins/api/src/main/kotlin/PluginProcessorProvider.kt @@ -26,6 +26,5 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider * A [SymbolProcessorProvider] that provides a [PluginProcessor]. */ class PluginProcessorProvider : SymbolProcessorProvider { - override fun create(environment: SymbolProcessorEnvironment) = - PluginProcessor(environment.codeGenerator, environment.logger) + override fun create(environment: SymbolProcessorEnvironment) = PluginProcessor(environment.codeGenerator) } diff --git a/plugins/api/src/main/kotlin/PluginSpec.kt b/plugins/api/src/main/kotlin/PluginSpec.kt new file mode 100644 index 0000000000000..9721c0afae59e --- /dev/null +++ b/plugins/api/src/main/kotlin/PluginSpec.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.api + +import com.google.devtools.ksp.symbol.KSFile + +import com.squareup.kotlinpoet.TypeName + +/** + * A specification for a plugin. + */ +data class PluginSpec( + val containingFile: KSFile?, + val descriptor: PluginDescriptor, + val packageName: String, + val typeName: TypeName, + val configClass: PluginConfigClassSpec?, + val factory: PluginFactorySpec +) + +/** + * A specification for a plugin configuration class. + */ +data class PluginConfigClassSpec( + val typeName: TypeName +) + +/** + * A specification for a plugin factory. This describes the base factory class that the generated factory should + * implement. + */ +data class PluginFactorySpec( + val typeName: TypeName, + val qualifiedName: String +) diff --git a/plugins/api/src/main/kotlin/PluginSpecFactory.kt b/plugins/api/src/main/kotlin/PluginSpecFactory.kt new file mode 100644 index 0000000000000..b115a8f894699 --- /dev/null +++ b/plugins/api/src/main/kotlin/PluginSpecFactory.kt @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.api + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.Modifier + +import com.squareup.kotlinpoet.ksp.toTypeName + +/** + * A generator for [PluginSpec] instances. + */ +class PluginSpecFactory { + /** + * Create a [PluginSpec] for the given [ortPlugin] using the [pluginClass] and [pluginFactoryClass]. + */ + fun create( + ortPlugin: OrtPlugin, + pluginClass: KSClassDeclaration, + pluginFactoryClass: KSClassDeclaration + ): PluginSpec { + val pluginType = pluginClass.asType(emptyList()).toTypeName() + val pluginFactoryType = pluginFactoryClass.asType(emptyList()).toTypeName() + + val constructor = getPluginConstructor(pluginClass) + val (configClass, configType) = if (constructor.parameters.size == 2) { + val type = constructor.parameters[1].type + type.resolve().declaration as KSClassDeclaration to type.toTypeName() + } else { + null to null + } + + val pluginOptions = configClass?.getPluginOptions().orEmpty() + + return PluginSpec( + containingFile = pluginClass.containingFile, + descriptor = PluginDescriptor( + name = ortPlugin.name, + className = pluginClass.simpleName.asString(), + description = ortPlugin.description, + options = pluginOptions + ), + packageName = pluginClass.packageName.asString(), + typeName = pluginType, + configClass = configType?.let { PluginConfigClassSpec(it) }, + factory = PluginFactorySpec(pluginFactoryType, pluginFactoryClass.qualifiedName?.asString().orEmpty()) + ) + } + + /** + * Get the constructor of the plugin class that has a [PluginDescriptor] and a config argument. Throw an + * [IllegalArgumentException] if more than one or no such constructor exists. + */ + private fun getPluginConstructor(pluginClass: KSClassDeclaration): KSFunctionDeclaration { + // TODO: Consider adding an @OrtPluginConstructor annotation to mark the constructor to use. This could be + // useful if a plugin needs multiple constructors for different purposes like testing. + val constructors = pluginClass.getConstructors().filterTo(mutableListOf()) { + if (it.parameters.size < 1 || it.parameters.size > 2) { + return@filterTo false + } + + val firstArgumentIsDescriptor = it.parameters[0].name?.asString() == "descriptor" && + it.parameters[0].type.resolve().declaration.qualifiedName?.asString() == + "org.ossreviewtoolkit.plugins.api.PluginDescriptor" + + val optionalSecondArgumentIsCalledConfig = + it.parameters.size == 1 || it.parameters[1].name?.asString() == "config" + + firstArgumentIsDescriptor && optionalSecondArgumentIsCalledConfig + } + + require(constructors.size == 1) { + "Plugin class $pluginClass must have exactly one constructor with a PluginDescriptor and a config " + + "argument." + } + + return constructors.first() + } + + /** + * Get the plugin options from the config class by mapping its properties to [PluginOption] instances. + */ + @OptIn(KspExperimental::class) + private fun KSClassDeclaration.getPluginOptions(): List { + require(Modifier.DATA in modifiers) { + "Config class $this must be a data class." + } + + require(getConstructors().toList().size == 1) { + "Config class $this must have exactly one constructor." + } + + val constructor = getConstructors().single() + + return constructor.parameters.map { param -> + val paramType = param.type.resolve() + val paramTypeString = getQualifiedNameWithTypeArguments(paramType) + val paramName = param.name?.asString() + + requireNotNull(paramName) { + "Config class constructor parameter has no name." + } + + require(param.isVal) { + "Config class constructor parameter $paramName must be a val." + } + + require(!param.hasDefault) { + "Config class constructor parameter $paramName must not have a default value. Default values must be " + + "set via the @OrtPluginOption annotation." + } + + val prop = getAllProperties().find { it.simpleName.asString() == paramName } + + requireNotNull(prop) { + "Config class must have a property with the name $paramName." + } + + val annotations = prop.getAnnotationsByType(OrtPluginOption::class).toList() + + require(annotations.size <= 1) { + "Config class constructor parameter $paramName must have at most one @OrtPluginOption annotation." + } + + val annotation = annotations.firstOrNull() + + val type = when (paramTypeString) { + "kotlin.Boolean" -> PluginOptionType.BOOLEAN + "kotlin.Int" -> PluginOptionType.INTEGER + "kotlin.Long" -> PluginOptionType.LONG + "org.ossreviewtoolkit.plugins.api.Secret" -> PluginOptionType.SECRET + "kotlin.String" -> PluginOptionType.STRING + "kotlin.collections.List" -> PluginOptionType.STRING_LIST + + else -> throw IllegalArgumentException( + "Config class constructor parameter ${param.name?.asString()} has unsupported type " + + "$paramTypeString." + ) + } + + val defaultValue = annotation?.defaultValue + + PluginOption( + name = param.name?.asString().orEmpty(), + description = prop.docString?.trim().orEmpty(), + type = type, + defaultValue = defaultValue, + isRequired = !paramType.isMarkedNullable && defaultValue == null + ) + } + } + + /** + * Get the qualified name of a [type] with its type arguments, for example, + * `kotlin.collections.List`. + */ + private fun getQualifiedNameWithTypeArguments(type: KSType): String = + buildString { + append(type.declaration.qualifiedName?.asString()) + if (type.arguments.isNotEmpty()) { + append("<") + append( + type.arguments.joinToString(", ") { argument -> + argument.type?.resolve()?.let { getQualifiedNameWithTypeArguments(it) } ?: "Unknown" + } + ) + append(">") + } + } +}