diff --git a/.gitignore b/.gitignore index 66e784944bc..51a1a4407d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ ### Kotlin ### **/.kotlin +gradle-test/**/.idea ### Android ### # Built application files diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/build.gradle.kts b/arrow-libs/optics/arrow-optics-compiler-plugin/build.gradle.kts index aba72c2ccef..16b348ab1c5 100644 --- a/arrow-libs/optics/arrow-optics-compiler-plugin/build.gradle.kts +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/build.gradle.kts @@ -6,11 +6,35 @@ plugins { kotlin { explicitApi = null compilerOptions { - optIn.add("org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi") + // optIn.add("org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi") + // optIn.add("org.jetbrains.kotlin.fir.extensions.ExperimentalTopLevelDeclarationsGenerationApi") + // optIn.add("org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI") + freeCompilerArgs.add("-Xcontext-parameters") } } dependencies { compileOnly(kotlin("compiler")) + + testImplementation(kotlin("test")) + testImplementation(libs.kotest.assertionsCore) + testImplementation(libs.classgraph) + testImplementation(libs.kotlinCompileTesting) { + exclude( + group = libs.classgraph.get().module.group, + module = libs.classgraph.get().module.name + ) + exclude( + group = "org.jetbrains.kotlin", + module = "kotlin-stdlib" + ) + } + testRuntimeOnly(projects.arrowAnnotations) + testRuntimeOnly(projects.arrowCore) + testRuntimeOnly(projects.arrowOptics) } +tasks.withType().configureEach { + maxParallelForks = 1 + useJUnitPlatform() +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsModel.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsModel.kt new file mode 100644 index 00000000000..57552ae09be --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsModel.kt @@ -0,0 +1,77 @@ +package arrow.optics.plugin + +import org.jetbrains.kotlin.descriptors.Visibilities +import org.jetbrains.kotlin.descriptors.Visibility +import org.jetbrains.kotlin.name.Name + +/** The kind of an `@optics`-annotated source class. */ +enum class OpticsClassKind { DATA, VALUE, SEALED, INELIGIBLE } + +/** A user-facing generation target, mirroring `arrow.optics.OpticsTarget` plus COPY. */ +enum class OpticsTargetKind { ISO, LENS, PRISM, DSL, COPY } + +/** The base optic actually produced for a single focus. */ +enum class OpticKind { ISO, LENS, PRISM } + +/** The optic kind of the *outer* optic in a DSL composition extension. */ +enum class DslKind { ISO, LENS, PRISM, OPTIONAL, TRAVERSAL } + +/** + * Which outer-optic variants are produced for a base optic of [kind] (algo §8.2): + * exactly the kinds `X` for which `X` composed with the base kind is still an `X`. + */ +fun dslVariantsFor(kind: OpticKind): List = when (kind) { + OpticKind.LENS -> listOf(DslKind.LENS, DslKind.OPTIONAL, DslKind.TRAVERSAL) + OpticKind.PRISM -> listOf(DslKind.OPTIONAL, DslKind.PRISM, DslKind.TRAVERSAL) + OpticKind.ISO -> listOf(DslKind.ISO, DslKind.LENS, DslKind.OPTIONAL, DslKind.PRISM, DslKind.TRAVERSAL) +} + +/** + * Compute the effective target set for a class, per algo §2.3: + * read the annotation's targets (OPTIONAL dropped), default to everything when empty, + * then intersect with what the class kind supports, and add COPY when requested. + * + * @param annotationTargets the names found in `@optics(targets = [...])`, or `null`/empty for the no-arg case. + */ +fun computeTargets( + kind: OpticsClassKind, + annotationTargets: Set, + hasCopy: Boolean, +): Set { + val requested = + annotationTargets.ifEmpty { setOf(OpticsTargetKind.ISO, OpticsTargetKind.LENS, OpticsTargetKind.PRISM, OpticsTargetKind.DSL) } + val allowed = when (kind) { + OpticsClassKind.SEALED -> setOf(OpticsTargetKind.PRISM, OpticsTargetKind.LENS, OpticsTargetKind.DSL) + OpticsClassKind.VALUE -> setOf(OpticsTargetKind.ISO, OpticsTargetKind.DSL) + OpticsClassKind.DATA -> setOf(OpticsTargetKind.LENS, OpticsTargetKind.DSL) + OpticsClassKind.INELIGIBLE -> emptySet() + } + return buildSet { + addAll(requested intersect allowed) + if (hasCopy && kind != OpticsClassKind.INELIGIBLE) add(OpticsTargetKind.COPY) + } +} + +/** The optic name for a PRISM focus: subclass simple name with the first letter lowercased (algo §3.2). */ +fun lowercaseFirst(name: Name): Name { + val s = name.identifierOrNullIfSpecial ?: return name + if (s.isEmpty() || !s[0].isUpperCase()) return name + return Name.identifier(s.replaceFirstChar { it.lowercaseChar() }) +} + +/** + * Most-restrictive combination of two visibilities (algo §3.3). + * `public` is the identity; `private` dominates; `internal` and `protected` collapse to `private`. + */ +fun mostRestrictive(a: Visibility, b: Visibility): Visibility = when { + a == Visibilities.Public -> b + + b == Visibilities.Public -> a + + a == Visibilities.Private || b == Visibilities.Private -> Visibilities.Private + + a == b -> a + + // mixing internal and protected + else -> Visibilities.Private +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsNames.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsNames.kt new file mode 100644 index 00000000000..efecfa74782 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsNames.kt @@ -0,0 +1,89 @@ +package arrow.optics.plugin + +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +/** + * Central registry of the fully-qualified names of the `arrow.optics` API that the + * generated optics refer to. FIR uses these to build cone types; IR uses them to + * resolve external symbols. + */ +object OpticsNames { + val ARROW_OPTICS_PACKAGE = FqName("arrow.optics") + + val OPTICS_ANNOTATION = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("optics")) + val OPTICS_ANNOTATION_FQNAME: FqName = OPTICS_ANNOTATION.asSingleFqName() + val OPTICS_COPY_ANNOTATION = OPTICS_ANNOTATION.createNestedClassId(Name.identifier("copy")) + + // Underlying monomorphic interfaces + val MLENS = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Lens")) + val MISO = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Iso")) + val MPRISM = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Prism")) + val MOPTIONAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Optional")) + val MTRAVERSAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Traversal")) + + // Underlying polymorphic interfaces (these carry the companion objects with the factories) + val PLENS = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("PLens")) + val PISO = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("PIso")) + val PPRISM = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("PPrism")) + val POPTIONAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("POptional")) + val PTRAVERSAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("PTraversal")) + + private val INVOKE = Name.identifier("invoke") + private val PLUS = Name.identifier("plus") + + val PLENS_COMPANION = PLENS.createNestedClassId(Name.identifier("Companion")) + val PISO_COMPANION = PISO.createNestedClassId(Name.identifier("Companion")) + val PPRISM_COMPANION = PPRISM.createNestedClassId(Name.identifier("Companion")) + + val LENS_INVOKE = CallableId(PLENS_COMPANION, INVOKE) + val ISO_INVOKE = CallableId(PISO_COMPANION, INVOKE) + val PRISM_INSTANCE_OF = CallableId(PPRISM_COMPANION, Name.identifier("instanceOf")) + + val ISO_PLUS = CallableId(PISO, PLUS) + val LENS_PLUS = CallableId(PLENS, PLUS) + val PRISM_PLUS = CallableId(PPRISM, PLUS) + val OPTIONAL_PLUS = CallableId(POPTIONAL, PLUS) + val TRAVERSAL_PLUS = CallableId(PTRAVERSAL, PLUS) + + val ARROW_OPTICS_COPY = CallableId(ARROW_OPTICS_PACKAGE, Name.identifier("copy")) + val COPY = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Copy")) + val COPY_METHOD_NAME = Name.identifier("copy") + + /** The monomorphic `arrow.optics` poly-interface backing a focus of the given kind. */ + fun monoClassOf(kind: OpticKind) = when (kind) { + OpticKind.LENS -> MLENS + OpticKind.ISO -> MISO + OpticKind.PRISM -> MPRISM + } + + /** Monomorphic interface ClassId for a DSL outer-optic kind. */ + @Suppress("UNUSED") + fun monoClassFor(kind: DslKind): ClassId = when (kind) { + DslKind.ISO -> MISO + DslKind.LENS -> MLENS + DslKind.PRISM -> MPRISM + DslKind.OPTIONAL -> MOPTIONAL + DslKind.TRAVERSAL -> MTRAVERSAL + } + + /** Polymorphic interface ClassId for a DSL outer-optic kind. */ + fun polyClassFor(kind: DslKind): ClassId = when (kind) { + DslKind.ISO -> PISO + DslKind.LENS -> PLENS + DslKind.PRISM -> PPRISM + DslKind.OPTIONAL -> POPTIONAL + DslKind.TRAVERSAL -> PTRAVERSAL + } + + /** `plus` CallableId for a DSL outer-optic kind. */ + fun plusFor(kind: DslKind): CallableId = when (kind) { + DslKind.ISO -> ISO_PLUS + DslKind.LENS -> LENS_PLUS + DslKind.PRISM -> PRISM_PLUS + DslKind.OPTIONAL -> OPTIONAL_PLUS + DslKind.TRAVERSAL -> TRAVERSAL_PLUS + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/FirOpticsExtractor.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/FirOpticsExtractor.kt new file mode 100644 index 00000000000..a8f850dde84 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/FirOpticsExtractor.kt @@ -0,0 +1,261 @@ +package arrow.optics.plugin.fir + +import arrow.optics.plugin.OpticKind +import arrow.optics.plugin.OpticsClassKind +import arrow.optics.plugin.OpticsNames +import arrow.optics.plugin.OpticsTargetKind +import arrow.optics.plugin.computeTargets +import arrow.optics.plugin.lowercaseFirst +import arrow.optics.plugin.mostRestrictive +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.descriptors.Modality +import org.jetbrains.kotlin.descriptors.Visibility +import org.jetbrains.kotlin.fir.FirElement +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.declarations.getSealedClassInheritors +import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny +import org.jetbrains.kotlin.fir.declarations.processAllDeclarations +import org.jetbrains.kotlin.fir.declarations.utils.isAbstract +import org.jetbrains.kotlin.fir.declarations.utils.isData +import org.jetbrains.kotlin.fir.declarations.utils.isInlineOrValue +import org.jetbrains.kotlin.fir.declarations.utils.visibility +import org.jetbrains.kotlin.fir.expressions.FirAnnotation +import org.jetbrains.kotlin.fir.expressions.FirAnnotationCall +import org.jetbrains.kotlin.fir.expressions.FirPropertyAccessExpression +import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.symbols.impl.FirPropertySymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirValueParameterSymbol +import org.jetbrains.kotlin.fir.types.ConeKotlinType +import org.jetbrains.kotlin.fir.types.ConeStarProjection +import org.jetbrains.kotlin.fir.types.ConeTypeProjection +import org.jetbrains.kotlin.fir.types.FirResolvedTypeRef +import org.jetbrains.kotlin.fir.types.FirUserTypeRef +import org.jetbrains.kotlin.fir.types.classId +import org.jetbrains.kotlin.fir.types.constructType +import org.jetbrains.kotlin.fir.types.isMarkedNullable +import org.jetbrains.kotlin.fir.types.type +import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.Name + +/** A single base-optic focus discovered on the source class, as seen from FIR. */ +data class FirFocus( + val kind: OpticKind, + val opticName: Name, + /** The focus type as used for a *monomorphic* parent (field type for a lens, `Sub<*>` for a prism). */ + val focusType: ConeKotlinType, + /** For lenses/isos: the source component (constructor parameter / property) name. */ + val componentName: Name? = null, + /** For prisms: the subclass symbol (used for generic parents, algo §6). */ + val subclass: FirRegularClassSymbol? = null, + /** For prisms with a generic parent: the subclass's supertype, e.g. `Parent`. */ + val refinedSource: ConeKotlinType? = null, +) + +/** Reads `@optics`-annotated FIR class symbols and extracts the foci to generate. */ +object FirOpticsExtractor { + fun classKind(symbol: FirRegularClassSymbol): OpticsClassKind { + val isData = symbol.isData + val isInlineOrValue = symbol.isInlineOrValue + val classKind = symbol.classKind + val modality = symbol.rawStatus.modality + return when { + isData -> OpticsClassKind.DATA + isInlineOrValue && classKind == ClassKind.CLASS -> OpticsClassKind.VALUE + modality == Modality.SEALED -> OpticsClassKind.SEALED + else -> OpticsClassKind.INELIGIBLE + } + } + + /** Base optic foci to generate as companion members of [symbol], honouring the requested targets. */ + fun foci(symbol: FirRegularClassSymbol, session: FirSession): List { + val targets = effectiveTargets(symbol) + val all = when (classKind(symbol)) { + OpticsClassKind.DATA -> constructorFoci(symbol, session, OpticKind.LENS) + OpticsClassKind.VALUE -> constructorFoci(symbol, session, OpticKind.ISO) + OpticsClassKind.SEALED -> prismFoci(symbol, session) + sealedLensFoci(symbol, session) + else -> emptyList() + } + return all.filter { targetOf(it.kind) in targets } + } + + private fun targetOf(kind: OpticKind): OpticsTargetKind = when (kind) { + OpticKind.LENS -> OpticsTargetKind.LENS + OpticKind.ISO -> OpticsTargetKind.ISO + OpticKind.PRISM -> OpticsTargetKind.PRISM + } + + /** Whether the DSL composition extensions should be generated for [symbol]. */ + fun dslEnabled(symbol: FirRegularClassSymbol): Boolean = OpticsTargetKind.DSL in effectiveTargets(symbol) + + /** The effective target set (algo §2.3): requested ∩ kind-allowed, plus COPY when present. */ + @OptIn(SymbolInternals::class) + fun effectiveTargets(symbol: FirRegularClassSymbol): Set { + val requested = requestedTargets(symbol) + val hasCopy = symbol.annotations.any { it.checkEvenIfUnresolved(OpticsNames.OPTICS_COPY_ANNOTATION) } + return computeTargets(classKind(symbol), requested, hasCopy) + } + + /** + * Parse the `targets` array of `@optics(...)`, collecting the referenced [OpticsTargetKind]s. + * + * We walk the *raw* annotation argument expressions (only the annotation's class id is forced to + * resolve) and read the referenced enum-entry names. This is robust to the resolution phase at + * which generation runs — relying on `resolvedAnnotationsWithArguments.argumentMapping` is not, as + * the argument mapping may not yet be populated when `getCallableNamesForClass` runs (review §2.6). + */ + @OptIn(SymbolInternals::class) + private fun requestedTargets(symbol: FirRegularClassSymbol): Set { + val annotation = symbol.annotations.firstOrNull { it.checkEvenIfUnresolved(OpticsNames.OPTICS_ANNOTATION) } + if (annotation !is FirAnnotationCall) return emptySet() + + val found = mutableSetOf() + val collector = object : FirVisitorVoid() { + override fun visitElement(element: FirElement) { + element.acceptChildren(this) + } + + override fun visitPropertyAccessExpression(propertyAccessExpression: FirPropertyAccessExpression) { + when (propertyAccessExpression.calleeReference.name.asString()) { + "ISO" -> found += OpticsTargetKind.ISO + "LENS" -> found += OpticsTargetKind.LENS + "PRISM" -> found += OpticsTargetKind.PRISM + "DSL" -> found += OpticsTargetKind.DSL + // OPTIONAL is silently dropped (algo §2.3) + } + propertyAccessExpression.acceptChildren(this) + } + } + annotation.argumentList.arguments.forEach { it.accept(collector, null) } + return found + } + + /** + * LENS foci for abstract properties that are uniform across every (data-class) subclass of a + * sealed type (algo §5.2). Only monomorphic parents for now. + */ + private fun sealedLensFoci(symbol: FirRegularClassSymbol, session: FirSession): List { + if (symbol.typeParameterSymbols.isNotEmpty()) return emptyList() + val abstractProps = mutableListOf() + symbol.processAllDeclarations(session) { + if (it is FirPropertySymbol && it.isAbstract && it.receiverParameterSymbol == null) { + abstractProps.add(it) + } + } + if (abstractProps.isEmpty()) return emptyList() + + val subclasses = symbol.getSealedClassInheritors(session).mapNotNull { + session.symbolProvider.getClassLikeSymbolByClassId(it) as? FirRegularClassSymbol + } + if (subclasses.isEmpty() || subclasses.any { !it.isData }) return emptyList() + + return abstractProps.mapNotNull { prop -> + val propType = prop.resolvedReturnType + val uniform = subclasses.all { sub -> + val ctorParam = sub.primaryConstructorIfAny(session) + ?.valueParameterSymbols?.firstOrNull { it.name == prop.name } + ctorParam != null && sameType(ctorParam.resolvedReturnType, propType) + } + if (!uniform) return@mapNotNull null + FirFocus( + kind = OpticKind.LENS, + opticName = prop.name, + focusType = propType, + componentName = prop.name, + ) + } + } + + /** + * Structural type equality for the §5.2 uniformity check: classifier, nullability, and (recursively) + * the type arguments together with their projection kind, so e.g. `List` and `List` + * are *not* considered uniform. + */ + private fun sameType(a: ConeKotlinType, b: ConeKotlinType): Boolean { + if (a.classId != b.classId || a.isMarkedNullable != b.isMarkedNullable) return false + val aArgs = a.typeArguments + val bArgs = b.typeArguments + if (aArgs.size != bArgs.size) return false + return aArgs.indices.all { i -> + val at = aArgs[i].type + val bt = bArgs[i].type + if (at == null || bt == null) { + aArgs[i].kind == bArgs[i].kind // star projections + } else { + aArgs[i].kind == bArgs[i].kind && sameType(at, bt) + } + } + } + + /** + * The most-restrictive visibility of [symbol] and all of its enclosing classifiers (algo §3.3). + * Used directly for the top-level DSL/copy extensions, and combined with the companion's own + * visibility for the base companion members. + */ + fun effectiveVisibility(symbol: FirRegularClassSymbol, session: FirSession): Visibility { + var result: Visibility = symbol.visibility + var outerId = symbol.classId.outerClassId + while (outerId != null) { + val outer = session.symbolProvider.getClassLikeSymbolByClassId(outerId) as? FirRegularClassSymbol + if (outer != null) result = mostRestrictive(result, outer.visibility) + outerId = outerId.outerClassId + } + return result + } + + /** One PRISM focus per sealed subclass (algo §6). */ + private fun prismFoci(symbol: FirRegularClassSymbol, session: FirSession): List = symbol.getSealedClassInheritors(session).mapNotNull { classId -> + val sub = session.symbolProvider.getClassLikeSymbolByClassId(classId) as? FirRegularClassSymbol + ?: return@mapNotNull null + val starArgs: Array = + Array(sub.typeParameterSymbols.size) { ConeStarProjection } + // The subclass's supertype that mentions the sealed parent, e.g. `Parent`. + val refined = sub.resolvedSuperTypes.firstOrNull { it.classId == symbol.classId } + FirFocus( + kind = OpticKind.PRISM, + opticName = lowercaseFirst(classId.shortClassName), + focusType = sub.constructType(starArgs, false), + subclass = sub, + refinedSource = refined, + ) + } + + /** One focus per primary-constructor value parameter (LENS for data, ISO for value classes). */ + private fun constructorFoci(symbol: FirRegularClassSymbol, session: FirSession, kind: OpticKind): List { + val ctor = symbol.primaryConstructorIfAny(session) ?: return emptyList() + return ctor.valueParameterSymbols.map { param: FirValueParameterSymbol -> + FirFocus( + kind = kind, + opticName = param.name, + focusType = param.resolvedReturnType, + componentName = param.name, + ) + } + } + + @OptIn(SymbolInternals::class) + private fun FirRegularClassSymbol.getSealedClassInheritors(session: FirSession): List = fir.getSealedClassInheritors(session) +} + +fun FirAnnotation.checkEvenIfUnresolved(classId: ClassId): Boolean { + when (val ref = annotationTypeRef) { + is FirResolvedTypeRef -> return ref.coneType.classId == classId + + is FirUserTypeRef -> { + var current: ClassId? = classId + var position = ref.qualifier.size - 1 + while (current != null) { + if (position < 0) return false + if (ref.qualifier[position] != current.shortClassName) return false + + current = current.outerClassId + position-- + } + return true + } + + else -> return false + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCompanionGenerator.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCompanionGenerator.kt index 6cd24afbdf2..5eb0315ba08 100644 --- a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCompanionGenerator.kt +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCompanionGenerator.kt @@ -1,10 +1,15 @@ package arrow.optics.plugin.fir +import arrow.optics.plugin.OpticKind +import arrow.optics.plugin.OpticsNames +import arrow.optics.plugin.mostRestrictive import org.jetbrains.kotlin.GeneratedDeclarationKey +import org.jetbrains.kotlin.descriptors.Visibility import org.jetbrains.kotlin.fir.FirSession -import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirDeclarationOrigin +import org.jetbrains.kotlin.fir.declarations.FirTypeParameterRef import org.jetbrains.kotlin.fir.declarations.utils.isCompanion +import org.jetbrains.kotlin.fir.declarations.utils.visibility import org.jetbrains.kotlin.fir.extensions.FirDeclarationGenerationExtension import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar import org.jetbrains.kotlin.fir.extensions.MemberGenerationContext @@ -13,21 +18,35 @@ import org.jetbrains.kotlin.fir.extensions.predicate.DeclarationPredicate import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider import org.jetbrains.kotlin.fir.plugin.createCompanionObject import org.jetbrains.kotlin.fir.plugin.createDefaultPrivateConstructor +import org.jetbrains.kotlin.fir.plugin.createMemberFunction +import org.jetbrains.kotlin.fir.plugin.createMemberProperty +import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider +import org.jetbrains.kotlin.fir.resolve.substitution.ConeSubstitutor +import org.jetbrains.kotlin.fir.resolve.substitution.substitutorByMap +import org.jetbrains.kotlin.fir.symbols.ConeTypeParameterLookupTag import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirClassSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirConstructorSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirNamedFunctionSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirPropertySymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol -import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.fir.symbols.impl.FirTypeParameterSymbol +import org.jetbrains.kotlin.fir.types.ConeKotlinType +import org.jetbrains.kotlin.fir.types.constructClassLikeType +import org.jetbrains.kotlin.fir.types.constructType +import org.jetbrains.kotlin.fir.types.impl.ConeTypeParameterTypeImpl +import org.jetbrains.kotlin.name.CallableId import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.name.SpecialNames import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract -@OptIn(DirectDeclarationsAccess::class, SymbolInternals::class, ExperimentalContracts::class) class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + object Key : GeneratedDeclarationKey() + companion object { - val OPTICS_ANNOTATION_FQNAME = FqName.fromSegments(listOf("arrow", "optics", "optics")) + val OPTICS_ANNOTATION_FQNAME = OpticsNames.OPTICS_ANNOTATION_FQNAME val predicate = DeclarationPredicate.create { annotated(setOf(OPTICS_ANNOTATION_FQNAME)) @@ -38,12 +57,20 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx register(predicate) } + // ---- companion object creation (for classes that lack one) ------------------------- + + // NOTE: the companion-object phase runs before declaration *status* is resolved, so eligibility + // (which inspects `modality`/`isSealed`) cannot be checked here without crashing the compiler. + // Ineligible classes are therefore filtered later, during member generation (`foci` returns none), + // so they simply receive no optics — see `TargetTests."ineligible class generates no optics"`. + override fun getNestedClassifiersNames(classSymbol: FirClassSymbol<*>, context: NestedClassGenerationContext): Set { if (classSymbol !is FirRegularClassSymbol) return emptySet() if (!session.predicateBasedProvider.matches(predicate, classSymbol)) return emptySet() return setOf(SpecialNames.DEFAULT_NAME_FOR_COMPANION_OBJECT) } + @OptIn(SymbolInternals::class) override fun generateNestedClassLikeDeclaration(owner: FirClassSymbol<*>, name: Name, context: NestedClassGenerationContext): FirClassLikeSymbol<*>? { if (owner !is FirRegularClassSymbol) return null if (!session.predicateBasedProvider.matches(predicate, owner)) return null @@ -54,6 +81,7 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx }.symbol } + @OptIn(ExperimentalContracts::class) fun FirClassSymbol<*>.isGeneratedOpticsCompanion(): Boolean { contract { returns(true) implies (this@isGeneratedOpticsCompanion is FirRegularClassSymbol) @@ -61,16 +89,99 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx return isCompanion && this is FirRegularClassSymbol && (origin as? FirDeclarationOrigin.Plugin)?.key == Key } + // ---- base optic member generation -------------------------------------------------- + + /** The `@optics`-annotated source class enclosing [companion], if eligible. */ + private fun sourceClassOf(companion: FirClassSymbol<*>): FirRegularClassSymbol? { + if (!companion.isCompanion) return null + val outerId = companion.classId.outerClassId ?: return null + val source = session.symbolProvider.getClassLikeSymbolByClassId(outerId) as? FirRegularClassSymbol ?: return null + if (!session.predicateBasedProvider.matches(predicate, source)) return null + return source + } + + /** Base optic foci to generate as members of [companion]. */ + private fun fociFor(companion: FirClassSymbol<*>): List { + val source = sourceClassOf(companion) ?: return emptyList() + return FirOpticsExtractor.foci(source, session) + } + override fun getCallableNamesForClass(classSymbol: FirClassSymbol<*>, context: MemberGenerationContext): Set { - if (!classSymbol.isGeneratedOpticsCompanion()) return emptySet() - return setOf(SpecialNames.INIT) + val names = fociFor(classSymbol).mapTo(mutableSetOf()) { it.opticName } + if (classSymbol.isGeneratedOpticsCompanion()) names += SpecialNames.INIT + return names } + override fun generateProperties(callableId: CallableId, context: MemberGenerationContext?): List { + val owner = context?.owner ?: return emptyList() + val source = sourceClassOf(owner) ?: return emptyList() + if (source.typeParameterSymbols.isNotEmpty()) return emptyList() // generic -> function form + val focus = fociFor(owner).firstOrNull { it.opticName == callableId.callableName } ?: return emptyList() + val sourceType = source.constructType(emptyArray(), false) + val opticType = OpticsNames.monoClassOf(focus.kind).constructClassLikeType(arrayOf(sourceType, focus.focusType)) + val vis = mostRestrictive(FirOpticsExtractor.effectiveVisibility(source, session), owner.visibility) + val property = createMemberProperty(owner, Key, callableId.callableName, opticType, isVal = true, hasBackingField = false) { + visibility = vis + } + return listOf(property.symbol) + } + + override fun generateFunctions(callableId: CallableId, context: MemberGenerationContext?): List { + val owner = context?.owner ?: return emptyList() + val source = sourceClassOf(owner) ?: return emptyList() + if (source.typeParameterSymbols.isEmpty()) return emptyList() // monomorphic -> property form + val focus = fociFor(owner).firstOrNull { it.opticName == callableId.callableName } ?: return emptyList() + val vis = mostRestrictive(FirOpticsExtractor.effectiveVisibility(source, session), owner.visibility) + + // A PRISM on a generic parent quantifies over the *subclass's* type parameters and uses the + // subclass's refined supertype as its source (algo §6). Lenses/isos mirror the parent's parameters. + val function = if (focus.kind == OpticKind.PRISM) { + opticFunction(owner, callableId, focus.kind, vis, focus.subclass?.typeParameterSymbols.orEmpty()) { substitutor, funCones -> + val sourceType = focus.refinedSource?.let { substitutor.substituteOrSelf(it) } + ?: source.constructType(emptyArray(), false) + val focusType = focus.subclass?.constructType(funCones.toTypedArray(), false) ?: focus.focusType + sourceType to focusType + } + } else { + opticFunction(owner, callableId, focus.kind, vis, source.typeParameterSymbols) { substitutor, funCones -> + source.constructType(funCones.toTypedArray(), false) to substitutor.substituteOrSelf(focus.focusType) + } + } + return listOf(function.symbol) + } + + /** + * Build a generic base-optic function quantified over [typeParams]. [sourceAndFocus] receives a + * substitutor mapping the declared parameters to the function's freshly-introduced ones plus those + * fresh cone types, and returns the `(source, focus)` pair used as `Poly`. + */ + private fun opticFunction( + owner: FirClassSymbol<*>, + callableId: CallableId, + kind: OpticKind, + vis: Visibility, + typeParams: List, + sourceAndFocus: (ConeSubstitutor, List) -> Pair, + ) = createMemberFunction( + owner, + Key, + callableId.callableName, + returnTypeProvider = { functionTypeParameters -> + val funCones = functionTypeParameters.coneTypes() + val substitutor = substitutorByMap(typeParams.zip(funCones).toMap(), session) + val (sourceType, focusType) = sourceAndFocus(substitutor, funCones) + OpticsNames.monoClassOf(kind).constructClassLikeType(arrayOf(sourceType, focusType)) + }, + ) { + typeParams.forEach { tp -> typeParameter(tp.name) } + visibility = vis + } + + private fun List.coneTypes(): List = map { ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) } + override fun generateConstructors(context: MemberGenerationContext): List { val owner = context.owner if (!owner.isGeneratedOpticsCompanion()) return emptyList() return listOf(createDefaultPrivateConstructor(owner, Key).symbol) } - - object Key : GeneratedDeclarationKey() } diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCopyGenerator.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCopyGenerator.kt new file mode 100644 index 00000000000..4d11578ffcf --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCopyGenerator.kt @@ -0,0 +1,78 @@ +package arrow.optics.plugin.fir + +import arrow.optics.plugin.OpticsNames +import org.jetbrains.kotlin.GeneratedDeclarationKey +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.extensions.FirDeclarationGenerationExtension +import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar +import org.jetbrains.kotlin.fir.extensions.MemberGenerationContext +import org.jetbrains.kotlin.fir.extensions.predicate.DeclarationPredicate +import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider +import org.jetbrains.kotlin.fir.plugin.createMemberFunction +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.symbols.impl.FirClassSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirNamedFunctionSymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol +import org.jetbrains.kotlin.fir.types.CompilerConeAttributes +import org.jetbrains.kotlin.fir.types.ConeAttributes +import org.jetbrains.kotlin.fir.types.constructClassLikeType +import org.jetbrains.kotlin.fir.types.constructType +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +/** + * Generates the `@optics.copy` builder (algo §9) as a **member** of the source class: + * `fun copy(block: context(Copy) Source.Companion.(Source) -> Unit): Source`. + * Only monomorphic sources are supported for now. + */ +class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + object Key : GeneratedDeclarationKey() + + private val declarationPredicate = DeclarationPredicate.create { + annotated(setOf(OpticsNames.OPTICS_ANNOTATION_FQNAME)) + } + + override fun FirDeclarationPredicateRegistrar.registerPredicates() { + register(declarationPredicate) + } + + /** A monomorphic class carrying both `@optics` and `@optics.copy`, onto which we add `copy`. */ + @OptIn(SymbolInternals::class) + private fun isCopySource(classSymbol: FirClassSymbol<*>): Boolean = classSymbol is FirRegularClassSymbol && + classSymbol.typeParameterSymbols.isEmpty() && + session.predicateBasedProvider.matches(declarationPredicate, classSymbol) && + classSymbol.annotations.any { it.checkEvenIfUnresolved(OpticsNames.OPTICS_COPY_ANNOTATION) } + + override fun getCallableNamesForClass(classSymbol: FirClassSymbol<*>, context: MemberGenerationContext): Set = if (isCopySource(classSymbol)) setOf(OpticsNames.COPY_METHOD_NAME) else emptySet() + + override fun generateFunctions(callableId: CallableId, context: MemberGenerationContext?): List { + val source = context?.owner as? FirRegularClassSymbol ?: return emptyList() + if (callableId.callableName != OpticsNames.COPY_METHOD_NAME || !isCopySource(source)) return emptyList() + val companion = source.resolvedCompanionObjectSymbol ?: return emptyList() + + val sourceType = source.constructType(emptyArray(), false) + val companionType = companion.constructType(emptyArray(), false) + val copyType = OpticsNames.COPY.constructClassLikeType(arrayOf(sourceType), false) + // context(Copy) Source.Companion.(Source) -> Unit ==> kotlin.Function3 with attributes. + val blockAttributes = ConeAttributes.create( + listOf(CompilerConeAttributes.ExtensionFunctionType, CompilerConeAttributes.ContextFunctionTypeParams(1)), + ) + val blockType = ClassId(FqName("kotlin"), Name.identifier("Function3")) + .constructClassLikeType( + typeArguments = arrayOf(copyType, companionType, sourceType, session.builtinTypes.unitType.coneType), + isMarkedNullable = false, + blockAttributes, + ) + val function = createMemberFunction(source, Key, OpticsNames.COPY_METHOD_NAME, sourceType) { + valueParameter(Name.identifier("block"), blockType) + visibility = FirOpticsExtractor.effectiveVisibility(source, session) + // Give the function a body up front so the synthetic data-class `copy` body generator (which + // runs in Fir2Ir, before our IR pass) does not treat this body-less generated `copy` as the + // data-class copy and try to fill it as one. The real body is installed in the IR phase. + withGeneratedDefaultBody() + } + return listOf(function.symbol) + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsDslGenerator.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsDslGenerator.kt new file mode 100644 index 00000000000..13b9298145e --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsDslGenerator.kt @@ -0,0 +1,108 @@ +package arrow.optics.plugin.fir + +import arrow.optics.plugin.OpticKind +import arrow.optics.plugin.OpticsClassKind +import arrow.optics.plugin.OpticsNames +import arrow.optics.plugin.dslVariantsFor +import org.jetbrains.kotlin.GeneratedDeclarationKey +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.extensions.ExperimentalTopLevelDeclarationsGenerationApi +import org.jetbrains.kotlin.fir.extensions.FirDeclarationGenerationExtension +import org.jetbrains.kotlin.fir.extensions.FirDeclarationPredicateRegistrar +import org.jetbrains.kotlin.fir.extensions.MemberGenerationContext +import org.jetbrains.kotlin.fir.extensions.predicate.DeclarationPredicate +import org.jetbrains.kotlin.fir.extensions.predicate.LookupPredicate +import org.jetbrains.kotlin.fir.extensions.predicateBasedProvider +import org.jetbrains.kotlin.fir.plugin.createTopLevelProperty +import org.jetbrains.kotlin.fir.symbols.ConeTypeParameterLookupTag +import org.jetbrains.kotlin.fir.symbols.impl.FirPropertySymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol +import org.jetbrains.kotlin.fir.types.constructClassLikeType +import org.jetbrains.kotlin.fir.types.constructType +import org.jetbrains.kotlin.fir.types.impl.ConeTypeParameterTypeImpl +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +/** + * Generates the DSL composition extensions (algo §8) as top-level extension properties: + * `val <__S> OuterOptic<__S, Source>.focus: OuterOptic<__S, Focus> get() = this + Source.focus`. + * + * These cannot be companion members (their receiver is an arbitrary outer optic), so they remain + * top-level extensions. Only monomorphic sources are supported for now. + */ +@OptIn(ExperimentalTopLevelDeclarationsGenerationApi::class) +class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + object Key : GeneratedDeclarationKey() + + private val lookupPredicate = LookupPredicate.create { + annotated(setOf(OpticsNames.OPTICS_ANNOTATION_FQNAME)) + } + private val declarationPredicate = DeclarationPredicate.create { + annotated(setOf(OpticsNames.OPTICS_ANNOTATION_FQNAME)) + } + + override fun FirDeclarationPredicateRegistrar.registerPredicates() { + register(declarationPredicate) + } + + /** Monomorphic `@optics`-annotated source classes for which the DSL target is enabled. */ + private fun annotatedSources(): List = session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) + .filterIsInstance() + .filter { it.typeParameterSymbols.isEmpty() && FirOpticsExtractor.dslEnabled(it) } + + /** + * The foci that get DSL composition helpers. Per algo §8.4 a sealed type contributes only its + * prism family — its shared-property lenses (§5.2) do *not* get DSL variants. + */ + private fun dslFoci(source: FirRegularClassSymbol): List { + val isSealed = FirOpticsExtractor.classKind(source) == OpticsClassKind.SEALED + return FirOpticsExtractor.foci(source, session) + .filter { !(isSealed && it.kind == OpticKind.LENS) } + } + + override fun getTopLevelCallableIds(): Set = buildSet { + annotatedSources().forEach { source -> + val pkg = source.classId.packageFqName + dslFoci(source).forEach { add(CallableId(pkg, it.opticName)) } + } + } + + override fun hasPackage(packageFqName: FqName): Boolean = annotatedSources().any { it.classId.packageFqName == packageFqName } + + override fun generateProperties(callableId: CallableId, context: MemberGenerationContext?): List { + if (context != null) return emptyList() // only top-level + val result = mutableListOf() + annotatedSources().forEach { source -> + if (source.classId.packageFqName != callableId.packageName) return@forEach + val sourceType = source.constructType(emptyArray(), false) + val fileName = "${source.classId.shortClassName.asString()}Optics" + dslFoci(source) + .filter { it.opticName == callableId.callableName } + .forEach { focus -> + for (dslKind in dslVariantsFor(focus.kind)) { + val property = createTopLevelProperty( + key = Key, + callableId = callableId, + returnTypeProvider = { tps -> + val s = ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) + OpticsNames.polyClassFor(dslKind).constructClassLikeType(arrayOf(s, s, focus.focusType, focus.focusType)) + }, + isVal = true, + hasBackingField = false, + containingFileName = fileName, + ) { + typeParameter(Name.identifier("__S")) + extensionReceiverType { tps -> + val s = ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) + OpticsNames.polyClassFor(dslKind).constructClassLikeType(arrayOf(s, s, sourceType, sourceType)) + } + visibility = FirOpticsExtractor.effectiveVisibility(source, session) + } + result += property.symbol + } + } + } + return result + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsPluginWrappers.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsPluginWrappers.kt index ea5f8070758..477aed2fd66 100644 --- a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsPluginWrappers.kt +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsPluginWrappers.kt @@ -1,9 +1,14 @@ +@file:OptIn(ExperimentalCompilerApi::class) + package arrow.optics.plugin.fir +import arrow.optics.plugin.ir.OpticsIrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption import org.jetbrains.kotlin.compiler.plugin.CliOption import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter @@ -26,11 +31,14 @@ class OpticsPluginComponentRegistrar : CompilerPluginRegistrar() { override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension(OpticsPluginRegistrar()) + IrGenerationExtension.registerExtension(OpticsIrGenerationExtension()) } } class OpticsPluginRegistrar : FirExtensionRegistrar() { override fun ExtensionRegistrarContext.configurePlugin() { +::OpticsCompanionGenerator + +::OpticsDslGenerator + +::OpticsCopyGenerator } } diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrGenerationExtension.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrGenerationExtension.kt new file mode 100644 index 00000000000..71225c7e0c4 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrGenerationExtension.kt @@ -0,0 +1,340 @@ +@file:OptIn(UnsafeDuringIrConstructionAPI::class) + +package arrow.optics.plugin.ir + +import arrow.optics.plugin.DslKind +import arrow.optics.plugin.OpticsNames +import arrow.optics.plugin.fir.OpticsCompanionGenerator +import arrow.optics.plugin.fir.OpticsCopyGenerator +import arrow.optics.plugin.fir.OpticsDslGenerator +import org.jetbrains.kotlin.GeneratedDeclarationKey +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.descriptors.Modality +import org.jetbrains.kotlin.ir.IrElement +import org.jetbrains.kotlin.ir.builders.IrBuilderWithScope +import org.jetbrains.kotlin.ir.builders.irBlockBody +import org.jetbrains.kotlin.ir.builders.irBranch +import org.jetbrains.kotlin.ir.builders.irCall +import org.jetbrains.kotlin.ir.builders.irCallConstructor +import org.jetbrains.kotlin.ir.builders.irElseBranch +import org.jetbrains.kotlin.ir.builders.irGet +import org.jetbrains.kotlin.ir.builders.irGetObjectValue +import org.jetbrains.kotlin.ir.builders.irImplicitCast +import org.jetbrains.kotlin.ir.builders.irIs +import org.jetbrains.kotlin.ir.builders.irReturn +import org.jetbrains.kotlin.ir.builders.irWhen +import org.jetbrains.kotlin.ir.declarations.IrClass +import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrProperty +import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction +import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.symbols.IrClassSymbol +import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrSimpleType +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.types.defaultType +import org.jetbrains.kotlin.ir.types.typeOrNull +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.ir.util.companionObject +import org.jetbrains.kotlin.ir.util.defaultType +import org.jetbrains.kotlin.ir.util.functions +import org.jetbrains.kotlin.ir.util.parentAsClass +import org.jetbrains.kotlin.ir.util.primaryConstructor +import org.jetbrains.kotlin.ir.util.properties +import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid +import org.jetbrains.kotlin.ir.visitors.acceptChildrenVoid +import org.jetbrains.kotlin.name.Name + +class OpticsIrGenerationExtension : IrGenerationExtension { + override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { + val symbols = OpticsIrSymbols(pluginContext) + moduleFragment.acceptChildrenVoid(OpticsBodyGenerator(pluginContext, symbols)) + } +} + +/** + * Resolved references to the `arrow.optics` API used inside generated bodies. + * + * Everything is resolved lazily: the symbols are only looked up the first time a generated optic + * body is actually built, so applying the plugin to a module that does not depend on `arrow-optics` + * (and therefore generates nothing) never forces resolution and never crashes (review §2.5). + */ +class OpticsIrSymbols(private val ctx: IrPluginContext) { + val finder get() = ctx.finderForBuiltins() + + val lensInvoke: IrSimpleFunctionSymbol by lazy { + finder.findFunctions(OpticsNames.LENS_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } + } + val isoInvoke: IrSimpleFunctionSymbol by lazy { + finder.findFunctions(OpticsNames.ISO_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } + } + val prismInstanceOf: IrSimpleFunctionSymbol by lazy { + finder.findFunctions(OpticsNames.PRISM_INSTANCE_OF).first { it.owner.parameters.none { p -> p.kind == IrParameterKind.Regular } } + } + val plens: IrClassSymbol by lazy { finder.findClass(OpticsNames.PLENS)!! } + val piso: IrClassSymbol by lazy { finder.findClass(OpticsNames.PISO)!! } + val pprism: IrClassSymbol by lazy { finder.findClass(OpticsNames.PPRISM)!! } + val plensCompanion: IrClassSymbol by lazy { finder.findClass(OpticsNames.PLENS_COMPANION)!! } + val pisoCompanion: IrClassSymbol by lazy { finder.findClass(OpticsNames.PISO_COMPANION)!! } + val pprismCompanion: IrClassSymbol by lazy { finder.findClass(OpticsNames.PPRISM_COMPANION)!! } + + /** For each optic poly-interface, its `plus` composition operator (keyed by the receiver class). */ + val polyPlus: Map by lazy { + DslKind.entries.associate { kind -> + val cls = finder.findClass(OpticsNames.polyClassFor(kind))!! + val plus = finder.findFunctions(OpticsNames.plusFor(kind)) + .first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 1 } + cls to plus + } + } + + // COPY builder support. + val copyClass: IrClassSymbol by lazy { finder.findClass(OpticsNames.COPY)!! } + val arrowOpticsCopy: IrSimpleFunctionSymbol by lazy { + finder.findFunctions(OpticsNames.ARROW_OPTICS_COPY).first { fn -> + fn.owner.parameters.any { it.kind == IrParameterKind.ExtensionReceiver } && + fn.owner.parameters.count { it.kind == IrParameterKind.Regular } == 1 + } + } +} + +private enum class IrOpticKind { LENS, ISO, PRISM } + +private class OpticsBodyGenerator( + private val ctx: IrPluginContext, + private val symbols: OpticsIrSymbols, +) : IrVisitorVoid() { + + override fun visitElement(element: IrElement) { + element.acceptChildrenVoid(this) + } + + override fun visitProperty(declaration: IrProperty) { + val getter = declaration.getter + if (getter != null) { + when (keyOf(declaration.origin)) { + OpticsCompanionGenerator.Key -> { + declaration.backingField = null + buildOpticBody(getter, declaration.name) + } + + OpticsDslGenerator.Key -> { + declaration.backingField = null + buildDslBody(getter, declaration.name) + } + } + } + super.visitProperty(declaration) + } + + override fun visitSimpleFunction(declaration: IrSimpleFunction) { + if (declaration.correspondingPropertySymbol == null) { + when (keyOf(declaration.origin)) { + OpticsCompanionGenerator.Key -> if (declaration.body == null) buildOpticBody(declaration, declaration.name) + + // The copy member is created with a placeholder body (see OpticsCopyGenerator), so overwrite it. + OpticsCopyGenerator.Key -> buildCopyBody(declaration) + } + } + super.visitSimpleFunction(declaration) + } + + /** `{ this.copy { block(this, Source.Companion, this@copy) } }` for a generated `@optics.copy` member. */ + private fun buildCopyBody(copyFn: IrSimpleFunction) { + val receiver = copyFn.parameters.first { it.kind == IrParameterKind.DispatchReceiver } + val blockParam = copyFn.parameters.first { it.kind == IrParameterKind.Regular } + val sourceType = receiver.type + val source = sourceType.classOrNull?.owner ?: return + val companion = source.companionObject() ?: return + val copyType = symbols.copyClass.typeWith(sourceType) + val unit = ctx.irBuiltIns.unitType + val function3Invoke = ctx.irBuiltIns.functionN(3).functions.first { it.name.asString() == "invoke" }.symbol + + copyFn.body = DeclarationIrBuilder(ctx, copyFn.symbol).irBlockBody { + val lambda = ctx.buildLambda(copyFn, listOf(copyType), unit) { (copyReceiver) -> + val invoke = irCall(function3Invoke, unit) + invoke.setDispatch(irGet(blockParam)) + invoke.setRegular(0, irGet(copyReceiver)) + invoke.setRegular(1, irGetObjectValue(companion.defaultType, companion.symbol)) + invoke.setRegular(2, irGet(receiver)) + +invoke + } + val call = irCall(symbols.arrowOpticsCopy, sourceType, listOf(sourceType)) + call.setExtension(irGet(receiver)) + call.setRegular(0, lambda) + +irReturn(call) + } + } + + private fun keyOf(origin: IrDeclarationOrigin): GeneratedDeclarationKey? = (origin as? IrDeclarationOrigin.GeneratedByPlugin)?.pluginKey + + /** Fill in the body of a generated companion optic ([opticFn] is the property getter or the standalone function). */ + private fun buildOpticBody(opticFn: IrSimpleFunction, opticName: Name) { + val kind = when (opticFn.returnType.classOrNull) { + symbols.plens -> IrOpticKind.LENS + symbols.piso -> IrOpticKind.ISO + symbols.pprism -> IrOpticKind.PRISM + else -> return + } + val source = opticFn.parentAsClass.parentAsClass + val rt = opticFn.returnType as IrSimpleType + val sourceType = rt.arguments[0].typeOrNull!! + val focusType = rt.arguments[2].typeOrNull!! + val ctorTypeArgs = opticFn.typeParameters.map { it.defaultType } + + opticFn.body = DeclarationIrBuilder(ctx, opticFn.symbol).irBlockBody { + val expr = when (kind) { + IrOpticKind.LENS -> buildLens(opticFn, source, sourceType, focusType, opticName, ctorTypeArgs) + IrOpticKind.ISO -> buildIso(opticFn, source, sourceType, focusType, opticName, ctorTypeArgs) + IrOpticKind.PRISM -> buildPrism(sourceType, focusType, opticFn.returnType) + } + +irReturn(expr) + } + } + + /** `get() = this + Source.focus` for a generated DSL composition extension. */ + private fun buildDslBody(getter: IrSimpleFunction, focusName: Name) { + val receiver = getter.parameters.first { it.kind == IrParameterKind.ExtensionReceiver } + val receiverType = receiver.type as IrSimpleType + val outerClass = receiverType.classOrNull ?: return + val plus = symbols.polyPlus[outerClass] ?: return + val sourceType = receiverType.arguments[2].typeOrNull ?: return + val source = sourceType.classOrNull?.owner ?: return + val focusType = (getter.returnType as IrSimpleType).arguments[2].typeOrNull ?: return + val companion = source.companionObject() ?: return + val baseProp = companion.properties.firstOrNull { it.name == focusName } ?: return + val baseGetter = baseProp.getter ?: return + + getter.body = DeclarationIrBuilder(ctx, getter.symbol).irBlockBody { + val base = irCall(baseGetter.symbol, baseGetter.returnType) + base.setDispatch(irGetObjectValue(companion.defaultType, companion.symbol)) + val composed = irCall(plus, getter.returnType, listOf(focusType, focusType)) + composed.setDispatch(irGet(receiver)) + composed.setRegular(0, base) + +irReturn(composed) + } + } + + private fun IrBuilderWithScope.buildPrism( + sourceType: IrType, + focusType: IrType, + returnType: IrType, + ): IrExpression { + val call = irCall(symbols.prismInstanceOf, returnType, listOf(sourceType, focusType)) + call.setDispatch(irGetObjectValue(symbols.pprismCompanion.owner.defaultType, symbols.pprismCompanion)) + return call + } + + private fun IrBuilderWithScope.buildLens( + opticFn: IrSimpleFunction, + source: IrClass, + sourceType: IrType, + focusType: IrType, + fieldName: Name, + ctorTypeArgs: List, + ): IrExpression { + val getLambda = ctx.buildLambda(opticFn, listOf(sourceType), focusType) { (s) -> + +irReturn(readComponent(source, fieldName, focusType, irGet(s))) + } + val setLambda = ctx.buildLambda(opticFn, listOf(sourceType, focusType), sourceType) { (s, v) -> + val body = if (source.modality == Modality.SEALED) { + sealedSet(source, sourceType, fieldName, s, v) + } else { + reconstruct(source, ctorTypeArgs, { irGet(s) }, fieldName) { irGet(v) } + } + +irReturn(body) + } + val call = irCall(symbols.lensInvoke, opticFn.returnType, listOf(sourceType, sourceType, focusType, focusType)) + call.setDispatch(irGetObjectValue(symbols.plensCompanion.owner.defaultType, symbols.plensCompanion)) + call.setRegular(0, getLambda) + call.setRegular(1, setLambda) + return call + } + + /** `when (s) { is Sub1 -> Sub1(prop = v, ...); ... }` over a sealed hierarchy. */ + private fun IrBuilderWithScope.sealedSet( + source: IrClass, + sourceType: IrType, + fieldName: Name, + instance: IrValueParameter, + value: IrValueParameter, + ): IrExpression { + val branches = source.sealedSubclasses.map { subSymbol -> + val sub = subSymbol.owner + val subType = sub.defaultType + irBranch( + irIs(irGet(instance), subType), + // A fresh `instance as Sub` is built for every field read, so no IR node is shared. + reconstruct(sub, emptyList(), { irImplicitCast(irGet(instance), subType) }, fieldName) { irGet(value) }, + ) + } + irElseBranch(irCall(ctx.irBuiltIns.noWhenBranchMatchedExceptionSymbol)) + return irWhen(sourceType, branches) + } + + private fun IrBuilderWithScope.buildIso( + opticFn: IrSimpleFunction, + source: IrClass, + sourceType: IrType, + focusType: IrType, + fieldName: Name, + ctorTypeArgs: List, + ): IrExpression { + val getLambda = ctx.buildLambda(opticFn, listOf(sourceType), focusType) { (s) -> + +irReturn(readComponent(source, fieldName, focusType, irGet(s))) + } + val reverseGetLambda = ctx.buildLambda(opticFn, listOf(focusType), sourceType) { (v) -> + +irReturn(reconstruct(source, ctorTypeArgs, { irGet(v) }, fieldName) { irGet(v) }) + } + val call = irCall(symbols.isoInvoke, opticFn.returnType, listOf(sourceType, sourceType, focusType, focusType)) + call.setDispatch(irGetObjectValue(symbols.pisoCompanion.owner.defaultType, symbols.pisoCompanion)) + call.setRegular(0, getLambda) + call.setRegular(1, reverseGetLambda) + return call + } + + /** `instance.field` via the property getter; [instance] must produce a fresh expression. */ + private fun IrBuilderWithScope.readComponent( + source: IrClass, + fieldName: Name, + focusType: IrType, + instance: IrExpression, + ): IrExpression { + val prop = source.properties.first { it.name == fieldName } + val call = irCall(prop.getter!!.symbol, focusType) + call.setDispatch(instance) + return call + } + + /** + * Reconstruct [source] via its primary constructor, replacing [overrideName] with [overrideValue]. + * [instance] and [overrideValue] are *factories* that must produce a fresh IR node on every call, so + * that reading several sibling components never shares the same IR node (which would break IR + * invariants — see review §2.1). + */ + private fun IrBuilderWithScope.reconstruct( + source: IrClass, + ctorTypeArgs: List, + instance: () -> IrExpression, + overrideName: Name, + overrideValue: () -> IrExpression, + ): IrExpression { + val ctor = source.primaryConstructor!! + val call = irCallConstructor(ctor.symbol, ctorTypeArgs) + ctor.parameters.filter { it.kind == IrParameterKind.Regular }.forEach { param -> + val arg = if (param.name == overrideName) { + overrideValue() + } else { + readComponent(source, param.name, param.type, instance()) + } + call.arguments[param] = arg + } + return call + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrHelpers.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrHelpers.kt new file mode 100644 index 00000000000..46cb285e21d --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrHelpers.kt @@ -0,0 +1,74 @@ +@file:OptIn(UnsafeDuringIrConstructionAPI::class) + +package arrow.optics.plugin.ir + +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder +import org.jetbrains.kotlin.descriptors.DescriptorVisibilities +import org.jetbrains.kotlin.descriptors.Modality +import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET +import org.jetbrains.kotlin.ir.builders.IrBlockBodyBuilder +import org.jetbrains.kotlin.ir.builders.declarations.addValueParameter +import org.jetbrains.kotlin.ir.builders.declarations.buildFun +import org.jetbrains.kotlin.ir.builders.irBlockBody +import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin +import org.jetbrains.kotlin.ir.declarations.IrDeclarationParent +import org.jetbrains.kotlin.ir.declarations.IrFunction +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.expressions.IrExpression +import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression +import org.jetbrains.kotlin.ir.expressions.IrMemberAccessExpression +import org.jetbrains.kotlin.ir.expressions.IrStatementOrigin +import org.jetbrains.kotlin.ir.expressions.impl.IrFunctionExpressionImpl +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.typeWith +import org.jetbrains.kotlin.name.SpecialNames + +/** + * Build an anonymous lambda `{ p0, p1, ... -> body }` as an [IrFunctionExpression], for use as a + * `get`/`set`/`reverseGet` argument to an optic factory. + */ +fun IrPluginContext.buildLambda( + parent: IrDeclarationParent, + parameterTypes: List, + returnType: IrType, + body: IrBlockBodyBuilder.(params: List) -> Unit, +): IrFunctionExpression { + val lambda = irFactory.buildFun { + name = SpecialNames.NO_NAME_PROVIDED + origin = IrDeclarationOrigin.LOCAL_FUNCTION_FOR_LAMBDA + visibility = DescriptorVisibilities.LOCAL + modality = Modality.FINAL + this.returnType = returnType + } + lambda.parent = parent + parameterTypes.forEachIndexed { i, t -> lambda.addValueParameter("p$i", t) } + lambda.body = DeclarationIrBuilder(this, lambda.symbol).irBlockBody { + body(lambda.parameters) + } + val functionType = irBuiltIns.functionN(parameterTypes.size).typeWith(parameterTypes + returnType) + return IrFunctionExpressionImpl(UNDEFINED_OFFSET, UNDEFINED_OFFSET, functionType, lambda, IrStatementOrigin.LAMBDA) +} + +/** Set the dispatch-receiver argument of [this] call, addressing it by parameter kind. */ +fun IrMemberAccessExpression<*>.setDispatch(receiver: IrExpression) { + val owner = (symbol.owner as? IrFunction) ?: return + val dispatch = owner.parameters.firstOrNull { it.kind == IrParameterKind.DispatchReceiver } ?: return + arguments[dispatch] = receiver +} + +/** Set the [n]-th regular argument of [this] call. */ +fun IrMemberAccessExpression<*>.setRegular(n: Int, value: IrExpression) { + val owner = (symbol.owner as? IrFunction) ?: return + val regulars = owner.parameters.filter { it.kind == IrParameterKind.Regular } + arguments[regulars[n]] = value +} + +/** Set the extension-receiver argument of [this] call. */ +fun IrMemberAccessExpression<*>.setExtension(receiver: IrExpression) { + val owner = (symbol.owner as? IrFunction) ?: return + val ext = owner.parameters.firstOrNull { it.kind == IrParameterKind.ExtensionReceiver } ?: return + arguments[ext] = receiver +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt new file mode 100644 index 00000000000..a302a8f3b34 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalCompilerApi::class) + +package arrow.optics.plugin + +import arrow.optics.plugin.fir.OpticsPluginComponentRegistrar +import com.tschuchort.compiletesting.CompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import io.github.classgraph.ClassGraph +import io.kotest.assertions.AssertionErrorBuilder.Companion.fail +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.JvmTarget +import java.io.File +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Paths + +val arrowVersion: String? = System.getProperty("arrowVersion") +const val SOURCE_FILENAME = "Source.kt" +const val CLASS_FILENAME = "SourceKt" + +fun String.failsWith(check: (String) -> Boolean) { + val compilationResult = compile(this) + compilationResult.exitCode shouldNotBe KotlinCompilation.ExitCode.OK + check(compilationResult.messages).shouldBeTrue() +} + +fun String.compilationFails() { + val compilationResult = compile(this) + compilationResult.exitCode shouldNotBe KotlinCompilation.ExitCode.OK +} + +fun String.compilationSucceeds( + allWarningsAsErrors: Boolean = false, + contextParameters: Boolean = false, +) { + compilationSucceeds(allWarningsAsErrors, contextParameters, SourceFile.kotlin(SOURCE_FILENAME, this.trimMargin())) +} + +fun compilationSucceeds( + allWarningsAsErrors: Boolean = false, + contextParameters: Boolean = false, + vararg sources: SourceFile, +) { + val compilationResult = compile(allWarningsAsErrors, contextParameters, *sources) + compilationResult.exitCode.shouldBe(KotlinCompilation.ExitCode.OK, compilationResult.messages) +} + +fun String.evals(thing: Pair, contextParameters: Boolean = false) { + val compilationResult = compile(this, contextParameters = contextParameters) + compilationResult.exitCode.shouldBe(KotlinCompilation.ExitCode.OK, compilationResult.messages) + val classesDirectory = compilationResult.outputDirectory + val (variable, output) = thing + eval(variable, classesDirectory) shouldBe output +} + +internal fun compile( + text: String, + allWarningsAsErrors: Boolean = false, + contextParameters: Boolean = false, +): CompilationResult = compile(allWarningsAsErrors, contextParameters, SourceFile.kotlin(SOURCE_FILENAME, text.trimMargin())) + +internal fun compile( + allWarningsAsErrors: Boolean = false, + contextParameters: Boolean = false, + vararg sources: SourceFile, +): CompilationResult = buildCompilation(allWarningsAsErrors, contextParameters, *sources).compile() + +fun buildCompilation( + allWarningsAsErrors: Boolean = false, + contextParameters: Boolean = false, + vararg sources: SourceFile, +) = KotlinCompilation().apply { + this.jvmTarget = JvmTarget.JVM_1_8.description + this.classpaths = listOf( + "arrow-annotations:$arrowVersion", + "arrow-core:$arrowVersion", + "arrow-optics:$arrowVersion", + ).map { classpathOf(it) } + this.sources = sources.toList() + this.verbose = false + this.allWarningsAsErrors = allWarningsAsErrors + this.compilerPluginRegistrars = listOf(OpticsPluginComponentRegistrar()) + if (contextParameters) { + this.kotlincArguments = listOf("-Xcontext-parameters") + } +} + +private fun classpathOf(dependency: String): File { + val file = + ClassGraph().classpathFiles.firstOrNull { classpath -> + dependenciesMatch(classpath, dependency) + } + if (file == null) { + fail("$dependency not found in test runtime. Check your build configuration.") + } + return file +} + +private fun dependenciesMatch(classpath: File, dependency: String): Boolean { + val dep = classpath.name + val dependencyName = sanitizeClassPathFileName(dep) + val testdep = dependency.substringBefore(":") + return testdep == dependencyName +} + +private fun sanitizeClassPathFileName(dep: String): String = buildList { + var skip = false + add(dep.first()) + val _ = dep.reduce { a, b -> + if (a == '-' && b.isDigit()) skip = true + if (!skip) add(b) + b + } + if (skip) removeLast() +} + .joinToString("") + .replace("-jvm.jar", "") + .replace("-jvm", "") + +private fun eval(expression: String, classesDirectory: File): Any? { + val classLoader = URLClassLoader(arrayOf(classesDirectory.toURI().toURL())) + val fullClassName = getFullClassName(classesDirectory) + val field = classLoader.loadClass(fullClassName).getDeclaredField(expression) + field.isAccessible = true + return field.get(Any()) +} + +private fun getFullClassName(classesDirectory: File): String = Files.walk(Paths.get(classesDirectory.toURI())) + .filter { it.toFile().name == "$CLASS_FILENAME.class" } + .toArray()[0] + .toString() + .removePrefix(classesDirectory.absolutePath + File.separator) + .removeSuffix(".class") + .replace(File.separator, ".") diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/CopyTest.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/CopyTest.kt new file mode 100644 index 00000000000..76084dd7ff9 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/CopyTest.kt @@ -0,0 +1,99 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +// from https://kotlinlang.slack.com/archives/C5UPMM0A0/p1688822411819599 +// and https://github.com/overfullstack/my-lab/blob/master/arrow/src/test/kotlin/ga/overfullstack/optics/OpticsLab.kt + +val copyCode = """ +@optics data class Person(val name: String, val age: Int, val address: Address) { + companion object +} +@optics data class Address(val street: Street, val city: City, val coordinates: List) { + companion object +} +@optics data class Street(val name: String, val number: Int?) { + companion object +} +@optics data class City(val name: String, val country: String) { + companion object +} + +fun Person.moveToAmsterdamCopy(): Person = copy { + Person.address.city.name set "Amsterdam" + Person.address.city.country set "Netherlands" + Person.address .coordinates set listOf(2, 3) +} + +fun Person.moveToAmsterdamInside(): Person = copy { + inside(Person.address.city) { + City.name set "Amsterdam" + City.country set "Netherlands" + } +} + +val me = + Person( + "Gopal", + 99, + Address(Street("Kotlinstraat", 1), City("Hilversum", "Netherlands"), listOf(1, 2)) + ) +""" + +class CopyTest { + @Test + fun `code compiles`() { + """ + |package PersonTest + |$imports + |$copyCode + """.compilationSucceeds() + } + + @Test + fun `birthday increments`() { + """ + |package PersonTest + |$imports + |$copyCode + |val meAfterBirthdayParty = Person.age.modify(me) { it + 1 } + |val r = Person.age.get(meAfterBirthdayParty) + """.evals("r" to 100) + } + + @Test + fun `moving to another city`() { + """ + |package PersonTest + |$imports + |$copyCode + |val newAddress = + | Address(Street("Kotlinplein", null), City("Amsterdam", "Netherlands"), listOf(1, 2)) + |val meAfterMoving = Person.address.set(me, newAddress) + |val r = Person.address.get(meAfterMoving).street.name + """.evals("r" to "Kotlinplein") + } + + @Test + fun `optics composition`() { + """ + |package PersonTest + |$imports + |$copyCode + |val personCity: Lens = Person.address compose Address.city compose City.name + |val meAtTheCapital = personCity.set(me, "Amsterdam") + |val r = meAtTheCapital.address.city.name + """.evals("r" to "Amsterdam") + } + + @Test + fun `optics copy to modify multiple fields`() { + """ + |package PersonTest + |$imports + |$copyCode + |val meAfterMoving = me.moveToAmsterdamInside() + |val r = meAfterMoving.address.city.name + """.evals("r" to "Amsterdam") + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/DSLTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/DSLTests.kt new file mode 100644 index 00000000000..53b5e0a1f53 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/DSLTests.kt @@ -0,0 +1,309 @@ +package arrow.optics.plugin + +import com.tschuchort.compiletesting.SourceFile +import kotlin.test.Test + +class DSLTests { + + @Test + fun `DSL is generated for complex model with Every`() { + """ + |$`package` + |$imports + |$dslModel + |$dslValues + |val modify = Employees.employees.every.company.notNull.address + | .street.name.modify(employees, String::uppercase) + |val r = modify.employees.map { it.company?.address?.street?.name }.toString() + """.evals("r" to "[LAMBDA STREET, LAMBDA STREET]") + } + + @Test + fun `DSL is generated for complex model with At`() { + """ + |$`package` + |$imports + |$dslModel + |$dslValues + |val modify = Db.content.at(At.map(), One).set(db, None) + |val r = modify.toString() + """.evals("r" to "Db(content={Two=two, Three=three, Four=four})") + } + + @Test + fun `DSL works with extensions in the file, issue #2803`() { + // it's important to keep the 'Source' name for the class, + // because files in the test are named 'Source.kt' + """ + |$`package` + |$imports + | + |@optics + |data class Source(val id: Int) { + | companion object + |} + | + |fun Source.toSomeObject() = 5 + """.compilationSucceeds() + } + + @Test + fun `DSL for a data class with property named as a package directive`() { + """ + |package main.program + | + |$imports + | + |@optics + |data class Source(val program: String) { + | companion object + |} + | + """.compilationSucceeds() + } + + @Test + fun `DSL for a class in a package including keywords, issue #2996`() { + """ + |package id.co.app_name.features.main.transaction.internal.outgoing.data.OutgoingInternalTransaction + | + |$imports + | + |@optics + |data class Source(val program: String) { + | companion object + |} + | + """.compilationSucceeds() + } + + @Test + fun `DSL for a class in a package including keywords, issue #3134, part 1`() { + """ + |package com.sats.core.data.workouts.models + | + |$imports + | + |@optics + |data class Source(val program: String) { + | companion object + |} + | + """.compilationSucceeds() + } + + /* + This test is for a very specific corner case, in which: + - The package name includes a Kotlin keyword, so we need to escape them, + - There's at least one property which shares name with part of the package, + so we need to include an explicit import + */ + @Test + fun `DSL for a class in a package including keywords and conflicting fields, issue #3134, part 2`() { + """ + |package com.sats.core.data.workouts.models + | + |$imports + | + |@optics + |data class Source(val models: String) { + | companion object + |} + | + """.compilationSucceeds() + } + + @Test + fun `DSL for a class in a package including it, issue #3441`() { + """ + |package it.facile.assicurati + | + |$imports + | + |@optics + |data class Source(val models: String) { + | companion object + |} + | + |@optics + |sealed class PrismSealed(val field: String, val nullable: String?) { + | data class PrismSealed1(private val a: String?) : PrismSealed("", a) + | data class PrismSealed2(private val b: String?) : PrismSealed("", b) + | companion object + |} + | + """.compilationSucceeds() + } + + @Test + fun `DSL works with variance, issue #3057`() { + """ + |$`package` + |$imports + | + |sealed interface ITest { + | data class Test1(val test: String) : ITest + |} + | + |interface Extendable + |@optics + |data class TestClass(val details: Extendable) { + | companion object + |} + """.compilationSucceeds() + } + + @Test + fun `Using S as a type, #3399`() { + """ + |$`package` + |$imports + |@optics + |data class Box(val s: S) { + | companion object + |} + | + |val i: Lens, Int> = Box.s() + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Nested generic sealed hierarchies, #3384`() { + """ + |$`package` + |$imports + |@optics + |sealed interface LoadingContentOrError { + | data object Loading : LoadingContentOrError + | + | @optics + | sealed interface ContentOrError : LoadingContentOrError { + | companion object + | } + | + | @optics + | data class Content(val data: Data) : ContentOrError { + | companion object + | } + | + | @optics + | data class Error(val error: Throwable) : ContentOrError { + | companion object + | } + | + | companion object + |} + """.compilationSucceeds() + } + + @Test + fun `Using Object as the name a class, prisms, #3474`() { + """ + |$`package` + |$imports + | + |@optics + |sealed interface Thing { + | data class Object(val value: Int) : Thing + | companion object + |} + | + |val i: Prism = Thing.`object` + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Using Object as the name a class, lenses, #3474`() { + """ + |$`package` + |$imports + | + |@optics + |data class Object(val value: Int) { + | companion object + |} + | + |val i: Lens = Object.value + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Respects upper bounds, #3692`() { + """ + |$`package` + |$imports + | + |interface Foo + | + |@optics + |data class Wrapper(val item: T) { + | companion object + |} + | + |val i: Lens, Foo> = Wrapper.item() + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Multiple files`() { + val source1 = SourceFile.kotlin( + "Source1.kt", + """ + package nofix + + import arrow.optics.optics + + @optics + data class Broken(val a: Int) { + companion object { + val breaks = a + } + } + """, + ) + val source2 = SourceFile.kotlin( + "Source2.kt", + """ + package fix + + import arrow.optics.optics + + @optics + data class Broken(val a: Int) { + companion object { + val breaks = a + } + } + + @optics + data class Fixes(val a: Int) { + companion object + }""", + ) + compilationSucceeds(allWarningsAsErrors = false, contextParameters = false, source1, source2) + } + + @Test + fun `Complicated hierarchy with generics (#3735)`() { + """ + |$`package` + |$imports + | + |@optics + |sealed interface Test { + | val value: String + | + | data class Test1(override val value: String) : Test + | data class Test2(override val value: String) : Test + | data class Test3(override val value: String) : Test + | data class Test4(override val value: String) : Test> + + | companion object + |} + """.compilationSucceeds() + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/GeneratedCopyTest.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/GeneratedCopyTest.kt new file mode 100644 index 00000000000..9d60114c249 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/GeneratedCopyTest.kt @@ -0,0 +1,79 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +val generatedCopyCode = """ +@optics @optics.copy data class Person(val name: String, val age: Int, val address: Address) { + companion object +} +@optics @optics.copy data class Address(val street: Street, val city: City, val coordinates: List) { + companion object +} +@optics @optics.copy data class Street(val name: String, val number: Int?) { + companion object +} +@optics @optics.copy data class City(val name: String, val country: String) { + companion object +} + +val me = + Person( + "Gopal", + 99, + Address(Street("Kotlinstraat", 1), City("Hilversum", "Netherlands"), listOf(1, 2)) + ) +""" + +class GeneratedCopyTest { + @Test + fun `code compiles`() { + """ + |package PersonTest + |$imports + |$generatedCopyCode + """.compilationSucceeds(contextParameters = true) + } + + @Test + fun `birthday increments`() { + """ + |package PersonTest + |$imports + |$generatedCopyCode + |val meAfterBirthdayParty = me.copy { + | age transform { it + 1 } + |} + |val r = Person.age.get(meAfterBirthdayParty) + """.evals("r" to 100, contextParameters = true) + } + + @Test + fun `moving to another city`() { + """ + |package PersonTest + |$imports + |$generatedCopyCode + |val newAddress = + | Address(Street("Kotlinplein", null), City("Amsterdam", "Netherlands"), listOf(1, 2)) + |val meAfterMoving = me.copy { + | address set newAddress + |} + |val r = Person.address.get(meAfterMoving).street.name + """.evals("r" to "Kotlinplein", contextParameters = true) + } + + @Test + fun `optics copy to modify multiple fields`() { + """ + |package PersonTest + |$imports + |$generatedCopyCode + |val meAfterMoving = me.copy { + | address.city.name set "Amsterdam" + | address.city.country set "Netherlands" + | address.coordinates set listOf(2, 3) + |} + |val r = meAfterMoving.address.city.name + """.evals("r" to "Amsterdam", contextParameters = true) + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/IsoTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/IsoTests.kt new file mode 100644 index 00000000000..99a2f8278b2 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/IsoTests.kt @@ -0,0 +1,60 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class IsoTests { + + @Test + fun `Isos will be generated for value class`() { + """ + |$`package` + |$imports + |@optics @JvmInline + |value class IsoData( + | val field1: String + |) { companion object } + | + |val i: Iso = IsoData.field1 + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Isos will be generated for value class with parameters having keywords as names`() { + """ + |$`package` + |$imports + |@optics @JvmInline + |value class IsoData( + | val `in`: String + |) { companion object } + """.compilationSucceeds() + } + + @Test + fun `Isos will be generated for generic value class with parameters having keywords as names`() { + """ + |$`package` + |$imports + |@optics @JvmInline + |value class IsoData( + | val `in`: T + |) { companion object } + """.compilationSucceeds() + } + + @Test + fun `Iso generation works without an explicit companion object`() { + """ + |$`package` + |$imports + |@optics @JvmInline + |value class IsoNoCompanion( + | val field1: String + |) + | + |val i: Iso = IsoNoCompanion.field1 + |val r = i != null + """.evals("r" to true) + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/LensTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/LensTests.kt new file mode 100644 index 00000000000..87ca922dff5 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/LensTests.kt @@ -0,0 +1,347 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class LensTests { + + @Test + fun `Lenses will be generated for data class`() { + """ + |$`package` + |$imports + |@optics + |data class LensData( + | val field1: String + |) { companion object } + | + |val i: Lens = LensData.field1 + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Lenses will be generated for data class with parameters having keywords as names`() { + """ + |$`package` + |$imports + |@optics + |data class LensData( + | val `in`: String + |) { companion object } + """.compilationSucceeds() + } + + @Test + fun `Lenses will be generated for generic data class with parameters having keywords as names`() { + """ + |$`package` + |$imports + |@optics + |data class LensData( + | val `in`: T + |) { companion object } + """.compilationSucceeds() + } + + @Test + fun `Lenses will be generated for data class with secondary constructors`() { + """ + |$`package` + |$imports + |@optics + |data class LensesSecondaryConstructor(val fieldNumber: Int, val fieldString: String) { + | constructor(number: Int) : this(number, number.toString()) + | companion object + |} + | + |val i: Lens = LensesSecondaryConstructor.fieldString + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Lenses are generated for data class referencing its own lenses for type inference`() { + """ + |$`package` + |$imports + |@optics + |data class UsingLens(val field: String) { + | fun getLens() = UsingLens.field + | companion object + |} + | + |val i: Lens = UsingLens.field + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Lenses are not generated for unresolved types`() { + """ + |$`package` + |$imports + |@optics + |data class InvalidType(val field: Foo) { + | companion object + |} + """.compilationFails() + } + + @Test + fun `Lenses which mentions imported elements`() { + """ + |$`package` + |$imports + | + |@optics + |data class OpticsTest(val time: kotlin.time.Duration) { + | companion object + |} + | + |val i: Lens = OpticsTest.time + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Lenses which mentions type arguments`() { + """ + |$`package` + |$imports + |@optics + |data class OpticsTest(val field: A) { + | companion object + |} + | + |val i: Lens, Int> = OpticsTest.field() + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Lenses for nested classes`() { + """ + |$`package` + |$imports + |@optics + |data class LensData(val field1: String) { + | @optics + | data class InnerLensData(val field2: String) { + | companion object + | } + | companion object + |} + | + |val i: Lens = LensData.InnerLensData.field2 + |val r = i != null + """.evals("r" to true) + } + + @Test + fun `Lenses for nested classes with repeated names (#2718)`() { + """ + |$`package` + |$imports + |@optics + |data class LensData(val field1: String) { + | @optics + | data class InnerLensData(val field2: String) { + | companion object + | } + | companion object + |} + | + |@optics + |data class OtherLensData(val field1: String) { + | @optics + | data class InnerLensData(val field2: String) { + | companion object + | } + | companion object + |} + | + |val i: Lens = LensData.InnerLensData.field2 + |val j: Lens = OtherLensData.InnerLensData.field2 + |val r = i != null && j != null + """.evals("r" to true) + } + + @Test + fun `Lenses for STAR arguments`() { + """ + |$`package` + |$imports + |@optics + |data class GenericType( + | val field1: A + |) { companion object } + | + |@optics + |data class IsoData(val genericType: GenericType<*>) { + | companion object + |} + """.compilationSucceeds() + } + + @Test + fun `Lens for sealed class property, one choice`() { + """ + |$`package` + |$imports + |@optics + |sealed class LensSealed { + | abstract val property1: String + | + | data class dataChild(override val property1: String) : LensSealed() + | + | companion object + |} + | + |val l: Lens? = LensSealed.property1 + |val r = l != null + """.evals("r" to true) + } + + @Test + fun `Lens for sealed class property, three choices`() { + """ + |$`package` + |$imports + |@optics + |sealed class LensSealed { + | abstract val property1: String + | + | data class dataChild1(override val property1: String) : LensSealed() + | data class dataChild2(override val property1: String, val number: Int) : LensSealed() + | data class dataChild3(override val property1: String, val enabled: Boolean) : LensSealed() + | + | companion object + |} + | + |val l: Lens? = LensSealed.property1 + |val r = l != null + """.evals("r" to true) + } + + @Test + fun `Lens for sealed class property, three choices outside`() { + """ + |$`package` + |$imports + |@optics + |sealed class LensSealed { + | abstract val property1: String + | + | companion object + |} + | + |data class dataChild1(override val property1: String) : LensSealed() + |data class dataChild2(override val property1: String, val number: Int) : LensSealed() + |data class dataChild3(override val property1: String, val enabled: Boolean) : LensSealed() + | + |val l: Lens? = LensSealed.property1 + |val r = l != null + """.evals("r" to true) + } + + @Test + fun `Lens for sealed class property, zero choices`() { + """ + |$`package` + |$imports + |@optics + |sealed class LensSealed { + | abstract val property1: String + | + | companion object + |} + | + |val l: Lens? = LensSealed.property1 + |val r = l != null + """.compilationFails() + } + + @Test + fun `Lens for sealed class property, ignoring changed nullability`() { + """ + |$`package` + |$imports + |@optics + |sealed class LensSealed { + | abstract val property1: String? + | + | data class dataChild1(override val property1: String?) : LensSealed() + | data class dataChild2(override val property1: String?, val number: Int) : LensSealed() + | data class dataChild3(override val property1: String, val enabled: Boolean) : LensSealed() + | + | companion object + |} + | + |val l: Lens? = LensSealed.property1 + |val r = l != null + """.compilationFails() + } + + @Test + fun `Lens for sealed class property, ignoring changed types`() { + """ + |$`package` + |$imports + |@optics + |sealed interface Base { + | val prop: T + | + | companion object + |} + | + |@optics + |data class Child1(override val prop: String) : Base { + | companion object + |} + | + |@optics + |data class Child2(override val prop: Int) : Base { + | companion object + |} + | + |val l: Lens, String> = Base.prop() + |val r = l != null + """.compilationFails() + } + + @Test + fun `Lenses will be generated for data class with property named arrow (issue #3789)`() { + """ + |$`package` + |$imports + |@optics + |data class AutoLambdaData( + | val leftBrace: String = "", + | val arrow: String = "->", + | val rightBrace: String = "" + |) { companion object } + | + |val lens: Lens = AutoLambdaData.arrow + |val r = lens != null + """.evals("r" to true) + } + + @Test + fun `Visibilities are correctly computed (#3869)`() { + """ + |$`package` + |$imports + |@optics + |internal sealed interface Interface { + | @optics + | data class DataClass(val value: Int) : Interface { + | companion object + | } + | companion object + |} + | + |internal val lens: Lens = Interface.DataClass.value + |internal val r = lens != null + """.evals("r" to true) + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/OptionalTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/OptionalTests.kt new file mode 100644 index 00000000000..5aa64d19b23 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/OptionalTests.kt @@ -0,0 +1,55 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class OptionalTests { + + @Test + fun `Optional will be generated for data class`() { + """ + |$`package` + |$imports + |@optics + |data class OptionalData( + | val field1: String? + |) { companion object } + | + |val i: Lens = OptionalData.field1 + |val j: Optional = OptionalData.field1.notNull + |val r = i != null && j != null + """.evals("r" to true) + } + + @Test + fun `Optional will be generated for generic data class`() { + """ + |$`package` + |$imports + |@optics + |data class OptionalData( + | val field1: A? + |) { companion object } + | + |val i: Lens, String?> = OptionalData.field1() + |val j: Optional, String> = OptionalData.field1().notNull + |val r = i != null && j != null + """.evals("r" to true) + } + + @Test + fun `Optional will be generated for data class with secondary constructors`() { + """ + |$`package` + |$imports + |@optics + |data class OptionalSecondaryConstructor(val fieldNumber: Int?, val fieldString: String?) { + | constructor(number: Int?) : this(number, number?.toString()) + | companion object + |} + | + |val i: Lens = OptionalSecondaryConstructor.fieldString + |val j: Optional = OptionalSecondaryConstructor.fieldString.notNull + |val r = i != null && j != null + """.evals("r" to true) + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/PrismTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/PrismTests.kt new file mode 100644 index 00000000000..1757b6f46a0 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/PrismTests.kt @@ -0,0 +1,96 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class PrismTests { + + @Test + fun `Prism will be generated for sealed class`() { + """ + |$`package` + |$imports + |@optics + |sealed class PrismSealed(val field: String, val nullable: String?) { + | data class PrismSealed1(private val a: String?) : PrismSealed("", a) + | data class PrismSealed2(private val b: String?) : PrismSealed("", b) + | companion object + |} + |val i: Prism = PrismSealed.prismSealed1 + """.compilationSucceeds() + } + + @Test + fun `Prism will be generated for generic sealed class`() { + """ + |$`package` + |$imports + |@optics + |sealed class PrismSealed(val field: A, val nullable: B?) { + | data class PrismSealed1(private val a: String?) : PrismSealed("", a) + | data class PrismSealed2(private val b: C?) : PrismSealed("", b) + | companion object + |} + |val i: Prism, PrismSealed.PrismSealed1> = PrismSealed.prismSealed1() + """.compilationSucceeds() + } + + @Test + fun `Prism will be generated for sealed class and subclasses having keywords as names`() { + """ + |$`package` + |$imports + |@optics + |sealed class PrismSealed(val field: String, val nullable: String?) { + | data class In(private val a: String?) : PrismSealed("", a) + | data class PrismSealed2(private val b: String?) : PrismSealed("", b) + | companion object + |} + |val i: Prism = PrismSealed.`in` + """.compilationSucceeds() + } + + @Test + fun `Prism will be generated for generic sealed class and subclasses having keywords as names`() { + """ + |$`package` + |$imports + |@optics + |sealed class PrismSealed(val field: A, val nullable: B?) { + | data class In(private val a: String?) : PrismSealed("", a) + | data class PrismSealed2(private val b: C?) : PrismSealed("", b) + | companion object + |} + |val i: Prism, PrismSealed.In> = PrismSealed.`in`() + """.compilationSucceeds() + } + + @Test + fun `Prism will be generated without warning for sealed class with only one subclass`() { + """ + |$`package` + |$imports + |@optics + |sealed class PrismSealed(val field: String, val nullable: String?) { + | data class PrismSealed1(private val a: String?) : PrismSealed("", a) + | companion object + |} + |val i: Prism = PrismSealed.prismSealed1 + """.compilationSucceeds() + } + + @Test + fun `Prism will not be generated for sealed class if DSL Target is specified`() { + """ + |$`package` + |$imports + |@optics([OpticsTarget.DSL]) + |sealed class PrismSealed(val field: String, val nullable: String?) { + | data class PrismSealed1(private val a: String?) : PrismSealed("", a) + | data class PrismSealed2(private val b: String?) : PrismSealed("", b) + | companion object + |} + |val i: Prism = PrismSealed.prismSealed1 + |val r = i != null + """.compilationFails() + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/RuntimeTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/RuntimeTests.kt new file mode 100644 index 00000000000..df99248790d --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/RuntimeTests.kt @@ -0,0 +1,171 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +/** + * These tests actually *execute* the generated optic bodies (`get`/`set`/`reverseGet`/`getOrNull`), + * unlike the ported KSP suite which mostly checks that the optics resolve. They are the regression + * net for the IR body generation — see review §1.1. + */ +class RuntimeTests { + + // ---- LENS (data class) ------------------------------------------------------------- + + @Test + fun `lens get and set on a data class`() { + """ + |$`package` + |$imports + |@optics data class Point(val x: Int, val y: Int) { companion object } + | + |val lx: Lens = Point.x + |val p = Point(1, 2) + |val r = lx.get(p) == 1 && + | lx.set(p, 9) == Point(9, 2) && + | lx.modify(p) { it + 10 } == Point(11, 2) + """.evals("r" to true) + } + + @Test + fun `lens laws hold`() { + """ + |$`package` + |$imports + |@optics data class Point(val x: Int, val y: Int) { companion object } + | + |val lx = Point.x + |val p = Point(1, 2) + |val getSet = lx.get(lx.set(p, 9)) == 9 + |val setGet = lx.set(p, lx.get(p)) == p + |val setSet = lx.set(lx.set(p, 3), 9) == lx.set(p, 9) + |val r = getSet && setGet && setSet + """.evals("r" to true) + } + + // ---- ISO (value class) ------------------------------------------------------------- + + @Test + fun `iso get and reverseGet round-trip`() { + """ + |$`package` + |$imports + |@optics @JvmInline value class Cents(val value: Int) { companion object } + | + |val iso: Iso = Cents.value + |val r = iso.get(Cents(3)) == 3 && + | iso.reverseGet(7) == Cents(7) && + | iso.reverseGet(iso.get(Cents(42))) == Cents(42) + """.evals("r" to true) + } + + @Test + fun `generic iso get and reverseGet round-trip`() { + """ + |$`package` + |$imports + |@optics @JvmInline value class Wrap(val unwrap: T) { companion object } + | + |val iso: Iso, String> = Wrap.unwrap() + |val r = iso.get(Wrap("hi")) == "hi" && iso.reverseGet("bye") == Wrap("bye") + """.evals("r" to true) + } + + // ---- PRISM ------------------------------------------------------------------------- + + @Test + fun `prism getOrNull matches the right branch`() { + """ + |$`package` + |$imports + |@optics sealed interface Shape { + | data class Dot(val at: Int) : Shape + | data class Line(val len: Int) : Shape + | companion object + |} + | + |val p: Prism = Shape.dot + |val r = p.getOrNull(Shape.Dot(1)) == Shape.Dot(1) && + | p.getOrNull(Shape.Line(2)) == null + """.evals("r" to true) + } + + @Test + fun `generic prism getOrNull at a concrete instantiation`() { + """ + |$`package` + |$imports + |@optics sealed class Tree { + | data class Leaf(val value: A) : Tree() + | data class Branch(val left: A, val right: A) : Tree() + | companion object + |} + | + |val p: Prism, Tree.Leaf> = Tree.leaf() + |val r = p.getOrNull(Tree.Leaf(5)) == Tree.Leaf(5) && + | p.getOrNull(Tree.Branch(1, 2)) == null + """.evals("r" to true) + } + + // ---- Sealed shared-property LENS (§5.2) — the `when`-dispatch `set` ---------------- + + @Test + fun `sealed shared-property lens get and set across subclasses with extra fields`() { + // `Circle` has TWO extra fields (radius, color), so the `set` reconstruction reads multiple + // siblings — this is the case that exposes IR node sharing (review §2.1). + """ + |$`package` + |$imports + |@optics sealed class Shape { + | abstract val name: String + | data class Circle(override val name: String, val radius: Int, val color: String) : Shape() + | data class Square(override val name: String, val side: Int) : Shape() + | companion object + |} + | + |val nameLens: Lens = Shape.name + |val circle: Shape = Shape.Circle("c", 5, "red") + |val square: Shape = Shape.Square("s", 3) + |val r = nameLens.get(circle) == "c" && + | nameLens.set(circle, "z") == Shape.Circle("z", 5, "red") && + | nameLens.set(square, "z") == Shape.Square("z", 3) && + | nameLens.modify(circle) { it + "!" } == Shape.Circle("c!", 5, "red") + """.evals("r" to true) + } + + // ---- Generic LENS ------------------------------------------------------------------ + + @Test + fun `generic lens get and set with a sibling of a different type parameter`() { + // Setting `first` reconstructs `Pair2`, reading the sibling `second` (type `B`) — exercises the + // generic sibling-substitution path (review §2.4). + """ + |$`package` + |$imports + |@optics data class Pair2(val first: A, val second: B) { companion object } + | + |val l: Lens, String> = Pair2.first() + |val original = Pair2("x", 1) + |val r = l.get(original) == "x" && + | l.set(original, "y") == Pair2("y", 1) + """.evals("r" to true) + } + + // ---- Nullable focus + notNull ------------------------------------------------------ + + @Test + fun `nullable lens and notNull optional behave at runtime`() { + """ + |$`package` + |$imports + |@optics data class Maybe(val value: String?) { companion object } + | + |val l: Lens = Maybe.value + |val opt: Optional = Maybe.value.notNull + |val r = l.get(Maybe(null)) == null && + | l.set(Maybe("a"), "b") == Maybe("b") && + | opt.getOrNull(Maybe("x")) == "x" && + | opt.getOrNull(Maybe(null)) == null && + | opt.set(Maybe("x"), "y") == Maybe("y") + """.evals("r" to true) + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/TargetTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/TargetTests.kt new file mode 100644 index 00000000000..6f736ca0fc5 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/TargetTests.kt @@ -0,0 +1,58 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +/** Target selection (algo §2.3) and the handling of ineligible classes (review §3.1). */ +class TargetTests { + + @Test + fun `explicit LENS target still generates the base lens`() { + """ + |$`package` + |$imports + |@optics([OpticsTarget.LENS]) + |data class OnlyLens(val x: Int) { companion object } + | + |val l: Lens = OnlyLens.x + |val r = l.get(OnlyLens(5)) == 5 + """.evals("r" to true) + } + + @Test + fun `PRISM target on a data class generates nothing (empty intersection)`() { + """ + |$`package` + |$imports + |@optics([OpticsTarget.PRISM]) + |data class OnlyPrism(val x: Int) { companion object } + | + |val l = OnlyPrism.x + """.compilationFails() + } + + @Test + fun `ISO target on a value class generates the iso`() { + """ + |$`package` + |$imports + |@optics([OpticsTarget.ISO]) @JvmInline + |value class OnlyIso(val v: Int) { companion object } + | + |val i: Iso = OnlyIso.v + |val r = i.get(OnlyIso(2)) == 2 + """.evals("r" to true) + } + + @Test + fun `ineligible class generates no optics`() { + // A plain class is not data/value/sealed: no optics are generated, so referencing one fails. + """ + |$`package` + |$imports + |@optics + |class Plain(val x: Int) { companion object } + | + |val l = Plain.x + """.compilationFails() + } +} diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Utils.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Utils.kt new file mode 100644 index 00000000000..7b4b06d2e3a --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Utils.kt @@ -0,0 +1,69 @@ +@file:Suppress("ktlint:standard:property-naming") + +package arrow.optics.plugin + +const val `package` = "package `if`.`this`.`object`.`is`.`finally`.`null`.`expect`.`annotation`" + +const val imports = + """ + import arrow.core.None + import arrow.optics.* + import arrow.optics.dsl.* + import arrow.optics.typeclasses.* + import kotlin.time.Duration.Companion.hours + """ + +const val dslModel = + """ + @optics data class Street(val number: Int, val name: String) { + companion object + } + @optics data class Address(val city: String, val street: Street) { + companion object + } + @optics data class Company(val name: String, val address: Address) { + companion object + } + @optics data class Employee(val name: String, val company: Company?, val weeklyWorkingHours: kotlin.time.Duration = 5.hours) { + companion object + } + @optics data class Employees(val employees: List) { + companion object + } + sealed class Keys + object One : Keys() { + override fun toString(): String = "One" + } + object Two : Keys() { + override fun toString(): String = "Two" + } + object Three : Keys() { + override fun toString(): String = "Three" + } + object Four : Keys() { + override fun toString(): String = "Four" + } + @optics data class Db(val content: Map) { + companion object + } + """ + +const val dslValues = + """ + |val john = Employee("Audrey Tang", + | Company("Arrow", + | Address("Functional city", + | Street(42, "lambda street")))) + |val jane = Employee("Bestian Tang", + | Company("Arrow", + | Address("Functional city", + | Street(42, "lambda street")))) + |val employees = Employees(listOf(john, jane)) + |val db = Db( + | mapOf( + | One to "one", + | Two to "two", + | Three to "three", + | Four to "four" + | ) + |)""" diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/README.md b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/README.md new file mode 100644 index 00000000000..0ef47caca61 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/README.md @@ -0,0 +1,16 @@ +# Vibecoding trace + +Most of this compiler plugin has been vibecoded using +[Claude Code](https://claude.com/product/claude-code) +and [JetBrains Air](https://air.dev/), and then manually +reviewed and improved. + +For full traceability, this directory contains all the accompanying +resources that were generated during the vibecoding process. + +- `algo.md` contains the description of the algorithm that generates + the optics, obtained by Claude from the original KSP implementation. +- `impl.md` contains the implementation plan created by JetBrains Air + and Claude. +- `review.md` contains a review done with maximum effort for the first + implementation, leading to improvements in the code. \ No newline at end of file diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/algo.md b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/algo.md new file mode 100644 index 00000000000..24ceed4a830 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/algo.md @@ -0,0 +1,842 @@ +# How Arrow Optics generates code + +This document describes, in precise detail, the algorithm used to generate Arrow +Optics code from classes annotated with `@optics`. It is written purely in terms +of *what code is produced from what input* — the program-analysis machinery used +to inspect the source classes is deliberately omitted. + +Throughout, the **source class** is the class carrying the `@optics` annotation, +a **focus** is a single thing an optic points at (a constructor parameter, an +abstract property, or a sealed subclass), and the **companion** is the source +class's companion object. + +--- + +## Table of contents + +1. [High-level model](#1-high-level-model) +2. [The `@optics` annotation and target selection](#2-the-optics-annotation-and-target-selection) +3. [Conventions shared by every generator](#3-conventions-shared-by-every-generator) +4. [ISO — value classes](#4-iso--value-classes) +5. [LENS](#5-lens) +6. [PRISM — sealed classes and interfaces](#6-prism--sealed-classes-and-interfaces) +7. [OPTIONAL — nullable foci](#7-optional--nullable-foci) +8. [DSL — composition extensions](#8-dsl--composition-extensions) +9. [COPY — the `@optics.copy` builder](#9-copy--the-opticscopy-builder) +10. [Behaviour by kind of class](#10-behaviour-by-kind-of-class) +11. [Generics and variance, consolidated](#11-generics-and-variance-consolidated) +12. [Diagnostics and failure modes](#12-diagnostics-and-failure-modes) +13. [Known limitations and vestigial behaviour](#13-known-limitations-and-vestigial-behaviour) + +--- + +## 1. High-level model + +For each annotated source class the generator produces **one source file** that +contains a set of *top-level declarations*. Every generated optic is an +**extension member on the source class's companion object** (e.g. +`val Person.Companion.age: Lens`), which is why a companion object +is required. + +The pipeline is: + +1. Determine the **set of targets** to generate for the class (ISO, LENS, PRISM, + DSL, COPY) from the annotation arguments, intersected with what the class's + *kind* (data / value / sealed) supports. +2. For each target, extract its **foci** from the class structure. +3. For each target, render a **snippet** (a package, a set of imports, and a body + of declarations). +4. Group all snippets that share the same package and same enclosing-name, join + them (concatenating bodies and unioning imports), and emit one file per group. + +The five user-facing optic "cases" map onto generation as follows. Note that +**`OPTIONAL` is not a standalone generator**: optionals arise from a lens onto a +nullable focus combined with the library combinator `notNull` (see §7). + +| Case | Produced by | Applies to | +|-----------|------------------------------------------|-----------------------------------------| +| ISO | iso generator | value classes | +| LENS | lens generator | data classes; sealed types (shared properties) | +| PRISM | prism generator | sealed classes / interfaces | +| OPTIONAL | *no generator* — lens-to-nullable + `notNull` | nullable foci of the above | +| DSL | DSL generator (iso/lens/prism variants) | value / data / sealed | +| (COPY) | copy generator (opt-in via `@optics.copy`) | data classes | + +--- + +## 2. The `@optics` annotation and target selection + +### 2.1 The annotation + +```kotlin +annotation class optics(val targets: Array = emptyArray()) { + annotation class copy() +} +enum class OpticsTarget { ISO, LENS, PRISM, OPTIONAL, DSL } +``` + +* `@optics` with no arguments means *"generate everything that matches this + class's kind"*. +* `@optics([OpticsTarget.LENS, OpticsTarget.DSL])` restricts generation to the + listed targets (still intersected with what the kind supports). +* `@optics.copy` is a separate marker that additionally requests the `copy` + builder (§9). + +### 2.2 Eligible classes + +Only **data classes**, **value classes** (`@JvmInline value class`), and **sealed +classes / sealed interfaces** may be annotated. Any other kind of class is a +hard error ("Only data, sealed, and value classes can be annotated with +@optics") and nothing is generated. + +A **companion object must be declared** on the source class; otherwise it is a +hard error ("must declare a companion object"). (This check can be disabled by a +configuration flag, in which case the user is responsible for supplying a +companion; the default is on.) + +### 2.3 How the target set is computed + +First the annotation's `targets` array is read. Each entry is mapped to an +internal target; **`ISO`, `LENS`, `PRISM`, `DSL` are recognised, and `OPTIONAL` +is silently dropped** (it has no dedicated generator). If the resulting set is +empty (the `@optics()` no-arg case), it defaults to `{ISO, LENS, PRISM, DSL}`. + +That set is then **intersected with the targets allowed for the class's kind**: + +| Class kind | Allowed targets | +|--------------------|----------------------------| +| sealed class/iface | `PRISM`, `LENS`, `DSL` | +| value class | `ISO`, `DSL` | +| data class (other) | `LENS`, `DSL` | + +Finally, `COPY` is added iff the class also carries `@optics.copy`. + +Consequences of the intersection: + +* A bare `@optics` on a **data class** generates a **LENS** and a **lens DSL** + (never an ISO — even though ISO is in the default set, it is intersected away). +* A bare `@optics` on a **value class** generates an **ISO** and an **iso DSL**. +* A bare `@optics` on a **sealed type** generates a **PRISM**, *and* a **LENS** + for any shared abstract properties (§5.2), *and* a **prism DSL**. +* Asking for a target the kind does not support (e.g. `@optics([PRISM])` on a + data class) intersects to the empty set and produces nothing for that target. + +### 2.4 The `@optics.copy` marker + +Independent of the optic targets, `@optics.copy` adds a `COPY` target that +generates a `copy { … }` builder function (§9). It is meaningful for data +classes (it delegates to Kotlin's `copy`). + +--- + +## 3. Conventions shared by every generator + +### 3.1 Everything is a companion extension + +Each generated base optic is an extension on `SourceClass.Companion`. This is +what lets users write `Person.age`, `Person.address`, `Thing.object`, etc.: +the bare class name resolves to its companion, and the optic is an extension on +that companion. + +### 3.2 Names and keyword escaping + +* The **optic name** for a LENS/ISO focus is the **parameter/property name**. + For a PRISM focus it is the **subclass's simple name with the first letter + lowercased** (`PrismSealed1` → `prismSealed1`, `Object` → `object`, + `In` → `in`). +* Every optic name is emitted **wrapped in backticks** unconditionally + (``` `age` ```, ``` `object` ```, ``` `in` ```). Back-ticking an ordinary + identifier is a no-op in Kotlin and uniformly handles names that collide with + keywords. This is why users reference, e.g., ``Thing.`object` `` and + ``PrismSealed.`in` ``. +* **Type names, package names and the source-class name** are escaped + *segment-by-segment*: each dotted segment that is a Kotlin keyword is + back-ticked (so the package `it.facile.assicurati` becomes + `` `it`.facile.assicurati ``), while non-keyword segments are left alone. +* The lambda parameter used inside generated `get`/`set` bodies is the source + class's simple name **with the first letter lowercased** (`LensData` → + `lensData`), also keyword-sanitised. + +### 3.3 Visibility + +The generated optic's visibility is the **most restrictive** of: + +* the companion object's visibility, +* the source class's visibility, and +* the visibilities of *all* enclosing classes. + +These are combined pairwise (`public` is the identity; `private` dominates; +mixing `internal` and `protected` collapses to `private`; `local` propagates). +The resulting modifier (`public`, `internal`, `private`, `protected`) is emitted +as a prefix on every generated declaration. For example, a data class nested in +an `internal sealed interface` yields `internal` lenses. + +### 3.4 The non-generic vs generic split: property vs function + +This split is decided by whether the **source class has type parameters**: + +* **No type parameters** → the optic is an **extension property with a getter**: + ```kotlin + val Source.Companion.x: Optic get() = … + ``` +* **Has type parameters** → the optic is an **extension function** (so it can + introduce its own type parameters): + ```kotlin + fun Source.Companion.x(): Optic, Focus> = … + ``` + +So users access `Source.x` for monomorphic classes and `Source.x()` for generic +ones (and `Source.x()` when they need to fix the type). + +### 3.5 Type parameters, bounds, variance, star + +When the source class is generic, the generator renders two related strings: + +* **Declaration form** (``): each parameter is `name` optionally + followed by `: bound1, bound2`. A bound equal to `kotlin.Any?` is omitted + (it is the trivial bound). This form is used to declare the extension + function's own type parameters, so **declared upper bounds are preserved** + (e.g. `Wrapper` produces `fun Wrapper.Companion.item(): …`). +* **Reference form** (``): just the names, used wherever the source type is + *mentioned* (e.g. `Wrapper` as the optic's source argument). + +Two important rules: + +* **Declaration-site variance on the source class's own type parameters is + dropped.** A function type parameter cannot carry `out`/`in`, so a class + declared `Foo` contributes the parameter `T` (no variance) to the + generated function. +* A type parameter that is **star-projected** (`*`) at the source is rendered as + `*` in both forms. + +### 3.6 Nullability + +A nullable focus type is rendered with a trailing `?`. A lens onto a nullable +field therefore has a **nullable focus type** (`Lens`); turning it +into an `Optional` is the user's job via `notNull` (§7). + +### 3.7 Type-argument variance + +When a focus *type argument* (as opposed to the source class's own parameter) +carries variance, it is rendered literally: + +* `*` (star) → `*` +* invariant → the type as-is +* covariant → `out Type` +* contravariant → `in Type` + +So a field of type `Extendable` produces a focus type +`Extendable`. + +### 3.8 Fully-qualified emission, imports, and collision aliasing + +Generated code refers to the source class, the focus types, and the optic types +(`arrow.optics.Lens`, …) using **fully-qualified names**, so generated files +normally need **no imports** at all. The exceptions: + +* **Property-name vs optic-type collision.** If the source class declares a + property whose name equals the first package segment of an optic type (e.g. a + property literally named `arrow`, colliding with `arrow.optics.Lens`), the + optic type is imported under an alias `ArrowOptics` and the alias is + used in the body. +* **Property-name vs package-segment collision (DSL).** If a property name + equals one of the source class's own package segments, the source class is + imported under a sanitised alias and that alias is used. +* The prism generator always lists `arrow.core.left`, `arrow.core.right`, + `arrow.core.identity` as imports (vestigial — the current prism body does not + use them; see §13). +* The copy generator imports `arrow.optics.copy`. + +### 3.9 The `inline` option + +A configuration flag controls whether generated optics are `inline`. When on, +the keyword `inline` is inserted both before the `val`/`fun` and before `get()`. +When off (the default), no `inline` is emitted. This affects only the modifier; +the structure is identical. + +### 3.10 File organisation + +All snippets produced for one annotated class are grouped by `(package, name)`, +joined (bodies concatenated, imports unioned, de-duplicated), and written to a +single file whose name is the source class's (possibly nested) name plus the +suffix `__Optics`. The file begins with a `package` directive (unless the +package is unnamed) followed by the unioned imports. + +--- + +## 4. ISO — value classes + +**Applies to:** value classes (`@JvmInline value class`). An iso expresses the +loss-less isomorphism between the wrapper and its single wrapped value. + +**Focus extraction:** the iso has exactly **one focus**, the value class's single +constructor parameter (its type and name). + +**Generated shape (monomorphic):** + +```kotlin +@optics @JvmInline +value class IsoData(val field1: String) { companion object } +``` +produces +```kotlin +public val IsoData.Companion.`field1`: arrow.optics.Iso get() = + arrow.optics.Iso( + get = { isoData: IsoData -> isoData.`field1` }, + reverseGet = { `field1`: kotlin.String -> IsoData(`field1`) } + ) +``` + +* `get` projects the wrapped value. +* `reverseGet` re-wraps it by calling the value class constructor. + +**Generated shape (generic):** + +```kotlin +@optics @JvmInline +value class IsoData(val field1: T) { companion object } +``` +produces +```kotlin +public fun IsoData.Companion.`field1`(): arrow.optics.Iso, T> = + arrow.optics.Iso( + get = { isoData: IsoData -> isoData.`field1` }, + reverseGet = { `field1`: T -> IsoData(`field1`) } + ) +``` + +(Note: the ISO generator references `arrow.optics.Iso` directly and does not +apply the alias-on-collision handling of §3.8.) + +--- + +## 5. LENS + +A lens focuses on one component of a product that is always present, providing a +`get` and a `set`. The lens generator handles two structurally different inputs. + +### 5.1 Data classes + +**Focus extraction:** **one focus per primary-constructor parameter**, taking the +parameter's type and name. (Secondary constructors are ignored; only the primary +constructor's parameters become lenses, because `set` relies on Kotlin's +generated `copy`.) + +**Generated shape (monomorphic):** + +```kotlin +@optics data class LensData(val field1: String) { companion object } +``` +produces +```kotlin +public val LensData.Companion.`field1`: arrow.optics.Lens get() = + arrow.optics.Lens( + get = { lensData: LensData -> lensData.`field1` }, + set = { lensData: LensData, value: kotlin.String -> lensData.copy(`field1` = value) } + ) +``` + +**Generated shape (generic):** + +```kotlin +@optics data class OpticsTest(val field: A) { companion object } +``` +produces +```kotlin +public fun OpticsTest.Companion.`field`(): arrow.optics.Lens, A> = + arrow.optics.Lens( + get = { opticsTest: OpticsTest -> opticsTest.`field` }, + set = { opticsTest: OpticsTest, value: A -> opticsTest.copy(`field` = value) } + ) +``` + +Each parameter produces an independent lens; a class with N constructor +parameters produces N lenses. + +### 5.2 Sealed classes / interfaces — lenses on shared properties + +When a sealed type is annotated, the lens generator additionally tries to produce +a lens for each **abstract property that is uniform across the whole hierarchy**. +The extraction algorithm: + +1. Collect the sealed type's **abstract properties that have no extension + receiver**. If there are none, **emit an informational note and generate no + lens** for this class. +2. Collect the **sealed subclasses**. If **any subclass is not a data class** + (e.g. a plain `object`, a `data object`, or a non-data class), emit a note and + **generate no lens** (the `set` body relies on every subclass having `copy`). +3. Partition the abstract properties into: + * **uniform** ("good") — properties for which **every** subclass declares a + property of the *same name* and *exactly the same resolved type* (nullability + included); and + * **non-uniform** ("bad") — the rest. Each non-uniform property triggers a note + ("not uniform across all children") and is **ignored** (no lens for it). +4. If any uniform property is **not a constructor parameter** in some subclass + (i.e. it is overridden as a body property rather than in the constructor), + emit a note and **generate no lens** for the class (again because `set` uses + `copy(name = …)`). +5. For each surviving uniform property, build a focus with the property's type and + name, plus the **list of all subclasses** (each rendered with star projections + for its own type parameters, e.g. `Box.Full<*>`). + +**Generated shape.** The `get` reads the abstract property; the `set` dispatches +over the concrete subclass and calls `copy`: + +```kotlin +@optics sealed class LensSealed { + abstract val property1: String + data class Child1(override val property1: String) : LensSealed() + data class Child2(override val property1: String, val n: Int) : LensSealed() + companion object +} +``` +produces +```kotlin +public val LensSealed.Companion.`property1`: arrow.optics.Lens get() = + arrow.optics.Lens( + get = { lensSealed: LensSealed -> lensSealed.`property1` }, + set = { lensSealed: LensSealed, value: kotlin.String -> + when (lensSealed) { + is LensSealed.Child1 -> lensSealed.copy(`property1` = value) + is LensSealed.Child2 -> lensSealed.copy(`property1` = value) + } + } + ) +``` + +* The `when` is exhaustive over the sealed subclasses. +* The subclasses may be declared **inside** the sealed type or as **top-level** + siblings — both are discovered. + +**Generic sealed parents and the unchecked cast.** If any subclass is rendered +with a star projection (because it is itself generic), each `copy` returns a +star-projected type, so the whole `when` is force-cast back to the parent's +parameterised type and annotated with `@Suppress("UNCHECKED_CAST")`: + +```kotlin +@optics sealed class Box { + abstract val tag: String + data class Full(override val tag: String, val a: A) : Box() + data class Empty(override val tag: String) : Box() + companion object +} +``` +produces +```kotlin +public fun Box.Companion.`tag`(): arrow.optics.Lens, kotlin.String> = + arrow.optics.Lens( + get = { box: Box -> box.`tag` }, + set = { box: Box, value: kotlin.String -> + @Suppress("UNCHECKED_CAST") + when (box) { + is Box.Full<*> -> box.copy(`tag` = value) + is Box.Empty<*> -> box.copy(`tag` = value) + } as Box + } + ) +``` + +As in §3.4, the parent being generic switches the declaration from a property to +a function. + +--- + +## 6. PRISM — sealed classes and interfaces + +A prism focuses on one branch of a sum type: it succeeds when the value is of a +particular subtype and re-injects that subtype unchanged. + +**Applies to:** sealed classes and sealed interfaces. + +**Focus extraction:** **one focus per sealed subclass**. For each subclass the +generator records: + +* the subclass's fully-qualified name (and the parameterised form, e.g. + `Sub`, used as the prism's focus type); +* the optic name = subclass simple name, first letter lowercased; +* the **refined source type** = the supertype as written in the subclass's + `extends`/`implements` clause (e.g. `Parent`), which becomes the + prism's *source* type; +* the subclass's own type parameters. + +**Generated body.** Every prism body is simply the library combinator that builds +a "is-instance-of" prism: + +```kotlin +… = arrow.optics.Prism.instanceOf() +``` + +The combinator's source and focus types are inferred from the declared optic +type, and its `reverseGet` is the identity (every subclass value *is* a value of +the parent). + +**Generated shape (monomorphic parent):** + +```kotlin +@optics sealed class PrismSealed { + data class PrismSealed1(val a: String?) : PrismSealed() + data class PrismSealed2(val b: String?) : PrismSealed() + companion object +} +``` +produces +```kotlin +public val PrismSealed.Companion.`prismSealed1`: arrow.optics.Prism get() = + arrow.optics.Prism.instanceOf() + +public val PrismSealed.Companion.`prismSealed2`: arrow.optics.Prism get() = + arrow.optics.Prism.instanceOf() +``` + +A sealed type with a single subclass produces a single prism with no special +casing. + +**Generated shape (generic parent).** The source type of each prism is the +**refined supertype** of the subclass, and the function's type parameters are the +union of: + +* the **free type variables appearing in the refined supertype's arguments** + (type arguments that are themselves type parameters), and +* the **subclass's own type parameters**. + +```kotlin +@optics sealed class PrismSealed { + data class PrismSealed1(val a: String?) : PrismSealed() + data class PrismSealed2(val b: C?) : PrismSealed() + companion object +} +``` +produces +```kotlin +// PrismSealed1 extends PrismSealed; no free variables → no type params +public fun PrismSealed.Companion.`prismSealed1`(): arrow.optics.Prism, PrismSealed.PrismSealed1> = + arrow.optics.Prism.instanceOf() + +// PrismSealed2 extends PrismSealed; free var C +public fun PrismSealed.Companion.`prismSealed2`(): arrow.optics.Prism, PrismSealed.PrismSealed2> = + arrow.optics.Prism.instanceOf() +``` + +Note how the source type is the *specialised* `PrismSealed` rather than +the bare `PrismSealed`: a prism that picks `PrismSealed1` can only do so out +of a `PrismSealed`. (Whether the parent is treated as generic is +decided by the parent's own type parameters, per §3.4.) + +--- + +## 7. OPTIONAL — nullable foci + +There is **no separate optional generator**, and the `OPTIONAL` value of +`OpticsTarget` is ignored by target selection (§2.3). Optionals are obtained +compositionally: + +1. The lens generator produces a lens whose **focus type is nullable** for any + nullable field (§5.1, §3.6). For example: + + ```kotlin + @optics data class OptionalData(val field1: String?) { companion object } + // generated: + public val OptionalData.Companion.`field1`: arrow.optics.Lens get() = … + ``` + +2. The library combinator `notNull` (hand-written in Arrow, not generated) turns + an optic whose focus is `S?` into an `Optional` whose focus is `S`. Because + `Lens` is a subtype of `Optional`, it applies directly to the generated lens: + + ```kotlin + val opt: Optional = OptionalData.field1.notNull + ``` + +3. The same holds for generic classes (`OptionalData.field1().notNull`) + and for the DSL: every generated DSL family includes an **`Optional` variant** + (§8), and `notNull` is available within DSL chains + (`…company.notNull.address…`). + +In other words, the plugin's contribution to "optionals" is to make the lens' +focus correctly nullable; promotion to `Optional` is a library step. + +--- + +## 8. DSL — composition extensions + +In addition to the base companion optics, the generator emits **composition +helpers** so that optics can be chained with property-like syntax +(`Employees.employees.every.company.notNull.address.street.name`). These are the +"DSL" target. + +### 8.1 The shape of a DSL extension + +Every DSL extension has the form + +```kotlin +val <__S> OuterOptic<__S, Source>.`focus`: OuterOptic<__S, Focus> get() = this + Source.`focus` +``` + +(or the `fun`-with-type-parameters form when the source class is generic). Here: + +* `__S` is a fresh type variable standing for "whatever the outer optic starts + from". The receiver is *any* optic that currently focuses on `Source`. +* `this + Source.\`focus\`` **composes** the outer optic with the base companion + optic generated for that focus (`+` is optic composition). The result is an + optic from `__S` straight to `Focus`. +* `Source` is the source class (alias-qualified per §3.8); `Source.\`focus\`` + refers to the base optic from §4–6. + +For generic source classes the extension becomes a function that re-introduces +the class's type parameters alongside `__S`: + +```kotlin +fun <__S, A, B> OuterOptic<__S, Source>.`focus`(): OuterOptic<__S, Focus> = + this + Source.`focus`() +``` + +### 8.2 Which optic kinds get a variant, and why + +For each focus the generator emits **several copies** of the extension above, one +per *outer optic kind*. The set of kinds is exactly those `X` for which +`X` composed with the base optic's kind is still an `X`. This follows the optic +subtyping lattice (`Iso` is both a `Lens` and a `Prism`; `Lens` and `Prism` are +each an `Optional`; `Optional` is a `Traversal`) together with the fact that each +`+` overload requires its argument to be of the same kind: + +| Base optic kind (source) | Generated outer-optic variants | +|--------------------------|-------------------------------------------| +| Lens (data class field) | `Lens`, `Optional`, `Traversal` | +| Prism (sealed subclass) | `Optional`, `Prism`, `Traversal` | +| Iso (value class) | `Iso`, `Lens`, `Optional`, `Prism`, `Traversal` | + +Intuition: composing with a `Lens` cannot turn an outer `Prism` back into a +`Prism` (it weakens to `Optional`), so no `Prism` variant is produced for a lens +focus; composing with an `Iso` preserves *every* kind, so all five variants are +produced for a value-class focus. + +### 8.3 Lens DSL (data classes) + +For a data class, each constructor-parameter focus produces three extensions: + +```kotlin +@optics data class Street(val number: Int, val name: String) { companion object } +``` +produces (for `name`): +```kotlin +public val <__S> arrow.optics.Lens<__S, Street>.`name`: arrow.optics.Lens<__S, kotlin.String> get() = this + Street.`name` +public val <__S> arrow.optics.Optional<__S, Street>.`name`: arrow.optics.Optional<__S, kotlin.String> get() = this + Street.`name` +public val <__S> arrow.optics.Traversal<__S, Street>.`name`:arrow.optics.Traversal<__S, kotlin.String>get() = this + Street.`name` +``` + +### 8.4 Prism DSL (sealed types) + +For a sealed type, each subclass focus produces three extensions (`Optional`, +`Prism`, `Traversal`), referencing the base prism: + +```kotlin +@optics sealed interface Thing { + data class Object(val value: Int) : Thing + companion object +} +``` +produces (for the `Object` branch, named `object`): +```kotlin +public val <__S> arrow.optics.Optional<__S, Thing>.`object`: arrow.optics.Optional<__S, Thing.Object> get() = this + Thing.`object` +public val <__S> arrow.optics.Prism<__S, Thing>.`object`: arrow.optics.Prism<__S, Thing.Object> get() = this + Thing.`object` +public val <__S> arrow.optics.Traversal<__S, Thing>.`object`: arrow.optics.Traversal<__S, Thing.Object> get() = this + Thing.`object` +``` + +For a **generic** sealed type the DSL uses the refined source type and the same +union of type parameters as the base prism (§6), prefixed with `__S`. + +(Note: for a sealed type, the DSL target produces only the **prism** family of +composition helpers. The shared-property lenses of §5.2 are still generated as +base companion optics, but they do not get their own DSL composition variants.) + +### 8.5 Iso DSL (value classes) + +For a value class, the single focus produces all five variants (`Iso`, `Lens`, +`Optional`, `Prism`, `Traversal`): + +```kotlin +@optics @JvmInline value class Cents(val value: Int) { companion object } +``` +produces: +```kotlin +public val <__S> arrow.optics.Iso<__S, Cents>.`value`: arrow.optics.Iso<__S, kotlin.Int> get() = this + Cents.`value` +public val <__S> arrow.optics.Lens<__S, Cents>.`value`: arrow.optics.Lens<__S, kotlin.Int> get() = this + Cents.`value` +public val <__S> arrow.optics.Optional<__S, Cents>.`value`: arrow.optics.Optional<__S, kotlin.Int> get() = this + Cents.`value` +public val <__S> arrow.optics.Prism<__S, Cents>.`value`: arrow.optics.Prism<__S, kotlin.Int> get() = this + Cents.`value` +public val <__S> arrow.optics.Traversal<__S, Cents>.`value`:arrow.optics.Traversal<__S, kotlin.Int> get() = this + Cents.`value` +``` + +### 8.6 How chains and library combinators interleave + +The DSL extensions only handle drilling into *generated* optics. Built-in +combinators provided by the library — `every` (into all elements of a +collection/traversable), `at`/`index` (into a map/list position), `notNull` +(§7) — are also optics of these kinds, so they compose seamlessly in the middle +of a generated chain, e.g. + +```kotlin +Employees.employees.every.company.notNull.address.street.name +``` + +Each `.segment` is either a generated DSL extension (composing the next base +optic) or a library combinator; all of them are just `this + …` compositions +under the hood. + +--- + +## 9. COPY — the `@optics.copy` builder + +When `@optics.copy` is present, the generator emits an **extension `copy` +function** that mirrors Kotlin's `copy` but lets the body address nested fields +through the generated optics instead of manual nesting. + +**Generated shape (monomorphic):** + +```kotlin +@optics @optics.copy data class Person(val name: String, val age: Int, val address: Address) { + companion object +} +``` +produces +```kotlin +public fun Person.copy( + block: context(arrow.optics.Copy) Person.Companion.(Person) -> Unit +): Person { + val me = this + return me.copy { block(this, Person.Companion, me) } +} +``` + +* The inner `me.copy { … }` is the **library** `copy` builder (imported as + `arrow.optics.copy`), which threads a mutable `Copy` through the block. +* The `block` is invoked with three things in scope: + * the `Copy` **context** — providing `set` and `transform` operations + on any `Traversal`; + * the **companion** `Person.Companion` as receiver — so the base optics + (`address`, `age`, …) and their DSL chains resolve inside the block; and + * the original value `me` as the lambda argument. + +This lets users write: + +```kotlin +me.copy { + age transform { it + 1 } + address.city.name set "Amsterdam" + address.coordinates set listOf(2, 3) +} +``` + +where `address.city.name` is resolved by composing the companion lens +`Person.address` with the DSL extensions for `city` and `name`, and `set` comes +from the `Copy` context. + +**Generic classes** produce the analogous function with the class's type +parameters added: `fun Source.copy(block: context(Copy>) +Source.Companion.(Source) -> Unit): Source`. + +The `copy` builder relies on Kotlin **context parameters**. + +--- + +## 10. Behaviour by kind of class + +| Source class | Base optics generated | DSL family | `@optics.copy` | +|------------------------------------|----------------------------------------------------------------------------------------|------------|----------------| +| **data class** | one **Lens** per primary-constructor parameter (focus nullable if the field is) | lens DSL (Lens/Optional/Traversal) | yes → `copy { }` | +| **value class** (`@JvmInline`) | one **Iso** for the single wrapped value | iso DSL (Iso/Lens/Optional/Prism/Traversal) | (not typical) | +| **sealed class / sealed interface**| one **Prism** per subclass; **plus** one **Lens** per *uniform abstract* property (§5.2) | prism DSL (Optional/Prism/Traversal) | (not typical) | +| anything else | none — hard error | — | — | + +Notes on sealed hierarchies specifically: + +* **Subclasses** may be nested in the sealed type or declared as top-level + siblings; both are found and used. +* A **single-subclass** sealed type still gets a prism (no special handling). +* **Non-data subclasses** (plain `object`, `data object`, ordinary classes) + disable the *shared-property lens* path (§5.2 step 2) but do **not** affect + prism generation — each subclass still gets a prism. +* **Sealed interfaces** behave exactly like sealed classes. +* Annotated subclasses that are themselves data classes get their *own* lenses, + isos, etc., independently of the parent's prisms. + +--- + +## 11. Generics and variance, consolidated + +* **Presence of type parameters** flips every base optic from an extension + *property* to an extension *function* that re-declares those parameters + (§3.4). Accessors therefore become calls: `Source.field()`, + `Source.field()`. +* **Upper bounds are preserved** on the generated function's type parameters, + except the trivial `Any?` bound which is dropped (§3.5). E.g. + `Wrapper` ⇒ `fun Wrapper.Companion.item(): Lens, T>`. +* **Declaration-site variance on the source class's own parameters is dropped** + in the generated parameter list (`out`/`in` cannot appear on a function type + parameter) (§3.5). +* **Star projections** in the source's parameters are carried through as `*` + (§3.5), and in sealed-lens `set` dispatch they appear as `is Sub<*> ->` with an + `@Suppress("UNCHECKED_CAST")` cast on the result (§5.2). +* **Variance on focus type arguments** is rendered literally (`out`/`in`/`*`) + (§3.7), so `Extendable` stays `Extendable`. +* **Prisms specialise the source type** to the subclass's actual supertype and + only quantify over the type variables that genuinely remain free (§6). +* **Nullability** flows into the focus type verbatim; an `Optional` is then + obtained via `notNull` (§7). + +--- + +## 12. Diagnostics and failure modes + +The generator distinguishes **errors** (reported against the source, nothing +generated) from **informational notes** (a particular optic is quietly skipped). + +**Errors:** + +* Annotating a class that is not data/value/sealed. +* Missing companion object (when the companion check is enabled). + +**Informational notes (skip, do not fail the build by themselves):** all are in +the sealed-class *lens* path (§5.2): + +* the sealed type has **no abstract properties** without extension receiver; +* the sealed type has a **non-data-class subclass**; +* a candidate property is **not uniform** across subclasses (different type or + nullability) → that property is ignored; +* a uniform property is **not a constructor parameter** in some subclass. + +Because skipping is silent, code that *references* an optic which was not +generated fails to compile at the use site (not at the annotation). For example: + +* a sealed type with an abstract property but **zero subclasses** → no lens → + `Sealed.property` does not resolve; +* a sealed type whose subclasses **change the property's type or nullability** → + the property is ignored → `Sealed.property()` does not resolve. + +Separately, if a class **references a type that cannot be resolved**, optics for +it are not produced (so referencing them fails to compile). + +--- + +## 13. Known limitations and vestigial behaviour + +* **`OpticsTarget.OPTIONAL` is inert.** It is part of the public enum but is not + mapped to any generator; optionals are produced via lens-to-nullable + + `notNull` (§7). +* **Generic value-class DSL is currently broken.** For a generic value class the + iso-DSL generic branch emits a dangling type-parameter list (e.g. `<__S,>`) and + does not bind the class's own parameter, so it does not compile. (The base iso + itself, §4, is fine; only the DSL composition helpers are affected.) The base + iso and the non-generic iso DSL work. +* **Prism imports are vestigial.** The prism file always imports + `arrow.core.left`, `arrow.core.right`, `arrow.core.identity`, left over from an + earlier implementation that built prisms by hand; the current body is just + `Prism.instanceOf()` and uses none of them. +* **Unused diagnostic messages.** Messages such as "Iso generation is supported + for data classes with up to 22 constructor parameters" and the DSL "invalid + target" message exist but are not currently reachable, because ISO is now + restricted to value classes and targets are pre-filtered by class kind before + the per-target evaluators run (so most "invalid target for this kind" branches + are defensive and never execute). +* **ISO does not alias on collision.** Unlike the lens and DSL generators, the iso + generator refers to `arrow.optics.Iso` directly and does not apply the + property-name/type collision aliasing of §3.8. diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/impl.md b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/impl.md new file mode 100644 index 00000000000..48428779457 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/impl.md @@ -0,0 +1,464 @@ +# Arrow Optics K2 Compiler Plugin — Implementation Plan + +## Implementation status (as of this change) + +The full KSP-plugin test suite has been ported to `arrow-optics-compiler-plugin/src/test` and **all 57 tests pass** (LensTests, IsoTests, PrismTests, OptionalTests, DSLTests, CopyTest, GeneratedCopyTest; one generic-value-class iso test is `@Ignore`d exactly as in the KSP suite). + +Implemented end-to-end (FIR signature generation **as companion members** + IR body generation): + +| Feature | Mono | Generic | Notes | +|---|---|---|---| +| **LENS** (data class fields) | ✅ | ✅ (`fun field()`) | bounds preserved via mirrored type params | +| **LENS** nullable focus (`Optional` via `notNull`) | ✅ | ✅ | | +| **LENS** sealed shared abstract property (§5.2) | ✅ | n/a | `when`-dispatch `set`, exhaustive `else` | +| **ISO** (value classes) | ✅ | ✅ | | +| **PRISM** (sealed subclasses, `Prism.instanceOf`) | ✅ | ✅ (§6) | refined supertype + subclass type params | +| **DSL** composition extensions (top-level, §8.2 matrix) | ✅ | — (mono sources) | | +| **COPY** (`@optics.copy`, §9) | ✅ | — (mono sources) | `context(Copy) S.Companion.(S)->Unit` built as `kotlin.Function3` + context/extension attributes | +| **Target selection** (§2.3) | ✅ | ✅ | reads `@optics([...])`, intersects with class kind | + +Key infrastructure: companion-member generation + target selection + generic PRISM (`OpticsCompanionGenerator`, `FirOpticsExtractor`), top-level DSL extensions (`OpticsDslGenerator`), the `@optics.copy` builder (`OpticsCopyGenerator`), the IR body generator (`OpticsIrGenerationExtension` + `OpticsIrHelpers`), and the shared model (`OpticsModel`, `OpticsNames`). Both FIR and IR phases are wired in `OpticsPluginWrappers`. + +**Review-driven hardening (see `arrow-optics-impl-review1.md`):** +- IR body builders no longer share IR nodes — `reconstruct`/`sealedSet` take fresh-expression factories, so multi-field reconstruction (and the sealed `when`-`set`) is valid IR. +- `OpticsIrSymbols` resolves every `arrow.optics` reference lazily, so applying the plugin to a module without `arrow-optics` never forces resolution / crashes. +- Visibility folds `mostRestrictive` over the source **and all enclosing classifiers** (§3.3), and that result is used for the top-level DSL/copy extensions too. +- The §5.2 uniformity check compares full resolved types (classifier + nullability + type arguments), not just the classifier. +- Sealed types emit DSL helpers only for their prisms (§8.4); their shared-property lenses get no DSL variants. +- Target parsing reads the **raw** annotation arguments, so `@optics([...])` is honoured regardless of the resolution phase at which generation runs. +- Tests now **execute** the generated bodies for every optic kind (`RuntimeTests`: lens get/set/modify + laws, iso round-trips, prism `getOrNull` both branches incl. generic, sealed shared-property lens across subclasses with extra fields, generic lens with a heterogeneous sibling, nullable + `notNull`) and cover target selection / ineligible classes (`TargetTests`). + +**Intentional differences from the KSP processor / known limitations:** +- **Missing companion is *not* an error.** The compiler plugin auto-generates the companion object when absent (it can, unlike the KSP processor), so the "must declare a companion object" diagnostic no longer applies; the corresponding `IsoTests` case was updated to expect success. +- **No custom §12 diagnostics.** Ineligible classes / non-uniform sealed properties silently generate no optics, so use sites fail to resolve (same observable outcome as the KSP "informational note" cases that the ported `compilationFails()` tests assert). +- **Generic DSL and generic COPY** are restricted to monomorphic sources (no ported test exercises them; generic value-class DSL is broken in KSP too, algo §13). + +Everything below is the original design. + +--- + +This document is the complete, file-by-file implementation plan for replacing the KSP source generator with a **K2 compiler plugin** (FIR declaration generation + IR body generation) in module `arrow-libs/optics/arrow-optics-compiler-plugin`. + +The behavioral specification of *what* must be produced is `arrow-optics-algo.md` (§ references below point at it). The one deliberate divergence from that spec is restated explicitly in §0. + +--- + +## 0. The key reinterpretation: companion *members*, not companion *extensions* + +The KSP generator emits every base optic as an **extension on `Source.Companion`**: + +```kotlin +val Person.Companion.age: Lens get() = … +``` + +This plugin instead generates each base optic as a **real member declaration inside `Person.Companion`**: + +```kotlin +// conceptually, inside Person's companion object: +val age: Lens = Lens(get = …, set = …) +``` + +Why this is possible and preferable: + +- FIR's `FirDeclarationGenerationExtension` can add member callables to a class via `getCallableNamesForClass` / `generateProperties` / `generateFunctions`, with `context.owner` being the companion's `FirClassSymbol`. The existing `OpticsCompanionGenerator` already *creates the companion object itself* as a generated nested class; we extend the same generator (or a sibling) to populate it. +- A member `val age` on `Person.Companion` is resolved by user code exactly as `Person.age` (companion members are accessed through the class name), so the **user-facing surface syntax is identical** to the KSP version (`Person.age`, `Thing.`object``, `Source.field()`). + +Where a companion member is **impossible**, we keep extensions: + +| Declaration | KSP form | Plugin form | +|---|---|---| +| Base ISO/LENS/PRISM | `val S.Companion.x` (ext) | **member of `S.Companion`** | +| Shared-property LENS (sealed) | `val S.Companion.p` (ext) | **member of `S.Companion`** | +| DSL composition helpers (§8) | `val <__S> OuterOptic<__S, S>.x` (ext) | **stays an extension** — receiver is an arbitrary outer optic type, not the companion; cannot be a companion member. Generated as **top-level** callables. | +| COPY builder (§9) | `fun S.copy(block…)` (ext on `S`) | **stays an extension on `S`** — generated as **top-level** callable. | + +Consequences that ripple through the plan: + +- **Base optics**: declared as companion members in FIR. The §3.4 property-vs-function split still applies. For the **generic** case the member must be a *function with its own type parameters* — companion-object **members can be functions with type parameters** (`fun field(): Lens, A>`), so this is fine; a member *property* cannot introduce type parameters, which is exactly why §3.4 already switches to a function in the generic case. +- **No imports / aliasing logic is needed.** §3.8's fully-qualified-name + alias-on-collision machinery is a *text-generation* concern. In FIR/IR we build cone types and IR symbol references directly against `ClassId`/`CallableId`; there is no rendered source, so collisions are structurally impossible. Backticking (§3.2) likewise disappears: a `Name` carries the raw identifier, and the compiler back-end handles keyword identifiers at the symbol level. **We never render strings.** (This is a major simplification versus KSP.) +- The `inline` option (§3.9) is dropped initially (it was a text modifier; can be revisited as a `status{}` flag later). + +--- + +## 1. Architecture overview — the two-phase split + +The plugin runs in two compiler phases: + +1. **FIR (frontend) — declaration generation + checkers.** Decides *which* declarations exist and their *signatures* (receiver, type parameters, value parameters, return cone type, containing symbol). Bodies are left empty. Also runs **checkers** that emit the §12 diagnostics. Driven by `FirDeclarationGenerationExtension` subclasses and a `FirAdditionalCheckersExtension`. +2. **IR (backend) — body generation.** Walks generated declarations (`origin is IrDeclarationOrigin.GeneratedByPlugin && pluginKey == Key`), asserts `body == null`, and fills in each body using `DeclarationIrBuilder`. Driven by an `IrGenerationExtension`. + +### 1.1 Per-kind decision table + +For each optic kind, what FIR declares and what IR builds: + +| Kind | Where (FIR) | Signature (FIR) | IR body | +|---|---|---|---| +| **LENS** (data class field) | member of `S.Companion` | mono: `val name: Lens`; generic: `fun name(): Lens, F>` | `Lens(get = { s -> s.name }, set = { s, v -> s.copy(name = v) })` | +| **LENS** (sealed shared prop, §5.2) | member of `S.Companion` | same as above, focus = property type | `Lens(get = { s -> s.prop }, set = { s, v -> when(s){ is Sub1 -> s.copy(prop=v); … } [as S] })` | +| **ISO** (value class) | member of `S.Companion` | mono: `val name: Iso`; generic: `fun name(): Iso, F>` | `Iso(get = { s -> s.name }, reverseGet = { v -> S(v) })` | +| **PRISM** (sealed subclass) | member of `S.Companion` | mono: `val sub: Prism`; generic: `fun sub(): Prism>` where `Refined` is the subclass's *declared supertype* and `free…` is the union per §6 | `Prism.instanceOf()` (reified) | +| **DSL** (one per base optic kind variant per §8.2) | **top-level** extension | `val <__S [,Tp…]> OuterOptic<__S, S[]>.name: OuterOptic<__S, F> get()` (or `fun` form) | `this + S.name` (compose the receiver with the base companion optic) | +| **COPY** (`@optics.copy`) | **top-level** extension on `S` | `fun [] S[].copy(block: context(Copy) S.Companion.(S) -> Unit): S` | `val me = this; return me.copy { block(this, S.Companion, me) }` using `arrow.optics.copy` | + +`F` = focus type (nullable verbatim per §3.6). `OuterOptic` ∈ {`Iso`,`Lens`,`Prism`,`Optional`,`Traversal`} per the §8.2 matrix. + +### 1.2 Phase data flow + +FIR cannot pass arbitrary objects to IR. Both phases independently re-derive the **focus model** from the source `FirClassSymbol` / IR `IrClass`. We therefore put focus extraction in a **session-independent, symbol-driven module** with two thin adapters (one reading FIR symbols, one reading IR symbols), or — simpler and recommended — re-derive in each phase from the public symbol APIs since the logic is small. The plan uses **shared pure model classes** (data classes describing targets/kinds/foci by `Name`/`ClassId`, no compiler types) plus per-phase extractors that the FIR generator and IR generator each call. + +To correlate an IR declaration back to its meaning, IR matches on **name + containing class + signature shape** (it re-runs extraction on the owner `IrClass` and finds the focus whose generated name equals the declaration's name). No cross-phase state is stored. + +--- + +## 2. Lambda bodies in IR — the concrete hard part + +All bodies are built with `DeclarationIrBuilder(pluginContext, symbol).irBlockBody { +irReturn(expr) }`. External references are resolved once via `pluginContext.referenceClass/referenceFunctions/referenceConstructors/referenceProperties` against `ClassId`/`CallableId` constants for `arrow.optics`. The recurring difficulty is **building `IrFunctionExpression` lambdas** to pass as `get`/`set`/`reverseGet`. + +### 2.1 Building a lambda (the core helper) + +A lambda passed to `Lens(get = …, …)` is an `IrFunctionExpression` of `kotlin.FunctionN` type wrapping an `IrSimpleFunction` (origin `LOCAL_FUNCTION_FOR_LAMBDA`). Plan a single helper: + +``` +fun IrBuilder.buildLambda( + parameterTypes: List, + returnType: IrType, + body: IrBlockBodyBuilder.(params: List) -> Unit, +): IrFunctionExpression +``` + +Implementation outline: +- `pluginContext.irFactory.buildFun { origin = LOCAL_FUNCTION_FOR_LAMBDA; name = SpecialNames.NO_NAME_PROVIDED; visibility = LOCAL; returnType = returnType }`. +- Add value parameters (one per `parameterTypes`) via `addValueParameter`. +- Set `parent` to the enclosing generated function/property accessor. +- Set `fn.body = DeclarationIrBuilder(...).irBlockBody { body(fn.valueParameters) }`. +- Wrap: `IrFunctionExpressionImpl(startOffset, endOffset, type = kFunctionNType(parameterTypes + returnType), function = fn, origin = LAMBDA)`. The function type is obtained via `pluginContext.irBuiltIns.functionN(n).typeWith(parameterTypes + returnType)`. + +This helper is used for every `get`/`set`/`reverseGet`. + +### 2.2 LENS body (data class) + +``` +Lens.invoke( + get = buildLambda([S]) { (s) -> +irReturn(irCall(prop.getter)(receiver = irGet(s))) }, + set = buildLambda([S, F]) { (s, v) -> +irReturn(irCall(S.copy)(receiver = irGet(s), arg name=v)) } +) +``` + +- `Lens(...)` resolves to `PLens.Companion.invoke` — `referenceFunctions(CallableId(PLens.Companion classId, Name("invoke")))`, pick the 2-arg `get/set` overload. Receiver of the call is `irGetObject(PLens.Companion)`. +- `s.name` getter: `referenceProperties` on the source class property, call its getter with dispatch receiver `irGet(s)`. (For a primary-ctor `val`, the property symbol exists on the source `IrClass`.) +- `s.copy(name = v)`: the data class's synthetic `copy` is `referenceFunctions(CallableId(sourceClassId, Name("copy")))`. It has **one value parameter per component, all with default values**; we set only the relevant argument by index and rely on IR default-argument handling. **Trickiness:** in IR you cannot omit defaulted args by name the way source can; you must either supply all arguments (re-reading every other component from `s`) or emit the call with `putValueArgument(i, value)` only for the target index and leave others null *iff* the `copy` symbol's parameters carry `hasDefaultValue` and the back-end inserts a `$default` stub call. The robust approach: **call the `copy$default` synthetic** with a bitmask, OR—simpler and recommended—**reconstruct via the primary constructor**: `S(comp0 = s.c0, …, name = v, …)` reading each other component through its getter. Recommended: use the **constructor reconstruction** for data classes (deterministic, no `$default` mask handling), reading siblings via their property getters. Document both; default to constructor reconstruction. + +### 2.3 LENS body (sealed shared property, §5.2) + +`get` is `s.prop` (abstract property getter). `set` builds an `irWhen`: + +``` +set = buildLambda([S, F]) { (s, v) -> + val branches = subclasses.map { sub -> + irBranch( + condition = irIs(irGet(s), sub.defaultTypeStarProjected), + result = subReconstruct(irImplicitCast(irGet(s), subType), prop, v) // sub copy/ctor with prop = v + ) + } + val whenExpr = irWhen(type = S_or_Star, branches) // exhaustive over sealed + +irReturn( if (parentGeneric) irImplicitCast(whenExpr, S) else whenExpr ) +} +``` + +- Each branch reconstructs the subclass with `prop = v` (constructor reconstruction as in §2.2, reading the subclass's other components from the cast `s`). +- **Unchecked cast (§5.2 generic):** when the parent is generic, each branch yields a star-projected `Sub<*>`; the whole `when` is `irImplicitCast`-ed to `S`. There is no `@Suppress` needed in IR (suppression is a frontend/source concern; the IR cast is unchecked by construction). Use `IrTypeOperator.IMPLICIT_CAST` (or `CAST`); document that the generated IR is trusted. +- `irIs` uses the subclass's type **star-projected** for generic parents. +- Exhaustiveness: provide all sealed inheritors; no `else` branch. Obtain inheritors in IR via `IrClass.sealedSubclasses`. + +### 2.4 ISO body (value class) + +``` +Iso.invoke( + get = buildLambda([S]) { (s) -> +irReturn(irCall(prop.getter)(irGet(s))) }, + reverseGet = buildLambda([F]) { (v) -> +irReturn(irCall(S.primaryConstructor)(irGet(v))) } +) +``` +`Iso(...)` → `PIso.Companion.invoke`. Value-class constructor call is an ordinary `irCallConstructor`. + +### 2.5 PRISM body + +The entire body is `Prism.instanceOf()` — the **inline reified** factory on `PPrism.Companion`. + +- `referenceFunctions(CallableId(PPrismCompanionClassId, Name("instanceOf")))` and pick the **reified** overload (the one with a reified type parameter, no `KClass` value parameter). +- Build `irCall(instanceOf)` with dispatch receiver `irGetObject(PPrism.Companion)`, and **set its two type arguments**: `putTypeArgument(0, sourceType)` and `putTypeArgument(1, subType)`. Because the callee is `inline`, the back-end inlines `instanceOf` and resolves the `reified B` at the call site from the supplied type argument — **this is the key point**: an IR call to an inline-reified function must carry concrete type arguments, which it does here. **Risk:** confirm the inliner runs on plugin-generated calls (it runs in a standard lowering after IR generation, so it does). Alternatively, to avoid reliance on reified inlining, generate the `instanceOf(klass = SubClass::class)` overload by emitting an `IrClassReference`; document this as the fallback if reified inlining misbehaves. + +### 2.6 DSL body (composition) + +`this + S.name`: +- The receiver is the extension receiver value parameter (kind `ExtensionReceiver`): `irGet(function.extensionReceiverParameter!!)`. +- `S.name` is the **base companion optic**: `irGetObject(S.Companion)` then `irCall(base getter)` (mono) or `irCall(base function)()` with the source type args (generic). +- `+` is `plus` on the outer optic kind: `referenceFunctions(CallableId(outerOpticClassId, Name("plus")))`. Build `irCall(plus)` with dispatch receiver = the extension receiver, value arg 0 = the base optic expression. **Trickiness:** there are several `plus` overloads (per kind) with `in`/`out` projected parameters; select by the outer-optic kind that matches the generated variant. Set type arguments (`C`, `D`) to the focus type. + +### 2.7 COPY body + +``` +val me = irTemporary(irGet(extensionReceiver)) // val me = this +val innerLambda = buildLambda([Copy]) { (copyCtx) -> // me.copy { block(this, S.Companion, me) } + +irCall(block.invoke)( + receiver = irGet(blockParam), // block is the value param + contextArg = irGet(copyCtx), // context(Copy) + extReceiver = irGetObject(S.Companion), // S.Companion.( ... ) + valueArg = irGet(me) // (me) + ) +} ++irReturn(irCall(arrowOpticsCopy)(receiver = irGet(me), lambda = innerLambda)) +``` + +- `arrow.optics.copy` = top-level `referenceFunctions(CallableId(FqName("arrow.optics"), null, Name("copy")))`. +- The `block` parameter type is a **context-parameter function type** `context(Copy) S.Companion.(S) -> Unit`. Building this *type* in FIR is the hard part (see §3 & §9); invoking it in IR means calling its synthetic `invoke` with the context receiver, extension receiver, and value argument in the right slots. **This is the single trickiest piece** and is the last milestone; it depends on context-parameters being enabled (`-Xcontext-parameters`). + +### 2.8 Trickiest IR pieces, ranked +1. **COPY** context-parameter function type + invocation (§2.7, §9). +2. **Data-class `set`** without a clean `copy(name=v)` — use constructor reconstruction (§2.2). +3. **Sealed `set`** exhaustive `irWhen` + unchecked cast for generics (§2.3). +4. **`Prism.instanceOf` reified** call emission (§2.5). +5. **DSL `plus` overload selection** and extension-receiver `irGet` (§2.6). +6. **Lambda construction** helper correctness (parent linking, function type) (§2.1). + +--- + +## 3. Shared infrastructure (pure model + extractors) + +### 3.1 Focus / target / kind model (compiler-type-free) + +Pure data classes (no FIR/IR imports) so both phases share them: + +``` +enum class ClassKind { DATA, VALUE, SEALED, INELIGIBLE } +enum class OpticKind { ISO, LENS, PRISM } // base optics actually generated +data class Focus( + val opticName: Name, // §3.2 name rule already applied (lowercased-first for prism) + val focusClassId/coneShape, // described abstractly; resolved per phase + val nullable: Boolean, + val sourceComponentName: Name?, // for lens get/set + val subclass: ClassRef?, // for prism / sealed-lens dispatch +) +data class OpticDecl(val kind: OpticKind, val focus: Focus, val isFunction: Boolean /*§3.4*/, val typeParams: List) +``` + +Type/cone construction is **not** stored here; each phase builds cone (FIR) or `IrType` (IR) from the source symbol on demand. The model only carries names, nullability flags, and which source component/subclass each focus maps to. + +### 3.2 Focus extraction (read once per phase) + +From the source class symbol: +- **Data class** → `primaryConstructor.valueParameters` → one LENS focus each (name = param name; focus type = param type, nullability preserved §3.6). +- **Value class** → single primary-ctor param → one ISO focus. +- **Sealed** → (a) **PRISM**: one focus per sealed inheritor (`getSealedClassInheritors` in FIR / `sealedSubclasses` in IR); optic name = subclass simple name, first letter lowercased (§3.2). (b) **shared-prop LENS** per §5.2 algorithm: + 1. Collect abstract properties with **no extension receiver**. None → note, skip. + 2. Collect inheritors; **any non-data subclass** → note, skip the lens path (PRISM unaffected, §10). + 3. Partition uniform vs non-uniform (same name + exact resolved type incl. nullability across **all** subclasses). Non-uniform → note, ignore that property. + 4. Any uniform property **not a primary-ctor param** in some subclass → note, skip that property. + 5. Survivors → LENS foci with subclass list (star-projected per-subclass type args). + +### 3.3 Companion-vs-extension decision + +- Base ISO/LENS/PRISM → **companion member** (this plan's divergence, §0). +- DSL → **top-level extension** (§8). +- COPY → **top-level extension on `S`** (§9). + +### 3.4 Property-vs-function decision (§3.4) + +`isFunction = sourceClass.typeParameters.isNotEmpty()`. Property when monomorphic, function (carrying re-declared type params) when generic. For DSL/COPY the same rule applies, with the extra `__S` type parameter (DSL) or the class's params (COPY) always present. + +### 3.5 Type-parameter declaration vs reference (§3.5) + +- **Declaration form**: re-declare each source type parameter on the generated function via FIR `typeParameter(name, variance = INVARIANT, isReified = false){ bound(...) }`. **Variance dropped** (functions can't carry `out`/`in`). **Upper bounds preserved**, except a trivial `Any?` bound is omitted. +- **Reference form**: when mentioning `S` build a cone type with the freshly declared type-parameter symbols as arguments. Star projection in source → star projection in the built cone. +- **PRISM specialisation (§6)**: source type is the subclass's *declared supertype* (`Refined`), and the generated function's type params are the **union** of (free type vars in `Refined`'s arguments) ∪ (subclass's own type params). Compute by walking the subclass's supertype reference for the sealed parent. + +### 3.6 Visibility (§3.3) + +Most-restrictive combine of companion visibility, source-class visibility, and all enclosing-class visibilities. Combine pairwise: `public` identity; `private` dominates; `internal`+`protected` → `private`; `local` propagates. Apply via `status { visibility = computed }` in the FIR DSL builders. (Note the existing `OpticsCompanionGenerator` already sets companion visibility to the source's `rawStatus.visibility`.) + +### 3.7 Naming (§3.2) — no backticking needed + +Optic name is a `Name` (`Name.identifier(...)`). LENS/ISO = component name verbatim. PRISM = subclass simple name with first char lowercased. **No backticks** — `Name` holds the raw identifier and the back-end emits valid bytecode for keyword names. Keyword handling and the lambda-parameter naming rules of §3.2 are irrelevant (lambda params are anonymous in IR). + +### 3.8 Nullability (§3.6) + +Focus cone/`IrType` carries `isMarkedNullable` directly from the source component type. No `Optional` is generated; `notNull` promotion is the library's job (§7). Nothing to do beyond preserving nullability. + +### 3.9 Target-set computation (§2.3) intersected with kind + +Read `@optics(targets)`: map ISO/LENS/PRISM/DSL; drop OPTIONAL; empty → `{ISO,LENS,PRISM,DSL}`. Intersect with kind: +- sealed → `{PRISM, LENS, DSL}` +- value → `{ISO, DSL}` +- data → `{LENS, DSL}` +Add COPY iff `@optics.copy` present. Implement as a pure function `computeTargets(kind, annotationTargets, hasCopy): Set`. + +--- + +## 4. File-by-file plan + +All paths under `arrow-libs/optics/arrow-optics-compiler-plugin/`. + +### New shared files — `src/main/kotlin/arrow/optics/plugin/` + +| File | Responsibility | Key declarations | +|---|---|---| +| `OpticsNames.kt` | Central `ClassId`/`CallableId`/`FqName` constants for arrow-optics API. | `PLENS_COMPANION_INVOKE`, `PISO_COMPANION_INVOKE`, `PPRISM_COMPANION_INSTANCE_OF`, `LENS_CLASS_ID`, `OPTIONAL/TRAVERSAL/PRISM/ISO_CLASS_ID`, `PLUS` callable ids per kind, `ARROW_OPTICS_COPY`, `COPY_CLASS_ID`, `OPTICS_ANNOTATION_FQNAME` (move from generator), `OPTICS_COPY_ANNOTATION_FQNAME`, `OPTICS_TARGET_*`. | +| `model/OpticsModel.kt` | Compiler-type-free model. | `ClassKind`, `OpticKind`, `Target`, `Focus`, `OpticDecl`, `computeTargets(...)`, `lowercaseFirst(Name)`, visibility-combine helper `mostRestrictive(...)`. | + +### New FIR files — `src/main/kotlin/arrow/optics/plugin/fir/` + +| File | Responsibility | Key declarations | +|---|---|---| +| `OpticsCompanionGenerator.kt` *(modify existing)* | Keep creating the empty companion. **Add base-optic member generation.** | Extend `getCallableNamesForClass` (when `owner` is the generated/existing companion of an `@optics` class, return the optic `Name`s computed from the source); add `generateProperties(callableId, context)` for monomorphic base optics and `generateFunctions(callableId, context)` for generic ones. Use `createMemberProperty(owner, Key, name, returnType, isVal=true)` / `createMemberFunction(...)` from `org.jetbrains.kotlin.fir.plugin`, computing the return cone type via a new `FirFocusExtractor`. Reuse existing `Key`, predicate, `isGeneratedOpticsCompanion`. **Important:** to attach members to a *user-declared* companion (not only the generated one), the predicate match must be on the **source class**; resolve the source class from the companion via `owner.getContainingClassSymbol()` / outer class, and only emit when that source matches the predicate. | +| `FirFocusExtractor.kt` | FIR adapter reading a `FirRegularClassSymbol` to produce `List` + the cone-type builders. | `extract(sourceClassSymbol, session): List`; helpers `coneForFocus`, `coneForSource(tpSymbols)`, sealed-inheritor scan via `sourceClassSymbol.getSealedClassInheritors(session)`, abstract-property scan, uniformity check (§5.2). Reused by checker. | +| `OpticsDslGenerator.kt` | Generates the **top-level** DSL extension callables (§8). | `FirDeclarationGenerationExtension`; `getTopLevelCallableIds()` returns the DSL callable ids; `generateProperties`/`generateFunctions` build extension props/funcs with `createTopLevelProperty`/`createTopLevelFunction`, `extensionReceiverType { tps -> OuterOptic<__S, S> }`, an extra `__S` type parameter, `hasBackingField = false`. Emits one variant per §8.2 matrix entry. Needs `@ExperimentalTopLevelDeclarationsGenerationApi` and possibly `hasPackage`. Own `object Key`. | +| `OpticsCopyGenerator.kt` | Generates the **top-level** `copy` extension (§9) when `@optics.copy`. | `FirDeclarationGenerationExtension`; top-level `fun S.copy(block: context(Copy) S.Companion.(S) -> Unit): S`. Builds the context-parameter function type for `block`. Own `object Key`. Gated on context-parameters support. | +| `OpticsCheckers.kt` | §12 diagnostics. | `FirAdditionalCheckersExtension` providing a `FirClassChecker` (or declaration checker) that reports: ineligible-class error, missing-companion error, and the §5.2 informational notes. Uses a `KtDiagnosticFactory0`/`Factory1` set defined in `OpticsErrors.kt`. | +| `OpticsErrors.kt` | Diagnostic factory + message bundle. | `object OpticsErrors`-style factories (`NOT_DATA_VALUE_SEALED`, `MISSING_COMPANION` as errors; `NO_ABSTRACT_PROPERTIES`, `NON_DATA_SUBCLASS`, `NON_UNIFORM_PROPERTY`, `PROPERTY_NOT_CTOR_PARAM` as warnings/infos) and a `BaseDiagnosticRendererFactory` with messages. Register via the checkers extension. | + +### New IR files — `src/main/kotlin/arrow/optics/plugin/ir/` + +| File | Responsibility | Key declarations | +|---|---|---| +| `OpticsIrGenerationExtension.kt` | Entry point; visits generated declarations, dispatches by kind/owner. | `class … : IrGenerationExtension { override fun generate(moduleFragment, pluginContext) }`. A `IrElementVisitorVoid` that finds `IrSimpleFunction`/`IrProperty` accessors whose `origin is GeneratedByPlugin` with `pluginKey ∈ {CompanionGenerator.Key, DslGenerator.Key, CopyGenerator.Key}` and dispatches to builders. Resolves all external symbols once into an `OpticsIrSymbols` holder. | +| `OpticsIrSymbols.kt` | Resolved external IR symbols. | `referenceFunctions`/`referenceClass`/`referenceConstructors`/`referenceProperties` for everything in `OpticsNames.kt`; cached. | +| `IrBuilders.kt` | Reusable IR builder helpers. | `buildLambda(...)` (§2.1), `irFunctionType(...)`, constructor-reconstruction helper `reconstruct(classSymbol, overrides)`, `composePlus(...)`. | +| `LensIrBuilder.kt` | LENS body (data + sealed) (§2.2, §2.3). | `buildDataLens`, `buildSealedLens`. | +| `IsoIrBuilder.kt` | ISO body (§2.4). | `buildIso`. | +| `PrismIrBuilder.kt` | PRISM body (§2.5). | `buildPrism`. | +| `DslIrBuilder.kt` | DSL body (§2.6). | `buildDslComposition`. | +| `CopyIrBuilder.kt` | COPY body (§2.7). | `buildCopy`. | +| `IrFocusExtractor.kt` | IR-side re-derivation of foci from `IrClass` (mirror of FIR extractor). | `extract(irClass, pluginContext): List` + `irTypeForFocus`, sealed dispatch via `IrClass.sealedSubclasses`. | + +### Modified wiring — `src/main/kotlin/arrow/optics/plugin/fir/OpticsPluginWrappers.kt` + +- Register the new FIR generators and checkers, and the IR extension: + +``` +override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + FirExtensionRegistrarAdapter.registerExtension(OpticsPluginRegistrar()) + IrGenerationExtension.registerExtension(OpticsIrGenerationExtension()) +} + +class OpticsPluginRegistrar : FirExtensionRegistrar() { + override fun ExtensionRegistrarContext.configurePlugin() { + +::OpticsCompanionGenerator // now also generates base optic members + +::OpticsDslGenerator + +::OpticsCopyGenerator + +::OpticsCheckers // FirAdditionalCheckersExtension + } +} +``` + +(If COPY must be gated on `-Xcontext-parameters`, read a CLI option in `OpticsCommandLineProcessor` and only register the copy generator when enabled — or always register and let it no-op when context params are off.) + +### `build.gradle.kts` additions + +Add a test source set with the K2-plugin test harness deps (mirroring the KSP module, minus KSP): + +``` +dependencies { + compileOnly(kotlin("compiler")) + + testImplementation(kotlin("test")) + testImplementation(kotlin("compiler")) // to instantiate OpticsPluginComponentRegistrar + testImplementation(libs.kotest.assertionsCore) + testImplementation(libs.classgraph) + testImplementation(libs.kotlinCompileTesting) { + exclude(group = libs.classgraph.get().module.group, module = libs.classgraph.get().module.name) + exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib") + } + testRuntimeOnly(projects.arrowAnnotations) + testRuntimeOnly(projects.arrowCore) + testRuntimeOnly(projects.arrowOptics) +} +tasks.withType().configureEach { maxParallelForks = 1; useJUnitPlatform() } +``` + +Also pass `arrowVersion` system property to tests as the KSP module does (check that module's parent build for how `arrowVersion` is wired; replicate). No `libs.ksp`, no `kotlinCompileTestingKsp`. + +### Service files — `src/main/resources/META-INF/services/` + +Already present for the `CommandLineProcessor` and `CompilerPluginRegistrar`. No change unless we add a second registrar; we do not. + +### Test files — `src/test/kotlin/arrow/optics/plugin/` + +| File | Responsibility | +|---|---| +| `Compilation.kt` | Port from KSP module; replace `configureKsp{…}` with `compilerPluginRegistrars = listOf(OpticsPluginComponentRegistrar())`. Keep `classpathOf`, `evals`, `compilationSucceeds`, `failsWith`. | +| `Utils.kt` | Port the `package`/`imports`/`dslModel` fixtures. | +| `LensTests.kt`, `IsoTests.kt`, `PrismTests.kt`, `OptionalTests.kt`, `DSLTests.kt`, `CopyTest.kt`, `GeneratedCopyTest.kt` | Port from KSP module — the *behavioral* expectations are unchanged because the user-facing surface (`Person.age`, `Source.field()`) is identical. These become the cross-implementation conformance suite. | + +--- + +## 5. Diagnostics (§12) in FIR + +In `OpticsCheckers.kt` (a `FirAdditionalCheckersExtension`), register a class-level checker: + +- **Error `NOT_DATA_VALUE_SEALED`**: on a class annotated `@optics` (predicate match) whose `classKind`/modality is not data / `@JvmInline value` / sealed → `reporter.reportOn(source, OpticsErrors.NOT_DATA_VALUE_SEALED)`. +- **Error `MISSING_COMPANION`**: annotated class with no companion. **Subtlety:** the `OpticsCompanionGenerator` *creates* a companion when missing, so by checker time a companion always exists. Two options: (a) make companion auto-generation conditional on a config flag and emit the error when the flag is off and the user omitted a companion (matches algo §2.2 "can be disabled by a configuration flag"); (b) since we auto-generate, **drop** this error by default. Recommended: keep the flag (default = auto-generate, no error), and emit `MISSING_COMPANION` only when auto-generation is disabled. +- **Informational notes (warnings)** in the §5.2 path, reported from the **shared extractor** results so FIR and the user see them: `NO_ABSTRACT_PROPERTIES`, `NON_DATA_SUBCLASS`, `NON_UNIFORM_PROPERTY` (Factory1 carrying the property name), `PROPERTY_NOT_CTOR_PARAM`. These are non-fatal; the corresponding optic is simply not generated, so use-site references fail to resolve later (matches §12). + +Messages live in `OpticsErrors.kt` via a `BaseDiagnosticRendererFactory`; register the renderer so messages display. If `MutableDiagnosticReporter` is needed inside generation (it generally is not — prefer checkers), it can be obtained from the checker context. + +--- + +## 6. Sequencing / milestones + +Each milestone ends with a green test. Build the harness first so every later step is verifiable. + +**M0 — Harness + wiring.** +- Add IR extension registration in `OpticsPluginWrappers.kt` (empty `OpticsIrGenerationExtension` that does nothing yet). +- `build.gradle.kts` test deps; port `Compilation.kt`/`Utils.kt` with `compilerPluginRegistrars = listOf(OpticsPluginComponentRegistrar())`. +- **Test:** an `@optics data class` with no references compiles (companion is generated; no members yet). + +**M1 — LENS, monomorphic data class, end to end.** +- `OpticsNames.kt`, `model/OpticsModel.kt`, `FirFocusExtractor.kt` (data-class branch only). +- `OpticsCompanionGenerator`: announce one property `Name` per ctor param via `getCallableNamesForClass`; `generateProperties` returns `createMemberProperty(companion, Key, name, Lens cone, isVal=true)`. +- IR: `IrBuilders.buildLambda`, `LensIrBuilder.buildDataLens` (constructor-reconstruction `set`), `OpticsIrSymbols`, dispatch in `OpticsIrGenerationExtension`. +- **Test (port `LensTests` first case):** `val i: Lens = LensData.field1` evaluates / `.evals("r" to true)`. Also nullable focus → `Lens` (OptionalTests subset). + +**M2 — LENS, generic data class (function form).** +- §3.4 function switch; `FirFocusExtractor` type-param re-declaration (§3.5, bounds preserved, variance dropped); reference cone `S`. +- `generateFunctions` with `createMemberFunction` + `typeParameter(...)`, `returnTypeProvider`. +- IR: `buildDataLens` handles type-parameterized owner (type args on `copy`/ctor calls). +- **Test:** `OpticsTest(val field: A)` → `OpticsTest.field()` typed `Lens, String>`. + +**M3 — Sealed shared-property LENS (§5.2).** +- `FirFocusExtractor` sealed branch: inheritor scan, abstract-prop uniformity, ctor-param check; emit notes via checker. +- IR `buildSealedLens`: exhaustive `irWhen`, per-subclass reconstruction, generic unchecked cast. +- **Test (port `LensTests` sealed cases):** `LensSealed.property1` get/set across `Child1`/`Child2`; `Box.tag()` generic with cast. + +**M4 — ISO (value class).** +- `FirFocusExtractor` value branch; `IsoIrBuilder.buildIso`. +- **Test:** port `IsoTests` (`IsoData.field1`, generic `IsoData.field1()`). + +**M5 — PRISM (sealed).** +- `FirFocusExtractor` prism foci (subclass list, refined supertype, free-var union §6); `PrismIrBuilder.buildPrism` with reified `instanceOf`. +- **Test:** port `PrismTests` (mono `PrismSealed.prismSealed1`; generic `PrismSealed2()` with refined source `PrismSealed`). + +**M6 — DSL (§8).** +- `OpticsDslGenerator` top-level extensions, per-kind variant matrix (§8.2); `DslIrBuilder.buildDslComposition` (`this + S.x`) with correct `plus` overload. +- **Test:** port `DSLTests` (`Employees.employees.every.company.notNull.address.street.name` chain). + +**M7 — COPY (§9).** +- `OpticsCopyGenerator` top-level `copy` with context-parameter `block` type; `CopyIrBuilder.buildCopy`. +- Gate on `-Xcontext-parameters` (test passes `contextParameters = true`). +- **Test:** port `CopyTest`/`GeneratedCopyTest` (`me.copy { age transform { it+1 }; address.city.name set "…" }`). + +**M8 — Diagnostics + conformance sweep.** +- `OpticsCheckers.kt`, `OpticsErrors.kt`; port `failsWith`/`compilationFails` tests (ineligible class, etc.). +- Run the full ported KSP test suite as conformance. + +--- + +## 7. Risks / open questions + +1. **Companion members carrying type parameters.** A member *property* cannot declare type params, but the generic case already uses a **member function**, which can — so generic base optics as companion members are sound. Confirm `createMemberFunction` with `typeParameter{}` on a companion-object owner works (it should; member functions on objects routinely have type params). +2. **Visibility of generated members to same-module user code at IR/resolution.** FIR-generated members must be visible to the FIR resolution of user call sites (`Person.age`). This requires the generator to **announce names** (`getCallableNamesForClass`) reliably for the *user-declared* companion, not just the plugin-generated one. The existing generator only handles the case where it *created* the companion. **Open:** verify name announcement works when the user wrote `companion object` themselves — match on the source class via the companion's containing class and the predicate. This is the highest-risk correctness item; test in M1 with both an explicit and an absent companion. +3. **`Prism.instanceOf` reified inlining in IR.** A plugin-emitted `irCall` to an inline-reified function with concrete type arguments must survive the inliner. Generally fine, but if problematic, fall back to the `instanceOf(klass: KClass)` overload with an `IrClassReference` (§2.5). Decide in M5. +4. **Data-class `set` without source-level named/defaulted `copy`.** Use **constructor reconstruction** to avoid `copy$default` bitmask handling (§2.2). Confirm value classes and sealed subclasses always expose a primary constructor (they do for data/value classes). +5. **Backticking / keyword names.** Not a concern at the symbol level — `Name.identifier("in")` is valid and the back-end emits correct bytecode. No special handling, unlike KSP's unconditional backticks. +6. **Context parameters for COPY (§9).** Requires `-Xcontext-parameters`; building a `context(Copy) S.Companion.(S) -> Unit` *type* in FIR (cone with context-receiver) and invoking it in IR are both experimental-API surfaces. Treat COPY as the last, most fragile milestone; gate behind the flag and possibly behind a CLI option. +7. **DSL `plus` overload selection.** Each optic kind's `plus` has variance-projected parameters; selecting the right `FirNamedFunctionSymbol`/`IrSimpleFunctionSymbol` per outer-optic kind needs care (filter `referenceFunctions` by dispatch-receiver class). Validate types compile in M6. +8. **"Extension on Companion" reinterpretation.** Re-read §3.1/§3.4 of the algo as "companion **member**" for base optics throughout this plan; only DSL (§8) and COPY (§9) remain genuine extensions (top-level). The algo's import/aliasing (§3.8), backticking (§3.2), and file-grouping (§3.10) sections are **not implemented** — they are artifacts of text generation and have no analogue in FIR/IR. +9. **`getSealedClassInheritors` API stability** across the K2 version pinned by `kotlin("compiler")`. Verify the exact signature (`session` parameter) against the compiler version resolved by the version catalog before M3/M5. diff --git a/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/review.md b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/review.md new file mode 100644 index 00000000000..6fa8920f1a0 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/review.md @@ -0,0 +1,328 @@ +# Review #1 — Arrow Optics K2 compiler plugin + +Scope: `arrow-libs/optics/arrow-optics-compiler-plugin` (FIR generators, IR body +generator, shared model, test suite). The plugin compiles and all 57 ported +tests pass. This review focuses on **(a) cases missing from the tests** and +**(b) how the code is structured**, plus the latent correctness risks those gaps +hide. + +Overall: the architecture is sound and idiomatic for a K2 plugin (FIR decides +signatures as companion members, IR fills bodies, correlation by +origin-key + return-type classifier). The main weaknesses are **shallow runtime +test coverage** (most generated *bodies* are never executed), a couple of +**latent IR/semantic bugs** that the current tests cannot catch, some +**spec deviations**, and a few **structural/robustness** rough edges. + +--- + +## 1. Test coverage gaps (primary focus) + +### 1.1 Most generated optic *bodies* are never executed + +The ported KSP tests are overwhelmingly *compile-only* (`compilationSucceeds`) +or assert only `optic != null`. They prove the **signatures** resolve, but they +do **not** run the IR bodies the plugin generates. Concretely, grepping the +tests: + +| Optic kind | `get`/`set`/`reverseGet`/`getOrModify` executed at runtime? | Where | +|---|---|---| +| **LENS** (mono data class) | ✅ yes | `CopyTest` (`Person.age.modify`, `Person.address.set/get`, `compose`) | +| **DSL** chains (mono) | ✅ yes | `DSLTests` (`…every…notNull…modify`, `at().set`) | +| **COPY** generated builder | ✅ yes | `GeneratedCopyTest` | +| **ISO** (`get`/`reverseGet`) | ❌ **never** | `IsoTests` only do `val i = …; r = i != null` | +| **PRISM** (`getOrModify`/`reverseGet`) | ❌ **never** | `PrismTests` are all `compilationSucceeds` | +| **Sealed shared-property LENS** (`when`-dispatch `set`) | ❌ **never** | `LensTests` sealed cases only do `LensSealed.property1 != null` | +| **Generic LENS** (`get`/`set`) | ❌ **never** | `LensTests`/`OptionalTests` only `!= null` | +| **Nullable focus** via `notNull` | ❌ **never** (only `!= null`) | `OptionalTests` | + +This is the single biggest gap. The **sealed-property lens `set`** is by far the +most intricate body the plugin produces (`when (s) { is Sub -> Sub.copy/ctor … }` +with an exhaustive `else`, constructor reconstruction, and — for generic parents +— an unchecked cast). It is currently generated and type-checked but **never +run**, so a wrong branch, a bad reconstruction, or a wrong field would sail +through CI. The earlier hand-written tests *did* execute these; replacing them +wholesale with the KSP ports lost that coverage. + +**Recommendation:** add `evals(...)`-style tests that actually exercise each +kind, e.g. + +```kotlin +// sealed lens — the highest-value missing test +val l = LensSealed.property1 +val r = l.get(Child2("a", 5)) == "a" && + l.set(Child2("a", 5), "z") == Child2("z", 5) && + l.set(Child1("a"), "z") == Child1("z") + +// iso round-trips +val r = IsoData.field1.reverseGet(IsoData.field1.get(IsoData("x"))) == IsoData("x") + +// prism +val r = PrismSealed.prismSealed1.getOrModify(PrismSealed1("x")).isRight() && + PrismSealed.prismSealed1.getOrModify(PrismSealed2("y")).isLeft() + +// generic lens get/set at a concrete instantiation +val r = OpticsTest.field().set(OpticsTest("x"), "y") == OpticsTest("y") +``` + +### 1.2 Input shapes never tested + +- **Sealed subclass with ≥2 extra constructor fields.** Every sealed-lens test + subclass has at most one non-uniform field (`number`, `enabled`). The + multi-sibling reconstruction path (which has a real IR bug, §2.1) is never hit. +- **Generic data class with ≥2 fields** (so the generic `set`/`reconstruct` + reads a *sibling* whose type must be substituted). Every generic case is + single-field (`OpticsTest(field)`, `Box(s)`, `Wrapper(item)`), so the + generic sibling-substitution path (§2.4) is never exercised. +- **Target restrictions other than the one sealed `[DSL]` case.** No test for + `@optics([LENS])` on a data class (should suppress DSL), `@optics([ISO])`, + `@optics([PRISM])` on a data class (empty intersection → nothing generated), + or multi-target combinations. The DSL-suppression-by-target logic + (`dslEnabled`) is only proven on one path. +- **Multi-level sealed hierarchies** (a sealed subclass of a sealed type): + `getSealedClassInheritors` returns *direct* inheritors only — behaviour for a + grandchild reached through an intermediate sealed node is unspecified and + untested. +- **`object` / `data object` subclasses' prisms executed.** `Loading` exists in + the "nested generic" compile-only test, but no prism over an object branch is + run. +- **Private / protected source classes**, classes with a private primary + constructor, type parameters with interdependent bounds (`>`), + and **star projection in the *source* class** combined with a prism. + +### 1.3 No negative/diagnostic tests with messages + +All failure tests are bare `compilationFails()` (no message assertion), and they +fail for *incidental* reasons (unresolved reference at the use site), not because +the plugin reports anything. There is **no test that annotating an ineligible +type** (an `enum class`, a normal `class`, a non-`@JvmInline` class) is rejected +— and indeed the plugin does **not** reject it (§3.1). A reader cannot tell from +the tests whether "ineligible class" is handled at all. At minimum add tests +pinning the *current* behaviour, and ideally a real diagnostic + message test. + +### 1.4 No law/round-trip checks + +Nothing asserts the lens laws (`get(set(s,a)) == a`, `set(s, get(s)) == s`) or +prism/iso round-trips. Given the bodies are hand-built IR, a couple of +property-style round-trip tests per kind would be cheap, high-value insurance. + +--- + +## 2. Latent correctness risks (not caught by current tests) + +### 2.1 IR node sharing in `reconstruct` / `sealedSet` ⚠️ + +`OpticsIrGenerationExtension.reconstruct` (line ~296) takes a single +`instance: IrExpression` and reuses **the same node** as the dispatch receiver of +every sibling getter: + +```kotlin +ctor.parameters.filter { it.kind == Regular }.forEach { param -> + val arg = if (param.name == overrideName) overrideValue + else readComponent(source, param.name, param.type, instance) // <- same `instance` node reused + call.arguments[param] = arg +} +``` + +and `sealedSet` (line ~242) builds `val cast = irImplicitCast(irGet(instance), subType)` +once and passes that one `cast` node into `reconstruct`, so a subclass with ≥2 +reconstructed fields shares the `IrTypeOperatorCall` node across multiple parents. +Sharing IR nodes violates IR tree invariants (each node should have one parent). + +It happens to work today because: +- `CopyTest` exercises the data-class path (`Person.address.set`) where the shared + node is an `IrGetValue`, which the **JVM** backend tolerates; and +- no sealed-lens `set` is executed at all (§1.1), and no subclass has ≥2 extra + fields (§1.2), so the shared-`cast` case never runs. + +This is fragile: it would likely break under IR validation +(`-Xverify-ir`), on the JS/Native backends, or as soon as a multi-field sealed +`set` is actually executed. **Fix:** don't pass pre-built expressions; pass the +`IrValueParameter`/`IrVariable` (or a `() -> IrExpression` factory) and call +`irGet`/`irImplicitCast` fresh at each use. For `sealedSet`, bind the cast to an +`irTemporary` and `irGet` it per field. + +### 2.2 Visibility only combines source + companion, not enclosing classes + +`mostRestrictive(source.visibility, owner.visibility)` (companion generator, +lines 120/132) ignores the visibilities of *enclosing* classes that algo §3.3 +calls for. For **companion members** this is mostly harmless (the container +already constrains member visibility), but the **top-level DSL extensions** +(`OpticsDslGenerator`, line 92) and **COPY** (`OpticsCopyGenerator`) use only +`source.visibility`. A `public` data class nested inside a `private`/`internal` +outer class can therefore get a top-level extension that is *more visible than +the types it mentions*, which is an "exposed declaration" error — or, worse, a +silently over-broad public API. The `#3869` test only assigns a base member lens +to an `internal val`, so it never stresses this. **Fix:** compute visibility by +folding `mostRestrictive` over the source *and all its containing classifiers*, +and use that for the DSL/COPY top-level declarations too. + +### 2.3 `sameType` (sealed-lens uniformity) is shallow + +`FirOpticsExtractor.sameType` (line 151) compares only `classId` + +`isMarkedNullable`, ignoring type arguments and variance. Two subclasses +declaring the property as `List` vs `List` would be considered +"uniform". In practice Kotlin's own override-type checking shields most cases, +and the one "ignoring changed types" test passes for an *unrelated* reason (the +parent is generic, so `sealedLensFoci` bails out entirely — see §3.2), so this +predicate is barely exercised. It should compare full resolved types (e.g. via +the type-context `equalTypes`, or at least recurse into type arguments). + +### 2.4 Generic sibling reconstruction uses unsubstituted types + +In the generic LENS path the constructor reconstruction reads siblings with +`param.type` (line ~309), which is expressed in the *source class's* type +parameters, while the call actually runs with the *function's* type parameters. +JVM erasure hides this for the single-field generic classes in the suite, but a +multi-field generic data class would produce IR whose argument types disagree +with the (substituted) constructor-parameter types — again likely fine on JVM, +likely flagged by IR verification. A multi-field generic test (§1.2) plus +substituting sibling types would close this. + +### 2.5 Eager symbol resolution can crash unrelated compilations + +`OpticsIrSymbols` (line 58) resolves every `arrow.optics` symbol with `!!` +*unconditionally* in `IrGenerationExtension.generate`, before checking whether +the module contains any `@optics` class. If the plugin is ever applied to a +module that doesn't depend on `arrow-optics`, `referenceClass(PLENS)!!` throws and +crashes the compiler for *every* file. The test harness always has arrow-optics +on the classpath, so this never surfaces. **Fix:** make `OpticsIrSymbols` lazy +(or construct it only when at least one generated declaration is found), and +prefer a clean diagnostic over `!!` when the runtime is missing. + +### 2.6 Target parsing depends on annotation-argument resolution timing + +`requestedTargets` reads `resolvedAnnotationsWithArguments` during FIR +*generation* and walks the `targets` expression with a `FirVisitorVoid`, +matching callee names `"ISO"/"LENS"/…`. If argument resolution isn't available at +that phase (version-dependent), it silently returns the empty set → "generate +everything", i.e. a *silent* wrong answer rather than a failure. It also matches +the enum-entry *simple name* anywhere in the subtree, so a hypothetical +`targets = someAliasFor(OpticsTarget.DSL)` or a constant could be mis-read. Low +probability, but worth a defensive comment and a test that pins +`@optics([OpticsTarget.LENS])` behaviour. + +--- + +## 3. Deviations from the algorithm spec (`arrow-optics-algo.md`) + +### 3.1 No diagnostics at all (algo §12) + +- **Ineligible class** (not data/value/sealed) is a hard error in the spec. Here + it silently produces an (empty) companion and no optics; the only feedback is a + later unresolved-reference at the use site. A `FirAdditionalCheckersExtension` + with a `FirRegularClassChecker` reporting the §12 errors is missing. (This was a + deliberate, documented descope, but it is a real behavioural divergence and is + untested in either direction.) +- **Missing companion** is intentionally *not* an error (the plugin + auto-generates one). Documented and reasonable, but note it changes the + observable contract vs the KSP processor. +- The §5.2 *informational notes* (non-uniform property, non-data subclass, etc.) + are silently swallowed. + +### 3.2 Generic sealed types: shared-property lens silently skipped + +`sealedLensFoci` returns empty whenever the parent has type parameters +(line 121). So for a generic sealed parent with a uniform abstract property, **no +lens is generated** even though §5.2 allows it. The "ignoring changed types" test +*relies* on this skip to fail, which masks the missing feature: the test would +still pass even if `sameType` were broken. Generic prisms (§6) *are* implemented; +generic shared-property lenses are not, and there is no test asserting either the +intended behaviour or the current limitation. + +### 3.3 Sealed types get DSL variants for their shared-property lenses + +Algo §8.4 says a sealed type's DSL family contains **only the prism variants**; +shared-property lenses do *not* get DSL composition helpers. But +`OpticsDslGenerator.generateProperties` iterates **all** `foci` (line 71), +including the sealed-lens foci, emitting `Lens/Optional/Traversal` DSL extensions +for them. These compile (the base lens exists) so nothing breaks, but it is extra +surface area not in the spec and could cause overload-resolution surprises. No +test checks the §8.4 restriction. + +### 3.4 Generic DSL and generic COPY unimplemented + +`OpticsDslGenerator` and `OpticsCopyGenerator` both filter to +`typeParameterSymbols.isEmpty()`. Generic DSL chains (e.g. drilling into a +`Box`) and `@optics.copy` on a generic class are not generated. Documented as +a limitation; no test exercises or pins it. (Generic value-class DSL is broken in +KSP too, so parity is partial.) + +### 3.5 `inline` option (§3.9) and config flags (§2.2) absent + +`OpticsCommandLineProcessor` exposes no options, so the `inline`-optics flag and +the "disable companion requirement" flag from the spec don't exist. Fine for now, +but the command-line processor is dead scaffolding until then. + +--- + +## 4. Structure / maintainability + +**Good:** clear phase split; a single source of truth for names +(`OpticsNames`); a compiler-type-free `OpticsModel` (kinds, target computation, +visibility, name lowering); IR symbol resolution centralised in +`OpticsIrSymbols`; the IR side cleverly recovers `source`/`focus` types from the +generated return type, avoiding any FIR→IR side channel. + +Rough edges: + +- **Dead code in `OpticsNames`.** `LENS`, `ISO`, `PRISM`, `OPTIONAL`, + `TRAVERSAL` (the type-alias `ClassId`s) and `OPTICS_TARGET` are never + referenced — generation uses the `P*` interfaces exclusively. Remove them or + use them. +- **`FirFocus.focusType` is overloaded in meaning.** Its KDoc says "for a + monomorphic parent", but it is also fed through `substituteOrSelf` in the + generic LENS/ISO path, while prisms ignore it in favour of `subclass` + + `refinedSource`. One field with three regimes (lens/iso source-param-relative, + prism `Sub<*>`, prism-generic-unused) is a readability trap. Consider modelling + prism foci as a separate type, or document each field's regime precisely. +- **Duplicated generic-function construction.** The two `createMemberFunction` + blocks in `generateFunctions` (lines 136–171) are near-identical; only the + type-parameter source (subclass vs parent) and the source/focus computation + differ. Extract a helper taking `(typeParamSymbols, returnTypeBuilder)`. +- **`foci`/`effectiveTargets` recomputed repeatedly with no caching.** + `getCallableNamesForClass`, `generateProperties`, `generateFunctions`, plus the + DSL generator's `annotatedSources`/`getTopLevelCallableIds`/`generateProperties` + each re-run focus extraction, which re-resolves annotations, re-scans sealed + inheritors and re-resolves supertypes. On a large module this is O(members × + rescans). FIR offers `FirCache`/session-scoped caching; at least memoise + `effectiveTargets` per symbol. +- **Three separate FIR generators each enumerate annotated symbols.** The + companion, DSL, and COPY generators independently build predicates and + re-derive foci. A shared "model of what to generate for class X" computed once + would reduce duplication and the recomputation above. +- **`coneTypes()` / `sCone()` duplication.** The "FIR type-parameter → + `ConeTypeParameterTypeImpl`" helper is reimplemented in + `OpticsCompanionGenerator` and `OpticsDslGenerator`. Hoist into a shared util. +- **Pervasive `!!` and `.first { }` in IR.** `primaryConstructor!!`, + `prop.getter!!`, `properties.first { … }`, `referenceClass(...)!!`. Most are + "can't happen" given the FIR contract, but they convert contract violations + into compiler crashes instead of diagnostics. A few guarded `?: return` with a + comment (or an internal error reporter) would be friendlier. +- **Test harness is black-box only.** `Compilation.kt`'s `evals` loads a single + `SourceKt` class and reads one top-level field; there is no way to inspect the + *generated* declarations (as the KSP suite could inspect generated sources). + That makes the §1.1 runtime tests the only line of defence — another reason to + add them. + +--- + +## 5. Prioritised recommendations + +1. **Restore runtime tests for every optic kind** (§1.1) — especially the + sealed-property lens `get`/`set`, ISO round-trip, prism `getOrModify`, and a + generic lens `set`. Highest value, lowest effort, and would immediately expose + §2.1/§2.4. +2. **Fix IR node sharing** in `reconstruct`/`sealedSet` (§2.1) — pass value + symbols / factories, not pre-built nodes; bind the sealed cast to a temporary. +3. **Add a multi-field sealed subclass and a multi-field generic data class** + to the suite (§1.2) to lock down reconstruction. +4. **Decide on diagnostics** (§3.1): either implement the §12 checker (ineligible + class at least) or add tests pinning the current silent behaviour so it is + intentional and visible. +5. **Make `OpticsIrSymbols` lazy / guarded** (§2.5) so the plugin degrades + gracefully without `arrow-optics`. +6. **Tidy structure** (§4): delete dead `OpticsNames` entries, de-duplicate the + two generic-function branches and the cone-type helper, and memoise + `effectiveTargets`/`foci`. +7. **Reconcile with the spec** the sealed-DSL-on-shared-lenses behaviour (§3.3) + and document/skip generic shared-property lenses deliberately (§3.2). diff --git a/arrow-libs/optics/arrow-optics-ksp-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt b/arrow-libs/optics/arrow-optics-ksp-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt index 81793a4118e..398487ac02c 100644 --- a/arrow-libs/optics/arrow-optics-ksp-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt +++ b/arrow-libs/optics/arrow-optics-ksp-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt @@ -70,10 +70,7 @@ internal fun compile( allWarningsAsErrors: Boolean = false, contextParameters: Boolean = false, vararg sources: SourceFile, -): CompilationResult { - val compilation = buildCompilation(allWarningsAsErrors, contextParameters, *sources) - return compilation.compile() -} +): CompilationResult = buildCompilation(allWarningsAsErrors, contextParameters, *sources).compile() fun buildCompilation( allWarningsAsErrors: Boolean = false, @@ -89,7 +86,8 @@ fun buildCompilation( this.sources = sources.toList() this.verbose = false this.allWarningsAsErrors = allWarningsAsErrors - this.languageVersion = "2.1" + this.languageVersion = "2.2" + this.apiVersion = "2.2" if (contextParameters) { this.kotlincArguments = listOf("-Xcontext-parameters") } diff --git a/arrow-libs/optics/arrow-optics-plugin/build.gradle.kts b/arrow-libs/optics/arrow-optics-plugin/build.gradle.kts index 736cbd1b28e..485ae6cc6bd 100644 --- a/arrow-libs/optics/arrow-optics-plugin/build.gradle.kts +++ b/arrow-libs/optics/arrow-optics-plugin/build.gradle.kts @@ -24,9 +24,9 @@ dependencies { compileOnly(kotlin("compiler")) implementation(kotlin("gradle-plugin-api")) implementation(kotlin("gradle-plugin")) - implementation(projects.arrowOpticsKspPlugin) + // implementation(projects.arrowOpticsKspPlugin) implementation(projects.arrowOpticsCompilerPlugin) - implementation("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${libs.versions.kspVersion.get()}") + // implementation("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${libs.versions.kspVersion.get()}") } buildConfig { @@ -37,12 +37,14 @@ buildConfig { buildConfigField("String", "KOTLIN_PLUGIN_NAME", "\"${compilerPluginProject.name}\"") buildConfigField("String", "KOTLIN_PLUGIN_VERSION", "\"${compilerPluginProject.version}\"") + /* No more KSP plugin required val kspPluginProject = project(":arrow-optics-ksp-plugin") buildConfigField( type = "String", name = "KSP_PLUGIN_LIBRARY_COORDINATES", expression = "\"${kspPluginProject.group}:${kspPluginProject.name}:${kspPluginProject.version}\"" ) + */ val annotationsProject = project(":arrow-annotations") buildConfigField( diff --git a/arrow-libs/optics/arrow-optics-plugin/src/main/kotlin/arrow/optics/plugin/ArrowOpticsPlugin.kt b/arrow-libs/optics/arrow-optics-plugin/src/main/kotlin/arrow/optics/plugin/ArrowOpticsPlugin.kt index 3804c9bc092..68a86820c02 100644 --- a/arrow-libs/optics/arrow-optics-plugin/src/main/kotlin/arrow/optics/plugin/ArrowOpticsPlugin.kt +++ b/arrow-libs/optics/arrow-optics-plugin/src/main/kotlin/arrow/optics/plugin/ArrowOpticsPlugin.kt @@ -1,20 +1,12 @@ package arrow.optics.plugin -import com.google.devtools.ksp.gradle.KspAATask -import com.google.devtools.ksp.gradle.KspExtension -import com.google.devtools.ksp.gradle.KspGradleSubplugin -import org.gradle.api.Project -import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Provider -import org.gradle.internal.extensions.stdlib.capitalized -import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin -import org.jetbrains.kotlin.gradle.plugin.KotlinTarget import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact import org.jetbrains.kotlin.gradle.plugin.SubpluginOption +/* No more KSP plugin required public fun KotlinSingleTargetExtension<*>.arrowOptics() { project.dependencies.add("ksp", BuildConfig.KSP_PLUGIN_LIBRARY_COORDINATES) @@ -71,8 +63,10 @@ public fun KotlinMultiplatformExtension.arrowOptics(targets: List) } } } +*/ public class ArrowOpticsPlugin : KotlinCompilerPluginSupportPlugin { + /* No more KSP plugin required override fun apply(target: Project) { target.pluginManager.apply(KspGradleSubplugin::class.java) target.extensions.configure(KspExtension::class.java) { @@ -81,6 +75,7 @@ public class ArrowOpticsPlugin : KotlinCompilerPluginSupportPlugin { target.extensions.create("optics", OpticsGradleExtension::class.java) } + */ override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>): Provider> = kotlinCompilation.target.project.provider { emptyList() } @@ -96,4 +91,4 @@ public class ArrowOpticsPlugin : KotlinCompilerPluginSupportPlugin { override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean = true } -public open class OpticsGradleExtension(objectFactory: ObjectFactory) +// public open class OpticsGradleExtension(objectFactory: ObjectFactory) diff --git a/build.gradle.kts b/build.gradle.kts index b22deb1a5d2..4e7a068c4c7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,7 @@ configure { include("**/*.kts") exclude("**/build/**") exclude("**/.gradle/**") + exclude("**/vibe/**") } } diff --git a/gradle-test/jvmOnly/build.gradle.kts b/gradle-test/jvmOnly/build.gradle.kts index 46ad6e366c0..39b6d643d72 100644 --- a/gradle-test/jvmOnly/build.gradle.kts +++ b/gradle-test/jvmOnly/build.gradle.kts @@ -1,5 +1,3 @@ -import arrow.optics.plugin.arrowOptics - plugins { kotlin("jvm") version "2.4.0" id("io.arrow-kt.optics") version "10.0-test" @@ -10,6 +8,6 @@ repositories { mavenCentral() } -kotlin { - arrowOptics() +dependencies { + implementation("io.arrow-kt:arrow-optics:10.0-test") } diff --git a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt index 295f5775aeb..1c962b695c9 100644 --- a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt +++ b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt @@ -2,10 +2,14 @@ package example import arrow.optics.optics -@optics -data class Person(val name: String, val age: Int) +@optics @optics.copy +data class Person(val name: String, val age: Int, val address: Address) +@optics data class Address(val street: String, val city: String) @optics internal data class Thing(val essence: String) + +@optics +data class Generic(val value: A) diff --git a/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt b/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt index 3e963497ef3..7cada6a0977 100644 --- a/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt +++ b/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt @@ -1,3 +1,11 @@ package example -val nameLens = Person.name +import arrow.optics.set +import arrow.optics.transform + +val nameLens = Person.address.street + +fun test(person: Person): Person = person.copy { + name set "John" + age transform { it + 1 } +} diff --git a/gradle-test/multiplatform/build.gradle.kts b/gradle-test/multiplatform/build.gradle.kts index 487e1c6fe1e..8e274d1ddc6 100644 --- a/gradle-test/multiplatform/build.gradle.kts +++ b/gradle-test/multiplatform/build.gradle.kts @@ -1,5 +1,3 @@ -import arrow.optics.plugin.arrowOpticsCommon - plugins { kotlin("multiplatform") version "2.4.0" id("io.arrow-kt.optics") version "10.0-test" @@ -16,5 +14,11 @@ kotlin { applyDefaultHierarchyTemplate() - arrowOpticsCommon() + sourceSets { + getByName("commonMain") { + dependencies { + implementation("io.arrow-kt:arrow-optics:10.0-test") + } + } + } } diff --git a/test-optics-gradle-plugin.sh b/test-optics-gradle-plugin.sh index 5d594517e22..6f38bfc044a 100755 --- a/test-optics-gradle-plugin.sh +++ b/test-optics-gradle-plugin.sh @@ -1,10 +1,10 @@ set -e ./gradlew :arrow-optics-plugin:publish :arrow-optics-ksp-plugin:publish :arrow-optics-compiler-plugin:publish :arrow-annotations:publish :arrow-optics:publish :arrow-exception-utils:publish :arrow-core:publish :arrow-atomic:publish -PonlyLocal=true -Pversion=10.0-test cd gradle-test -cd multiplatform +cd jvmOnly ./gradlew build cd .. -cd jvmOnly +cd multiplatform ./gradlew build cd .. cd .. \ No newline at end of file