diff --git a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt index ea5babc95f..86a9eeebbf 100644 --- a/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt +++ b/kotlin-runtime/ftl-runtime/src/main/kotlin/xyz/block/ftl/schemaextractor/ExtractSchemaRule.kt @@ -5,7 +5,6 @@ import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution import io.gitlab.arturbosch.detekt.rules.fqNameOrNull import org.jetbrains.kotlin.cfg.getElementParentDeclaration import org.jetbrains.kotlin.descriptors.ClassDescriptor -import org.jetbrains.kotlin.js.descriptorUtils.getKotlinTypeFqName import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.* import org.jetbrains.kotlin.psi.psiUtil.getValueParameters @@ -15,14 +14,12 @@ import org.jetbrains.kotlin.resolve.calls.util.getType import org.jetbrains.kotlin.resolve.source.getPsi import org.jetbrains.kotlin.resolve.typeBinding.createTypeBindingForReturnType import org.jetbrains.kotlin.types.KotlinType -import org.jetbrains.kotlin.types.getAbbreviation -import org.jetbrains.kotlin.types.typeUtil.requiresTypeAliasExpansion import org.jetbrains.kotlin.utils.addToStdlib.ifNotEmpty import xyz.block.ftl.Context import xyz.block.ftl.Ignore import xyz.block.ftl.Ingress import xyz.block.ftl.Method -import xyz.block.ftl.schemaextractor.SchemaExtractor.Companion.extractModuleName +import xyz.block.ftl.schemaextractor.SchemaExtractor.Companion.moduleName import xyz.block.ftl.v1.schema.* import xyz.block.ftl.v1.schema.Array import java.io.File @@ -32,14 +29,15 @@ import java.time.OffsetDateTime import kotlin.Boolean import kotlin.String import kotlin.collections.Map +import kotlin.collections.set import kotlin.io.path.createDirectories +data class ModuleData(val comments: List = emptyList(), val decls: MutableSet = mutableSetOf()) + @RequiresTypeResolution class ExtractSchemaRule(config: Config) : Rule(config) { private val output: String by config(defaultValue = ".") - private var moduleName: String? = null - private val decls: MutableSet = mutableSetOf() - private val comments: MutableSet = mutableSetOf() + private val modules: MutableMap = mutableMapOf() override val issue = Issue( javaClass.simpleName, @@ -59,8 +57,8 @@ class ExtractSchemaRule(config: Config) : Rule(config) { } runCatching { - moduleName = annotationEntry.containingKtFile.packageFqName.extractModuleName() - val extractor = SchemaExtractor(this.bindingContext, decls, comments, moduleName!!, annotationEntry) + val currentModuleName = annotationEntry.containingKtFile.packageFqName.moduleName() + val extractor = SchemaExtractor(this.bindingContext, modules, currentModuleName, annotationEntry) extractor.extract() }.onFailure { when (it) { @@ -72,82 +70,42 @@ class ExtractSchemaRule(config: Config) : Rule(config) { override fun postVisit(root: KtFile) { val outputDirectory = File(output).also { Path.of(it.absolutePath).createDirectories() } - val file = File(outputDirectory.absolutePath, OUTPUT_FILENAME) - file.createNewFile() - val os = FileOutputStream(file) - os.write(toModule().encode()) - os.close() - } - private fun toModule(): Module { - return Module(name = moduleName!!, decls = decls.sortedBy { it.data_ == null }, comments = comments.toList()) + modules.toModules().forEach { + val file = File(outputDirectory.absolutePath, OUTPUT_FILENAME) + file.createNewFile() + val os = FileOutputStream(file) + os.write(it.encode()) + os.close() + } } companion object { const val OUTPUT_FILENAME = "schema.pb" + + private fun Map.toModules(): List { + return this.map { + Module(name = it.key, decls = it.value.decls.sortedBy { it.data_ == null }, comments = it.value.comments) + } + } } } class IgnoredModuleException : Exception() class SchemaExtractor( private val bindingContext: BindingContext, - private val decls: MutableSet, - private val comments: MutableSet, + private val modules: MutableMap, private val currentModuleName: String, annotation: KtAnnotationEntry ) { private val verb: KtNamedFunction private val module: KtDeclaration - - init { - requireNotNull(annotation.getElementParentDeclaration()) { "Could not extract $currentModuleName verb definition" }.let { - require(it is KtNamedFunction) { "Failure extracting ${it.name}; verbs must be functions" } - verb = it - } - module = requireNotNull(verb.getElementParentDeclaration()) { "Could not extract $currentModuleName definition" } - - // Skip ignored modules. - if (module.annotationEntries.firstOrNull { - bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Ignore::class.qualifiedName - } != null) { - throw IgnoredModuleException() - } - - requireNotNull(verb.fqName?.asString()) { - "Verbs must be defined in a package" - }.let { fqName -> - require(fqName.split(".").let { it.size >= 2 && it.first() == "ftl" }) { - "Expected @Verb to be in package ftl., but was $fqName" - } - - // Validate parameters - require(verb.valueParameters.size == 2) { "Verbs must have exactly two arguments, ${verb.name} did not" } - val ctxParam = verb.valueParameters.first() - val reqParam = verb.valueParameters.last() - require(ctxParam.typeReference?.resolveType()?.fqNameOrNull()?.asString() == Context::class.qualifiedName) { - "First argument of verb must be Context" - } - - require(reqParam.typeReference?.resolveType() - ?.let { it.toClassDescriptor().isData || it.isEmptyClassTypeAlias() } - ?: false - ) { - "Second argument of ${verb.name} must be a data class or typealias of Unit" - } - - // Validate return type - val respClass = verb.createTypeBindingForReturnType(bindingContext)?.type?.toClassDescriptor() - ?: throw IllegalStateException("Could not resolve ${verb.name} return type") - require(respClass.isData) { "Return type of ${verb.name} must be a data class" } - } - } - fun extract() { val requestType = requireNotNull(verb.valueParameters.last().typeReference?.resolveType()) { - "Could not resolve request type for ${verb.name}" + "Could not resolve verb request type" } val responseType = requireNotNull(verb.createTypeBindingForReturnType(bindingContext)?.type) { - "Could not resolve response type for ${verb.name}" + "Could not resolve verb response type" } val metadata = mutableListOf() @@ -162,17 +120,19 @@ class SchemaExtractor( comments = verb.comments(), ) - decls += setOf(Decl(verb = verb), *extractDataDeclarations().toTypedArray()) - comments += module.comments() + val moduleData = ModuleData( + decls = mutableSetOf(Decl(verb = verb), *extractDataDeclarations().toTypedArray()), + comments = module.comments() + ) + modules[currentModuleName]?.decls?.addAll(moduleData.decls) ?: run { + modules[currentModuleName] = moduleData + } } private fun extractDataDeclarations(): Set { return verb.containingKtFile.children - .filter { (it is KtClass && it.isData()) || it is KtTypeAlias } - .mapNotNull { - val data = (it as? KtClass)?.toSchemaData() ?: (it as? KtTypeAlias)?.toSchemaData() - data?.let { Decl(data_ = data) } - } + .filter { it is KtClass && it.isData() } + .map { Decl(data_ = (it as KtClass).toSchemaData()) } .toSet() } @@ -218,7 +178,7 @@ class SchemaExtractor( val func = element as? KtNamedFunction if (func != null) { - val body = requireNotNull(func.bodyExpression) { "Function body cannot be empty; was in ${func.name}" } + val body = requireNotNull(func.bodyExpression) { "Could not parse empty function body" } val imports = func.containingKtFile.importList?.imports?.mapNotNull { it.importedFqName } ?: emptyList() // Look for all params of type Context and extract a matcher for each based on its variable name. @@ -237,7 +197,7 @@ class SchemaExtractor( } // TODO(worstell): Figure out how to get module name when not imported from another Kt file val moduleRefName = imports.firstOrNull { import -> import.toString().contains(req) } - ?.extractModuleName().takeIf { refModule -> refModule != currentModuleName } + ?.moduleName().takeIf { refModule -> refModule != currentModuleName } VerbRef( name = verbCall.split("::")[1].trim(), @@ -256,13 +216,6 @@ class SchemaExtractor( } } - private fun KtTypeAlias.toSchemaData(): Data { - return Data( - name = this.name!!, - comments = this.comments() - ) - } - private fun KtClass.toSchemaData(): Data { return Data( name = this.name!!, @@ -302,30 +255,14 @@ class SchemaExtractor( } else -> { - require(this.toClassDescriptor().isData || this.isEmptyClassTypeAlias()) { - "Expected type to be a data class or typealias of Unit, but was ${this.fqNameOrNull()?.asString()}" - } - - var refName: String - var fqName: String - if (this.isEmptyClassTypeAlias()) { - this.unwrap().getAbbreviation()!!.run { - fqName = this.getKotlinTypeFqName(false) - refName = this.constructor.declarationDescriptor?.name?.asString()!! - } - } else { - fqName = this.fqNameOrNull()!!.asString() - refName = this.toClassDescriptor().name.asString() - } - - require(fqName.startsWith("ftl.")) { - "Expected module name to be in the form ftl., but was ${this.fqNameOrNull()?.asString()}" - } - + require( + this.toClassDescriptor().isData + && (this.fqNameOrNull()?.asString()?.startsWith("ftl.") ?: false) + ) { "Expected module name to be in the form ftl., but was ${this.fqNameOrNull()?.asString()}" } return Type( dataRef = DataRef( - name = refName, - module = fqName.extractModuleName().takeIf { it != currentModuleName } ?: "", + name = this.toClassDescriptor().name.asString(), + module = this.fqNameOrNull()!!.moduleName().takeIf { it != currentModuleName } ?: "", ) ) } @@ -336,6 +273,45 @@ class SchemaExtractor( bindingContext.get(BindingContext.TYPE, this) ?: throw IllegalStateException("Could not resolve type ${this.text}") + init { + requireNotNull(annotation.getElementParentDeclaration()) { "Could not extract $currentModuleName verb definition" }.let { + require(it is KtNamedFunction) { "Verbs must be functions" } + verb = it + } + module = requireNotNull(verb.getElementParentDeclaration()) { "Could not extract $currentModuleName definition" } + + // Skip ignored modules. + if (module.annotationEntries.firstOrNull { + bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Ignore::class.qualifiedName + } != null) { + throw IgnoredModuleException() + } + + requireNotNull(verb.fqName?.asString()) { + "Verbs must be defined in a package" + }.let { fqName -> + require(fqName.split(".").let { it.size >= 2 && it.first() == "ftl" }) { + "Expected @Verb to be in package ftl., but was $fqName" + } + + // Validate parameters + require(verb.valueParameters.size == 2) { "Verbs must have exactly two arguments" } + val ctxParam = verb.valueParameters.first() + val reqParam = verb.valueParameters.last() + require(ctxParam.typeReference?.resolveType()?.fqNameOrNull()?.asString() == Context::class.qualifiedName) { + "First argument of verb must be Context" + } + require(reqParam.typeReference?.resolveType()?.toClassDescriptor()?.isData ?: false) { + "Second argument of verb must be a data class" + } + + // Validate return type + val respClass = verb.createTypeBindingForReturnType(bindingContext)?.type?.toClassDescriptor() + ?: throw IllegalStateException("Could not resolve verb return type") + require(respClass.isData) { "Return type of verb must be a data class" } + } + } + companion object { private fun getCallMatcher(ctxVarName: String): Regex { return """${ctxVarName}.call\((?[^)]+),(?[^)]+)\(.*\)\)""".toRegex(RegexOption.IGNORE_CASE) @@ -345,24 +321,13 @@ class SchemaExtractor( this.unwrap().constructor.declarationDescriptor as? ClassDescriptor ?: throw IllegalStateException("Could not resolve KotlinType to class") - fun FqName.extractModuleName(): String { - return this.asString().extractModuleName() - } - - private fun String.extractModuleName(): String { - return this.split(".")[1] + fun FqName.moduleName(): String { + return this.asString().split(".")[1] } private fun KtDeclaration.comments(): List { return this.docComment?.text?.trim()?.let { listOf(it) } ?: emptyList() } - - // `typealias = Unit` can be used in Kotlin to declare an empty FTL data type. - // This is a workaround to support empty objects in the FTL schema despite being unsupported by Kotlin data classes. - private fun KotlinType.isEmptyClassTypeAlias(): Boolean { - return this.fqNameOrNull()?.asString() == Unit::class.qualifiedName - && (this.unwrap().getAbbreviation()?.requiresTypeAliasExpansion() ?: false) - } } }