Skip to content

Commit ffa61aa

Browse files
dmealingclaude
andcommitted
feat(codegen-kotlin): FR-019 Kotlin — @provided enums
Kotlin already materializes a package-level abstract field.enum ONCE as a standalone `enum class` (KotlinEnumEmitter + KotlinTypeMapper.enumTypeName, deduped by FQN), so the shared-materialize half of FR-019 was already satisfied. This adds the @provided arm, gated by the shared fixtures/codegen-conformance/shared-provided-enum oracle: - New Fr019SharedEnum helper (Kotlin port of the TS/C#/Java reference): resolves the package-level abstract super, reads @provided own-only, and computes the external ClassName <ns>.E for a provided enum — ns resolved from the providedEnumPackages (package->namespace) generator arg then the providedEnumNamespace fallback (ADR-0001). Unresolved package => codegen error naming the enum + package. - KotlinEntityGenerator: skip emitting the enum class for a @provided shared enum and reference it at the configured namespace; inline + non-provided shared enums unchanged (byte-identical default). - No loader work — @provided registration lives in the Java metadata module (metadata-ktx shares it). - New KotlinFr019SharedProvidedConformanceTest (materialize-once / provided-ref / missing-config-error). Existing KotlinEnumConformanceTest unchanged (standalone materialization already its expectation). codegen-kotlin 225/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 62a1b0b commit ffa61aa

3 files changed

Lines changed: 217 additions & 2 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.metaobjects.MetaRoot
4+
import com.metaobjects.field.EnumField
5+
import com.metaobjects.field.MetaField
6+
import com.metaobjects.generator.GeneratorException
7+
import com.metaobjects.generator.util.GeneratorUtil
8+
import com.squareup.kotlinpoet.ClassName
9+
10+
/**
11+
* FR-019 — shared + externally-provided enum resolution (Kotlin port of the TS reference
12+
* `enum-shared.ts`, the C# `Fr019SharedEnum`, and the Java `Fr019SharedEnum`).
13+
*
14+
* Kotlin already materializes a package-level abstract `field.enum` ONCE as a standalone
15+
* `enum class` ([KotlinEnumEmitter] + [KotlinTypeMapper.enumTypeName], deduped by FQN), so the
16+
* shared-materialize half of FR-019 is already satisfied. This object adds the `@provided` arm:
17+
* detecting a `@provided` shared enum (skip materialization) and resolving its reference to a
18+
* per-port-configured namespace (ADR-0001 — never an FQN in metadata). The namespace binds to the
19+
* enum's declaring metadata package via a package→namespace map + a single fallback, both
20+
* generator args.
21+
*/
22+
internal object Fr019SharedEnum {
23+
24+
/** Per-port config for resolving a `@provided` enum's Kotlin namespace. Generator args only. */
25+
data class ProvidedEnumConfig(val fallbackNamespace: String?, val packageNamespaces: Map<String, String>) {
26+
companion object {
27+
fun of(fallback: String?, packageMapArg: String?): ProvidedEnumConfig =
28+
ProvidedEnumConfig(fallback?.trim()?.takeIf { it.isNotEmpty() }, parsePackageMap(packageMapArg))
29+
}
30+
}
31+
32+
/**
33+
* Parse the `providedEnumPackages` generator arg — a `;`-delimited list of
34+
* `<metaPackage>=<namespace>` entries (metadata package in `::` form), e.g.
35+
* `"acme::shop=com.acme.ext;acme::billing=com.acme.money"`. Empty map for null/blank.
36+
*/
37+
fun parsePackageMap(arg: String?): Map<String, String> {
38+
if (arg.isNullOrBlank()) return emptyMap()
39+
val map = LinkedHashMap<String, String>()
40+
for (entry in arg.split(";")) {
41+
val eq = entry.indexOf('=')
42+
if (eq <= 0) continue
43+
val key = entry.substring(0, eq).trim()
44+
val value = entry.substring(eq + 1).trim()
45+
if (key.isNotEmpty() && value.isNotEmpty()) map[key] = value
46+
}
47+
return map
48+
}
49+
50+
/**
51+
* The package-level abstract `field.enum` a concrete enum field resolves to via `extends`, or
52+
* `null` for an inline enum. Qualifies only when the immediate super is (a) an [EnumField],
53+
* (b) abstract, AND (c) a direct child of the metadata root ([MetaRoot]) — a sibling of
54+
* `object.entity`, not an abstract field nested inside an object.
55+
*/
56+
fun resolveSharedEnumDecl(field: MetaField<*>): EnumField? {
57+
if (field !is EnumField) return null
58+
val sup = field.superField as? EnumField ?: return null
59+
if (!GeneratorUtil.isAbstract(sup)) return null
60+
if (sup.parent !is MetaRoot) return null
61+
return sup
62+
}
63+
64+
/** Own-only read of `@provided` on an enum declaration. */
65+
fun isProvided(decl: EnumField): Boolean {
66+
if (!decl.hasMetaAttr(EnumField.ATTR_PROVIDED, false)) return false
67+
val raw = runCatching { decl.getMetaAttr(EnumField.ATTR_PROVIDED, false).value }.getOrNull()
68+
return when (raw) {
69+
is Boolean -> raw
70+
else -> raw?.toString()?.toBoolean() ?: false
71+
}
72+
}
73+
74+
/** True iff [field] resolves to a `@provided` shared enum (its type is referenced, not emitted). */
75+
fun isProvidedEnumField(field: MetaField<*>): Boolean {
76+
val decl = resolveSharedEnumDecl(field) ?: return false
77+
return isProvided(decl)
78+
}
79+
80+
/**
81+
* The [ClassName] a consuming property emits for a `@provided` shared enum — `<ns>.<Name>`, with
82+
* `ns` resolved from the package map (keyed by the enum's declaring metadata package) then the
83+
* single fallback. Throws a codegen-time [GeneratorException] naming the enum + package when
84+
* unresolved. Returns `null` when [field] is NOT a `@provided` shared enum, so the caller falls
85+
* back to the normal materialized [KotlinTypeMapper.enumTypeName].
86+
*/
87+
fun providedClassName(field: MetaField<*>, config: ProvidedEnumConfig): ClassName? {
88+
val decl = resolveSharedEnumDecl(field) ?: return null
89+
if (!isProvided(decl)) return null
90+
val (_, shortRaw) = PackageMapping.splitFqn(decl.name)
91+
val simple = shortRaw.replaceFirstChar { it.uppercase() }
92+
val metaPackage = metaPackageOf(decl.name)
93+
val ns = config.packageNamespaces[metaPackage]?.takeIf { it.isNotBlank() } ?: config.fallbackNamespace
94+
if (ns.isNullOrBlank()) {
95+
throw GeneratorException(
96+
"provided enum \"$simple\" (declared in package \"$metaPackage\") is marked @provided " +
97+
"but no Kotlin namespace is configured to reference it from. Map its package via the " +
98+
"providedEnumPackages generator arg (\"$metaPackage=your.app.enums\") or set the single " +
99+
"providedEnumNamespace fallback, so the generated code can reference \"$simple\"."
100+
)
101+
}
102+
return ClassName(ns, simple)
103+
}
104+
105+
/** The declaration's metadata package in `::` form, stripped of the trailing `::Name`. */
106+
private fun metaPackageOf(fqn: String): String {
107+
val idx = fqn.lastIndexOf("::")
108+
return if (idx < 0) "" else fqn.substring(0, idx)
109+
}
110+
}

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,20 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
5353

5454
override fun getFilterClass(): Class<MetaObject> = MetaObject::class.java
5555

56+
/**
57+
* FR-019 per-port config for resolving `@provided` enum namespaces, parsed from the
58+
* `providedEnumNamespace` (single fallback) + `providedEnumPackages` (package→namespace map)
59+
* generator args. Empty by default; a referenced provided enum with no resolvable namespace is
60+
* a codegen-time error.
61+
*/
62+
private var fr019Config = Fr019SharedEnum.ProvidedEnumConfig(null, emptyMap())
63+
5664
/** Real work happens here — sidesteps the parent's print-style writer machinery. */
5765
override fun execute(loader: MetaDataLoader) {
5866
parseArgs()
5967
val outRoot = Paths.get(outDir.absolutePath)
68+
fr019Config = Fr019SharedEnum.ProvidedEnumConfig.of(
69+
getArg("providedEnumNamespace"), getArg("providedEnumPackages"))
6070
// emitAbstractShapes (default OFF): when ON, an abstract entity is emitted as a Kotlin
6171
// `interface` shape (read-only properties) instead of being suppressed. It is NEVER
6272
// emitted as an instantiable @Serializable data class — abstracts are scaffolding.
@@ -83,7 +93,9 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
8393
// Emit one Kotlin enum class file per `field.enum` child BEFORE the data class
8494
// so the resolved property type (a ClassName) points at a real file. Deduped per run.
8595
for (field in obj.metaFields) {
86-
if (field is EnumField) KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns)
96+
// FR-019: a @provided shared enum is referenced externally, never materialized — skip it.
97+
if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field))
98+
KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns)
8799
}
88100

