diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/WispClassName.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/WispClassName.kt new file mode 100644 index 0000000..2d0ceae --- /dev/null +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/WispClassName.kt @@ -0,0 +1,13 @@ +package com.angrypodo.wisp + +import com.squareup.kotlinpoet.ClassName + +internal object WispClassName { + private const val RUNTIME_PACKAGE = "com.angrypodo.wisp.runtime" + const val GENERATED_PACKAGE = "com.angrypodo.wisp.generated" + + val ROUTE_FACTORY = ClassName(RUNTIME_PACKAGE, "RouteFactory") + + val MISSING_PARAMETER_ERROR = ClassName(RUNTIME_PACKAGE, "WispError", "MissingParameter") + val INVALID_PARAMETER_ERROR = ClassName(RUNTIME_PACKAGE, "WispError", "InvalidParameter") +} diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt index e1bfa48..0bc4cc5 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt @@ -1,7 +1,9 @@ package com.angrypodo.wisp +import com.angrypodo.wisp.WispValidator.validateDuplicatePaths import com.angrypodo.wisp.annotations.Wisp import com.angrypodo.wisp.generator.RouteFactoryGenerator +import com.angrypodo.wisp.generator.WispRegistryGenerator import com.angrypodo.wisp.mapper.toRouteInfo import com.angrypodo.wisp.model.RouteInfo import com.google.devtools.ksp.processing.CodeGenerator @@ -11,6 +13,7 @@ import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile import com.google.devtools.ksp.validate import com.squareup.kotlinpoet.ksp.writeTo @@ -32,22 +35,39 @@ internal class WispProcessor( val (processableSymbols, deferredSymbols) = symbols.partition { it.validate() } - processableSymbols.forEach { processSymbol(it) } + val routesWithSymbols = processableSymbols.mapNotNull { routeClass -> + val routeInfo = processSymbol(routeClass) ?: return@mapNotNull null + routeInfo to routeClass + } + + val routeInfos = routesWithSymbols.map { it.first } + + val duplicateValidationResult = validateDuplicatePaths(routeInfos) + + if (duplicateValidationResult is WispValidator.ValidationResult.Failure) { + duplicateValidationResult.errors.forEach { logger.error(it) } + return deferredSymbols + } + + if (routesWithSymbols.isNotEmpty()) { + val sourceFiles = routesWithSymbols.mapNotNull { it.second.containingFile }.distinct() + generateRouteRegistry(routeInfos, sourceFiles) + } return deferredSymbols } - private fun processSymbol(routeClass: KSClassDeclaration) { - if (!validateSerializable(routeClass)) { - return - } + private fun processSymbol(routeClass: KSClassDeclaration): RouteInfo? { + if (!validateSerializable(routeClass)) return null val routeInfo = routeClass.toRouteInfo() ?: run { logInvalidRouteError(routeClass) - return + return null } generateRouteFactory(routeClass, routeInfo) + + return routeInfo } private fun validateSerializable(routeClass: KSClassDeclaration): Boolean { @@ -77,6 +97,15 @@ internal class WispProcessor( fileSpec.writeTo(codeGenerator, dependencies) } + private fun generateRouteRegistry( + routeInfos: List, + sourceFiles: List + ) { + val fileSpec = WispRegistryGenerator.generate(routeInfos) + val dependencies = Dependencies(true, *sourceFiles.toTypedArray()) + fileSpec.writeTo(codeGenerator, dependencies) + } + private fun KSClassDeclaration.hasSerializableAnnotation(): Boolean { return annotations.any { annotation -> val shortName = annotation.shortName.asString() diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/WispValidator.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/WispValidator.kt index 165fad5..a73417d 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/WispValidator.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/WispValidator.kt @@ -1,19 +1,37 @@ package com.angrypodo.wisp +import com.angrypodo.wisp.model.RouteInfo + internal object WispValidator { sealed interface ValidationResult { data object Success : ValidationResult - data class Failure(val message: String) : ValidationResult + data class Failure(val errors: List) : ValidationResult } fun validate(routeInfo: RouteClassInfo): ValidationResult { if (!routeInfo.isSerializable()) { return ValidationResult.Failure( - message = "Wisp Error: Route Class '${routeInfo.qualifiedName}' " + - "must be annotated with @Serializable." + listOf( + "Wisp Error: Route Class '${routeInfo.qualifiedName}' " + + "must be annotated with @Serializable." + ) ) } return ValidationResult.Success } + + fun validateDuplicatePaths(routes: List): ValidationResult { + val duplicates = routes.groupBy { it.wispPath } + .filter { it.value.size > 1 } + + if (duplicates.isEmpty()) return ValidationResult.Success + + val errorMessages = duplicates.map { (path, routeInfos) -> + val conflictingClasses = routeInfos.joinToString(", ") { it.routeClassName.simpleName } + "Wisp Error: The path '$path' is already used by multiple routes: [$conflictingClasses]" + } + + return ValidationResult.Failure(errorMessages) + } } diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/RouteFactoryGenerator.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/RouteFactoryGenerator.kt index 45d803c..2b717e6 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/RouteFactoryGenerator.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/RouteFactoryGenerator.kt @@ -1,5 +1,8 @@ package com.angrypodo.wisp.generator +import com.angrypodo.wisp.WispClassName.INVALID_PARAMETER_ERROR +import com.angrypodo.wisp.WispClassName.MISSING_PARAMETER_ERROR +import com.angrypodo.wisp.WispClassName.ROUTE_FACTORY import com.angrypodo.wisp.model.ClassRouteInfo import com.angrypodo.wisp.model.ObjectRouteInfo import com.angrypodo.wisp.model.ParameterInfo @@ -7,7 +10,6 @@ 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 @@ -24,19 +26,6 @@ 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) @@ -47,7 +36,7 @@ internal class RouteFactoryGenerator( val factoryObject = TypeSpec.objectBuilder(routeInfo.factoryClassName) .addModifiers(KModifier.INTERNAL) - .addSuperinterface(routeFactoryInterface) + .addSuperinterface(ROUTE_FACTORY) .addFunction(createFun) .build() @@ -87,8 +76,8 @@ internal class RouteFactoryGenerator( val nonNullableType = param.typeName.copy(nullable = false) val errorType = when (nonNullableType) { - STRING -> missingParameterError - else -> invalidParameterError + STRING -> MISSING_PARAMETER_ERROR + else -> INVALID_PARAMETER_ERROR } return CodeBlock.of( diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt new file mode 100644 index 0000000..d36979e --- /dev/null +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt @@ -0,0 +1,56 @@ +package com.angrypodo.wisp.generator + +import com.angrypodo.wisp.WispClassName.GENERATED_PACKAGE +import com.angrypodo.wisp.WispClassName.ROUTE_FACTORY +import com.angrypodo.wisp.model.RouteInfo +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.MAP +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeSpec + +internal object WispRegistryGenerator { + private const val REGISTRY_NAME = "WispRegistry" + private const val FACTORIES_PROPERTY_NAME = "factories" + private const val GET_FACTORY_FUN_NAME = "getRouteFactory" + + fun generate(routes: List): FileSpec { + val mapType = MAP.parameterizedBy(STRING, ROUTE_FACTORY) + + val initializerBlock = CodeBlock.builder() + .add("mapOf(\n") + .indent() + + routes.forEach { route -> + initializerBlock.add("%S to %T,\n", route.wispPath, route.factoryClassName) + } + + initializerBlock.unindent().add(")") + + val factoriesProperty = PropertySpec.builder(FACTORIES_PROPERTY_NAME, mapType) + .addModifiers(KModifier.PRIVATE) + .initializer(initializerBlock.build()) + .build() + + val getFactoryFun = FunSpec.builder(GET_FACTORY_FUN_NAME) + .addModifiers(KModifier.INTERNAL) + .addParameter("path", STRING) + .returns(ROUTE_FACTORY.copy(nullable = true)) + .addStatement("return %N[path]", factoriesProperty) + .build() + + val registryObject = TypeSpec.objectBuilder(REGISTRY_NAME) + .addModifiers(KModifier.INTERNAL) + .addProperty(factoriesProperty) + .addFunction(getFactoryFun) + .build() + + return FileSpec.builder(GENERATED_PACKAGE, REGISTRY_NAME) + .addType(registryObject) + .build() + } +} diff --git a/wisp-processor/src/test/java/com/angrypodo/wisp/WispValidatorTest.kt b/wisp-processor/src/test/java/com/angrypodo/wisp/WispValidatorTest.kt index 98b6286..bcee6d1 100644 --- a/wisp-processor/src/test/java/com/angrypodo/wisp/WispValidatorTest.kt +++ b/wisp-processor/src/test/java/com/angrypodo/wisp/WispValidatorTest.kt @@ -26,7 +26,10 @@ class WispValidatorTest { assertTrue(result is WispValidator.ValidationResult.Failure) { "결과 타입은 Failure여야 합니다." } - assertEquals(expectedMessage, (result as WispValidator.ValidationResult.Failure).message) + assertEquals( + listOf(expectedMessage), + (result as WispValidator.ValidationResult.Failure).errors + ) } @Test diff --git a/wisp-processor/src/test/java/com/angrypodo/wisp/generator/RegistryGeneratorTest.kt b/wisp-processor/src/test/java/com/angrypodo/wisp/generator/RegistryGeneratorTest.kt new file mode 100644 index 0000000..604bcdc --- /dev/null +++ b/wisp-processor/src/test/java/com/angrypodo/wisp/generator/RegistryGeneratorTest.kt @@ -0,0 +1,47 @@ +package com.angrypodo.wisp.generator + +import com.angrypodo.wisp.model.ClassRouteInfo +import com.angrypodo.wisp.model.ObjectRouteInfo +import com.squareup.kotlinpoet.ClassName +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +internal class RegistryGeneratorTest { + + @Test + @DisplayName("RouteInfo를 받아 WispRegistry 오브젝트와 맵을 생성한다") + fun `generate_registry_with_multiple_routes`() { + // Given: RouteInfo 데이터 2개 + val homeRoute = ObjectRouteInfo( + routeClassName = ClassName("com.example", "Home"), + factoryClassName = ClassName("com.example", "HomeRouteFactory"), + wispPath = "home" + ) + + val profileRoute = ClassRouteInfo( + routeClassName = ClassName("com.example", "Profile"), + factoryClassName = ClassName("com.example", "ProfileRouteFactory"), + wispPath = "profile/{id}", + parameters = emptyList() + ) + + val routes = listOf(homeRoute, profileRoute) + + // When: 코드 생성 실행 + val fileSpec = WispRegistryGenerator.generate(routes) + val generatedCode = fileSpec.toString() + + println(generatedCode) + + // Then: 생성된 WispRegistry 객체를 반환 + assertTrue(generatedCode.contains("object WispRegistry")) + assertTrue(generatedCode.contains("val factories: Map = mapOf(")) + + assertTrue(generatedCode.contains("import com.example.HomeRouteFactory")) + assertTrue(generatedCode.contains("\"home\" to HomeRouteFactory")) + + assertTrue(generatedCode.contains("import com.example.ProfileRouteFactory")) + assertTrue(generatedCode.contains("\"profile/{id}\" to ProfileRouteFactory")) + } +}