Coverage Summary for Class: EnvelopeAnalyzer (com.ghost.serialization.compiler)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| EnvelopeAnalyzer |
100%
(1/1)
|
88.9%
(8/9)
|
66.9%
(111/166)
|
70.6%
(96/136)
|
80.7%
(760/942)
|
package com.ghost.serialization.compiler
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSPropertyDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.squareup.kotlinpoet.ksp.toClassName
import com.ghost.serialization.compiler.GhostEmitterConstants as C
/**
* Resolves [@GhostJsonEnvelope][com.ghost.serialization.annotations.GhostJsonEnvelope] metadata
* and validates payload field conventions.
*/
internal class EnvelopeAnalyzer(private val logger: KSPLogger) {
fun analyze(
classDeclaration: KSClassDeclaration,
properties: List<GhostPropertyModel>
): GhostEnvelopeModel? {
val envelopeAnnotation = classDeclaration.annotations.find {
it.shortName.asString() == C.STR_GHOST_JSON_ENVELOPE
} ?: return null
val discriminatorField = envelopeAnnotation.arguments
.find { it.name?.asString() == C.ARG_ENVELOPE_DISCRIMINATOR }
?.value as? String
?: C.STR_DEFAULT_DISCRIMINATOR
val timeField = (envelopeAnnotation.arguments
.find { it.name?.asString() == C.ARG_ENVELOPE_TIME_FIELD }
?.value as? String)
?.takeIf { it.isNotEmpty() }
val dataField = (envelopeAnnotation.arguments
.find { it.name?.asString() == C.ARG_ENVELOPE_DATA_FIELD }
?.value as? String)
?.takeIf { it.isNotEmpty() }
val discriminatorProperty = resolveProperty(properties, discriminatorField)
?: run {
logger.error(
"${C.STR_ERR_ENVELOPE_DISC_1}$discriminatorField${C.STR_ERR_ENVELOPE_DISC_2}${
classDeclaration.simpleName.asString()
}${C.STR_ERR_ENVELOPE_DISC_3}",
classDeclaration
)
return null
}
if (!isStringType(discriminatorProperty.type)) {
logger.error(
"${C.STR_ERR_ENVELOPE_DISC_TYPE_1}${discriminatorProperty.kotlinName}${C.STR_ERR_ENVELOPE_DISC_TYPE_2}",
classDeclaration
)
}
val timeKotlinName = timeField?.let { fieldName ->
resolveProperty(properties, fieldName)?.kotlinName
?: run {
logger.warn(
"${C.STR_WARN_ENVELOPE_TIME_1}$fieldName${C.STR_WARN_ENVELOPE_TIME_2}",
classDeclaration
)
null
}
}
val propertyDeclarations = classDeclaration.getAllProperties()
.filterNot { prop -> prop.annotations.any { it.shortName.asString() == C.GHOST_IGNORE } }
.associateBy { it.simpleName.asString() }
val fallbackDeclarations = propertyDeclarations.values.filter { prop ->
prop.annotations.any { it.shortName.asString() == C.STR_GHOST_ENVELOPE_FALLBACK }
}
if (fallbackDeclarations.size > C.VAL_ONE) {
logger.error(C.STR_ERR_ENVELOPE_MULTI_FALLBACK, classDeclaration)
}
val fallbackMapping = fallbackDeclarations.firstOrNull()?.let { prop ->
buildFallbackMapping(prop, properties)
}
val isGenericMode = dataField != null
val genericDataProperty = if (isGenericMode) {
resolveProperty(properties, dataField!!)
?: run {
logger.error(
"${C.STR_ERR_ENVELOPE_DATA_1}$dataField${C.STR_ERR_ENVELOPE_DATA_2}",
classDeclaration
)
return null
}
} else {
null
}
if (isGenericMode) {
validateOpaquePayload(genericDataProperty!!, classDeclaration)
}
val payloadMappings = if (isGenericMode) {
val genericProp = genericDataProperty!!
properties.filter { prop ->
prop.kotlinName != discriminatorProperty.kotlinName &&
prop.kotlinName != timeKotlinName &&
prop.kotlinName != fallbackMapping?.kotlinName
}.mapNotNull { prop ->
val declaration = propertyDeclarations[prop.kotlinName] ?: return@mapNotNull null
val payloadAnnotation = declaration.annotations.find {
it.shortName.asString() == C.STR_GHOST_ENVELOPE_PAYLOAD
}
if (payloadAnnotation == null) {
if (prop.kotlinName == genericProp.kotlinName) {
null
} else {
null
}
} else {
buildPayloadMapping(payloadAnnotation, prop, classDeclaration)
}
}
} else {
properties.mapNotNull { prop ->
if (prop.kotlinName == discriminatorProperty.kotlinName ||
prop.kotlinName == timeKotlinName ||
prop.kotlinName == fallbackMapping?.kotlinName
) {
return@mapNotNull null
}
val declaration = propertyDeclarations[prop.kotlinName] ?: return@mapNotNull null
val payloadAnnotation = declaration.annotations.find {
it.shortName.asString() == C.STR_GHOST_ENVELOPE_PAYLOAD
} ?: run {
if (prop.isNullable && prop.type.isRawJson()) {
logger.warn(
"${C.STR_WARN_ENVELOPE_UNTAGGED_1}${prop.kotlinName}${C.STR_WARN_ENVELOPE_UNTAGGED_2}",
declaration
)
}
return@mapNotNull null
}
validateOpaquePayload(prop, classDeclaration)
buildPayloadMapping(payloadAnnotation, prop, classDeclaration)
}
}
if (!isGenericMode && payloadMappings.isEmpty()) {
logger.error(C.STR_ERR_ENVELOPE_NO_PAYLOADS, classDeclaration)
}
val duplicateValues = payloadMappings.groupBy { it.discriminatorValue }.filter { it.value.size > 1 }
duplicateValues.forEach { (value, mappings) ->
logger.error(
"${C.STR_ERR_ENVELOPE_DUP_VALUE_1}$value${C.STR_ERR_ENVELOPE_DUP_VALUE_2}${
mappings.joinToString { it.kotlinName }
}",
classDeclaration
)
}
return GhostEnvelopeModel(
discriminatorKotlinName = discriminatorProperty.kotlinName,
discriminatorJsonName = discriminatorProperty.jsonName,
timeKotlinName = timeKotlinName,
isGenericMode = isGenericMode,
genericDataKotlinName = genericDataProperty?.kotlinName,
payloadMappings = payloadMappings,
fallbackMapping = fallbackMapping
)
}
private fun resolveProperty(
properties: List<GhostPropertyModel>,
fieldName: String
): GhostPropertyModel? =
properties.find { it.jsonName == fieldName || it.kotlinName == fieldName }
private fun isStringType(type: KSType): Boolean =
type.declaration.qualifiedName?.asString() == C.STRING_QUALIFIED
private fun validateOpaquePayload(prop: GhostPropertyModel, classDeclaration: KSClassDeclaration) {
if (!prop.isNullable || !prop.type.isRawJson()) {
logger.error(
"${C.STR_ERR_ENVELOPE_PAYLOAD_TYPE_1}${prop.kotlinName}${C.STR_ERR_ENVELOPE_PAYLOAD_TYPE_2}",
classDeclaration
)
}
}
private fun buildFallbackMapping(
prop: KSPropertyDeclaration,
properties: List<GhostPropertyModel>
): EnvelopePayloadMapping? {
val model = properties.find { it.kotlinName == prop.simpleName.asString() } ?: return null
validateOpaquePayload(model, prop.parentDeclaration as KSClassDeclaration)
return EnvelopePayloadMapping(
discriminatorValue = C.STR_EMPTY,
kotlinName = model.kotlinName,
isRawJson = model.type.isRawJson(),
targetType = null
)
}
private fun buildPayloadMapping(
payloadAnnotation: com.google.devtools.ksp.symbol.KSAnnotation,
prop: GhostPropertyModel,
classDeclaration: KSClassDeclaration
): EnvelopePayloadMapping? {
val value = payloadAnnotation.arguments
.find { it.name?.asString() == C.STR_VALUE_ARG }
?.value as? String
?: run {
logger.error(C.STR_ERR_ENVELOPE_PAYLOAD_VALUE, classDeclaration)
return null
}
val targetType = payloadAnnotation.arguments
.find { it.name?.asString() == C.ARG_ENVELOPE_TARGET }
?.value as? KSType
val targetClassName = if (targetType != null && !isSentinelTarget(targetType)) {
targetType.toClassName()
} else {
null
}
return EnvelopePayloadMapping(
discriminatorValue = value,
kotlinName = prop.kotlinName,
isRawJson = prop.type.isRawJson(),
targetType = targetClassName
)
}
private fun isSentinelTarget(type: KSType): Boolean {
val name = type.declaration.qualifiedName?.asString()
return name == C.K_UNIT || name == C.K_NOTHING
}
}