Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
916bd24
refactor: improve wisp processor to validate @Wisp annotation and gen…
angryPodo Nov 17, 2025
459a7f2
fix: correct WispProcessorProvider to properly instantiate WispProces…
angryPodo Nov 17, 2025
e0fe587
add: implement route factory generator for parameter conversion and v…
angryPodo Nov 17, 2025
99f5d4d
add: RouteInfo and ParameterInfo data classes
angryPodo Nov 17, 2025
9fbad7b
feat: add RouteInfoMapper for route configuration mapping from class …
angryPodo Nov 17, 2025
e2d6016
refactor: extract conversion logic into separate method for clarity a…
angryPodo Nov 17, 2025
57c18f6
refactor: extract symbol processing into separate method for clarity …
angryPodo Nov 17, 2025
70789e3
refactor: optimize route factory code generation for object and class…
angryPodo Nov 17, 2025
909006d
refactor: update RouteInfo to use sealed interface with specific data…
angryPodo Nov 17, 2025
3c57e38
refactor: update route info mapping to distinguish object and class r…
angryPodo Nov 17, 2025
08fcdf3
add: define RouteFactory interface for runtime route creation impleme…
angryPodo Nov 17, 2025
a061c36
add: generate route factory code for object and class routes with par…
angryPodo Nov 17, 2025
00db1f3
add: generate route factory code for object and class routes with par…
angryPodo Nov 17, 2025
47d3e3d
fix: lint format
angryPodo Nov 17, 2025
582ddbc
fix: improve parameter handling and error type selection in route fac…
angryPodo Nov 18, 2025
1bbb0b2
fix: correct indentation and formatting in RouteInfoMapper.kt for lint
angryPodo Nov 18, 2025
6e1a0f0
fix: improve symbol validation and code generation in WispProcessor.kt
angryPodo Nov 18, 2025
76d1913
refactor: Apply review for import and incremental build
angryPodo Nov 19, 2025
59fef41
feat: Add support for Double query parameter type
angryPodo Nov 19, 2025
4780cf8
refactor: Use KSPLogger for unsupported type errors
angryPodo Nov 19, 2025
30b279f
test: Update tests for RouteFactoryGenerator
angryPodo Nov 19, 2025
7f53466
fix: lint format
angryPodo Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 62 additions & 37 deletions wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt
Original file line number Diff line number Diff line change
@@ -1,71 +1,96 @@
package com.angrypodo.wisp

import com.angrypodo.wisp.annotations.Wisp
import com.angrypodo.wisp.generator.RouteFactoryGenerator
import com.angrypodo.wisp.mapper.toRouteInfo
import com.angrypodo.wisp.model.RouteInfo
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
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.validate
import com.squareup.kotlinpoet.ksp.writeTo

