Coverage Summary for Class: GhostAnalyzer (com.ghost.serialization.compiler)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| GhostAnalyzer |
90.7%
(39/43)
|
58%
(271/467)
|
84.2%
(357/424)
|
80.7%
(2198/2722)
|
| GhostAnalyzer$Companion |
|
| GhostAnalyzer$WrappedKeysConfig |
100%
(1/1)
|
|
100%
(4/4)
|
100%
(17/17)
|
| Total |
90.9%
(40/44)
|
58%
(271/467)
|
84.3%
(361/428)
|
80.9%
(2215/2739)
|
@file:OptIn(com.google.devtools.ksp.KspExperimental::class)
package com.ghost.serialization.compiler
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.symbol.ClassKind
import com.google.devtools.ksp.getAnnotationsByType
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSDeclaration
import com.google.devtools.ksp.symbol.KSPropertyDeclaration
import com.google.devtools.ksp.symbol.KSValueParameter
import com.google.devtools.ksp.symbol.KSType
import com.google.devtools.ksp.symbol.Modifier
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.toTypeName
import com.ghost.serialization.compiler.GhostEmitterConstants as C
import com.ghost.serialization.annotations.GhostWrappedKeys
/**
* Analyzes Kotlin classes during KSP processing to generate serialization metadata.
*
* This analyzer is responsible for inspecting a [KSClassDeclaration], validating it against
* the framework's serialization rules, and converting its valid properties into a list of
* [GhostPropertyModel] instances for code generation.
*
* ### Validations:
* - **Supported Types:** The target must be a `data class`, `sealed class`, `value class`, or `enum class`.
* - **Visibility:** Properties cannot be `private`.
* - **Maps:** If a property is a `Map`, its key must resolve to a `String`.
* - **Naming:** Duplicate JSON keys within the same class are not allowed.
*
* @property logger The [KSPLogger] used to report compilation errors for invalid declarations.
*/
internal class GhostAnalyzer(private val logger: KSPLogger) {
/**
* Analyzes the given class declaration and resolves its properties to a list of models.
*
* @param classDeclaration The class metadata declaration to analyze.
* @return List of parsed [GhostPropertyModel] configurations.
*/
fun analyze(classDeclaration: KSClassDeclaration): List<GhostPropertyModel> {
val isSealed = classDeclaration.modifiers.contains(Modifier.SEALED)
val isData = classDeclaration.modifiers.contains(Modifier.DATA)
val isValue = classDeclaration.modifiers.contains(Modifier.VALUE) ||
classDeclaration.modifiers.contains(Modifier.INLINE)
val isObject = classDeclaration.classKind == ClassKind.OBJECT
val isEnum = classDeclaration.classKind == ClassKind.ENUM_CLASS
if (isObject) return emptyList()
validateClassKind(classDeclaration, isData, isSealed, isValue, isEnum)
val parameters = classDeclaration.primaryConstructor?.parameters ?: emptyList()
val allProps = classDeclaration.getAllProperties().toList()
val overriddenProps = allProps.mapNotNull { it.findOverridee() }.toSet()
val properties = allProps
.filterNot { it in overriddenProps }
.filterNot { it.hasAnnotation(C.GHOST_IGNORE) }
.toList()
validatePropertyVisibility(classDeclaration, properties)
val enumValues = getEnumValues(classDeclaration, isEnum)
val propertyModels = resolvePropertyModels(classDeclaration, properties, parameters, isEnum, enumValues)
val finalModels = resolveSealedSubclasses(classDeclaration, propertyModels, isSealed)
validateNames(finalModels, classDeclaration)
validateWrappedKeys(finalModels, classDeclaration)
return finalModels
}
/**
* Validates that the class kind is supported by the Ghost framework.
*/
private fun validateClassKind(
classDeclaration: KSClassDeclaration,
isData: Boolean,
isSealed: Boolean,
isValue: Boolean,
isEnum: Boolean
) {
if (!isData && !isSealed && !isValue && !isEnum) {
logger.error(
C.STR_ERR_CLASS_1 +
"${C.STR_ERR_CLASS_2}${
classDeclaration
.simpleName
.asString()
}${C.STR_ERR_CLASS_3}",
classDeclaration
)
}
}
/**
* Validates that none of the serialization properties are declared as private.
*/
private fun validatePropertyVisibility(
classDeclaration: KSClassDeclaration,
properties: List<KSPropertyDeclaration>
) {
val hasPrivateProperties = properties.any {
it.modifiers.contains(Modifier.PRIVATE)
}
if (hasPrivateProperties) {
logger.error(
C.STR_ERR_PRIV_1 +
"${C.STR_ERR_PRIV_2}${
classDeclaration
.simpleName
.asString()
}${C.STR_ERR_PRIV_3}",
classDeclaration
)
}
}
/**
* Resolves the list of property models for standard or enum DTO classes.
*/
private fun resolvePropertyModels(
classDeclaration: KSClassDeclaration,
properties: List<KSPropertyDeclaration>,
parameters: List<com.google.devtools.ksp.symbol.KSValueParameter>,
isEnum: Boolean,
enumValues: Map<String, String>?
): List<GhostPropertyModel> {
return if (isEnum) {
listOf(
GhostPropertyModel(
kotlinName = C.NAME,
jsonName = C.NAME,
type = classDeclaration.asType(emptyList()),
typeName = classDeclaration.toClassName(),
isNullable = false,
isGhost = false,
isList = false,
isSet = false,
isEnum = true,
enumValues = enumValues
)
)
} else {
properties.map { prop -> buildPropertyModel(prop, parameters) }
}
}
/**
* Inspects the sealed subclass hierarchies and recursively builds inferred subclass metadata.
*/
private fun resolveSealedSubclasses(
classDeclaration: KSClassDeclaration,
propertyModels: List<GhostPropertyModel>,
isSealed: Boolean
): List<GhostPropertyModel> {
return if (isSealed) {
val inferredSubclasses = classDeclaration
.getSealedSubclasses()
.map { subclass ->
InferredSubclassModel(
subclass,
analyze(subclass)
)
}
.toList()
propertyModels.map { it.copy(inferredSubclasses = inferredSubclasses) }
.ifEmpty {
listOf(
GhostPropertyModel(
kotlinName = C.STR_EMPTY, jsonName = C.STR_EMPTY,
type = classDeclaration.asType(emptyList()),
typeName = classDeclaration.toClassName(),
isNullable = false, isGhost = false, isList = false, isSet = false, isEnum = false,
inferredSubclasses = inferredSubclasses
)
)
}
} else {
propertyModels
}
}
private fun getEnumValues(
classDeclaration: KSClassDeclaration,
isEnum: Boolean
): Map<String, String>? {
return if (isEnum) {
classDeclaration.declarations
.filter { it is KSClassDeclaration && it.classKind == ClassKind.ENUM_ENTRY }
.map { it as KSClassDeclaration }
.associate { entry -> entry.simpleName.asString() to getSerialName(entry) }
} else null
}
private fun validateWrappedKeys(
properties: List<GhostPropertyModel>,
clazz: KSClassDeclaration,
) {
val wireKeys = mutableMapOf<String, String>()
properties.forEach { prop ->
prop.wrappedSourceKeys?.forEach { key ->
val owner = wireKeys.put(key, prop.kotlinName)
if (owner != null && owner != prop.kotlinName) {
logger.error(
C.STR_ERR_WRAPPED_DUP_KEY_1 + key + C.STR_ERR_WRAPPED_DUP_KEY_2 +
clazz.simpleName.asString() + C.STR_ERR_WRAPPED_DUP_KEY_3 + owner +
C.STR_ERR_WRAPPED_DUP_KEY_4 + prop.kotlinName + C.STR_ERR_WRAPPED_DUP_KEY_5,
clazz,
)
}
}
if (prop.wrappedOmitIfEmpty && !prop.isNullable) {
logger.error(
C.STR_ERR_WRAPPED_OMIT_IF_EMPTY_1 + prop.kotlinName + C.STR_ERR_WRAPPED_OMIT_IF_EMPTY_2,
clazz,
)
}
if (prop.flattenPath != null || prop.wrapPath != null) {
if (prop.wrappedSourceKeys != null) {
logger.error(
C.STR_ERR_WRAPPED_COMBINE_1 + prop.kotlinName + C.STR_ERR_WRAPPED_COMBINE_2,
clazz,
)
}
}
}
properties.forEach { prop ->
if (prop.wrappedSourceKeys == null) {
return@forEach
}
prop.wrappedSourceKeys.forEach { key ->
if (properties.any { it.wrappedSourceKeys == null && it.jsonName == key }) {
logger.error(
C.STR_ERR_WRAPPED_KEY_CONFLICT_1 + key + C.STR_ERR_WRAPPED_KEY_CONFLICT_2 +
clazz.simpleName.asString() + C.STR_ERR_WRAPPED_KEY_CONFLICT_3,
clazz,
)
}
}
}
}
private fun validateNames(
properties: List<GhostPropertyModel>,
clazz: KSClassDeclaration
) {
val names = properties.groupBy { it.jsonName }
names.forEach { (name, props) ->
if (props.size > C.VAL_ONE) {
logger.error(
"${C.STR_ERR_DUP_1}$name${C.STR_ERR_DUP_2}${
clazz
.simpleName
.asString()
}${C.STR_ERR_DUP_3}" +
"${C.STR_ERR_DUP_4}${props.joinToString { it.kotlinName }}",
clazz
)
}
}
}
private fun buildPropertyModel(
prop: KSPropertyDeclaration,
parameters: List<com.google.devtools.ksp.symbol.KSValueParameter>
): GhostPropertyModel {
val type = prop.type.resolve()
val qualifiedName = type.declaration.qualifiedName?.asString()
val isList = qualifiedName == C.LIST_QUALIFIED
val isSet = qualifiedName == C.SET_QUALIFIED
val isMap = qualifiedName == C.MAP_QUALIFIED
val innerType = if (isList || isSet) {
resolveFirstTypeArg(type)
} else {
null
}
val mapKeyType = if (isMap) {
resolveFirstTypeArg(type)
} else {
null
}
val mapValueType = if (isMap) {
resolveSecondTypeArg(type)
} else {
null
}
validateMapKey(prop, isMap, mapKeyType)
val param = parameters.find {
it.name?.asString() == prop.simpleName.asString()
}
val isPrimitiveArray = qualifiedName in PRIMITIVE_ARRAYS
val primitiveArrayType =
if (isPrimitiveArray) {
qualifiedName?.removePrefix(C.STR_KOTLIN_DOT)
} else {
null
}
val customDecoder = resolveCustomCoder(prop, C.GHOST_DECODER)
val customEncoder = resolveCustomCoder(prop, C.GHOST_ENCODER)
val flattenPath = resolvePathAnnotation(prop, C.GHOST_FLATTEN)
val wrapPath = resolvePathAnnotation(prop, C.GHOST_WRAP)
val wrappedKeysConfig = resolveWrappedKeysAnnotation(prop)
warnIfCustomCoder(prop.simpleName.asString(), customDecoder, customEncoder)
val parentClass = prop.parentDeclaration as? KSClassDeclaration
val hasProto = parentClass?.annotations?.any {
it.shortName.asString() == C.ANNOTATION_GHOST_PROTO_SERIALIZATION
} == true
val serialNameAnnotation = prop.annotations.any {
val name = it.shortName.asString()
name == C.GHOST_NAME || name == C.SERIAL_NAME || name.endsWith(C.STR_SERIAL_NAME_SUFFIX)
}
var jsonName = flattenPath?.last() ?: getJsonName(prop)
if (hasProto && !serialNameAnnotation) {
jsonName = toLowerCamelCase(jsonName)
}
val wrappedUnwrapFields = if (wrappedKeysConfig != null) {
resolveWrappedUnwrapFields(
type = type.makeNotNullable(),
wrapperPath = emptyList(),
sourceKeys = wrappedKeysConfig.keys,
)
} else {
emptyList()
}
return GhostPropertyModel(
kotlinName = prop.simpleName.asString(),
jsonName = jsonName,
type = type,
typeName = type.toTypeName(),
isNullable = type.isMarkedNullable,
isGhost = isGhostType(type),
isList = isList,
isSet = isSet,
listInnerType = innerType,
isEnum = isEnumType(type),
listInnerIsGhost = innerType?.let { isGhostType(it) } ?: false,
listInnerIsEnum = innerType?.let { isEnumType(it) } ?: false,
hasDefaultValue = param?.hasDefault ?: false,
isInConstructor = param != null,
isMap = isMap,
mapValueType = mapValueType,
mapValueIsGhost = mapValueType?.let { isGhostType(it) } ?: false,
isPrimitiveArray = isPrimitiveArray,
primitiveArrayType = primitiveArrayType,
isValueClass = isValueClass(type),
valueClassProperty = if (isValueClass(type)) {
resolveValueClassProperty(type, hasProto)
} else {
null
},
isSealedClass = isSealedClass(type),
sealedSubclasses = resolveSealedSubclassesForType(type),
isResilient = isResilientProperty(prop),
isContextual = isContextualType(type, isList, isSet, isMap, isPrimitiveArray),
customDecoder = customDecoder,
customEncoder = customEncoder,
flattenPath = flattenPath,
wrapPath = wrapPath,
wrappedSourceKeys = wrappedKeysConfig?.keys,
wrappedOmitIfEmpty = wrappedKeysConfig?.omitIfEmpty ?: false,
wrappedOmitIfAbsent = wrappedKeysConfig?.omitIfAbsent ?: emptyList(),
wrappedUnwrapFields = wrappedUnwrapFields,
isInferredSignature = prop.hasAnnotation(C.GHOST_SIGNATURE),
isProto = hasProto
)
}
private fun toLowerCamelCase(str: String): String {
if (str.isEmpty()) return str
val sb = StringBuilder()
var uppercaseNext = false
var i = 0
val len = str.length
while (i < len) {
val c = str[i]
if (c == '_') {
uppercaseNext = true
} else {
if (uppercaseNext) {
sb.append(c.uppercaseChar())
uppercaseNext = false
} else {
if (i == 0) {
sb.append(c.lowercaseChar())
} else {
sb.append(c)
}
}
}
i++
}
return sb.toString()
}
/**
* Warns if a custom encoder or decoder is configured for the given property.
*/
private fun warnIfCustomCoder(
propName: String,
customDecoder: CustomCoderModel?,
customEncoder: CustomCoderModel?
) {
if (customDecoder != null || customEncoder != null) {
logger.info(
C.STR_WARN_CUSTOM_CODER.format(
propName,
customDecoder,
customEncoder
)
)
}
}
/**
* Determines whether the property or its parent class is annotated as resilient.
*/
private fun isResilientProperty(prop: KSPropertyDeclaration): Boolean {
return prop.hasAnnotation(C.GHOST_RESILIENT) || prop.parentDeclaration
?.let {
it is KSClassDeclaration &&
it.annotations.any { ann -> ann.shortName.asString() == C.GHOST_RESILIENT }
} ?: false
}
/**
* Resolves the sealed subclasses of the given type, if it represents a sealed class.
*/
private fun resolveSealedSubclassesForType(type: KSType): List<KSClassDeclaration> {
return if (isSealedClass(type)) {
(type.declaration as KSClassDeclaration).getSealedSubclasses().toList()
} else {
emptyList()
}
}
/**
* Resolves the custom decoder or encoder helper config model if declared.
*/
private fun resolveCustomCoder(prop: KSPropertyDeclaration, annotationName: String): CustomCoderModel? {
return prop.annotations.find {
it.shortName.asString() == annotationName
}?.let { ann ->
val provider =
ann.arguments.find { it.name?.asString() == C.PROVIDER_ARG }?.value as? KSType
val function =
ann.arguments.find { it.name?.asString() == C.FUNCTION_NAME_ARG }?.value as? String
if (provider != null && function != null) {
CustomCoderModel(
provider = provider.toTypeName(),
functionName = function,
readerKinds = resolveCustomCoderReaderKinds(provider, function),
)
} else null
}
}
private fun resolveCustomCoderReaderKinds(
provider: KSType,
functionName: String,
): Set<CustomCoderReaderKind> {
val declaration = provider.declaration as? KSClassDeclaration ?: return setOf(CustomCoderReaderKind.BYTES)
val kinds = declaration.getAllFunctions()
.filter { it.simpleName.asString() == functionName }
.mapNotNull { fn ->
when (fn.parameters.firstOrNull()?.type?.resolve()?.declaration?.qualifiedName?.asString()) {
C.STR_GHOST_JSON_STRING_READER_QUALIFIED -> CustomCoderReaderKind.STRING
C.STR_GHOST_JSON_FLAT_READER_QUALIFIED -> CustomCoderReaderKind.FLAT
C.STR_GHOST_JSON_READER_QUALIFIED -> CustomCoderReaderKind.BYTES
else -> null
}
}
.toSet()
return kinds.ifEmpty { setOf(CustomCoderReaderKind.BYTES) }
}
private data class WrappedKeysConfig(
val keys: List<String>,
val omitIfEmpty: Boolean,
val omitIfAbsent: List<String>,
)
private fun resolveWrappedKeysAnnotation(prop: KSPropertyDeclaration): WrappedKeysConfig? {
resolveWrappedKeysFromAnnotated(prop)?.let { return it }
val classDecl = prop.parentDeclaration as? KSClassDeclaration
val parameter = classDecl?.primaryConstructor?.parameters?.firstOrNull {
it.name?.asString() == prop.simpleName.asString()
}
parameter?.let { resolveWrappedKeysFromAnnotated(it) }?.let { return it }
return null
}
private fun resolveWrappedKeysFromAnnotated(annotated: KSAnnotated): WrappedKeysConfig? {
try {
when (annotated) {
is KSPropertyDeclaration -> {
annotated.getAnnotationsByType(GhostWrappedKeys::class)
.firstOrNull()
?.let { wrappedKeysConfigFromAnnotation(it, annotated) }
?.let { return it }
}
is KSValueParameter -> {
annotated.getAnnotationsByType(GhostWrappedKeys::class)
.firstOrNull()
?.let { wrappedKeysConfigFromAnnotation(it, annotated) }
?.let { return it }
}
}
} catch (_: Exception) {
// kspCommonMainKotlinMetadata may throw on getAnnotationsByType for array args.
}
return annotated.annotations
.find { it.shortName.asString() == C.GHOST_WRAPPED_KEYS }
?.let { wrappedKeysConfigFromKsAnnotation(it, annotated) }
}
private fun wrappedKeysConfigFromAnnotation(
annotation: GhostWrappedKeys,
source: KSAnnotated,
): WrappedKeysConfig {
return wrappedKeysConfigFromValues(
keys = annotation.keys.toList(),
omitIfEmpty = annotation.omitIfEmpty,
omitIfAbsent = annotation.omitIfAbsent.toList(),
source = source,
)
}
private fun wrappedKeysConfigFromKsAnnotation(
annotation: KSAnnotation,
source: KSAnnotated,
): WrappedKeysConfig {
return wrappedKeysConfigFromValues(
keys = annotation.readStringArrayArgument(C.KEYS_ARG),
omitIfEmpty = annotation.readBooleanArgument(C.OMIT_IF_EMPTY_ARG),
omitIfAbsent = annotation.readStringArrayArgument(C.OMIT_IF_ABSENT_ARG),
source = source,
)
}
private fun wrappedKeysConfigFromValues(
keys: List<String>,
omitIfEmpty: Boolean,
omitIfAbsent: List<String>,
source: KSAnnotated,
): WrappedKeysConfig {
if (keys.isEmpty()) {
val name = when (source) {
is KSPropertyDeclaration -> source.simpleName.asString()
is KSValueParameter -> source.name?.asString() ?: C.STR_EMPTY
else -> C.STR_EMPTY
}
logger.error(
C.STR_ERR_WRAPPED_EMPTY_KEYS_1 + name + C.STR_ERR_WRAPPED_EMPTY_KEYS_2,
source,
)
return WrappedKeysConfig(emptyList(), false, emptyList())
}
return WrappedKeysConfig(
keys = keys,
omitIfEmpty = omitIfEmpty,
omitIfAbsent = omitIfAbsent,
)
}
private fun KSAnnotation.readStringArrayArgument(argName: String): List<String> {
val value = arguments.find { it.name?.asString() == argName }?.value ?: return emptyList()
return when (value) {
is Array<*> -> value.filterIsInstance<String>()
is List<*> -> value.filterIsInstance<String>()
else -> emptyList()
}
}
private fun KSAnnotation.readBooleanArgument(argName: String): Boolean {
return arguments.find { it.name?.asString() == argName }?.value as? Boolean ?: false
}
private fun resolveWrappedUnwrapFields(
type: KSType,
wrapperPath: List<String>,
sourceKeys: List<String>,
): List<WrappedUnwrapFieldModel> {
val declaration = type.declaration as? KSClassDeclaration ?: return emptyList()
if (!isGhostType(type)) {
return emptyList()
}
val properties = declaration.getAllProperties()
.filterNot { it.hasAnnotation(C.GHOST_IGNORE) }
.toList()
return sourceKeys.mapNotNull { wireKey ->
resolveUnwrapFieldForWireKey(properties, wrapperPath, wireKey, type)
}
}
private fun resolveUnwrapFieldForWireKey(
properties: List<KSPropertyDeclaration>,
wrapperPath: List<String>,
wireKey: String,
ownerType: KSType,
): WrappedUnwrapFieldModel? {
val direct = properties.find { getJsonName(it) == wireKey }
if (direct != null) {
val directType = direct.type.resolve()
return WrappedUnwrapFieldModel(
jsonName = wireKey,
kotlinPath = wrapperPath + direct.simpleName.asString(),
isNullable = directType.isMarkedNullable,
typeName = directType.toTypeName(),
type = directType,
)
}
for (property in properties) {
val nestedConfig = resolveWrappedKeysAnnotation(property) ?: continue
if (wireKey !in nestedConfig.keys) {
continue
}
val nestedType = property.type.resolve().makeNotNullable()
val nestedPath = wrapperPath + property.simpleName.asString()
val nestedDecl = nestedType.declaration as? KSClassDeclaration ?: continue
val nestedProps = nestedDecl.getAllProperties()
.filterNot { it.hasAnnotation(C.GHOST_IGNORE) }
.toList()
val leaf = resolveUnwrapFieldForWireKey(nestedProps, nestedPath, wireKey, nestedType)
if (leaf != null) {
return leaf
}
}
resolveUnwrapFieldFromSealedSubclass(wrapperPath, wireKey, ownerType)?.let { return it }
logger.warn(
C.STR_WARN_WRAPPED_UNMAPPED_1 + wireKey + C.STR_WARN_WRAPPED_UNMAPPED_2 +
ownerType.declaration.simpleName.asString(),
)
return null
}
/**
* proto3 `oneof` mapping support: when the wrapped type is a sealed class (e.g. an
* `inferred = true` hierarchy used to decode whichever wire key is present), the wire keys
* live on its *subclasses*, not on the sealed parent itself (which typically declares no
* properties of its own). Resolves [wireKey] against each subclass' direct properties and
* tags the result with that subclass so the emitter can smart-cast before accessing it.
*/
private fun resolveUnwrapFieldFromSealedSubclass(
wrapperPath: List<String>,
wireKey: String,
ownerType: KSType,
): WrappedUnwrapFieldModel? {
val ownerDecl = ownerType.declaration as? KSClassDeclaration ?: return null
if (!ownerDecl.modifiers.contains(Modifier.SEALED)) {
return null
}
for (subclass in ownerDecl.getSealedSubclasses()) {
val subclassProps = subclass.getAllProperties()
.filterNot { it.hasAnnotation(C.GHOST_IGNORE) }
.toList()
val direct = subclassProps.find { getJsonName(it) == wireKey } ?: continue
val directType = direct.type.resolve()
return WrappedUnwrapFieldModel(
jsonName = wireKey,
kotlinPath = wrapperPath + direct.simpleName.asString(),
isNullable = directType.isMarkedNullable,
typeName = directType.toTypeName(),
type = directType,
sealedSubclassName = subclass.toClassName(),
)
}
return null
}
/**
* Resolves the flatten/wrap key paths from annotations.
*/
private fun resolvePathAnnotation(prop: KSPropertyDeclaration, annotationName: String): List<String>? {
return prop.annotations.find {
it.shortName.asString() == annotationName
}?.let { ann ->
val path = ann.arguments.find { it.name?.asString() == C.PATH_ARG }?.value as? String
path?.split(C.STR_DOT)
}
}
/**
* Validates that map keys are Strings.
*/
private fun validateMapKey(
prop: KSPropertyDeclaration,
isMap: Boolean,
mapKeyType: KSType?
) {
if (isMap && mapKeyType?.declaration?.qualifiedName?.asString() != C.STRING_QUALIFIED) {
logger.error(
"${C.STR_ERR_MAP_1}${prop.simpleName.asString()}${C.STR_ERR_MAP_2}" +
C.STR_ERR_MAP_3,
prop
)
}
}
/**
* Checks if a type requires contextual serialization (e.g., non-built-in/third-party types).
*/
private fun isContextualType(
type: KSType,
isList: Boolean,
isSet: Boolean,
isMap: Boolean,
isPrimitiveArray: Boolean
): Boolean {
if (isList || isSet || isMap || isPrimitiveArray) {
return false
}
if (isGhostType(type)) {
return false
}
if (isEnumType(type)) {
return false
}
val qualifiedName = type.declaration.qualifiedName?.asString()
val isBuiltIn = qualifiedName?.startsWith(C.STR_KOTLIN_PREFIX) == true ||
qualifiedName?.startsWith(C.STR_JAVA_PREFIX) == true
if (isBuiltIn) {
return when (qualifiedName) {
C.K_STRING, C.K_INT, C.K_LONG, C.K_DOUBLE, C.K_FLOAT,
C.K_BOOLEAN, C.K_BYTE, C.K_SHORT, C.K_CHAR, C.K_UNIT, C.K_ANY,
C.K_BYTE_ARRAY -> false
else -> true
}
}
if (qualifiedName == C.K_RAW_JSON) {
return false
}
return true // Third party types
}
/**
* Checks if the type is a value class or inline class.
*/
private fun isValueClass(type: KSType): Boolean {
val declaration = type.declaration as? KSClassDeclaration ?: return false
return declaration.modifiers.contains(Modifier.VALUE) ||
declaration.modifiers.contains(Modifier.INLINE)
}
/**
* Checks if the type is a sealed class.
*/
private fun isSealedClass(type: KSType): Boolean {
val declaration = type.declaration as? KSClassDeclaration ?: return false
return declaration.modifiers.contains(Modifier.SEALED)
}
/**
* Resolves the underlying property model for a value class type.
*/
private fun resolveValueClassProperty(type: KSType, isProto: Boolean = false): GhostPropertyModel? {
val declaration = type.declaration as? KSClassDeclaration ?: return null
val primaryConstructor = declaration.primaryConstructor ?: return null
val param = primaryConstructor.parameters.firstOrNull() ?: return null
val prop = declaration.getAllProperties()
.find { it.simpleName.asString() == param.name?.asString() } ?: return null
// The value class itself (e.g. `UserId`) is never @GhostProtoSerialization-annotated;
// it inherits proto-ness from whichever outer property wraps it.
return buildPropertyModel(prop, listOf(param)).copy(isProto = isProto)
}
/**
* Resolves the first type argument of a generic type.
*/
private fun resolveFirstTypeArg(type: KSType): KSType? {
return type.arguments.firstOrNull()?.type?.resolve()
}
/**
* Resolves the second type argument of a generic type.
*/
private fun resolveSecondTypeArg(type: KSType): KSType? {
return type.arguments.getOrNull(1)?.type?.resolve()
}
/**
* Returns the serialized JSON name of a property.
*/
private fun getJsonName(prop: KSPropertyDeclaration): String = getSerialName(prop)
/**
* Extension to check if a property has a specific annotation by name.
*/
private fun KSPropertyDeclaration.hasAnnotation(name: String): Boolean {
return annotations.any { it.shortName.asString() == name }
}
/**
* Resolves the serialized name for an annotated element, checking GhostName or kotlinx SerialName.
*/
internal fun getSerialName(declaration: KSAnnotated): String {
val annotations = declaration.annotations.toList()
// 1. GhostName (Primary)
val ghostName = annotations.find { it.shortName.asString() == C.GHOST_NAME }
if (ghostName != null) {
val arg = ghostName.arguments.find { it.name?.asString() == C.NAME_ARG }
?: ghostName.arguments.firstOrNull()
return arg?.value?.toString() ?: C.STR_EMPTY
}
// 2. SerialName (kotlinx compatibility)
val serialName = annotations.find {
val name = it.shortName.asString()
name == C.SERIAL_NAME || name.endsWith(C.STR_SERIAL_NAME_SUFFIX)
}
if (serialName != null) {
val arg = serialName.arguments.find { it.name?.asString() == C.STR_VALUE_ARG }
?: serialName.arguments.firstOrNull()
return arg?.value?.toString()
?: (declaration as? KSDeclaration)?.simpleName?.asString()
?: C.STR_EMPTY
}
return (declaration as? KSDeclaration)
?.simpleName?.asString()
?: C.STR_EMPTY
}
/**
* Checks if the type is an enum class.
*/
private fun isEnumType(type: KSType): Boolean =
(type.declaration as? KSClassDeclaration)?.classKind == ClassKind.ENUM_CLASS
/**
* Checks if the type is annotated with @GhostSerialization.
*/
private fun isGhostType(type: KSType): Boolean =
type.declaration.annotations.any {
val name = it.shortName.asString()
name == C.GHOST_SERIALIZATION || name == C.ANNOTATION_GHOST_PROTO_SERIALIZATION
}
companion object {
/**
* Set of fully qualified primitive array types.
*/
private val PRIMITIVE_ARRAYS = setOf(
C.STR_TYPE_INT_ARRAY,
C.STR_TYPE_LONG_ARRAY,
C.STR_TYPE_FLOAT_ARRAY,
C.STR_TYPE_DOUBLE_ARRAY,
C.STR_TYPE_BOOLEAN_ARRAY
)
}
}