Skip to content

Commit

Permalink
feat: extract enums in kotlin (#1078)
Browse files Browse the repository at this point in the history
fixes #866
  • Loading branch information
worstell authored Mar 14, 2024
1 parent 97f1b89 commit 456d2fe
Show file tree
Hide file tree
Showing 3 changed files with 260 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ import io.gitlab.arturbosch.detekt.api.Severity
import io.gitlab.arturbosch.detekt.api.config
import io.gitlab.arturbosch.detekt.api.internal.RequiresTypeResolution
import io.gitlab.arturbosch.detekt.rules.fqNameOrNull
import java.io.File
import java.io.FileOutputStream
import java.nio.file.Path
import java.time.OffsetDateTime
import kotlin.io.path.createDirectories
import org.jetbrains.kotlin.cfg.getDeclarationDescriptorIncludingConstructors
import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange
import org.jetbrains.kotlin.com.intellij.psi.PsiComment
import org.jetbrains.kotlin.com.intellij.psi.PsiElement
import org.jetbrains.kotlin.descriptors.ClassDescriptor
import org.jetbrains.kotlin.descriptors.ClassKind
import org.jetbrains.kotlin.descriptors.impl.referencedProperty
import org.jetbrains.kotlin.descriptors.isFinalOrEnum
import org.jetbrains.kotlin.diagnostics.DiagnosticUtils.getLineAndColumnInPsiFile
import org.jetbrains.kotlin.diagnostics.PsiDiagnosticUtils.LineAndColumn
import org.jetbrains.kotlin.name.FqName
Expand All @@ -22,15 +29,18 @@ import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtDeclaration
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtEnumEntry
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtFunction
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.KtProperty
import org.jetbrains.kotlin.psi.KtSuperTypeCallEntry
import org.jetbrains.kotlin.psi.KtTypeAlias
import org.jetbrains.kotlin.psi.KtTypeParameterList
import org.jetbrains.kotlin.psi.KtTypeReference
import org.jetbrains.kotlin.psi.KtValueArgument
import org.jetbrains.kotlin.psi.ValueArgument
import org.jetbrains.kotlin.psi.psiUtil.children
import org.jetbrains.kotlin.psi.psiUtil.getValueParameters
import org.jetbrains.kotlin.psi.psiUtil.startOffset
Expand Down Expand Up @@ -58,11 +68,8 @@ import xyz.block.ftl.Json
import xyz.block.ftl.Method
import xyz.block.ftl.v1.schema.*
import xyz.block.ftl.v1.schema.Array
import java.io.File
import java.io.FileOutputStream
import java.nio.file.Path
import java.time.OffsetDateTime
import kotlin.io.path.createDirectories
import xyz.block.ftl.v1.schema.Enum
import xyz.block.ftl.v1.schema.Verb

data class ModuleData(val comments: List<String> = emptyList(), val decls: MutableSet<Decl> = mutableSetOf())

Expand Down Expand Up @@ -132,7 +139,11 @@ class ExtractSchemaRule(config: Config) : Rule(config) {

private fun Map<String, ModuleData>.toModules(): List<Module> {
return this.map {
xyz.block.ftl.v1.schema.Module(name = it.key, decls = it.value.decls.sortedBy { it.data_ == null }, comments = it.value.comments)
xyz.block.ftl.v1.schema.Module(
name = it.key,
decls = it.value.decls.sortedBy { it.data_ == null },
comments = it.value.comments
)
}
}

Expand All @@ -157,6 +168,7 @@ class SchemaExtractor(
*extractVerbs().toTypedArray(),
*extractDataDeclarations().toTypedArray(),
*extractDatabases().toTypedArray(),
*extractEnums().toTypedArray(),
),
comments = moduleComments
)
Expand Down Expand Up @@ -286,6 +298,15 @@ class SchemaExtractor(
.toSet()
}

private fun extractEnums(): Set<Decl> {
return file.children
.filter { it is KtClass && it.isEnum() }
.mapNotNull {
(it as? KtClass)?.toSchemaEnum()?.let { enum -> Decl(enum_ = enum) }
}
.toSet()
}

private fun extractIngress(verb: KtNamedFunction, requestType: Type, responseType: Type): MetadataIngress? {
return verb.annotationEntries.firstOrNull {
bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == HttpIngress::class.qualifiedName
Expand Down Expand Up @@ -442,10 +463,10 @@ class SchemaExtractor(
fields = this.getValueParameters().map { param ->
// Metadata containing JSON alias if present.
val metadata = param.annotationEntries.firstOrNull {
bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Json::class.qualifiedName
}?.valueArguments?.single()?.let {
listOf(Metadata(alias = MetadataAlias(alias = (it as KtValueArgument).text.trim('"', ' '))))
} ?: listOf()
bindingContext.get(BindingContext.ANNOTATION, it)?.fqName?.asString() == Json::class.qualifiedName
}?.valueArguments?.single()?.let {
listOf(Metadata(alias = MetadataAlias(alias = (it as KtValueArgument).text.trim('"', ' '))))
} ?: listOf()
Field(
name = param.name!!,
type = param.typeReference?.let {
Expand All @@ -467,6 +488,59 @@ class SchemaExtractor(
)
}

private fun KtClass.toSchemaEnum(): Enum {
val variants: List<EnumVariant>
require(this.getValueParameters().isEmpty() || this.getValueParameters().size == 1) {
"${this.getLineAndColumn().toPosition()}: Enums can have at most one value parameter, of type string or number"
}

if (this.getValueParameters().isEmpty()) {
var ordinal = 0L
variants = this.declarations.filterIsInstance<KtEnumEntry>().map {
val variant = EnumVariant(
name = it.name!!,
value_ = Value(intValue = IntValue(value_ = ordinal))
)
ordinal = ordinal.inc()
return@map variant
}
} else {
variants = this.declarations.filterIsInstance<KtEnumEntry>().map { entry ->
val pos: Position = entry.getLineAndColumn().toPosition()
val name: String = entry.name!!
val arg: ValueArgument = entry.initializerList?.initializers?.single().let {
(it as KtSuperTypeCallEntry).valueArguments.single()
}

val value: Value
try {
value = arg.getArgumentExpression()?.text?.let {
if (it.startsWith('"')) {
return@let Value(stringValue = StringValue(value_ = it.trim('"')))
} else {
return@let Value(intValue = IntValue(value_ = it.toLong()))
}
} ?: throw IllegalArgumentException("${pos}: Could not extract enum variant value")
} catch (e: NumberFormatException) {
throw IllegalArgumentException("${pos}: Enum variant value must be a string or number")
}

EnumVariant(
name = name,
value_ = value,
pos = pos,
)
}
}

return Enum(
name = this.name!!,
variants = variants,
comments = this.comments(),
pos = getLineAndColumnInPsiFile(this.containingFile, this.textRange).toPosition(),
)
}

private fun KotlinType.toSchemaType(position: Position): Type {
if (this.unwrap().constructor.isTypeParameterTypeConstructor()) {
return Type(
Expand Down Expand Up @@ -511,7 +585,11 @@ class SchemaExtractor(
?.asString() == OffsetDateTime::class.qualifiedName -> Type(time = xyz.block.ftl.v1.schema.Time())

else -> {
require(this.toClassDescriptor().isData || this.isEmptyBuiltin()) {
require(
this.toClassDescriptor().isData
|| this.toClassDescriptor().kind == ClassKind.ENUM_CLASS
|| this.isEmptyBuiltin()
) {
"(${position.line},${position.column}) Expected type to be a data class or builtin.Empty, but was ${
this.fqNameOrNull()?.asString()
}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,39 @@ package xyz.block.ftl.schemaextractor
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.rules.KotlinCoreEnvironmentTest
import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.assertContains
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.jetbrains.kotlin.cli.jvm.config.addJvmClasspathRoots
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import xyz.block.ftl.schemaextractor.ExtractSchemaRule.Companion.OUTPUT_FILENAME
import xyz.block.ftl.v1.schema.*
import xyz.block.ftl.v1.schema.Array
import xyz.block.ftl.v1.schema.Data
import xyz.block.ftl.v1.schema.Decl
import xyz.block.ftl.v1.schema.Enum
import xyz.block.ftl.v1.schema.EnumVariant
import xyz.block.ftl.v1.schema.Field
import xyz.block.ftl.v1.schema.IngressPathComponent
import xyz.block.ftl.v1.schema.IngressPathLiteral
import xyz.block.ftl.v1.schema.IntValue
import xyz.block.ftl.v1.schema.Map
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.assertContains
import xyz.block.ftl.v1.schema.Metadata
import xyz.block.ftl.v1.schema.MetadataAlias
import xyz.block.ftl.v1.schema.MetadataCalls
import xyz.block.ftl.v1.schema.MetadataIngress
import xyz.block.ftl.v1.schema.Module
import xyz.block.ftl.v1.schema.Optional
import xyz.block.ftl.v1.schema.Position
import xyz.block.ftl.v1.schema.Ref
import xyz.block.ftl.v1.schema.StringValue
import xyz.block.ftl.v1.schema.Type
import xyz.block.ftl.v1.schema.TypeParameter
import xyz.block.ftl.v1.schema.Value
import xyz.block.ftl.v1.schema.Verb

@KotlinCoreEnvironmentTest
internal class ExtractSchemaRuleTest(private val env: KotlinCoreEnvironment) {
Expand Down Expand Up @@ -371,4 +391,141 @@ fun echo(context: Context, req: EchoRequest): EchoResponse {
val file = File(OUTPUT_FILENAME)
file.delete()
}

@Test
fun `extracts enums`() {
val code = """
package ftl.things
import ftl.time.Color
import xyz.block.ftl.Json
import xyz.block.ftl.Context
import xyz.block.ftl.Method
import xyz.block.ftl.Verb
class InvalidInput(val field: String) : Exception()
enum class Thing {
A,
B,
C,
}
/**
* Comments.
*/
enum class StringThing(val value: String) {
A("A"),
B("B"),
C("C"),
}
enum class IntThing(val value: Int) {
A(1),
B(2),
C(3),
}
data class Request(
val color: Color,
val thing: Thing,
val stringThing: StringThing,
val intThing: IntThing
)
data class Response(val message: String)
@Verb
fun something(context: Context, req: Request): Response {
return Response(message = "response")
}
"""
ExtractSchemaRule(Config.empty).compileAndLintWithContext(env, code)
val file = File(OUTPUT_FILENAME)
val module = Module.ADAPTER.decode(file.inputStream())

val expected = Module(
name = "things",
decls = listOf(
Decl(
data_ = Data(
name = "Request",
fields = listOf(
Field(
name = "color",
type = Type(ref = Ref(name = "Color", module = "time"))
),
Field(
name = "thing",
type = Type(ref = Ref(name = "Thing", module = "things"))
),
Field(
name = "stringThing",
type = Type(ref = Ref(name = "StringThing", module = "things"))
),
Field(
name = "intThing",
type = Type(ref = Ref(name = "IntThing", module = "things"))
),
),
),
),
Decl(
data_ = Data(
name = "Response",
fields = listOf(
Field(
name = "message",
type = Type(string = xyz.block.ftl.v1.schema.String())
)
),
),
),
Decl(
verb = Verb(
name = "something",
request = Type(ref = Ref(name = "Request", module = "things")),
response = Type(ref = Ref(name = "Response", module = "things")),
),
),
Decl(
enum_ = Enum(
name = "Thing",
variants = listOf(
EnumVariant(name = "A", value_ = Value(intValue = IntValue(value_ = 0))),
EnumVariant(name = "B", value_ = Value(intValue = IntValue(value_ = 1))),
EnumVariant(name = "C", value_ = Value(intValue = IntValue(value_ = 2))),
),
),
),
Decl(
enum_ = Enum(
name = "StringThing",
comments = listOf("Comments."),
variants = listOf(
EnumVariant(name = "A", value_ = Value(stringValue = StringValue(value_ = "A"))),
EnumVariant(name = "B", value_ = Value(stringValue = StringValue(value_ = "B"))),
EnumVariant(name = "C", value_ = Value(stringValue = StringValue(value_ = "C"))),
),
),
),
Decl(
enum_ = Enum(
name = "IntThing",
variants = listOf(
EnumVariant(name = "A", value_ = Value(intValue = IntValue(value_ = 1))),
EnumVariant(name = "B", value_ = Value(intValue = IntValue(value_ = 2))),
EnumVariant(name = "C", value_ = Value(intValue = IntValue(value_ = 3))),
),
),
),
)
)

assertThat(module)
.usingRecursiveComparison()
.withEqualsForType({ _, _ -> true }, Position::class.java)
.ignoringFieldsMatchingRegexes(".*hashCode\$")
.isEqualTo(expected)
}
}
Loading

0 comments on commit 456d2fe

Please sign in to comment.