/**
* @Wisp 어노테이션이 붙은 클래스를 찾아 유효성을 검증하고 코드를 생성하는 메인 프로세서 클래스입니다.
*/
internal class WispProcessor(
environment: SymbolProcessorEnvironment
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger
) : SymbolProcessor {
private val logger = environment.logger

/**
* KSP가 코드를 분석할 때 호출되는 함수입니다.
*/
private val factoryGenerator = RouteFactoryGenerator(logger)

override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(WISP_ANNOTATION)
.filterIsInstance<KSClassDeclaration>()

if (symbols.none()) return emptyList()

logger.info("Wisp: found ${symbols.count()} @Wisp Route Symbols.")

val (processableSymbols, deferredSymbols) = symbols.partition { it.validate() }

processableSymbols.forEach { routeClass ->
val routeInfo = routeClass.toRouteClassInfo()
val result = WispValidator.validate(routeInfo)
processableSymbols.forEach { processSymbol(it) }

return deferredSymbols
}

when (result) {
is WispValidator.ValidationResult.Success ->
logger.info(
"Wisp: Route '${routeClass.simpleName.asString()}' validation successful."
)
private fun processSymbol(routeClass: KSClassDeclaration) {
if (!validateSerializable(routeClass)) {
return
}

is WispValidator.ValidationResult.Failure ->
logger.error(result.message, routeClass)
}
val routeInfo = routeClass.toRouteInfo() ?: run {
logInvalidRouteError(routeClass)
return
}

return deferredSymbols
generateRouteFactory(routeClass, routeInfo)
}

/**
* KSP의 KSClassDeclaration을 RouteClassInfo로 변환하는 매퍼 함수입니다.
*/
private fun KSClassDeclaration.toRouteClassInfo(): RouteClassInfo = RouteClassInfo(
qualifiedName = qualifiedName?.asString(),
simpleName = simpleName.asString(),
annotations = annotations.map { it.toAnnotationInfo() }.toList()
)

/**
* KSP의 KSAnnotation을 AnnotationInfo로 변환하는 매퍼 함수입니다.
*/
private fun KSAnnotation.toAnnotationInfo(): AnnotationInfo = AnnotationInfo(
qualifiedName = annotationType.resolve().declaration.qualifiedName?.asString(),
shortName = shortName.asString()
)
private fun validateSerializable(routeClass: KSClassDeclaration): Boolean {
if (routeClass.hasSerializableAnnotation()) return true
val routeName = routeClass.qualifiedName?.asString()
logger.error(
"Wisp Error: Route '$routeName' must be annotated with @Serializable.",
routeClass
)
return false
}

private fun logInvalidRouteError(routeClass: KSClassDeclaration) {
val routeName = routeClass.simpleName.asString()
logger.error(
"Wisp Error: Route '$routeName' is missing @Wisp path or has invalid parameters.",
routeClass
)
}

private fun generateRouteFactory(
routeClass: KSClassDeclaration,
routeInfo: RouteInfo
) {
val fileSpec = factoryGenerator.generate(routeInfo)
val dependencies = Dependencies(false, routeClass.containingFile!!)
fileSpec.writeTo(codeGenerator, dependencies)
}

private fun KSClassDeclaration.hasSerializableAnnotation(): Boolean {
return annotations.any { annotation ->
val shortName = annotation.shortName.asString()
val qualifiedName = annotation.annotationType.resolve()
.declaration.qualifiedName?.asString()
val isSerializable = shortName == SERIALIZABLE_SHORT_NAME &&
qualifiedName == SERIALIZABLE_ANNOTATION
isSerializable
}
}

companion object {
private val WISP_ANNOTATION = requireNotNull(Wisp::class.qualifiedName)
private const val SERIALIZABLE_SHORT_NAME = "Serializable"
private const val SERIALIZABLE_ANNOTATION = "kotlinx.serialization.Serializable"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorProvider
*/
class WispProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = WispProcessor(
environment
codeGenerator = environment.codeGenerator,
logger = environment.logger
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.angrypodo.wisp.generator

import com.angrypodo.wisp.model.ClassRouteInfo
import com.angrypodo.wisp.model.ObjectRouteInfo
import com.angrypodo.wisp.model.ParameterInfo
import com.angrypodo.wisp.model.RouteInfo
import com.google.devtools.ksp.processing.KSPLogger
import com.squareup.kotlinpoet.ANY
import com.squareup.kotlinpoet.BOOLEAN
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.DOUBLE
import com.squareup.kotlinpoet.FLOAT
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.INT
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.LONG
import com.squareup.kotlinpoet.MAP
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.STRING
import com.squareup.kotlinpoet.TypeSpec

internal class RouteFactoryGenerator(
private val logger: KSPLogger
) {

private val routeFactoryInterface = ClassName("com.angrypodo.wisp.runtime", "RouteFactory")
private val missingParameterError = ClassName(
"com.angrypodo.wisp.runtime",
"WispError",
"MissingParameter"
)
private val invalidParameterError = ClassName(
"com.angrypodo.wisp.runtime",
"WispError",
"InvalidParameter"
)

fun generate(routeInfo: RouteInfo): FileSpec {
val createFun = FunSpec.builder("create")
.addModifiers(KModifier.OVERRIDE)
.addParameter("params", MAP.parameterizedBy(STRING, STRING))
.returns(ANY)
.addCode(buildCreateFunctionBody(routeInfo))
.build()

val factoryObject = TypeSpec.objectBuilder(routeInfo.factoryClassName)
.addModifiers(KModifier.INTERNAL)
.addSuperinterface(routeFactoryInterface)
.addFunction(createFun)
.build()

return FileSpec.builder(
routeInfo.factoryClassName.packageName,
routeInfo.factoryClassName.simpleName
)
.addType(factoryObject)
.build()
}

private fun buildCreateFunctionBody(routeInfo: RouteInfo): CodeBlock {
return when (routeInfo) {
is ObjectRouteInfo -> CodeBlock.of("return %T", routeInfo.routeClassName)
is ClassRouteInfo -> {
val block = CodeBlock.builder()
routeInfo.parameters.forEach { parameter ->
val conversion = buildConversionCode(parameter, routeInfo.wispPath)
block.addStatement("val %L = %L", parameter.name, conversion)
}
val constructorArgs = routeInfo.parameters.joinToString(", ") {
"${it.name} = ${it.name}"
}
block.addStatement("return %T(%L)", routeInfo.routeClassName, constructorArgs)
block.build()
}
}
}

private fun buildConversionCode(param: ParameterInfo, wispPath: String): CodeBlock {
val rawAccess = CodeBlock.of("params[%S]", param.name)
val conversionLogic = getConversionLogic(param, rawAccess)

if (param.isNullable) {
return conversionLogic
}

val nonNullableType = param.typeName.copy(nullable = false)
val errorType = when (nonNullableType) {
STRING -> missingParameterError
else -> invalidParameterError
}

return CodeBlock.of(
"(%L ?: throw %T(%S, %S))",
conversionLogic,
errorType,
wispPath,
param.name
)
}

private fun getConversionLogic(param: ParameterInfo, rawAccess: CodeBlock): CodeBlock {
val nonNullableType = param.typeName.copy(nullable = false)
return when {
param.isEnum -> CodeBlock.of(
"runCatching { %T.valueOf(%L!!.uppercase()) }.getOrNull()",
nonNullableType,
rawAccess
)
nonNullableType == STRING -> rawAccess
nonNullableType == INT -> CodeBlock.of("%L?.toIntOrNull()", rawAccess)
nonNullableType == LONG -> CodeBlock.of("%L?.toLongOrNull()", rawAccess)
nonNullableType == BOOLEAN -> CodeBlock.of("%L?.toBooleanStrictOrNull()", rawAccess)
nonNullableType == FLOAT -> CodeBlock.of("%L?.toFloatOrNull()", rawAccess)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요기 Double은 따로 지원하지 않는 이유가 있나요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가적으로 지원하지 않는 타입에 대해서 예외를 던지기보다는 KSPLogger로 처리하는건 어떻게 생각하시나요?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요기 Double은 따로 지원하지 않는 이유가 있나요?

Long만 고려하고 Double은 누락했네요😅 바로 추가하도록 하겠습니다!

추가적으로 지원하지 않는 타입에 대해서 예외를 던지기보다는 KSPLogger로 처리하는건 어떻게 생각하시나요?

좋은 생각이네요!! 오류가 발생한 위치를 알려주는게 사용자 경험 측면에서 훨씬 좋을 것 같습니다ㅎㅎ 이것도 리팩토링 하겠습니다~!

nonNullableType == DOUBLE -> CodeBlock.of("%L?.toDoubleOrNull()", rawAccess)
else -> {
logger.error(
"Wisp Error: Unsupported type " +
"'${param.typeName}' for parameter '${param.name}'."
)
CodeBlock.of("null")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.angrypodo.wisp.mapper

import com.angrypodo.wisp.model.ClassRouteInfo
import com.angrypodo.wisp.model.ObjectRouteInfo
import com.angrypodo.wisp.model.ParameterInfo
import com.angrypodo.wisp.model.RouteInfo
import com.google.devtools.ksp.symbol.ClassKind
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.toTypeName

private const val WISP_SIMPLE_NAME = "Wisp"
private const val WISP_PATH_ARGUMENT = "path"

internal fun KSClassDeclaration.toRouteInfo(): RouteInfo? {
val wispPath = getWispPath() ?: return null
val routeClassName = toClassName()
val factoryClassName = ClassName(packageName.asString(), "${simpleName.asString()}RouteFactory")

if (classKind == ClassKind.OBJECT) {
return ObjectRouteInfo(
routeClassName = routeClassName,
factoryClassName = factoryClassName,
wispPath = wispPath
)
}

return ClassRouteInfo(
routeClassName = routeClassName,
factoryClassName = factoryClassName,
wispPath = wispPath,
parameters = extractParameters()
)
}

private fun KSClassDeclaration.getWispPath(): String? {
val wispAnnotation = annotations.find { it.shortName.asString() == WISP_SIMPLE_NAME }
?: return null
val pathArgument =
wispAnnotation.arguments.firstOrNull { it.name?.asString() == WISP_PATH_ARGUMENT }
?: wispAnnotation.arguments.firstOrNull()
return pathArgument?.value as? String
}

private fun KSClassDeclaration.extractParameters(): List<ParameterInfo> {
return primaryConstructor?.parameters?.mapNotNull { parameter ->
val parameterName = parameter.name?.asString() ?: return@mapNotNull null
val resolvedType = parameter.type.resolve()
val declaration = resolvedType.declaration
val isEnum =
declaration is KSClassDeclaration && declaration.classKind == ClassKind.ENUM_CLASS

ParameterInfo(
name = parameterName,
typeName = resolvedType.toTypeName(),
isNullable = resolvedType.isMarkedNullable,
isEnum = isEnum
)
} ?: emptyList()
}
42 changes: 42 additions & 0 deletions wisp-processor/src/main/java/com/angrypodo/wisp/model/RouteInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.angrypodo.wisp.model

import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.TypeName

/**
* 라우트 정보를 타입에 따라 분리하여 표현하는 Sealed Interface 입니다.
*/
sealed interface RouteInfo {
val routeClassName: ClassName
val factoryClassName: ClassName
val wispPath: String
}

/**
* 파라미터가 없는 object/data object 타입 라우트의 정보입니다.
*/
internal data class ObjectRouteInfo(
override val routeClassName: ClassName,
override val factoryClassName: ClassName,
override val wispPath: String
) : RouteInfo

/**
* 생성자 파라미터가 있는 class/data class 타입 라우트의 정보입니다.
*/
internal data class ClassRouteInfo(
override val routeClassName: ClassName,
override val factoryClassName: ClassName,
override val wispPath: String,
val parameters: List<ParameterInfo>
) : RouteInfo

/**
* 라우트 생성자 파라미터 정보를 표현하는 데이터 클래스입니다.
*/
internal data class ParameterInfo(
val name: String,
val typeName: TypeName,
val isNullable: Boolean,
val isEnum: Boolean
)
Loading
Loading