diff --git a/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtRenderer.kt b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtRenderer.kt index cedcf19c..d08a8222 100644 --- a/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtRenderer.kt +++ b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtRenderer.kt @@ -88,9 +88,16 @@ private fun renderType(type: KtTypeSpec, sb: StringBuilder, indent: Int) { if (KtModifier.CONST in prop.modifiers) add("const") } if (mods.isNotEmpty()) sb.append(mods.joinToString(" ")).append(" ") - sb.append("val ").append(prop.name) + sb.append(if (prop.mutable) "var " else "val ").append(prop.name) prop.type?.let { sb.append(": ").append(it.simpleName) } - sb.append(" = ").append(prop.initializer).append("\n") + prop.initializer?.let { sb.append(" = ").append(it) } + sb.append("\n") + + listOfNotNull(prop.getter, prop.setter).forEach { accessor -> + accessor.lines().forEach { line -> + sb.append(bodyPad).append("\t").append(line).append("\n") + } + } } if (type.properties.isNotEmpty()) sb.append("\n") diff --git a/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtType.kt b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtType.kt index 52ab0ef4..aa7448b1 100644 --- a/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtType.kt +++ b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/codegen/KtType.kt @@ -18,9 +18,14 @@ data class KtPropertySpec( val name: String, val type: KtRef? = null, val modifiers: Set = emptySet(), + val mutable: Boolean = false, /** Fully-rendered Kotlin initializer expression, e.g. `"\"foo\""` or `"NAMESPACE"`. */ - val initializer: String, - /** Extra symbols referenced by [initializer] that must be imported. */ + val initializer: String? = null, + /** Fully-rendered getter, e.g. `"get() = NAMESPACE"`. */ + val getter: String? = null, + /** Fully-rendered setter, e.g. `"set(value) = error(\"...\")"`. */ + val setter: String? = null, + /** Extra symbols referenced by [initializer], [getter], or [setter] that must be imported. */ val referencedTypes: List = emptyList(), ) diff --git a/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/enumGenerators.kt b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/enumGenerators.kt index 0ad29af1..9dbc607d 100644 --- a/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/enumGenerators.kt +++ b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/enumGenerators.kt @@ -45,15 +45,54 @@ private fun createSerializableAnnotation() = KtAnnotationSpec( /** * Helper function to create a namespace property. */ -private fun createNamespaceProperty(namespace: String, isConstant: Boolean = false): KtPropertySpec { +private fun createNamespaceProperty( + namespace: String, + isConstant: Boolean = false, + useGetter: Boolean = false, +): KtPropertySpec { val modifiers = buildSet { if (isConstant) add(KtModifier.CONST) add(KtModifier.OVERRIDE) } val initializer = if (isConstant) kotlinStringLiteral(namespace) else "NAMESPACE" - return KtPropertySpec(name = "namespace", type = stringRef, modifiers = modifiers, initializer = initializer) + return KtPropertySpec( + name = "namespace", + type = stringRef, + modifiers = modifiers, + initializer = initializer.takeUnless { useGetter }, + getter = "get() = $initializer".takeIf { useGetter }, + ) } +private fun createFunctionNameProperty() = KtPropertySpec( + name = "name", + type = stringRef, + modifiers = setOf(KtModifier.OVERRIDE), + getter = "get() = asId().substringAfter(\":\").substringAfterLast(\"/\")", +) + +private fun createFunctionDirectoryProperty() = KtPropertySpec( + name = "directory", + type = stringRef, + modifiers = setOf(KtModifier.OVERRIDE), + mutable = true, + getter = "get() = asId().substringAfter(\":\").substringBeforeLast(\"/\", \"\")", + setter = $$"set(value) = error(\"Generated function bindings are immutable; cannot set directory to '$value'\")", +) + +private fun createMappedAsIdFunction(entries: List>) = KtFunSpec( + name = "asId", + modifiers = setOf(KtModifier.OVERRIDE), + returnType = stringRef, + statements = buildList { + add("return when (this) {") + entries.forEach { (enumName, path) -> + add($$"\t$$enumName -> \"$NAMESPACE:$$path\"") + } + add("}") + }, +) + /** * Helper function to create an asId() function for simple resources. */ @@ -67,17 +106,25 @@ private fun createAsIdFunction(pathPrefix: String = "", tagPrefix: String = "") /** * Generates a simple enum for functions when there are no nested directories. */ -fun generateSimpleFunctionsEnum(functions: List, namespace: String) = KtTypeSpec( - kind = KtTypeKind.ENUM, - name = "Functions", - annotations = listOf(createSerializableAnnotation()), - superinterfaces = listOf(functionArgumentRef), - properties = listOf(createNamespaceProperty(namespace)), - functions = listOf(createAsIdFunction()), - enumConstants = functions.map { function -> - function.id.substringAfter(":").replace(Regex("[^a-zA-Z0-9_]"), "_").snakeCase().uppercase() - }, -) +fun generateSimpleFunctionsEnum(functions: List, namespace: String): KtTypeSpec { + val nameAllocator = KotlinNameAllocator() + val entriesById = functions.sortedBy(Function::id).associate { function -> + function.id to nameAllocator.allocate(function.id.substringAfter(":").kotlinEnumName(), "_FUNCTION") + } + val entries = functions.map { function -> + entriesById.getValue(function.id) to function.id.substringAfter(":") + } + + return KtTypeSpec( + kind = KtTypeKind.ENUM, + name = "Functions", + annotations = listOf(createSerializableAnnotation()), + superinterfaces = listOf(functionArgumentRef), + properties = listOf(createNamespaceProperty(namespace), createFunctionDirectoryProperty()), + functions = listOf(createMappedAsIdFunction(entries)), + enumConstants = entries.map { it.first }, + ) +} /** * Generates a simple enum for resources when there are no nested directories. @@ -105,37 +152,65 @@ fun generateFunctionsEnumTree( namespace: String, ): KtTypeSpec { val selfRef = KtRef(packageName, "$datapackObjectName.Functions") - val sealedInterface = MutableTypeNode( kind = KtTypeKind.INTERFACE, name = "Functions", modifiers = setOf(KtModifier.SEALED), superinterfaces = listOf(functionArgumentRef), ) - sealedInterface.properties += KtPropertySpec( - name = "namespace", - type = stringRef, - modifiers = setOf(KtModifier.OVERRIDE), - initializer = "NAMESPACE", - ) - - // Group functions by depth and parent val functionPaths = functions.map { it.id.substringAfter(":") } - val maxDepth = functionPaths.maxOfOrNull { it.count { c -> c.toString() == separator } } ?: 0 - val typeBuilders = MutableList(maxDepth + 1) { mutableMapOf() } + sealedInterface.properties += createNamespaceProperty(namespace, useGetter = true) + sealedInterface.properties += createFunctionNameProperty() + sealedInterface.properties += createFunctionDirectoryProperty() + + // Allocate directory names before function names so a directory keeps the concise name when + // `foo.mcfunction` and `foo/...` coexist. The function then becomes `FooFunction`. + val directories = buildSet { + functionPaths.forEach { path -> + val segments = path.split(separator) + for (index in 1 until segments.size) add(segments.take(index).joinToString(separator)) + } + } + val scopeAllocators = mutableMapOf() + fun scopeAllocator(path: String) = scopeAllocators.getOrPut(path) { KotlinNameAllocator() } + fun parentPath(path: String) = path.substringBeforeLast(separator, "") + + val directoryNames = mutableMapOf() + directories.sortedWith(compareBy({ it.count { char -> char.toString() == separator } }, { it })).forEach { path -> + val preferredName = path.substringAfterLast(separator).kotlinTypeName() + directoryNames[path] = scopeAllocator(parentPath(path)).allocate(preferredName, "Group") + } - // Build enums from deepest to shallowest - for (function in functions) { + val functionNames = mutableMapOf() + functions.sortedBy(Function::id).forEach { function -> val path = function.id.substringAfter(":") - val depth = path.count { it.toString() == separator } - val enumValue = path.substringAfterLast(separator).replace(Regex("[^a-zA-Z0-9_]"), "_").snakeCase().uppercase() + val parent = parentPath(path) + val isTopLevel = parent.isEmpty() + val preferredName = if (isTopLevel) { + path.kotlinTypeName() + } else { + path.substringAfterLast(separator).kotlinEnumName() + } + val collisionSuffix = if (isTopLevel) "Function" else "_FUNCTION" + functionNames[function.id] = scopeAllocator(parent).allocate(preferredName, collisionSuffix) + } - if (depth == 0) { - // Top-level function - create data object - val objectName = path.pascalCase() - sealedInterface.nestedTypes += KtTypeSpec( + data class FunctionEnumGroup( + val node: MutableTypeNode, + val entries: MutableList> = mutableListOf(), + ) + + val rootFunctions = mutableListOf() + val functionGroups = mutableMapOf() + functions.forEach { function -> + val path = function.id.substringAfter(":") + val parent = parentPath(path) + val kotlinName = functionNames.getValue(function.id) + + if (parent.isEmpty()) { + rootFunctions += KtTypeSpec( kind = KtTypeKind.OBJECT, - name = objectName, + name = kotlinName, modifiers = setOf(KtModifier.DATA), superinterfaces = listOf(selfRef), functions = listOf( @@ -143,63 +218,46 @@ fun generateFunctionsEnumTree( name = "asId", modifiers = setOf(KtModifier.OVERRIDE), returnType = stringRef, - statements = listOf("return \"\$NAMESPACE:${path.lowercase()}\""), + statements = listOf("return \"\$NAMESPACE:$path\""), ) ), ) } else { - // Get or create enum for this parent - val parent = path.substringBeforeLast(separator) - val enumName = parent.substringAfterLast(separator).pascalCase() - - val enumBuilder = typeBuilders[depth - 1].getOrPut(parent) { - val node = MutableTypeNode( - kind = KtTypeKind.ENUM, - name = enumName, - annotations = listOf(createSerializableAnnotation()), - superinterfaces = listOf(selfRef), - ) - node.functions += KtFunSpec( - name = "asId", - modifiers = setOf(KtModifier.OVERRIDE), - returnType = stringRef, - statements = listOf("return \"\$NAMESPACE:$parent/\${name.lowercase()}\""), + val group = functionGroups.getOrPut(parent) { + FunctionEnumGroup( + MutableTypeNode( + kind = KtTypeKind.ENUM, + name = directoryNames.getValue(parent), + annotations = listOf(createSerializableAnnotation()), + superinterfaces = listOf(selfRef), + ) ) - node } - - enumBuilder.enumConstants += enumValue + group.node.enumConstants += kotlinName + group.entries += kotlinName to path } } - - // Nest enums from deepest to shallowest - for (depth in typeBuilders.lastIndex downTo 1) { - for ((path, typeBuilder) in typeBuilders[depth]) { - val parent = path.substringBeforeLast(separator) - val objectName = parent.substringAfterLast(separator).pascalCase() - - val parentBuilder = typeBuilders[depth - 1].getOrPut(parent) { - val node = MutableTypeNode( - kind = KtTypeKind.OBJECT, - name = objectName, - modifiers = setOf(KtModifier.DATA), - superinterfaces = listOf(selfRef), - ) - node.functions += KtFunSpec( - name = "asId", - modifiers = setOf(KtModifier.OVERRIDE), - returnType = stringRef, - statements = listOf("return \"\$NAMESPACE:${parent.lowercase()}\""), - ) - node - } - - parentBuilder.nestedTypes += typeBuilder.build() - } + functionGroups.values.forEach { group -> group.node.functions += createMappedAsIdFunction(group.entries) } + + // A directory with direct functions is an enum; a directory used only for nesting is a plain + // container object and must not pretend to be a callable function itself. + val directoryNodes = directories.associateWithTo(mutableMapOf()) { path -> + functionGroups[path]?.node ?: MutableTypeNode( + kind = KtTypeKind.OBJECT, + name = directoryNames.getValue(path), + modifiers = setOf(KtModifier.DATA), + ) } + directories.sortedWith(compareByDescending { it.count { char -> char.toString() == separator } }.thenBy { it }) + .forEach { path -> + val parent = parentPath(path) + if (parent.isNotEmpty()) directoryNodes.getValue(parent).nestedTypes += directoryNodes.getValue(path).build() + } - // Add top-level enums/objects to sealed interface - typeBuilders.firstOrNull()?.forEach { (_, builder) -> sealedInterface.nestedTypes += builder.build() } + directories.filter { parentPath(it).isEmpty() }.sorted().forEach { path -> + sealedInterface.nestedTypes += directoryNodes.getValue(path).build() + } + sealedInterface.nestedTypes += rootFunctions return sealedInterface.build() } diff --git a/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/identifiers.kt b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/identifiers.kt new file mode 100644 index 00000000..deac2f47 --- /dev/null +++ b/bindings/src/commonMain/kotlin/io/github/ayfri/kore/bindings/generation/identifiers.kt @@ -0,0 +1,40 @@ +package io.github.ayfri.kore.bindings.generation + + +/** Converts a Minecraft path segment into a valid, conventional Kotlin type name. */ +internal fun String.kotlinTypeName(): String { + val words = replace(Regex("[^a-zA-Z0-9]+"), "_") + .split("_") + .filter(String::isNotEmpty) + val baseName = words.joinToString("") { word -> + word.lowercase().replaceFirstChar { it.titlecase() } + }.ifEmpty { "Unnamed" } + + return if (baseName.first().isDigit()) "N$baseName" else baseName +} + +/** Converts a Minecraft path segment into a valid Kotlin enum-entry name. */ +internal fun String.kotlinEnumName(): String { + val baseName = replace(Regex("[^a-zA-Z0-9_]"), "_") + .snakeCase() + .uppercase() + .ifEmpty { "UNNAMED" } + + return if (baseName.first().isDigit()) "_$baseName" else baseName +} + +/** Allocates unique Kotlin identifiers within one declaration scope. */ +internal class KotlinNameAllocator { + private val allocatedNames = mutableSetOf() + + fun allocate(preferredName: String, collisionSuffix: String): String { + if (allocatedNames.add(preferredName)) return preferredName + + val suffixedName = preferredName + collisionSuffix + if (allocatedNames.add(suffixedName)) return suffixedName + + var index = 2 + while (!allocatedNames.add(suffixedName + index)) index++ + return suffixedName + index + } +} diff --git a/bindings/src/commonTest/kotlin/io/github/ayfri/kore/bindings/CommonExploreAndRenderTests.kt b/bindings/src/commonTest/kotlin/io/github/ayfri/kore/bindings/CommonExploreAndRenderTests.kt index 29b63145..52ab5bf1 100644 --- a/bindings/src/commonTest/kotlin/io/github/ayfri/kore/bindings/CommonExploreAndRenderTests.kt +++ b/bindings/src/commonTest/kotlin/io/github/ayfri/kore/bindings/CommonExploreAndRenderTests.kt @@ -7,6 +7,7 @@ import kotlinx.io.files.Path fun commonExploreAndRenderTests() { testExploreAndRenderInMemoryDatapack() + testFunctionBindingsUseValidKotlinIdentifiersAndImplementTheirContract() } fun testExploreAndRenderInMemoryDatapack() { @@ -37,6 +38,66 @@ fun testExploreAndRenderInMemoryDatapack() { source.contains("enum class Enchantments") assertsIs true } +fun testFunctionBindingsUseValidKotlinIdentifiersAndImplementTheirContract() { + val datapack = InMemoryDatapack( + mapOf( + "pack.mcmeta" to """{"pack":{"description":"Test pack","min_format":95,"max_format":95}}""", + "data/mypack/function/_3div.mcfunction" to "", + "data/mypack/function/_get_double.mcfunction" to "", + "data/mypack/function/get_double/-3_0.mcfunction" to "", + "data/mypack/function/get_double/1_2.mcfunction" to "", + "data/mypack/function/test.mcfunction" to "", + "data/mypack/function/test/nested.mcfunction" to "", + "data/mypack/function/only/deep/value.mcfunction" to "", + ) + ) + + val explored = explore(datapack, "function_edge_cases", Path("function_edge_cases")) + val (_, source) = renderDatapackFile(explored, remappings = RemappingState()) + + // FunctionArgument's properties need interface accessors because interfaces cannot hold state. + source.contains("override val namespace: String = NAMESPACE") assertsIs false + source.contains("override val namespace: String\n\t\t\tget() = NAMESPACE") assertsIs true + source.contains("override val name: String\n\t\t\tget() = asId()") assertsIs true + source.contains("override var directory: String") assertsIs true + source.contains("set(value) = error(") assertsIs true + + // Leading digits, punctuation, and directory/function collisions must remain valid declarations. + source.contains("data object N3div : FunctionEdgeCases.Functions") assertsIs true + source.contains("data object GetDoubleFunction : FunctionEdgeCases.Functions") assertsIs true + source.contains("data object TestFunction : FunctionEdgeCases.Functions") assertsIs true + source.contains("data object 3div") assertsIs false + source.contains("_1_2,") assertsIs true + source.contains("_3_0,") assertsIs true + + // asId() must use the original Minecraft path, not reconstruct it from a sanitized identifier. + source.contains("\"\$NAMESPACE:get_double/-3_0\"") assertsIs true + source.contains("\"\$NAMESPACE:get_double/1_2\"") assertsIs true + + // Directories used only to contain deeper groups are not phantom FunctionArgument instances. + source.contains("data object Only : FunctionEdgeCases.Functions") assertsIs false + source.contains("data object Only {") assertsIs true + + val simpleDatapack = InMemoryDatapack( + mapOf( + "pack.mcmeta" to """{"pack":{"description":"Test pack","min_format":95,"max_format":95}}""", + "data/mypack/function/-3_0.mcfunction" to "", + "data/mypack/function/1_2.mcfunction" to "", + "data/mypack/function/foo-bar.mcfunction" to "", + "data/mypack/function/foo.bar.mcfunction" to "", + ) + ) + val simpleExplored = explore(simpleDatapack, "simple_function_edge_cases", Path("simple_function_edge_cases")) + val (_, simpleSource) = renderDatapackFile(simpleExplored, remappings = RemappingState()) + + simpleSource.contains("enum class Functions : FunctionArgument") assertsIs true + simpleSource.contains("FOO_BAR,") assertsIs true + simpleSource.contains("FOO_BAR_FUNCTION,") assertsIs true + simpleSource.contains("_3_0 -> \"\$NAMESPACE:-3_0\"") assertsIs true + simpleSource.contains("_1_2 -> \"\$NAMESPACE:1_2\"") assertsIs true + simpleSource.contains("override var directory: String") assertsIs true +} + class CommonExploreAndRenderTests : FunSpec({ test("explore and render") { commonExploreAndRenderTests() diff --git a/bindings/src/jvmTest/kotlin/io/github/ayfri/kore/bindings/ImportingTests.kt b/bindings/src/jvmTest/kotlin/io/github/ayfri/kore/bindings/ImportingTests.kt index c36bfe63..9ab743c1 100644 --- a/bindings/src/jvmTest/kotlin/io/github/ayfri/kore/bindings/ImportingTests.kt +++ b/bindings/src/jvmTest/kotlin/io/github/ayfri/kore/bindings/ImportingTests.kt @@ -140,7 +140,10 @@ fun testCodeGeneration() = newTest("codegen") { content.contains(": FunctionArgument") assertsIs true content.contains("override fun asId()") assertsIs true - content.contains("override val namespace: String = NAMESPACE") assertsIs true + content.contains("override val namespace: String = NAMESPACE") assertsIs false + content.contains("override val namespace: String") assertsIs true + content.contains("get() = NAMESPACE") assertsIs true + content.contains("override var directory: String") assertsIs true } fun testZipImport() = newTest("zip") {