89101
val (pkg, shortName) = PackageMapping.splitFqn(obj.name)
@@ -146,7 +158,9 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
146158
*/
147159
protected open fun emitAbstractShape(obj: MetaObject, outRoot: Path, loader: MetaDataLoader, emittedEnumFqns: MutableSet<String>) {
148160
for (field in obj.metaFields) {
149-
if (field is EnumField) KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns)
161+
// FR-019: a @provided shared enum is referenced externally, never materialized — skip it.
162+
if (field is EnumField && !Fr019SharedEnum.isProvidedEnumField(field))
163+
KotlinEnumEmitter.emitEnumFile(obj, field, outRoot, emittedEnumFqns)
150164
}
151165

152166
val (pkg, shortName) = PackageMapping.splitFqn(obj.name)
@@ -189,6 +203,9 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
189203

190204
/** The Kotlin TypeName for a single (non-array) element of [field]. */
191205
protected open fun resolveElementType(field: MetaField<*>, owner: MetaObject, loader: MetaDataLoader): TypeName {
206+
// FR-019: a @provided shared enum is referenced at its configured external namespace
207+
// (<ns>.E) instead of the materialized in-package class; an unresolved namespace throws.
208+
Fr019SharedEnum.providedClassName(field, fr019Config)?.let { return it }
192209
// field.enum → typed enum class generated alongside this entity.
193210
KotlinTypeMapper.enumTypeName(field, owner)?.let { return it }
194211
if (field is ObjectField) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.metaobjects.metadata.ktx.loadString
4+
import com.metaobjects.loader.MetaDataLoader
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.api.assertThrows
7+
import java.nio.file.Files
8+
import java.nio.file.Path
9+
import kotlin.io.path.readText
10+
import kotlin.test.assertFalse
11+
import kotlin.test.assertTrue
12+
13+
/**
14+
* Cross-port FR-019 conformance (Kotlin). Loads the shared fixture
15+
* `fixtures/codegen-conformance/shared-provided-enum/input/meta.json` and asserts the two FR-019
16+
* behaviors (ADR-0026):
17+
* - Shared materialization — a package-level abstract `field.enum` (`Priority`) extended by TWO
18+
* entities is materialized ONCE as a standalone `enum class Priority`; both data classes type
19+
* their property as it (no per-entity redeclaration).
20+
* - `@provided` — a package-level abstract `field.enum` (`Currency`, `@provided: true`) is NOT
21+
* materialized; consuming data classes reference it at the configured namespace
22+
* (`<ns>.Currency`). A referenced provided enum with no configured namespace is a codegen-time
23+
* error naming the enum.
24+
*/
25+
class KotlinFr019SharedProvidedConformanceTest {
26+
27+
private fun loadFixture(): MetaDataLoader {
28+
val fixture = Path.of(System.getProperty("user.dir"))
29+
.resolve("../../..").normalize()
30+
.resolve("fixtures/codegen-conformance/shared-provided-enum/input/meta.json")
31+
assertTrue(Files.exists(fixture), "shared FR-019 fixture missing at $fixture")
32+
return loadString("sharedProvidedEnumFixture", Files.readString(fixture))
33+
}
34+
35+
private fun generate(providedEnumNamespace: String?): Path {
36+
val out = Files.createTempDirectory("kgen-fr019-conf-")
37+
val args = mutableMapOf("outputDir" to out.toString())
38+
if (providedEnumNamespace != null) args["providedEnumNamespace"] = providedEnumNamespace
39+
KotlinEntityGenerator().apply { setArgs(args) }.execute(loadFixture())
40+
return out
41+
}
42+
43+
@Test
44+
fun `shared enum is materialized once and referenced by both entities`() {
45+
val out = generate("com.acme.ext")
46+
try {
47+
val priorityEnum = out.resolve("acme/shop/Priority.kt")
48+
assertTrue(Files.exists(priorityEnum), "shared Priority.kt enum class must be materialized")
49+
val enumSrc = priorityEnum.readText()
50+
assertTrue("enum class Priority" in enumSrc, "expected shared `enum class Priority`; saw:\n$enumSrc")
51+
for (m in listOf("LOW", "MEDIUM", "HIGH"))
52+
assertTrue(m in enumSrc, "member $m must be present; saw:\n$enumSrc")
53+
54+
for (name in listOf("Ticket.kt", "Order.kt")) {
55+
val dc = out.resolve("acme/shop").resolve(name)
56+
assertTrue(Files.exists(dc), "$name must be emitted")
57+
val src = dc.readText()
58+
assertTrue("priority" in src && "Priority" in src,
59+
"$name must type its priority property as the shared enum; saw:\n$src")
60+
// Same-package reference: no redeclaration and no cross-package import.
61+
assertFalse("enum class Priority" in src, "$name must NOT redeclare the shared enum; saw:\n$src")
62+
}
63+
} finally {
64+
out.toFile().deleteRecursively()
65+
}
66+
}
67+
68+
@Test
69+
fun `provided enum is not materialized and referenced at the configured namespace`() {
70+
val out = generate("com.acme.ext")
71+
try {
72+
assertFalse(Files.exists(out.resolve("acme/shop/Currency.kt")),
73+
"provided Currency must NOT be materialized")
74+
val ticket = out.resolve("acme/shop/Ticket.kt").readText()
75+
assertTrue("com.acme.ext.Currency" in ticket || "import com.acme.ext.Currency" in ticket,
76+
"currency property must reference the configured external namespace; saw:\n$ticket")
77+
} finally {
78+
out.toFile().deleteRecursively()
79+
}
80+
}
81+
82+
@Test
83+
fun `provided enum with no namespace config is a codegen error naming the enum`() {
84+
val ex = assertThrows<RuntimeException> { generate(null) }
85+
assertTrue(ex.message?.contains("Currency") == true,
86+
"error must name the provided enum Currency; was: ${ex.message}")
87+
}
88+
}

0 commit comments

Comments
 (0)