Coverage Summary for Class: BaseSerializeEmitter (com.ghost.serialization.compiler)

Class Class, % Method, % Branch, % Line, % Instruction, %
BaseSerializeEmitter 100% (1/1) 77.3% (17/22) 67.1% (139/207) 71.4% (205/287) 68.7% (1473/2143)


 package com.ghost.serialization.compiler
 
 import com.google.devtools.ksp.symbol.KSType
 import com.squareup.kotlinpoet.ClassName
 import com.squareup.kotlinpoet.CodeBlock
 import com.squareup.kotlinpoet.KModifier
 import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
 import com.squareup.kotlinpoet.PropertySpec
 import com.squareup.kotlinpoet.TypeSpec
 import com.squareup.kotlinpoet.ksp.toTypeName
 import com.ghost.serialization.compiler.GhostEmitterConstants as C
 
 /**
  * Abstract base class for all serialization emitters within the Ghost compiler.
  *
  * It manages shared state such as contextual serializers and provides common code
  * generation utilities for serializing properties, collections (lists, maps), primitive types,
  * value/inline classes, and custom-encoded fields.
  *
  * @property properties The list of [GhostPropertyModel] representing properties to serialize.
  * @property originalClassName The [ClassName] of the DTO class being serialized.
  * @property writerClass The [ClassName] of the JSON writer used (e.g., GhostJsonWriter).
  */
 internal abstract class BaseSerializeEmitter(
     protected val properties: List<GhostPropertyModel>,
     protected val originalClassName: ClassName,
     protected val writerClass: ClassName
 ) {
     protected val contextualSerializers = mutableMapOf<KSType, String>()
 
     /**
      * Monotonically increasing counter to generate unique loop variable names
      * (`sizeN`, `iN`, `keyN`, `valN`) within a single serialization function scope.
      *
      * Using [depth] alone caused collisions when a DTO had multiple list/map fields
      * at the same nesting level (all start at depth=0 → duplicate `size0`, `i0`).
      */
     private var loopCounter = 0
 
     /**
      * Determines whether the property is of a primitive or basic type that can be serialized
      * using optimized fast-path writer methods directly.
      *
      * @param prop The property metadata model.
      * @return True if the property can be written using a direct/fused writer method call.
      */
     protected fun isFusedType(prop: GhostPropertyModel): Boolean {
         if (prop.customEncoder != null) {
             return false
         }
         val type = prop.type.declaration.qualifiedName?.asString()
         // Proto3 JSON mapping requires int64 fields on the wire as quoted decimal strings —
         // route Long through emitValue()'s dedicated proto branch instead of the fused
         // writer.writeField(header, Long) fast path, which always writes a bare number.
         if (prop.isProto && type == C.K_LONG) {
             return false
         }
         return when (type) {
             C.K_INT,
             C.K_LONG,
             C.K_STRING,
             C.K_BOOLEAN,
             C.K_DOUBLE,
             C.K_FLOAT -> {
                 true
             }
             else -> {
                 false
             }
         }
     }
 
     /**
      * proto3 canonical JSON mapping omits scalar fields that hold their type's zero value
      * (`0`, `""`, `false`, an empty collection) — only applies to non-nullable properties of a
      * `@GhostProtoSerialization` class, and only for types with an unambiguous zero value.
      * Nested Ghost message types, enums, RawJson, and contextual types are left unconditional
      * (always written), since a reliable "is this the default instance" check isn't available.
      *
      * @return The `!= zeroValue` (or `isNotEmpty()` / bare truthy) condition to guard the write
      *   with, or `null` if this property is not subject to proto3 default omission.
      */
     private fun buildProtoNonDefaultCondition(prop: GhostPropertyModel, accessor: CodeBlock): CodeBlock? {
         if (!prop.isProto || prop.customEncoder != null) {
             return null
         }
         if (prop.isList || prop.isSet || prop.isMap) {
             return CodeBlock.of(C.TEMPLATE_IS_NOT_EMPTY, accessor)
         }
         val typeName = prop.type.declaration.qualifiedName?.asString()
         return when (typeName) {
             C.K_INT -> CodeBlock.of(C.TEMPLATE_NEQ_ZERO_INT, accessor)
             C.K_LONG -> CodeBlock.of(C.TEMPLATE_NEQ_ZERO_LONG, accessor)
             C.K_DOUBLE -> CodeBlock.of(C.TEMPLATE_NEQ_ZERO_DOUBLE, accessor)
             C.K_FLOAT -> CodeBlock.of(C.TEMPLATE_NEQ_ZERO_FLOAT, accessor)
             C.K_SHORT -> CodeBlock.of(C.TEMPLATE_NEQ_ZERO_SHORT, accessor)
             C.K_BYTE -> CodeBlock.of(C.TEMPLATE_NEQ_ZERO_BYTE, accessor)
             C.K_BOOLEAN -> CodeBlock.of(C.TEMPLATE_ACCESSOR_L, accessor)
             C.K_STRING -> CodeBlock.of(C.TEMPLATE_IS_NOT_EMPTY, accessor)
             C.K_BYTE_ARRAY -> CodeBlock.of(C.TEMPLATE_IS_NOT_EMPTY, accessor)
             else -> null
         }
     }
 
     /**
      * Generates a single property serialization step.
      *
      * It writes the key name, resolves nullable checks, wraps conditional default arguments,
      * and delegates values writing to [emitValue].
      *
      * @param code The target [CodeBlock.Builder].
      * @param prop The property metadata model.
      */
     fun emitProperty(code: CodeBlock.Builder, prop: GhostPropertyModel) {
         if (prop.wrappedSourceKeys != null) {
             emitWrappedKeysProperty(code, prop)
             return
         }
 
         val cleanName = prop.jsonName
             .replace(C.STR_DOT, C.STR_UNDERSCORE)
             .uppercase()
 
         val isStringWriter = writerClass.simpleName == C.STR_GHOST_JSON_STRING_WRITER
         val headerName = if (isStringWriter) {
             C.STR_HS_PREFIX + cleanName
         } else {
             C.STR_H_VAL_PREFIX + cleanName
         }
         val accessor = CodeBlock.of(C.TEMPLATE_ACCESSOR, C.STR_PARAM_VALUE, prop.kotlinName)
 
         if (!prop.isNullable) {
             val nonDefaultCondition = buildProtoNonDefaultCondition(prop, accessor)
             if (nonDefaultCondition != null) {
                 code.beginControlFlow(C.TEMPLATE_IF_L, nonDefaultCondition)
                 emitNonNullProperty(code, prop, headerName, accessor)
                 code.endControlFlow()
                 return
             }
         }
 
         if (prop.isNullable) {
             emitNullableProperty(code, prop, headerName, accessor)
             return
         }
 
         emitNonNullProperty(code, prop, headerName, accessor)
     }
 
     /**
      * Unwraps a [@GhostWrappedKeys][com.ghost.serialization.annotations.GhostWrappedKeys]
      * property by writing each wire field at the current JSON object level.
      */
     private fun emitWrappedKeysProperty(code: CodeBlock.Builder, prop: GhostPropertyModel) {
         val accessorRoot = CodeBlock.of(C.TEMPLATE_ACCESSOR, C.STR_PARAM_VALUE, prop.kotlinName)
         val isStringWriter = writerClass.simpleName == C.STR_GHOST_JSON_STRING_WRITER
         val prefix = if (isStringWriter) {
             C.STR_HS_PREFIX
         } else {
             C.STR_H_VAL_PREFIX
         }
 
         if (prop.isNullable || prop.wrappedOmitIfEmpty) {
             code.beginControlFlow(C.TEMPLATE_IF_NOT_NULL, accessorRoot)
         }
 
         prop.wrappedUnwrapFields.forEach { field ->
             val headerName = prefix + field.jsonName.replace(C.STR_DOT, C.STR_UNDERSCORE).uppercase()
 
             // proto3 oneof: this wire key lives on one sealed subclass of the wrapped type, not
             // on the wrapped (sealed parent) type itself — guard with an `is` smart-cast instead
             // of a plain path accessor.
             if (field.sealedSubclassName != null) {
                 val accessor = buildSealedSubclassFieldAccessor(prop.kotlinName, field.sealedSubclassName, field.kotlinPath)
                 code.beginControlFlow(
                     C.TEMPLATE_IF_L,
                     CodeBlock.of(C.TEMPLATE_IS_INSTANCE, accessorRoot, field.sealedSubclassName)
                 )
                 code.addStatement(C.STR_WRITE_NAME_RAW, headerName)
                 emitTypeValue(code, field.type, accessor, skipNullCheck = true)
                 code.endControlFlow()
                 return@forEach
             }
 
             val accessor = buildWrappedPathAccessor(prop.kotlinName, field.kotlinPath)
             if (field.isNullable) {
                 code.beginControlFlow(C.TEMPLATE_IF_NOT_NULL, accessor)
                 code.addStatement(C.STR_WRITE_NAME_RAW, headerName)
                 emitTypeValue(code, field.type, accessor, skipNullCheck = true)
                 code.endControlFlow()
             } else {
                 code.addStatement(C.STR_WRITE_NAME_RAW, headerName)
                 emitTypeValue(code, field.type, accessor, skipNullCheck = true)
             }
         }
 
         if (prop.isNullable || prop.wrappedOmitIfEmpty) {
             code.endControlFlow()
         }
     }
 
     private fun buildWrappedPathAccessor(wrapperName: String, path: List<String>): CodeBlock {
         var expr = CodeBlock.of(C.TEMPLATE_CHAINED_MEMBER, C.STR_PARAM_VALUE, wrapperName)
         for (segment in path) {
             expr = CodeBlock.of(C.TEMPLATE_CHAINED_MEMBER, expr, segment)
         }
         return expr
     }
 
     private fun buildSealedSubclassFieldAccessor(
         wrapperName: String,
         subclassName: ClassName,
         path: List<String>
     ): CodeBlock {
         val wrapperAccessor = CodeBlock.of(C.TEMPLATE_ACCESSOR, C.STR_PARAM_VALUE, wrapperName)
         var expr = CodeBlock.of(C.TEMPLATE_CAST, wrapperAccessor, subclassName)
         for (segment in path) {
             expr = CodeBlock.of(C.TEMPLATE_CHAINED_MEMBER, expr, segment)
         }
         return expr
     }
 
     /**
      * Helper to serialize a nullable property.
      */
     private fun emitNullableProperty(
         code: CodeBlock.Builder,
         prop: GhostPropertyModel,
         headerName: String,
         accessor: CodeBlock
     ) {
         val canUseFused = isFusedType(prop) && !prop.isContextual
         if (prop.hasDefaultValue) {
             code.beginControlFlow(C.TEMPLATE_IF_NOT_NULL, accessor)
             if (canUseFused) {
                 code.addStatement(C.STR_WRITE_FIELD, headerName, accessor)
             } else {
                 code.addStatement(C.STR_WRITE_NAME_RAW, headerName)
                 emitValue(code, prop, accessor)
             }
             code.endControlFlow()
         } else {
             if (canUseFused) {
                 code.beginControlFlow(C.TEMPLATE_IF_NOT_NULL, accessor)
                 code.addStatement(C.STR_WRITE_FIELD, headerName, accessor)
                 code.nextControlFlow(C.STR_ELSE)
                 code.addStatement(C.STR_WRITE_NAME_RAW_NULL, headerName)
                 code.endControlFlow()
             } else {
                 code.addStatement(C.STR_WRITE_NAME_RAW, headerName)
                 code.beginControlFlow(C.TEMPLATE_IF_NOT_NULL, accessor)
                 emitValue(code, prop, accessor)
                 code.nextControlFlow(C.STR_ELSE)
                 code.addStatement(C.STR_WRITER_NULL_VAL)
                 code.endControlFlow()
             }
         }
     }
 
     /**
      * Helper to serialize a non-nullable property.
      */
     private fun emitNonNullProperty(
         code: CodeBlock.Builder,
         prop: GhostPropertyModel,
         headerName: String,
         accessor: CodeBlock
     ) {
         val canUseFused = isFusedType(prop) && !prop.isContextual
         if (canUseFused) {
             code.addStatement(C.STR_WRITE_FIELD, headerName, accessor)
         } else {
             code.addStatement(C.STR_WRITE_NAME_RAW, headerName)
             emitValue(code, prop, accessor)
         }
     }
 
     /**
      * Outputs the value serialization statement.
      * Supports value classes, sealed classes, custom encoders, contextual serializers, and primitives.
      *
      * @param code The target [CodeBlock.Builder].
      * @param prop The property metadata model.
      * @param accessor The accessor expression string/object (e.g. `value.age`).
      */
     fun emitValue(code: CodeBlock.Builder, prop: GhostPropertyModel, accessor: Any) {
         if (prop.customEncoder != null) {
             if (writerClass.simpleName == C.STR_GHOST_JSON_STRING_WRITER) {
                 code.addStatement("val tempFlatWriter = com.ghost.serialization.writer.GhostJsonFlatWriter(com.ghost.serialization.writer.FlatByteArrayWriter())")
                 code.addStatement(
                     "%T.%L(tempFlatWriter, %L)",
                     prop.customEncoder.provider,
                     prop.customEncoder.functionName,
                     accessor
                 )
                 code.addStatement("writer.buffer.writeString(tempFlatWriter.buffer.toStringUtf8())")
             } else {
                 code.addStatement(
                     C.STR_CUSTOM_ENCODER_CALL,
                     prop.customEncoder.provider,
                     prop.customEncoder.functionName,
                     accessor
                 )
             }
             return
         }
         when {
             prop.isValueClass && prop.valueClassProperty != null -> {
                 val innerAccessor = CodeBlock.of(
                     C.TEMPLATE_ACCESSOR,
                     accessor,
                     prop.valueClassProperty.kotlinName
                 )
                 emitValue(code, prop.valueClassProperty, innerAccessor)
             }
 
             prop.isSealedClass -> {
                 code.addStatement(
                     C.STR_T_SERIALIZE_WRITER_ACC,
                     prop.type.serializerClassName(),
                     accessor
                 )
             }
 
             prop.isPrimitiveArray -> {
                 code.addStatement(
                     C.STR_T_SERIALIZE_WRITER_ACC,
                     ClassName(
                         C.STR_SERIALIZERS_PKG,
                         prop.primitiveArrayType + C.STR_SERIALIZER_SUFFIX
                     ),
                     accessor
                 )
             }
 
             prop.isContextual -> {
                 val name = getContextualSerializerName(prop.type)
                 code.addStatement(C.STR_SERIALIZE_CALL, name, accessor)
             }
 
             prop.isProto && prop.type.declaration.qualifiedName?.asString() == C.K_LONG -> {
                 code.addStatement(C.STR_WRITER_VAL_LONG_AS_STRING, accessor)
             }
 
             prop.isProto && prop.type.declaration.qualifiedName?.asString() == C.K_BYTE_ARRAY -> {
                 code.addStatement(C.STR_WRITER_VAL_BYTES_AS_BASE64, accessor)
             }
 
             else -> {
                 emitTypeValue(code, prop.type, accessor, skipNullCheck = true, isProto = prop.isProto)
             }
         }
     }
 
     /**
      * Resolves the serialization call for raw type objects recursively.
      *
      * @param code The target [CodeBlock.Builder].
      * @param type The [KSType] of the target value.
      * @param accessor Accessor key expression.
      * @param skipNullCheck True if the outer check has already guaranteed non-null value.
      * @param isProto True when the enclosing class is `@GhostProtoSerialization` — propagated
      *   into `List`/`Set`/`Map` element recursion so `Long`/`ByteArray` elements also get
      *   proto3 quoting/Base64 treatment.
      */
     protected fun emitTypeValue(
         code: CodeBlock.Builder,
         type: KSType,
         accessor: Any,
         skipNullCheck: Boolean = false,
         isProto: Boolean = false
     ) {
         val isNullable = type.isMarkedNullable
         if (isNullable && !skipNullCheck) {
             code.beginControlFlow(C.TEMPLATE_IF_NULL, accessor)
             code.addStatement(C.STR_NULL_VAL_CALL)
             code.nextControlFlow(C.STR_ELSE)
         }
 
         if (isValueClassType(type)) {
             val innerType = resolveValueClassInnerType(type)
             if (innerType != null) {
                 val valueClassProperty = (type.declaration as? com.google.devtools.ksp.symbol.KSClassDeclaration)
                     ?.primaryConstructor?.parameters?.firstOrNull()?.name?.asString()
                 if (valueClassProperty != null) {
                     val innerAccessor = CodeBlock.of(C.TEMPLATE_CHAINED_MEMBER, accessor, valueClassProperty)
                     emitTypeValue(code, innerType, innerAccessor, skipNullCheck = true, isProto = isProto)
                     if (isNullable && !skipNullCheck) {
                         code.endControlFlow()
                     }
                     return
                 }
             }
         }
 
         val typeName = type.declaration.qualifiedName?.asString()
         when {
             type.isRawJson() -> {
                 code.addStatement(
                     C.STR_WRITER_RAW_VALUE_SLICE,
                     accessor,
                     accessor,
                     accessor
                 )
             }
 
             type.isGhost() -> {
                 code.addStatement(
                     C.STR_T_SERIALIZE_WRITER_ACC,
                     type.serializerClassName(),
                     accessor
                 )
             }
 
             type.isEnum() -> {
                 code.addStatement(
                     C.STR_T_SERIALIZE_WRITER_ACC,
                     type.serializerClassName(),
                     accessor
                 )
             }
 
             typeName == C.K_INT -> {
                 code.addStatement(C.STR_WRITER_VAL_L, accessor)
             }
             typeName == C.K_LONG -> {
                 if (isProto) {
                     code.addStatement(C.STR_WRITER_VAL_LONG_AS_STRING, accessor)
                 } else {
                     code.addStatement(C.STR_WRITER_VAL_L, accessor)
                 }
             }
             typeName == C.K_STRING -> {
                 code.addStatement(C.STR_WRITER_VAL_L, accessor)
             }
             typeName == C.K_BOOLEAN -> {
                 code.addStatement(C.STR_WRITER_VAL_L, accessor)
             }
             typeName == C.K_DOUBLE -> {
                 code.addStatement(C.STR_WRITER_VAL_L, accessor)
             }
             typeName == C.K_FLOAT -> {
                 code.addStatement(C.STR_WRITER_VAL_FLOAT, accessor)
             }
             typeName == C.K_BYTE -> {
                 code.addStatement("writer.value(%L.toInt())", accessor)
             }
             typeName == C.K_SHORT -> {
                 code.addStatement("writer.value(%L.toInt())", accessor)
             }
             typeName == C.K_CHAR -> {
                 code.addStatement(C.STR_WRITER_VAL_L, accessor)
             }
             typeName == C.K_BYTE_ARRAY -> {
                 if (isProto) {
                     code.addStatement(C.STR_WRITER_VAL_BYTES_AS_BASE64, accessor)
                 } else {
                     code.addStatement(C.STR_WRITER_RAW_VALUE_L, accessor)
                 }
             }
             type.isList() -> {
                 emitList(code, type, accessor, isProto)
             }
             type.isSet() -> {
                 emitSet(code, type, accessor, isProto)
             }
             type.isMap() -> {
                 emitMap(code, type, accessor, isProto)
             }
 
             else -> {
                 val name = getContextualSerializerName(type)
                 code.addStatement(C.STR_SERIALIZE_CALL, name, accessor)
             }
         }
 
         if (isNullable && !skipNullCheck) {
             code.endControlFlow()
         }
     }
 
     /**
      * Emits list collection serialization statements.
      *
      * Variables are named using [loopCounter] (e.g. `size2`, `i2`, `item2`) to prevent
      * name collisions when a DTO contains multiple list fields or nested list structures
      * within the same generated function scope.
      */
     private fun emitList(code: CodeBlock.Builder, type: KSType, accessor: Any, isProto: Boolean = false) {
         val slot = loopCounter++
         val sizeVar = "size$slot"
         val indexVar = "i$slot"
         val itemVar = C.STR_ITEM_PREFIX + slot
         code.addStatement(C.STR_WRITER_BEGIN_ARR)
         code.addStatement("val %L = %L.size", sizeVar, accessor)
         code.beginControlFlow("for (%L in 0 until %L)", indexVar, sizeVar)
         code.addStatement("val %L = %L[%L]", itemVar, accessor, indexVar)
         val innerType = type.arguments.firstOrNull()?.type?.resolve()
 
         if (innerType != null) {
             emitTypeValue(code, innerType, itemVar, skipNullCheck = false, isProto = isProto)
         } else {
             code.addStatement(C.TEMPLATE_WRITER_VALUE, itemVar)
         }
         code.endControlFlow()
         code.addStatement(C.STR_WRITER_END_ARR)
     }
 
     /**
      * Emits set collection serialization — iterates elements without materializing a [List].
      */
     private fun emitSet(code: CodeBlock.Builder, type: KSType, accessor: Any, isProto: Boolean = false) {
         val slot = loopCounter++
         val itemVar = C.STR_ITEM_PREFIX + slot
         code.addStatement(C.STR_WRITER_BEGIN_ARR)
         code.beginControlFlow("for (%L in %L)", itemVar, accessor)
         val innerType = type.arguments.firstOrNull()?.type?.resolve()
 
         if (innerType != null) {
             emitTypeValue(code, innerType, itemVar, skipNullCheck = false, isProto = isProto)
         } else {
             code.addStatement(C.TEMPLATE_WRITER_VALUE, itemVar)
         }
         code.endControlFlow()
         code.addStatement(C.STR_WRITER_END_ARR)
     }
 
     /**
      * Emits map collection serialization statements.
      *
      * Variables are named using [loopCounter] (e.g. `key2`, `val2`) to prevent
      * name collisions when a DTO contains multiple map fields or nested map structures
      * within the same generated function scope.
      */
     private fun emitMap(code: CodeBlock.Builder, type: KSType, accessor: Any, isProto: Boolean = false) {
         val slot = loopCounter++
         val keyVar = C.STR_MAP_KEY_PREFIX + slot
         val valVar = C.STR_MAP_VAL_PREFIX + slot
         code.addStatement(C.STR_WRITER_BEGIN_OBJ)
         code.beginControlFlow(C.TEMPLATE_FOR_MAP, keyVar, valVar, accessor)
         code.addStatement(C.TEMPLATE_WRITER_NAME, keyVar)
         val valueType = type.arguments.getOrNull(1)?.type?.resolve()
         if (valueType != null) {
             emitTypeValue(code, valueType, valVar, skipNullCheck = false, isProto = isProto)
         } else {
             code.addStatement(C.TEMPLATE_WRITER_VALUE, valVar)
         }
         code.endControlFlow()
         code.addStatement(C.STR_WRITER_END_OBJ)
     }
 
     /**
      * Registers and caches the contextual serializer for the target type.
      */
     protected fun getContextualSerializerName(type: KSType): String {
         return contextualSerializers.getOrPut(type) {
             val simpleName = type.declaration.simpleName.asString()
             val nullableSuffix = if (type.isMarkedNullable) "Nullable" else ""
             C.STR_CONTEXTUAL_PREFIX +
                 simpleName.replaceFirstChar { it.lowercase() } +
                 nullableSuffix +
                 C.STR_SERIALIZER_SUFFIX
         }
     }
 
     /**
      * Injects the required private fields for all resolved contextual serializers.
      *
      * @param typeSpecBuilder The KotlinPoet companion [TypeSpec.Builder].
      */
     fun injectContextualSerializers(typeSpecBuilder: TypeSpec.Builder) {
         val ghostClass = ClassName(C.STR_GHOST_PKG, C.STR_GHOST_OBJ)
 
         contextualSerializers.forEach { (type, name) ->
             val nonNullableType = type.makeNotNullable()
             typeSpecBuilder.addProperty(
                 PropertySpec.builder(
                     name,
                     ClassName(C.STR_CONTRACT_PKG, C.STR_GHOST_SERIALIZER)
                         .parameterizedBy(nonNullableType.toTypeName()),
                     KModifier.PRIVATE
                 )
                     .initializer(
                         C.TEMPLATE_RESOLVE_SERIALIZER,
                         ghostClass,
                         nonNullableType.toTypeName()
                     )
                     .build()
             )
         }
     }
 
     private fun isValueClassType(type: KSType): Boolean {
         val declaration = type.declaration as? com.google.devtools.ksp.symbol.KSClassDeclaration ?: return false
         return declaration.modifiers.contains(com.google.devtools.ksp.symbol.Modifier.VALUE) ||
                 declaration.modifiers.contains(com.google.devtools.ksp.symbol.Modifier.INLINE)
     }
 
     private fun resolveValueClassInnerType(type: KSType): KSType? {
         val declaration = type.declaration as? com.google.devtools.ksp.symbol.KSClassDeclaration ?: return null
         val primaryConstructor = declaration.primaryConstructor ?: return null
         val param = primaryConstructor.parameters.firstOrNull() ?: return null
         return param.type.resolve()
     }
 }