From ab699900911c25e9fb74ada3c20c4671b9dc4d30 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Fri, 26 Jun 2026 15:38:51 +0200 Subject: [PATCH 01/11] Vibe code --- .../build.gradle.kts | 24 + .../kotlin/arrow/optics/plugin/OpticsModel.kt | 73 ++ .../kotlin/arrow/optics/plugin/OpticsNames.kt | 75 ++ .../optics/plugin/fir/FirOpticsExtractor.kt | 126 +++ .../plugin/fir/OpticsCompanionGenerator.kt | 95 +- .../optics/plugin/fir/OpticsDslGenerator.kt | 105 +++ .../optics/plugin/fir/OpticsPluginWrappers.kt | 4 + .../plugin/ir/OpticsIrGenerationExtension.kt | 276 ++++++ .../arrow/optics/plugin/ir/OpticsIrHelpers.kt | 69 ++ .../kotlin/arrow/optics/plugin/Compilation.kt | 138 +++ .../kotlin/arrow/optics/plugin/DSLTests.kt | 38 + .../kotlin/arrow/optics/plugin/IsoTests.kt | 38 + .../kotlin/arrow/optics/plugin/LensTests.kt | 106 +++ .../kotlin/arrow/optics/plugin/PrismTests.kt | 42 + arrow-optics-algo.md | 842 ++++++++++++++++++ arrow-optics-impl.md | 451 ++++++++++ 16 files changed, 2498 insertions(+), 4 deletions(-) create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsModel.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsNames.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/FirOpticsExtractor.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsDslGenerator.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrGenerationExtension.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrHelpers.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Compilation.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/DSLTests.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/IsoTests.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/LensTests.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/PrismTests.kt create mode 100644 arrow-optics-algo.md create mode 100644 arrow-optics-impl.md 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..561bfb64ef7 100644 --- a/arrow-libs/optics/arrow-optics-compiler-plugin/build.gradle.kts +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/build.gradle.kts @@ -7,10 +7,34 @@ kotlin { explicitApi = null compilerOptions { 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..cc40020193c --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsModel.kt @@ -0,0 +1,73 @@ +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..c2ab4e8cead --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/OpticsNames.kt @@ -0,0 +1,75 @@ +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")) + val OPTICS_COPY_ANNOTATION_FQNAME: FqName = OPTICS_COPY_ANNOTATION.asSingleFqName() + + val OPTICS_TARGET = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("OpticsTarget")) + + // Optic type aliases (the user-facing names, 2 type arguments each). + val LENS = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Lens")) + val ISO = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Iso")) + val PRISM = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Prism")) + val OPTIONAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Optional")) + val TRAVERSAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Traversal")) + + // Underlying poly 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")) + + /** Poly-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..886e92db68b --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/FirOpticsExtractor.kt @@ -0,0 +1,126 @@ +package arrow.optics.plugin.fir + +import arrow.optics.plugin.OpticKind +import arrow.optics.plugin.OpticsClassKind +import arrow.optics.plugin.lowercaseFirst +import org.jetbrains.kotlin.descriptors.ClassKind +import org.jetbrains.kotlin.descriptors.Modality +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess +import org.jetbrains.kotlin.fir.declarations.getSealedClassInheritors +import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny +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.modality +import org.jetbrains.kotlin.fir.declarations.utils.isSealed +import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +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.classId +import org.jetbrains.kotlin.fir.types.constructType +import org.jetbrains.kotlin.fir.types.isMarkedNullable +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 (e.g. the field type for a lens, the subtype for a prism). */ + val focusType: ConeKotlinType, + /** For lenses/isos: the source component (constructor parameter / property) name. */ + val componentName: Name? = null, +) + +/** Reads `@optics`-annotated FIR class symbols and extracts the foci to generate. */ +@OptIn(SymbolInternals::class, DirectDeclarationsAccess::class) +object FirOpticsExtractor { + + fun classKind(symbol: FirRegularClassSymbol): OpticsClassKind = when { + symbol.isData -> OpticsClassKind.DATA + symbol.isInlineOrValue && symbol.classKind == ClassKind.CLASS -> OpticsClassKind.VALUE + symbol.isSealed || symbol.modality == Modality.SEALED -> OpticsClassKind.SEALED + else -> OpticsClassKind.INELIGIBLE + } + + /** Base optic foci to generate as companion members of [symbol]. */ + fun foci(symbol: FirRegularClassSymbol, session: FirSession): List = + 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() + } + + /** + * 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 = symbol.fir.declarations + .filterIsInstance() + .filter { it.isAbstract && it.receiverParameter == null } + .map { it.symbol } + if (abstractProps.isEmpty()) return emptyList() + + val subclasses = symbol.fir.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 sufficient for uniformity checks (classifier + nullability). */ + private fun sameType(a: ConeKotlinType, b: ConeKotlinType): Boolean = + a.classId == b.classId && a.isMarkedNullable == b.isMarkedNullable + + /** One PRISM focus per sealed subclass (algo §6). Generic parents are handled separately (TODO). */ + private fun prismFoci(symbol: FirRegularClassSymbol, session: FirSession): List { + if (symbol.typeParameterSymbols.isNotEmpty()) return emptyList() + val inheritorIds = symbol.fir.getSealedClassInheritors(session) + return inheritorIds.mapNotNull { classId -> + val sub = session.symbolProvider.getClassLikeSymbolByClassId(classId) as? FirRegularClassSymbol + ?: return@mapNotNull null + val args: Array = + Array(sub.typeParameterSymbols.size) { ConeStarProjection } + FirFocus( + kind = OpticKind.PRISM, + opticName = lowercaseFirst(classId.shortClassName), + focusType = sub.constructType(args, false), + ) + } + } + + /** 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, + ) + } + } +} 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..9ec6b6d8e8b 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,14 @@ 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.fir.FirSession import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirDeclarationOrigin 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,12 +17,23 @@ 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.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.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 @@ -27,7 +42,7 @@ import kotlin.contracts.contract @OptIn(DirectDeclarationsAccess::class, SymbolInternals::class, ExperimentalContracts::class) class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { 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,6 +53,8 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx register(predicate) } + // ---- companion object creation (for classes that lack one) ------------------------- + override fun getNestedClassifiersNames(classSymbol: FirClassSymbol<*>, context: NestedClassGenerationContext): Set { if (classSymbol !is FirRegularClassSymbol) return emptySet() if (!session.predicateBasedProvider.matches(predicate, classSymbol)) return emptySet() @@ -61,9 +78,79 @@ 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) + } + + /** The `arrow.optics` poly-interface backing a focus of the given kind. */ + private fun polyClassOf(kind: OpticKind) = when (kind) { + OpticKind.LENS -> OpticsNames.PLENS + OpticKind.ISO -> OpticsNames.PISO + OpticKind.PRISM -> OpticsNames.PPRISM + } + 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 = polyClassOf(focus.kind).constructClassLikeType( + arrayOf(sourceType, sourceType, focus.focusType, focus.focusType), + ) + val vis = mostRestrictive(source.visibility, 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(source.visibility, owner.visibility) + val sourceTypeParams = source.typeParameterSymbols + val function = createMemberFunction( + owner, + Key, + callableId.callableName, + returnTypeProvider = { functionTypeParameters -> + val funCones: List = functionTypeParameters.map { + ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) + } + val substitutor = substitutorByMap(sourceTypeParams.zip(funCones).toMap(), session) + val substFocus = substitutor.substituteOrSelf(focus.focusType) + val sourceType = source.constructType(funCones.toTypedArray(), false) + polyClassOf(focus.kind).constructClassLikeType( + arrayOf(sourceType, sourceType, substFocus, substFocus), + ) + }, + ) { + sourceTypeParams.forEach { tp -> typeParameter(tp.name) } + visibility = vis + } + return listOf(function.symbol) } override fun generateConstructors(context: MemberGenerationContext): List { 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..a7c25528d28 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsDslGenerator.kt @@ -0,0 +1,105 @@ +package arrow.optics.plugin.fir + +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.declarations.utils.visibility +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.impl.FirPropertySymbol +import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol +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.fir.symbols.ConeTypeParameterLookupTag +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. + */ +class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + + 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) + } + + private val DSL_S = Name.identifier("__S") + + /** Monomorphic `@optics`-annotated source classes. */ + private fun annotatedSources(): List = + session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) + .filterIsInstance() + .filter { it.typeParameterSymbols.isEmpty() } + + override fun getTopLevelCallableIds(): Set = buildSet { + annotatedSources().forEach { source -> + val pkg = source.classId.packageFqName + FirOpticsExtractor.foci(source, session).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" + FirOpticsExtractor.foci(source, session) + .filter { it.opticName == callableId.callableName } + .forEach { focus -> + dslVariantsFor(focus.kind).forEach { dslKind -> + val poly = OpticsNames.polyClassFor(dslKind) + val property = createTopLevelProperty( + Key, + callableId, + returnTypeProvider = { tps -> + val s = sCone(tps) + poly.constructClassLikeType(arrayOf(s, s, focus.focusType, focus.focusType)) + }, + isVal = true, + hasBackingField = false, + containingFileName = fileName, + ) { + typeParameter(DSL_S) + extensionReceiverType { tps -> + val s = sCone(tps) + poly.constructClassLikeType(arrayOf(s, s, sourceType, sourceType)) + } + visibility = source.visibility + } + result += property.symbol + } + } + } + return result + } + + private fun sCone(tps: List): ConeKotlinType = + ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) + + object Key : GeneratedDeclarationKey() +} 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..76c6d03b542 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,5 +1,7 @@ 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 @@ -26,11 +28,13 @@ class OpticsPluginComponentRegistrar : CompilerPluginRegistrar() { override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { FirExtensionRegistrarAdapter.registerExtension(OpticsPluginRegistrar()) + IrGenerationExtension.registerExtension(OpticsIrGenerationExtension()) } } class OpticsPluginRegistrar : FirExtensionRegistrar() { override fun ExtensionRegistrarContext.configurePlugin() { +::OpticsCompanionGenerator + +::OpticsDslGenerator } } 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..d76166abdf6 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrGenerationExtension.kt @@ -0,0 +1,276 @@ +package arrow.optics.plugin.ir + +import arrow.optics.plugin.OpticsNames +import arrow.optics.plugin.fir.OpticsCompanionGenerator +import arrow.optics.plugin.fir.OpticsDslGenerator +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.GeneratedDeclarationKey +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.types.IrSimpleType +import org.jetbrains.kotlin.ir.types.IrType +import org.jetbrains.kotlin.ir.types.classOrNull +import org.jetbrains.kotlin.ir.types.typeOrNull +import org.jetbrains.kotlin.ir.util.companionObject +import org.jetbrains.kotlin.ir.util.defaultType +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. */ +class OpticsIrSymbols(ctx: IrPluginContext) { + val lensInvoke: IrSimpleFunctionSymbol = + ctx.referenceFunctions(OpticsNames.LENS_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } + val isoInvoke: IrSimpleFunctionSymbol = + ctx.referenceFunctions(OpticsNames.ISO_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } + val prismInstanceOf: IrSimpleFunctionSymbol = + ctx.referenceFunctions(OpticsNames.PRISM_INSTANCE_OF).first { it.owner.parameters.none { p -> p.kind == IrParameterKind.Regular } } + val plens: IrClassSymbol = ctx.referenceClass(OpticsNames.PLENS)!! + val piso: IrClassSymbol = ctx.referenceClass(OpticsNames.PISO)!! + val pprism: IrClassSymbol = ctx.referenceClass(OpticsNames.PPRISM)!! + val plensCompanion: IrClassSymbol = ctx.referenceClass(OpticsNames.PLENS_COMPANION)!! + val pisoCompanion: IrClassSymbol = ctx.referenceClass(OpticsNames.PISO_COMPANION)!! + val pprismCompanion: IrClassSymbol = ctx.referenceClass(OpticsNames.PPRISM_COMPANION)!! + + /** For each optic poly-interface, its `plus` composition operator (keyed by the receiver class). */ + val polyPlus: Map = + arrow.optics.plugin.DslKind.entries.associate { kind -> + val cls = ctx.referenceClass(OpticsNames.polyClassFor(kind))!! + val plus = ctx.referenceFunctions(OpticsNames.plusFor(kind)) + .first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 1 } + cls to plus + } +} + +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 (keyOf(declaration.origin) == OpticsCompanionGenerator.Key && + declaration.correspondingPropertySymbol == null && declaration.body == null + ) { + buildOpticBody(declaration, declaration.name) + } + super.visitSimpleFunction(declaration) + } + + 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.coneType() } + + 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 + val cast = irImplicitCast(irGet(instance), subType) + irBranch( + irIs(irGet(instance), subType), + reconstruct(sub, emptyList(), cast, 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. */ + 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]. */ + 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..beaaa9f244d --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrHelpers.kt @@ -0,0 +1,69 @@ +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.expressions.IrStatementOrigin +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.builders.IrBlockBodyBuilder +import org.jetbrains.kotlin.ir.declarations.IrDeclarationOrigin +import org.jetbrains.kotlin.ir.declarations.IrDeclarationParent +import org.jetbrains.kotlin.ir.declarations.IrParameterKind +import org.jetbrains.kotlin.ir.declarations.IrTypeParameter +import org.jetbrains.kotlin.ir.declarations.IrValueParameter +import org.jetbrains.kotlin.ir.types.defaultType +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.impl.IrFunctionExpressionImpl +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) +} + +/** The type-parameter's own type, e.g. `A` for `fun ...`. */ +fun IrTypeParameter.coneType(): IrType = defaultType + +/** Set the dispatch-receiver argument of [this] call, addressing it by parameter kind. */ +fun IrMemberAccessExpression<*>.setDispatch(receiver: IrExpression) { + val symbol = symbol + val owner = (symbol.owner as? org.jetbrains.kotlin.ir.declarations.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? org.jetbrains.kotlin.ir.declarations.IrFunction) ?: return + val regulars = owner.parameters.filter { it.kind == IrParameterKind.Regular } + arguments[regulars[n]] = value +} 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/DSLTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/DSLTests.kt new file mode 100644 index 00000000000..0cd0baeaa20 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/DSLTests.kt @@ -0,0 +1,38 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class DSLTests { + + @Test + fun `lens DSL composes nested data classes`() { + """ + |import arrow.optics.* + | + |@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 } + | + |val streetName: Lens = Company.address.street.name + |val c = Company("ACME", Address("AMS", Street(1, "Main"))) + |val r = streetName.get(c) == "Main" && + | streetName.set(c, "Side").address.street.name == "Side" + """.evals("r" to true) + } + + @Test + fun `prism DSL composes through a sealed branch`() { + """ + |import arrow.optics.* + | + |@optics data class Inner(val value: Int) { companion object } + |@optics data class Wrapper(val inner: Inner) : Thing { companion object } + |@optics sealed interface Thing { + | companion object + |} + | + |val opt: Optional = Thing.wrapper.inner.value + |val r = opt.getOrNull(Wrapper(Inner(7))) == 7 + """.evals("r" to 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..d7f3407eee0 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/IsoTests.kt @@ -0,0 +1,38 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class IsoTests { + + @Test + fun `companion iso is generated for a value class`() { + """ + |import arrow.optics.* + | + |@optics + |@JvmInline + |value class Cents(val value: Int) { + | companion object + |} + | + |val iso: Iso = Cents.value + |val r = iso.get(Cents(3)) == 3 && iso.reverseGet(5) == Cents(5) + """.evals("r" to true) + } + + @Test + fun `generic iso for a value class`() { + """ + |import arrow.optics.* + | + |@optics + |@JvmInline + |value class Wrapper(val wrapped: T) { + | companion object + |} + | + |val iso: Iso, String> = Wrapper.wrapped() + |val r = iso.get(Wrapper("hi")) == "hi" && iso.reverseGet("bye") == Wrapper("bye") + """.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..76f1568f9f5 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/LensTests.kt @@ -0,0 +1,106 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class LensTests { + + @Test + fun `companion lens is generated for a monomorphic data class`() { + """ + |import arrow.optics.* + | + |@optics + |data class LensData(val field1: String) { + | companion object + |} + | + |val lens: Lens = LensData.field1 + |val r = lens.get(LensData("hello")) == "hello" && + | lens.set(LensData("hello"), "world") == LensData("world") + """.evals("r" to true) + } + + @Test + fun `lens generated without an explicit companion object`() { + """ + |import arrow.optics.* + | + |@optics + |data class LensData(val field1: String) + | + |val r = LensData.field1.get(LensData("hello")) == "hello" + """.evals("r" to true) + } + + @Test + fun `generic data class produces a lens function`() { + """ + |import arrow.optics.* + | + |@optics + |data class OpticsTest(val field: A) { + | companion object + |} + | + |val lens: Lens, String> = OpticsTest.field() + |val r = lens.get(OpticsTest("x")) == "x" && + | lens.set(OpticsTest("x"), "y") == OpticsTest("y") + """.evals("r" to true) + } + + @Test + fun `nullable focus keeps nullability`() { + """ + |import arrow.optics.* + |import arrow.optics.dsl.* + | + |@optics + |data class OptionalData(val field1: String?) { + | companion object + |} + | + |val lens: Lens = OptionalData.field1 + |val opt: Optional = OptionalData.field1.notNull + |val r = lens.get(OptionalData(null)) == null && + | opt.getOrNull(OptionalData("x")) == "x" + """.evals("r" to true) + } + + @Test + fun `shared abstract property of a sealed class produces a lens`() { + """ + |import arrow.optics.* + | + |@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 + |} + | + |val lens: Lens = LensSealed.property1 + |val c1: LensSealed = LensSealed.Child1("a") + |val c2: LensSealed = LensSealed.Child2("b", 5) + |val r = lens.get(c1) == "a" && + | lens.set(c1, "z") == LensSealed.Child1("z") && + | lens.set(c2, "z") == LensSealed.Child2("z", 5) + """.evals("r" to true) + } + + @Test + fun `multiple fields each produce a lens`() { + """ + |import arrow.optics.* + | + |@optics + |data class Person(val name: String, val age: Int) { + | companion object + |} + | + |val p = Person("Alejandro", 40) + |val r = Person.name.get(p) == "Alejandro" && + | Person.age.set(p, 41) == Person("Alejandro", 41) + """.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..3e6b641801a --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/PrismTests.kt @@ -0,0 +1,42 @@ +package arrow.optics.plugin + +import kotlin.test.Test + +class PrismTests { + + @Test + fun `companion prisms are generated for a sealed class`() { + """ + |import arrow.optics.* + | + |@optics + |sealed class PrismSealed { + | data class PrismSealed1(val a: String?) : PrismSealed() + | data class PrismSealed2(val b: String?) : PrismSealed() + | companion object + |} + | + |val p1: Prism = PrismSealed.prismSealed1 + |val one: PrismSealed = PrismSealed.PrismSealed1("x") + |val two: PrismSealed = PrismSealed.PrismSealed2("y") + |val r = p1.getOrNull(one) == PrismSealed.PrismSealed1("x") && + | p1.getOrNull(two) == null + """.evals("r" to true) + } + + @Test + fun `prism for sealed interface with lowercased keyword name`() { + """ + |import arrow.optics.* + | + |@optics + |sealed interface Thing { + | data class Object(val value: Int) : Thing + | companion object + |} + | + |val prism: Prism = Thing.`object` + |val r = prism.getOrNull(Thing.Object(3)) == Thing.Object(3) + """.evals("r" to true) + } +} diff --git a/arrow-optics-algo.md b/arrow-optics-algo.md new file mode 100644 index 00000000000..24ceed4a830 --- /dev/null +++ b/arrow-optics-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-optics-impl.md b/arrow-optics-impl.md new file mode 100644 index 00000000000..d95f84d22e1 --- /dev/null +++ b/arrow-optics-impl.md @@ -0,0 +1,451 @@ +# Arrow Optics K2 Compiler Plugin — Implementation Plan + +## Implementation status (as of this change) + +Implemented end-to-end (FIR signature generation **as companion members** + IR body generation), each with passing `kotlin-compile-testing` tests under `src/test`: + +| Feature | Mono | Generic | Tests | +|---|---|---|---| +| **LENS** (data class fields) | ✅ | ✅ (`fun field()` form) | `LensTests` | +| **LENS** nullable focus (`Optional` via `notNull`) | ✅ | — | `LensTests` | +| **LENS** sealed shared abstract property (§5.2, `when`-dispatch `set`) | ✅ | ❌ (mono parents only) | `LensTests` | +| **ISO** (value classes) | ✅ | ✅ | `IsoTests` | +| **PRISM** (sealed subclasses, `Prism.instanceOf`) | ✅ | ❌ (mono parents only) | `PrismTests` | +| **DSL** composition extensions (top-level, §8.2 variant matrix) | ✅ | ❌ (mono sources only) | `DSLTests` | + +Key infrastructure in place: companion-member generation (`OpticsCompanionGenerator`), top-level DSL extension generation (`OpticsDslGenerator`), the IR body generator (`OpticsIrGenerationExtension` + `OpticsIrHelpers`), the shared model (`OpticsModel`, `OpticsNames`), and the FIR focus extractor (`FirOpticsExtractor`). The build wires both the FIR and IR phases (`OpticsPluginWrappers`), and a `kotlin-compile-testing` harness (`Compilation.kt`) runs the plugin. + +**Not yet implemented (documented follow-ups):** +- **COPY** (`@optics.copy`, §9) — blocked on constructing the `context(Copy) S.Companion.(S) -> Unit` function type in FIR (context parameters) and invoking it in IR; flagged as the most fragile milestone (§2.8, §7.6). +- **Generic PRISM** (§6 refined-supertype + free-var union) and **generic sealed-lens / generic DSL** — the generic-parent type-parameter logic differs from the LENS/ISO mirroring and is gated off for now. +- **§12 diagnostics** — custom FIR error/warning factories (ineligible class, non-uniform property, …). Ineligible classes currently generate no optics rather than reporting a hard error. + +Everything below is the original design; sections on COPY/generic-prism/diagnostics describe the intended (not-yet-built) behaviour. + +--- + +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. From 936fb97607261d9c6f07830f497ed6dc9ad5b6f0 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Fri, 26 Jun 2026 16:15:36 +0200 Subject: [PATCH 02/11] Vibe code more --- .../optics/plugin/fir/FirOpticsExtractor.kt | 76 +++- .../plugin/fir/OpticsCompanionGenerator.kt | 60 ++- .../optics/plugin/fir/OpticsCopyGenerator.kt | 91 ++++ .../optics/plugin/fir/OpticsDslGenerator.kt | 4 +- .../optics/plugin/fir/OpticsPluginWrappers.kt | 1 + .../plugin/ir/OpticsIrGenerationExtension.kt | 47 ++- .../arrow/optics/plugin/ir/OpticsIrHelpers.kt | 7 + .../kotlin/arrow/optics/plugin/CopyTest.kt | 99 +++++ .../kotlin/arrow/optics/plugin/DSLTests.kt | 319 ++++++++++++-- .../arrow/optics/plugin/GeneratedCopyTest.kt | 79 ++++ .../kotlin/arrow/optics/plugin/IsoTests.kt | 74 ++-- .../kotlin/arrow/optics/plugin/LensTests.kt | 395 ++++++++++++++---- .../arrow/optics/plugin/OptionalTests.kt | 55 +++ .../kotlin/arrow/optics/plugin/PrismTests.kt | 110 +++-- .../test/kotlin/arrow/optics/plugin/Utils.kt | 69 +++ arrow-optics-impl.md | 38 +- 16 files changed, 1321 insertions(+), 203 deletions(-) create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCopyGenerator.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/CopyTest.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/GeneratedCopyTest.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/OptionalTests.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/Utils.kt 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 index 886e92db68b..d52e74498c7 100644 --- 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 @@ -2,13 +2,21 @@ 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 org.jetbrains.kotlin.descriptors.ClassKind import org.jetbrains.kotlin.descriptors.Modality +import org.jetbrains.kotlin.fir.FirElement import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.getSealedClassInheritors +import org.jetbrains.kotlin.fir.declarations.hasAnnotation +import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny +import org.jetbrains.kotlin.fir.expressions.FirPropertyAccessExpression +import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid import org.jetbrains.kotlin.fir.declarations.utils.isAbstract import org.jetbrains.kotlin.fir.declarations.utils.isData import org.jetbrains.kotlin.fir.declarations.utils.isInlineOrValue @@ -30,10 +38,14 @@ import org.jetbrains.kotlin.name.Name data class FirFocus( val kind: OpticKind, val opticName: Name, - /** The focus type (e.g. the field type for a lens, the subtype for a prism). */ + /** 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. */ @@ -47,14 +59,59 @@ object FirOpticsExtractor { else -> OpticsClassKind.INELIGIBLE } - /** Base optic foci to generate as companion members of [symbol]. */ - fun foci(symbol: FirRegularClassSymbol, session: FirSession): List = - when (classKind(symbol)) { + /** 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, session) + 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, session: FirSession): Boolean = + OpticsTargetKind.DSL in effectiveTargets(symbol, session) + + /** The effective target set (algo §2.3): requested ∩ kind-allowed, plus COPY when present. */ + fun effectiveTargets(symbol: FirRegularClassSymbol, session: FirSession): Set { + val requested = requestedTargets(symbol, session) + val hasCopy = symbol.hasAnnotation(OpticsNames.OPTICS_COPY_ANNOTATION, session) + return computeTargets(classKind(symbol), requested, hasCopy) + } + + /** Parse the `targets` array of `@optics(...)`, collecting the referenced [OpticsTargetKind]s. */ + private fun requestedTargets(symbol: FirRegularClassSymbol, session: FirSession): Set { + val annotation = symbol.resolvedAnnotationsWithArguments + .firstOrNull { it.toAnnotationClassId(session) == OpticsNames.OPTICS_ANNOTATION } ?: return emptySet() + val targetsExpr = annotation.argumentMapping.mapping[Name.identifier("targets")] ?: return emptySet() + val found = mutableSetOf() + targetsExpr.acceptChildren(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) + } + }) + return found + } /** * LENS foci for abstract properties that are uniform across every (data-class) subclass of a @@ -94,19 +151,22 @@ object FirOpticsExtractor { private fun sameType(a: ConeKotlinType, b: ConeKotlinType): Boolean = a.classId == b.classId && a.isMarkedNullable == b.isMarkedNullable - /** One PRISM focus per sealed subclass (algo §6). Generic parents are handled separately (TODO). */ + /** One PRISM focus per sealed subclass (algo §6). */ private fun prismFoci(symbol: FirRegularClassSymbol, session: FirSession): List { - if (symbol.typeParameterSymbols.isNotEmpty()) return emptyList() val inheritorIds = symbol.fir.getSealedClassInheritors(session) return inheritorIds.mapNotNull { classId -> val sub = session.symbolProvider.getClassLikeSymbolByClassId(classId) as? FirRegularClassSymbol ?: return@mapNotNull null - val args: Array = + 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(args, false), + focusType = sub.constructType(starArgs, false), + subclass = sub, + refinedSource = refined, ) } } 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 9ec6b6d8e8b..89ea5f0aba8 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 @@ -130,29 +130,51 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx if (source.typeParameterSymbols.isEmpty()) return emptyList() // monomorphic -> property form val focus = fociFor(owner).firstOrNull { it.opticName == callableId.callableName } ?: return emptyList() val vis = mostRestrictive(source.visibility, owner.visibility) - val sourceTypeParams = source.typeParameterSymbols - val function = createMemberFunction( - owner, - Key, - callableId.callableName, - returnTypeProvider = { functionTypeParameters -> - val funCones: List = functionTypeParameters.map { - ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) - } - val substitutor = substitutorByMap(sourceTypeParams.zip(funCones).toMap(), session) - val substFocus = substitutor.substituteOrSelf(focus.focusType) - val sourceType = source.constructType(funCones.toTypedArray(), false) - polyClassOf(focus.kind).constructClassLikeType( - arrayOf(sourceType, sourceType, substFocus, substFocus), - ) - }, - ) { - sourceTypeParams.forEach { tp -> typeParameter(tp.name) } - visibility = vis + + // 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) { + val subParams = focus.subclass?.typeParameterSymbols.orEmpty() + createMemberFunction( + owner, Key, callableId.callableName, + returnTypeProvider = { functionTypeParameters -> + val funCones = functionTypeParameters.coneTypes() + val substitutor = substitutorByMap(subParams.zip(funCones).toMap(), session) + val sourceType = focus.refinedSource?.let { substitutor.substituteOrSelf(it) } + ?: source.constructType(emptyArray(), false) + val focusType = focus.subclass?.constructType(funCones.toTypedArray(), false) ?: focus.focusType + polyClassOf(focus.kind).constructClassLikeType( + arrayOf(sourceType, sourceType, focusType, focusType), + ) + }, + ) { + subParams.forEach { tp -> typeParameter(tp.name) } + visibility = vis + } + } else { + val sourceTypeParams = source.typeParameterSymbols + createMemberFunction( + owner, Key, callableId.callableName, + returnTypeProvider = { functionTypeParameters -> + val funCones = functionTypeParameters.coneTypes() + val substitutor = substitutorByMap(sourceTypeParams.zip(funCones).toMap(), session) + val substFocus = substitutor.substituteOrSelf(focus.focusType) + val sourceType = source.constructType(funCones.toTypedArray(), false) + polyClassOf(focus.kind).constructClassLikeType( + arrayOf(sourceType, sourceType, substFocus, substFocus), + ) + }, + ) { + sourceTypeParams.forEach { tp -> typeParameter(tp.name) } + visibility = vis + } } return listOf(function.symbol) } + 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() 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..89b455d4de2 --- /dev/null +++ b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/OpticsCopyGenerator.kt @@ -0,0 +1,91 @@ +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.declarations.hasAnnotation +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 +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.createTopLevelFunction +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 top-level extension: + * `fun Source.copy(block: context(Copy) Source.Companion.(Source) -> Unit): Source`. + * Only monomorphic sources are supported for now. + */ +class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + + 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) + } + + private val COPY_NAME = Name.identifier("copy") + private val FUNCTION3 = ClassId(FqName("kotlin"), Name.identifier("Function3")) + + private fun copySources(): List = + session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) + .filterIsInstance() + .filter { it.typeParameterSymbols.isEmpty() && it.hasAnnotation(OpticsNames.OPTICS_COPY_ANNOTATION, session) } + + override fun getTopLevelCallableIds(): Set = + copySources().mapTo(mutableSetOf()) { CallableId(it.classId.packageFqName, COPY_NAME) } + + override fun hasPackage(packageFqName: FqName): Boolean = + copySources().any { it.classId.packageFqName == packageFqName } + + override fun generateFunctions(callableId: CallableId, context: MemberGenerationContext?): List { + if (context != null) return emptyList() + return copySources() + .filter { it.classId.packageFqName == callableId.packageName } + .mapNotNull { source -> + val companion = source.resolvedCompanionObjectSymbol ?: return@mapNotNull null + 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.Companion.create( + listOf(CompilerConeAttributes.ExtensionFunctionType, CompilerConeAttributes.ContextFunctionTypeParams(1)), + ) + val blockType = FUNCTION3.constructClassLikeType( + arrayOf(copyType, companionType, sourceType, session.builtinTypes.unitType.coneType), + false, + blockAttributes, + ) + val function = createTopLevelFunction( + Key, + callableId, + returnType = sourceType, + containingFileName = "${source.classId.shortClassName.asString()}Copy", + ) { + extensionReceiverType(sourceType) + valueParameter(Name.identifier("block"), blockType) + visibility = source.visibility + } + function.symbol + } + } + + object Key : GeneratedDeclarationKey() +} 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 index a7c25528d28..a47491cd9b9 100644 --- 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 @@ -45,11 +45,11 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio private val DSL_S = Name.identifier("__S") - /** Monomorphic `@optics`-annotated source classes. */ + /** 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() } + .filter { it.typeParameterSymbols.isEmpty() && FirOpticsExtractor.dslEnabled(it, session) } override fun getTopLevelCallableIds(): Set = buildSet { annotatedSources().forEach { source -> 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 76c6d03b542..6b5938b37dd 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 @@ -36,5 +36,6 @@ 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 index d76166abdf6..f72f349793a 100644 --- 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 @@ -2,6 +2,7 @@ package arrow.optics.plugin.ir 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.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext @@ -35,8 +36,10 @@ 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.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 @@ -74,6 +77,14 @@ class OpticsIrSymbols(ctx: IrPluginContext) { .first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 1 } cls to plus } + + // COPY builder support. + val copyClass: IrClassSymbol = ctx.referenceClass(OpticsNames.COPY)!! + val arrowOpticsCopy: IrSimpleFunctionSymbol = + ctx.referenceFunctions(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 } @@ -105,14 +116,42 @@ private class OpticsBodyGenerator( } override fun visitSimpleFunction(declaration: IrSimpleFunction) { - if (keyOf(declaration.origin) == OpticsCompanionGenerator.Key && - declaration.correspondingPropertySymbol == null && declaration.body == null - ) { - buildOpticBody(declaration, declaration.name) + if (declaration.correspondingPropertySymbol == null && declaration.body == null) { + when (keyOf(declaration.origin)) { + OpticsCompanionGenerator.Key -> buildOpticBody(declaration, declaration.name) + OpticsCopyGenerator.Key -> buildCopyBody(declaration) + } } super.visitSimpleFunction(declaration) } + /** `{ val me = this; me.copy { block(this, Source.Companion, me) } }` for a generated `@optics.copy`. */ + private fun buildCopyBody(copyFn: IrSimpleFunction) { + val extReceiver = copyFn.parameters.first { it.kind == IrParameterKind.ExtensionReceiver } + val blockParam = copyFn.parameters.first { it.kind == IrParameterKind.Regular } + val sourceType = extReceiver.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(extReceiver)) + +invoke + } + val call = irCall(symbols.arrowOpticsCopy, sourceType, listOf(sourceType)) + call.setExtension(irGet(extReceiver)) + call.setRegular(0, lambda) + +irReturn(call) + } + } + private fun keyOf(origin: IrDeclarationOrigin): GeneratedDeclarationKey? = (origin as? IrDeclarationOrigin.GeneratedByPlugin)?.pluginKey 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 index beaaa9f244d..b224c43204f 100644 --- 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 @@ -67,3 +67,10 @@ fun IrMemberAccessExpression<*>.setRegular(n: Int, value: IrExpression) { 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? org.jetbrains.kotlin.ir.declarations.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/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 index 0cd0baeaa20..53b5e0a1f53 100644 --- 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 @@ -1,38 +1,309 @@ package arrow.optics.plugin +import com.tschuchort.compiletesting.SourceFile import kotlin.test.Test class DSLTests { @Test - fun `lens DSL composes nested data classes`() { + fun `DSL is generated for complex model with Every`() { """ - |import arrow.optics.* - | - |@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 } - | - |val streetName: Lens = Company.address.street.name - |val c = Company("ACME", Address("AMS", Street(1, "Main"))) - |val r = streetName.get(c) == "Main" && - | streetName.set(c, "Side").address.street.name == "Side" - """.evals("r" to true) + |$`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 `prism DSL composes through a sealed branch`() { + fun `DSL is generated for complex model with At`() { """ - |import arrow.optics.* - | - |@optics data class Inner(val value: Int) { companion object } - |@optics data class Wrapper(val inner: Inner) : Thing { companion object } - |@optics sealed interface Thing { - | companion object - |} - | - |val opt: Optional = Thing.wrapper.inner.value - |val r = opt.getOrNull(Wrapper(Inner(7))) == 7 - """.evals("r" to true) + |$`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 index d7f3407eee0..9df6b0f6e12 100644 --- 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 @@ -1,38 +1,64 @@ package arrow.optics.plugin +import kotlin.test.Ignore import kotlin.test.Test class IsoTests { @Test - fun `companion iso is generated for a value class`() { + fun `Isos will be generated for value class`() { """ - |import arrow.optics.* - | - |@optics - |@JvmInline - |value class Cents(val value: Int) { - | companion object - |} - | - |val iso: Iso = Cents.value - |val r = iso.get(Cents(3)) == 3 && iso.reverseGet(5) == Cents(5) - """.evals("r" to true) + |$`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 `generic iso for a value class`() { + fun `Isos will be generated for value class with parameters having keywords as names`() { """ - |import arrow.optics.* - | - |@optics - |@JvmInline - |value class Wrapper(val wrapped: T) { - | companion object - |} - | - |val iso: Iso, String> = Wrapper.wrapped() - |val r = iso.get(Wrapper("hi")) == "hi" && iso.reverseGet("bye") == Wrapper("bye") - """.evals("r" to true) + |$`package` + |$imports + |@optics @JvmInline + |value class IsoData( + | val `in`: String + |) { companion object } + """.compilationSucceeds() + } + + @Test + @Ignore("Needs fixing joinedTypeParams in processIsoSyntax function") + 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() + } + + // In the compiler plugin the companion object is generated automatically when missing, + // so a value class without a companion is now valid (unlike the KSP processor). + @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 index 76f1568f9f5..87ca922dff5 100644 --- 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 @@ -5,102 +5,343 @@ import kotlin.test.Test class LensTests { @Test - fun `companion lens is generated for a monomorphic data class`() { - """ - |import arrow.optics.* - | - |@optics - |data class LensData(val field1: String) { - | companion object - |} - | - |val lens: Lens = LensData.field1 - |val r = lens.get(LensData("hello")) == "hello" && - | lens.set(LensData("hello"), "world") == LensData("world") - """.evals("r" to true) + 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 `lens generated without an explicit companion object`() { + fun `Lenses will be generated for data class with parameters having keywords as names`() { """ - |import arrow.optics.* - | - |@optics - |data class LensData(val field1: String) - | - |val r = LensData.field1.get(LensData("hello")) == "hello" - """.evals("r" to true) + |$`package` + |$imports + |@optics + |data class LensData( + | val `in`: String + |) { companion object } + """.compilationSucceeds() } @Test - fun `generic data class produces a lens function`() { + fun `Lenses will be generated for generic data class with parameters having keywords as names`() { """ - |import arrow.optics.* - | - |@optics - |data class OpticsTest(val field: A) { - | companion object - |} - | - |val lens: Lens, String> = OpticsTest.field() - |val r = lens.get(OpticsTest("x")) == "x" && - | lens.set(OpticsTest("x"), "y") == OpticsTest("y") - """.evals("r" to true) + |$`package` + |$imports + |@optics + |data class LensData( + | val `in`: T + |) { companion object } + """.compilationSucceeds() } @Test - fun `nullable focus keeps nullability`() { + fun `Lenses will be generated for data class with secondary constructors`() { """ - |import arrow.optics.* - |import arrow.optics.dsl.* - | - |@optics - |data class OptionalData(val field1: String?) { - | companion object - |} - | - |val lens: Lens = OptionalData.field1 - |val opt: Optional = OptionalData.field1.notNull - |val r = lens.get(OptionalData(null)) == null && - | opt.getOrNull(OptionalData("x")) == "x" - """.evals("r" to true) + |$`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 `shared abstract property of a sealed class produces a lens`() { - """ - |import arrow.optics.* - | - |@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 - |} - | - |val lens: Lens = LensSealed.property1 - |val c1: LensSealed = LensSealed.Child1("a") - |val c2: LensSealed = LensSealed.Child2("b", 5) - |val r = lens.get(c1) == "a" && - | lens.set(c1, "z") == LensSealed.Child1("z") && - | lens.set(c2, "z") == LensSealed.Child2("z", 5) + 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 `multiple fields each produce a lens`() { + fun `Lenses are not generated for unresolved types`() { """ - |import arrow.optics.* - | - |@optics - |data class Person(val name: String, val age: Int) { - | companion object - |} - | - |val p = Person("Alejandro", 40) - |val r = Person.name.get(p) == "Alejandro" && - | Person.age.set(p, 41) == Person("Alejandro", 41) - """.evals("r" to true) + |$`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 index 3e6b641801a..1757b6f46a0 100644 --- 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 @@ -5,38 +5,92 @@ import kotlin.test.Test class PrismTests { @Test - fun `companion prisms are generated for a sealed class`() { + fun `Prism will be generated for sealed class`() { """ - |import arrow.optics.* - | - |@optics - |sealed class PrismSealed { - | data class PrismSealed1(val a: String?) : PrismSealed() - | data class PrismSealed2(val b: String?) : PrismSealed() - | companion object - |} - | - |val p1: Prism = PrismSealed.prismSealed1 - |val one: PrismSealed = PrismSealed.PrismSealed1("x") - |val two: PrismSealed = PrismSealed.PrismSealed2("y") - |val r = p1.getOrNull(one) == PrismSealed.PrismSealed1("x") && - | p1.getOrNull(two) == null - """.evals("r" to true) + |$`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 for sealed interface with lowercased keyword name`() { + fun `Prism will be generated for generic sealed class`() { """ - |import arrow.optics.* - | - |@optics - |sealed interface Thing { - | data class Object(val value: Int) : Thing - | companion object - |} - | - |val prism: Prism = Thing.`object` - |val r = prism.getOrNull(Thing.Object(3)) == Thing.Object(3) - """.evals("r" to true) + |$`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/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-optics-impl.md b/arrow-optics-impl.md index d95f84d22e1..72de016ad13 100644 --- a/arrow-optics-impl.md +++ b/arrow-optics-impl.md @@ -2,25 +2,29 @@ ## Implementation status (as of this change) -Implemented end-to-end (FIR signature generation **as companion members** + IR body generation), each with passing `kotlin-compile-testing` tests under `src/test`: +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). -| Feature | Mono | Generic | Tests | -|---|---|---|---| -| **LENS** (data class fields) | ✅ | ✅ (`fun field()` form) | `LensTests` | -| **LENS** nullable focus (`Optional` via `notNull`) | ✅ | — | `LensTests` | -| **LENS** sealed shared abstract property (§5.2, `when`-dispatch `set`) | ✅ | ❌ (mono parents only) | `LensTests` | -| **ISO** (value classes) | ✅ | ✅ | `IsoTests` | -| **PRISM** (sealed subclasses, `Prism.instanceOf`) | ✅ | ❌ (mono parents only) | `PrismTests` | -| **DSL** composition extensions (top-level, §8.2 variant matrix) | ✅ | ❌ (mono sources only) | `DSLTests` | - -Key infrastructure in place: companion-member generation (`OpticsCompanionGenerator`), top-level DSL extension generation (`OpticsDslGenerator`), the IR body generator (`OpticsIrGenerationExtension` + `OpticsIrHelpers`), the shared model (`OpticsModel`, `OpticsNames`), and the FIR focus extractor (`FirOpticsExtractor`). The build wires both the FIR and IR phases (`OpticsPluginWrappers`), and a `kotlin-compile-testing` harness (`Compilation.kt`) runs the plugin. +Implemented end-to-end (FIR signature generation **as companion members** + IR body generation): -**Not yet implemented (documented follow-ups):** -- **COPY** (`@optics.copy`, §9) — blocked on constructing the `context(Copy) S.Companion.(S) -> Unit` function type in FIR (context parameters) and invoking it in IR; flagged as the most fragile milestone (§2.8, §7.6). -- **Generic PRISM** (§6 refined-supertype + free-var union) and **generic sealed-lens / generic DSL** — the generic-parent type-parameter logic differs from the LENS/ISO mirroring and is gated off for now. -- **§12 diagnostics** — custom FIR error/warning factories (ineligible class, non-uniform property, …). Ineligible classes currently generate no optics rather than reporting a hard error. - -Everything below is the original design; sections on COPY/generic-prism/diagnostics describe the intended (not-yet-built) behaviour. +| 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`. + +**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. --- From f15b783dc72432f1dc93eba258a33f96114ac8a6 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Fri, 26 Jun 2026 16:28:09 +0200 Subject: [PATCH 03/11] Review by agent --- arrow-optics-impl-review1.md | 328 +++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 arrow-optics-impl-review1.md diff --git a/arrow-optics-impl-review1.md b/arrow-optics-impl-review1.md new file mode 100644 index 00000000000..6fa8920f1a0 --- /dev/null +++ b/arrow-optics-impl-review1.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). From bb6d4254bf5112c2cb476372710bdb3f259811f3 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Fri, 26 Jun 2026 17:09:30 +0200 Subject: [PATCH 04/11] Vibe code more more --- .../kotlin/arrow/optics/plugin/OpticsNames.kt | 13 +- .../optics/plugin/fir/FirOpticsExtractor.kt | 61 ++++++- .../plugin/fir/OpticsCompanionGenerator.kt | 75 ++++---- .../optics/plugin/fir/OpticsCopyGenerator.kt | 2 +- .../optics/plugin/fir/OpticsDslGenerator.kt | 18 +- .../plugin/ir/OpticsIrGenerationExtension.kt | 64 ++++--- .../arrow/optics/plugin/RuntimeTests.kt | 171 ++++++++++++++++++ .../kotlin/arrow/optics/plugin/TargetTests.kt | 58 ++++++ arrow-optics-impl.md | 9 + 9 files changed, 390 insertions(+), 81 deletions(-) create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/RuntimeTests.kt create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/TargetTests.kt 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 index c2ab4e8cead..c0218596bf3 100644 --- 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 @@ -17,18 +17,9 @@ object OpticsNames { val OPTICS_ANNOTATION_FQNAME: FqName = OPTICS_ANNOTATION.asSingleFqName() val OPTICS_COPY_ANNOTATION = OPTICS_ANNOTATION.createNestedClassId(Name.identifier("copy")) - val OPTICS_COPY_ANNOTATION_FQNAME: FqName = OPTICS_COPY_ANNOTATION.asSingleFqName() - val OPTICS_TARGET = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("OpticsTarget")) - - // Optic type aliases (the user-facing names, 2 type arguments each). - val LENS = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Lens")) - val ISO = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Iso")) - val PRISM = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Prism")) - val OPTIONAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Optional")) - val TRAVERSAL = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Traversal")) - - // Underlying poly interfaces (these carry the companion objects with the factories). + // Underlying poly interfaces (these carry the companion objects with the factories, and the + // generated optic types use them directly — the `Lens`/`Iso`/… type-aliases are never referenced). 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")) 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 index d52e74498c7..064f32d8956 100644 --- 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 @@ -6,8 +6,10 @@ 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.DirectDeclarationsAccess @@ -15,6 +17,7 @@ import org.jetbrains.kotlin.fir.declarations.getSealedClassInheritors import org.jetbrains.kotlin.fir.declarations.hasAnnotation import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny +import org.jetbrains.kotlin.fir.expressions.FirAnnotationCall import org.jetbrains.kotlin.fir.expressions.FirPropertyAccessExpression import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid import org.jetbrains.kotlin.fir.declarations.utils.isAbstract @@ -22,6 +25,7 @@ import org.jetbrains.kotlin.fir.declarations.utils.isData import org.jetbrains.kotlin.fir.declarations.utils.isInlineOrValue import org.jetbrains.kotlin.fir.declarations.utils.modality import org.jetbrains.kotlin.fir.declarations.utils.isSealed +import org.jetbrains.kotlin.fir.declarations.utils.visibility import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol @@ -32,6 +36,7 @@ import org.jetbrains.kotlin.fir.types.ConeTypeProjection 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.name.Name /** A single base-optic focus discovered on the source class, as seen from FIR. */ @@ -88,13 +93,20 @@ object FirOpticsExtractor { return computeTargets(classKind(symbol), requested, hasCopy) } - /** Parse the `targets` array of `@optics(...)`, collecting the referenced [OpticsTargetKind]s. */ + /** + * 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). + */ private fun requestedTargets(symbol: FirRegularClassSymbol, session: FirSession): Set { - val annotation = symbol.resolvedAnnotationsWithArguments - .firstOrNull { it.toAnnotationClassId(session) == OpticsNames.OPTICS_ANNOTATION } ?: return emptySet() - val targetsExpr = annotation.argumentMapping.mapping[Name.identifier("targets")] ?: return emptySet() + val annotation = symbol.resolvedAnnotationsWithClassIds + .firstOrNull { it.toAnnotationClassId(session) == OpticsNames.OPTICS_ANNOTATION } + as? FirAnnotationCall ?: return emptySet() val found = mutableSetOf() - targetsExpr.acceptChildren(object : FirVisitorVoid() { + val collector = object : FirVisitorVoid() { override fun visitElement(element: FirElement) { element.acceptChildren(this) } @@ -109,7 +121,8 @@ object FirOpticsExtractor { } propertyAccessExpression.acceptChildren(this) } - }) + } + annotation.argumentList.arguments.forEach { it.accept(collector, null) } return found } @@ -147,9 +160,39 @@ object FirOpticsExtractor { } } - /** Structural type equality sufficient for uniformity checks (classifier + nullability). */ - private fun sameType(a: ConeKotlinType, b: ConeKotlinType): Boolean = - a.classId == b.classId && a.isMarkedNullable == b.isMarkedNullable + /** + * 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 { 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 89ea5f0aba8..d4fec86dfab 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 @@ -4,6 +4,7 @@ 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 @@ -20,6 +21,7 @@ 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 @@ -29,6 +31,7 @@ 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.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 @@ -55,6 +58,11 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx // ---- 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() @@ -117,7 +125,7 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx val opticType = polyClassOf(focus.kind).constructClassLikeType( arrayOf(sourceType, sourceType, focus.focusType, focus.focusType), ) - val vis = mostRestrictive(source.visibility, owner.visibility) + val vis = mostRestrictive(FirOpticsExtractor.effectiveVisibility(source, session), owner.visibility) val property = createMemberProperty(owner, Key, callableId.callableName, opticType, isVal = true, hasBackingField = false) { visibility = vis } @@ -129,49 +137,50 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx 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(source.visibility, owner.visibility) + 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) { - val subParams = focus.subclass?.typeParameterSymbols.orEmpty() - createMemberFunction( - owner, Key, callableId.callableName, - returnTypeProvider = { functionTypeParameters -> - val funCones = functionTypeParameters.coneTypes() - val substitutor = substitutorByMap(subParams.zip(funCones).toMap(), session) - val sourceType = focus.refinedSource?.let { substitutor.substituteOrSelf(it) } - ?: source.constructType(emptyArray(), false) - val focusType = focus.subclass?.constructType(funCones.toTypedArray(), false) ?: focus.focusType - polyClassOf(focus.kind).constructClassLikeType( - arrayOf(sourceType, sourceType, focusType, focusType), - ) - }, - ) { - subParams.forEach { tp -> typeParameter(tp.name) } - visibility = vis + 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 { - val sourceTypeParams = source.typeParameterSymbols - createMemberFunction( - owner, Key, callableId.callableName, - returnTypeProvider = { functionTypeParameters -> - val funCones = functionTypeParameters.coneTypes() - val substitutor = substitutorByMap(sourceTypeParams.zip(funCones).toMap(), session) - val substFocus = substitutor.substituteOrSelf(focus.focusType) - val sourceType = source.constructType(funCones.toTypedArray(), false) - polyClassOf(focus.kind).constructClassLikeType( - arrayOf(sourceType, sourceType, substFocus, substFocus), - ) - }, - ) { - sourceTypeParams.forEach { tp -> typeParameter(tp.name) } - visibility = vis + 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) + polyClassOf(kind).constructClassLikeType(arrayOf(sourceType, sourceType, focusType, focusType)) + }, + ) { + typeParams.forEach { tp -> typeParameter(tp.name) } + visibility = vis + } + private fun List.coneTypes(): List = map { ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) } 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 index 89b455d4de2..dbb49b3a6cd 100644 --- 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 @@ -81,7 +81,7 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi ) { extensionReceiverType(sourceType) valueParameter(Name.identifier("block"), blockType) - visibility = source.visibility + visibility = FirOpticsExtractor.effectiveVisibility(source, session) } 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 index a47491cd9b9..37132fcff51 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -51,10 +53,20 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio .filterIsInstance() .filter { it.typeParameterSymbols.isEmpty() && FirOpticsExtractor.dslEnabled(it, session) } + /** + * 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 - FirOpticsExtractor.foci(source, session).forEach { add(CallableId(pkg, it.opticName)) } + dslFoci(source).forEach { add(CallableId(pkg, it.opticName)) } } } @@ -68,7 +80,7 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio if (source.classId.packageFqName != callableId.packageName) return@forEach val sourceType = source.constructType(emptyArray(), false) val fileName = "${source.classId.shortClassName.asString()}Optics" - FirOpticsExtractor.foci(source, session) + dslFoci(source) .filter { it.opticName == callableId.callableName } .forEach { focus -> dslVariantsFor(focus.kind).forEach { dslKind -> @@ -89,7 +101,7 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio val s = sCone(tps) poly.constructClassLikeType(arrayOf(s, s, sourceType, sourceType)) } - visibility = source.visibility + visibility = FirOpticsExtractor.effectiveVisibility(source, session) } result += property.symbol } 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 index f72f349793a..e85e73d146b 100644 --- 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 @@ -54,37 +54,48 @@ class OpticsIrGenerationExtension : IrGenerationExtension { } } -/** Resolved references to the `arrow.optics` API used inside generated bodies. */ -class OpticsIrSymbols(ctx: IrPluginContext) { - val lensInvoke: IrSimpleFunctionSymbol = +/** + * 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 lensInvoke: IrSimpleFunctionSymbol by lazy { ctx.referenceFunctions(OpticsNames.LENS_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } - val isoInvoke: IrSimpleFunctionSymbol = + } + val isoInvoke: IrSimpleFunctionSymbol by lazy { ctx.referenceFunctions(OpticsNames.ISO_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } - val prismInstanceOf: IrSimpleFunctionSymbol = + } + val prismInstanceOf: IrSimpleFunctionSymbol by lazy { ctx.referenceFunctions(OpticsNames.PRISM_INSTANCE_OF).first { it.owner.parameters.none { p -> p.kind == IrParameterKind.Regular } } - val plens: IrClassSymbol = ctx.referenceClass(OpticsNames.PLENS)!! - val piso: IrClassSymbol = ctx.referenceClass(OpticsNames.PISO)!! - val pprism: IrClassSymbol = ctx.referenceClass(OpticsNames.PPRISM)!! - val plensCompanion: IrClassSymbol = ctx.referenceClass(OpticsNames.PLENS_COMPANION)!! - val pisoCompanion: IrClassSymbol = ctx.referenceClass(OpticsNames.PISO_COMPANION)!! - val pprismCompanion: IrClassSymbol = ctx.referenceClass(OpticsNames.PPRISM_COMPANION)!! + } + val plens: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PLENS)!! } + val piso: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PISO)!! } + val pprism: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PPRISM)!! } + val plensCompanion: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PLENS_COMPANION)!! } + val pisoCompanion: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PISO_COMPANION)!! } + val pprismCompanion: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PPRISM_COMPANION)!! } /** For each optic poly-interface, its `plus` composition operator (keyed by the receiver class). */ - val polyPlus: Map = + val polyPlus: Map by lazy { arrow.optics.plugin.DslKind.entries.associate { kind -> val cls = ctx.referenceClass(OpticsNames.polyClassFor(kind))!! val plus = ctx.referenceFunctions(OpticsNames.plusFor(kind)) .first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 1 } cls to plus } + } // COPY builder support. - val copyClass: IrClassSymbol = ctx.referenceClass(OpticsNames.COPY)!! - val arrowOpticsCopy: IrSimpleFunctionSymbol = + val copyClass: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.COPY)!! } + val arrowOpticsCopy: IrSimpleFunctionSymbol by lazy { ctx.referenceFunctions(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 } @@ -227,7 +238,7 @@ private class OpticsBodyGenerator( val body = if (source.modality == Modality.SEALED) { sealedSet(source, sourceType, fieldName, s, v) } else { - reconstruct(source, ctorTypeArgs, irGet(s), fieldName, irGet(v)) + reconstruct(source, ctorTypeArgs, { irGet(s) }, fieldName) { irGet(v) } } +irReturn(body) } @@ -249,10 +260,10 @@ private class OpticsBodyGenerator( val branches = source.sealedSubclasses.map { subSymbol -> val sub = subSymbol.owner val subType = sub.defaultType - val cast = irImplicitCast(irGet(instance), subType) irBranch( irIs(irGet(instance), subType), - reconstruct(sub, emptyList(), cast, fieldName, irGet(value)), + // 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) @@ -270,7 +281,7 @@ private class OpticsBodyGenerator( +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))) + +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)) @@ -279,7 +290,7 @@ private class OpticsBodyGenerator( return call } - /** `instance.field` via the property getter. */ + /** `instance.field` via the property getter; [instance] must produce a fresh expression. */ private fun IrBuilderWithScope.readComponent( source: IrClass, fieldName: Name, @@ -292,21 +303,26 @@ private class OpticsBodyGenerator( return call } - /** Reconstruct [source] via its primary constructor, replacing [overrideName] with [overrideValue]. */ + /** + * 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, + instance: () -> IrExpression, overrideName: Name, - overrideValue: IrExpression, + 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 + overrideValue() } else { - readComponent(source, param.name, param.type, instance) + readComponent(source, param.name, param.type, instance()) } call.arguments[param] = arg } 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-optics-impl.md b/arrow-optics-impl.md index 72de016ad13..48428779457 100644 --- a/arrow-optics-impl.md +++ b/arrow-optics-impl.md @@ -19,6 +19,15 @@ Implemented end-to-end (FIR signature generation **as companion members** + IR b 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). From 41ca2c21a808852921ae7a0cdd8b7f1550da13f8 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Fri, 26 Jun 2026 22:07:41 +0200 Subject: [PATCH 05/11] Manual review --- .../build.gradle.kts | 6 +-- .../optics/plugin/fir/FirOpticsExtractor.kt | 41 +++++++++++-------- .../plugin/fir/OpticsCompanionGenerator.kt | 6 +-- .../optics/plugin/fir/OpticsCopyGenerator.kt | 5 ++- .../optics/plugin/fir/OpticsDslGenerator.kt | 5 ++- .../optics/plugin/fir/OpticsPluginWrappers.kt | 2 + .../plugin/ir/OpticsIrGenerationExtension.kt | 33 ++++++++------- .../arrow/optics/plugin/ir/OpticsIrHelpers.kt | 19 ++++----- .../kotlin/arrow/optics/plugin/IsoTests.kt | 4 -- .../vibe/README.md | 16 ++++++++ .../arrow-optics-compiler-plugin/vibe/algo.md | 0 .../arrow-optics-compiler-plugin/vibe/impl.md | 0 .../vibe/review.md | 0 .../kotlin/arrow/optics/plugin/Compilation.kt | 8 ++-- .../arrow-optics-plugin/build.gradle.kts | 6 ++- .../arrow/optics/plugin/ArrowOpticsPlugin.kt | 15 +++---- gradle-test/jvmOnly/build.gradle.kts | 6 +-- gradle-test/multiplatform/build.gradle.kts | 10 +++-- test-optics-gradle-plugin.sh | 4 +- 19 files changed, 102 insertions(+), 84 deletions(-) create mode 100644 arrow-libs/optics/arrow-optics-compiler-plugin/vibe/README.md rename arrow-optics-algo.md => arrow-libs/optics/arrow-optics-compiler-plugin/vibe/algo.md (100%) rename arrow-optics-impl.md => arrow-libs/optics/arrow-optics-compiler-plugin/vibe/impl.md (100%) rename arrow-optics-impl-review1.md => arrow-libs/optics/arrow-optics-compiler-plugin/vibe/review.md (100%) 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 561bfb64ef7..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,9 +6,9 @@ plugins { kotlin { explicitApi = null compilerOptions { - optIn.add("org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi") - optIn.add("org.jetbrains.kotlin.fir.extensions.ExperimentalTopLevelDeclarationsGenerationApi") - optIn.add("org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI") + // 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") } } 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 index 064f32d8956..0dffdaa7a41 100644 --- 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 @@ -12,22 +12,22 @@ 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.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.getSealedClassInheritors import org.jetbrains.kotlin.fir.declarations.hasAnnotation -import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny -import org.jetbrains.kotlin.fir.expressions.FirAnnotationCall -import org.jetbrains.kotlin.fir.expressions.FirPropertyAccessExpression -import org.jetbrains.kotlin.fir.visitors.FirVisitorVoid +import org.jetbrains.kotlin.fir.declarations.processAllDeclarations +import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId 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.modality import org.jetbrains.kotlin.fir.declarations.utils.isSealed +import org.jetbrains.kotlin.fir.declarations.utils.modality import org.jetbrains.kotlin.fir.declarations.utils.visibility +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 @@ -37,6 +37,8 @@ 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. */ @@ -54,9 +56,7 @@ data class FirFocus( ) /** Reads `@optics`-annotated FIR class symbols and extracts the foci to generate. */ -@OptIn(SymbolInternals::class, DirectDeclarationsAccess::class) object FirOpticsExtractor { - fun classKind(symbol: FirRegularClassSymbol): OpticsClassKind = when { symbol.isData -> OpticsClassKind.DATA symbol.isInlineOrValue && symbol.classKind == ClassKind.CLASS -> OpticsClassKind.VALUE @@ -104,7 +104,7 @@ object FirOpticsExtractor { private fun requestedTargets(symbol: FirRegularClassSymbol, session: FirSession): Set { val annotation = symbol.resolvedAnnotationsWithClassIds .firstOrNull { it.toAnnotationClassId(session) == OpticsNames.OPTICS_ANNOTATION } - as? FirAnnotationCall ?: return emptySet() + if (annotation !is FirAnnotationCall) return emptySet() val found = mutableSetOf() val collector = object : FirVisitorVoid() { override fun visitElement(element: FirElement) { @@ -132,13 +132,15 @@ object FirOpticsExtractor { */ private fun sealedLensFoci(symbol: FirRegularClassSymbol, session: FirSession): List { if (symbol.typeParameterSymbols.isNotEmpty()) return emptyList() - val abstractProps = symbol.fir.declarations - .filterIsInstance() - .filter { it.isAbstract && it.receiverParameter == null } - .map { it.symbol } + 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.fir.getSealedClassInheritors(session).mapNotNull { + val subclasses = symbol.getSealedClassInheritors(session).mapNotNull { session.symbolProvider.getClassLikeSymbolByClassId(it) as? FirRegularClassSymbol } if (subclasses.isEmpty() || subclasses.any { !it.isData }) return emptyList() @@ -195,9 +197,8 @@ object FirOpticsExtractor { } /** One PRISM focus per sealed subclass (algo §6). */ - private fun prismFoci(symbol: FirRegularClassSymbol, session: FirSession): List { - val inheritorIds = symbol.fir.getSealedClassInheritors(session) - return inheritorIds.mapNotNull { classId -> + 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 = @@ -212,7 +213,6 @@ object FirOpticsExtractor { 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 { @@ -226,4 +226,9 @@ object FirOpticsExtractor { ) } } + + @OptIn(SymbolInternals::class) + private fun FirRegularClassSymbol.getSealedClassInheritors(session: FirSession): List { + return fir.getSealedClassInheritors(session) + } } 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 d4fec86dfab..ea5d7bd89fe 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 @@ -6,7 +6,6 @@ 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.utils.isCompanion import org.jetbrains.kotlin.fir.declarations.utils.visibility @@ -24,7 +23,6 @@ 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 @@ -42,7 +40,6 @@ 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) { companion object { val OPTICS_ANNOTATION_FQNAME = OpticsNames.OPTICS_ANNOTATION_FQNAME @@ -72,13 +69,14 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx override fun generateNestedClassLikeDeclaration(owner: FirClassSymbol<*>, name: Name, context: NestedClassGenerationContext): FirClassLikeSymbol<*>? { if (owner !is FirRegularClassSymbol) return null if (!session.predicateBasedProvider.matches(predicate, owner)) return null - if (owner.companionObjectSymbol != null) return null + if (owner.resolvedCompanionObjectSymbol != null) return null if (name != SpecialNames.DEFAULT_NAME_FOR_COMPANION_OBJECT) return null return createCompanionObject(owner, Key) { this.visibility = owner.rawStatus.visibility }.symbol } + @OptIn(ExperimentalContracts::class) fun FirClassSymbol<*>.isGeneratedOpticsCompanion(): Boolean { contract { returns(true) implies (this@isGeneratedOpticsCompanion is FirRegularClassSymbol) 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 index dbb49b3a6cd..193aacf81e3 100644 --- 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 @@ -4,7 +4,7 @@ import arrow.optics.plugin.OpticsNames import org.jetbrains.kotlin.GeneratedDeclarationKey import org.jetbrains.kotlin.fir.FirSession import org.jetbrains.kotlin.fir.declarations.hasAnnotation -import org.jetbrains.kotlin.fir.declarations.utils.visibility +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 @@ -28,6 +28,7 @@ import org.jetbrains.kotlin.name.Name * `fun Source.copy(block: context(Copy) Source.Companion.(Source) -> Unit): Source`. * Only monomorphic sources are supported for now. */ +@OptIn(ExperimentalTopLevelDeclarationsGenerationApi::class) class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { private val lookupPredicate = LookupPredicate.create { @@ -65,7 +66,7 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi 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.Companion.create( + val blockAttributes = ConeAttributes.create( listOf(CompilerConeAttributes.ExtensionFunctionType, CompilerConeAttributes.ContextFunctionTypeParams(1)), ) val blockType = FUNCTION3.constructClassLikeType( 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 index 37132fcff51..450c51fb2e8 100644 --- 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 @@ -6,7 +6,7 @@ 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.declarations.utils.visibility +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 @@ -14,13 +14,13 @@ 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.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.fir.symbols.ConeTypeParameterLookupTag import org.jetbrains.kotlin.name.CallableId import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name @@ -32,6 +32,7 @@ import org.jetbrains.kotlin.name.Name * 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) { private val lookupPredicate = LookupPredicate.create { 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 6b5938b37dd..ddfb6b9fe39 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,3 +1,4 @@ +@file:OptIn(ExperimentalCompilerApi::class) package arrow.optics.plugin.fir import arrow.optics.plugin.ir.OpticsIrGenerationExtension @@ -6,6 +7,7 @@ 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 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 index e85e73d146b..a51b697f9d7 100644 --- 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 @@ -1,3 +1,4 @@ +@file:OptIn(UnsafeDuringIrConstructionAPI::class) package arrow.optics.plugin.ir import arrow.optics.plugin.OpticsNames @@ -32,9 +33,11 @@ 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 @@ -62,36 +65,38 @@ class OpticsIrGenerationExtension : IrGenerationExtension { * (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 { - ctx.referenceFunctions(OpticsNames.LENS_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } + finder.findFunctions(OpticsNames.LENS_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } } val isoInvoke: IrSimpleFunctionSymbol by lazy { - ctx.referenceFunctions(OpticsNames.ISO_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } + finder.findFunctions(OpticsNames.ISO_INVOKE).first { it.owner.parameters.count { p -> p.kind == IrParameterKind.Regular } == 2 } } val prismInstanceOf: IrSimpleFunctionSymbol by lazy { - ctx.referenceFunctions(OpticsNames.PRISM_INSTANCE_OF).first { it.owner.parameters.none { p -> p.kind == IrParameterKind.Regular } } + finder.findFunctions(OpticsNames.PRISM_INSTANCE_OF).first { it.owner.parameters.none { p -> p.kind == IrParameterKind.Regular } } } - val plens: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PLENS)!! } - val piso: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PISO)!! } - val pprism: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PPRISM)!! } - val plensCompanion: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PLENS_COMPANION)!! } - val pisoCompanion: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PISO_COMPANION)!! } - val pprismCompanion: IrClassSymbol by lazy { ctx.referenceClass(OpticsNames.PPRISM_COMPANION)!! } + 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 { arrow.optics.plugin.DslKind.entries.associate { kind -> - val cls = ctx.referenceClass(OpticsNames.polyClassFor(kind))!! - val plus = ctx.referenceFunctions(OpticsNames.plusFor(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 { ctx.referenceClass(OpticsNames.COPY)!! } + val copyClass: IrClassSymbol by lazy { finder.findClass(OpticsNames.COPY)!! } val arrowOpticsCopy: IrSimpleFunctionSymbol by lazy { - ctx.referenceFunctions(OpticsNames.ARROW_OPTICS_COPY).first { fn -> + 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 } @@ -178,7 +183,7 @@ private class OpticsBodyGenerator( val rt = opticFn.returnType as IrSimpleType val sourceType = rt.arguments[0].typeOrNull!! val focusType = rt.arguments[2].typeOrNull!! - val ctorTypeArgs = opticFn.typeParameters.map { it.coneType() } + val ctorTypeArgs = opticFn.typeParameters.map { it.defaultType } opticFn.body = DeclarationIrBuilder(ctx, opticFn.symbol).irBlockBody { val expr = when (kind) { 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 index b224c43204f..900365b4685 100644 --- 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 @@ -1,3 +1,4 @@ +@file:OptIn(UnsafeDuringIrConstructionAPI::class) package arrow.optics.plugin.ir import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext @@ -5,21 +6,21 @@ 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.expressions.IrStatementOrigin +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.builders.IrBlockBodyBuilder 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.IrTypeParameter import org.jetbrains.kotlin.ir.declarations.IrValueParameter -import org.jetbrains.kotlin.ir.types.defaultType 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 @@ -50,27 +51,23 @@ fun IrPluginContext.buildLambda( return IrFunctionExpressionImpl(UNDEFINED_OFFSET, UNDEFINED_OFFSET, functionType, lambda, IrStatementOrigin.LAMBDA) } -/** The type-parameter's own type, e.g. `A` for `fun ...`. */ -fun IrTypeParameter.coneType(): IrType = defaultType - /** Set the dispatch-receiver argument of [this] call, addressing it by parameter kind. */ fun IrMemberAccessExpression<*>.setDispatch(receiver: IrExpression) { - val symbol = symbol - val owner = (symbol.owner as? org.jetbrains.kotlin.ir.declarations.IrFunction) ?: return + 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? org.jetbrains.kotlin.ir.declarations.IrFunction) ?: return + 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? org.jetbrains.kotlin.ir.declarations.IrFunction) ?: return + 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/IsoTests.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/test/kotlin/arrow/optics/plugin/IsoTests.kt index 9df6b0f6e12..99a2f8278b2 100644 --- 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 @@ -1,6 +1,5 @@ package arrow.optics.plugin -import kotlin.test.Ignore import kotlin.test.Test class IsoTests { @@ -33,7 +32,6 @@ class IsoTests { } @Test - @Ignore("Needs fixing joinedTypeParams in processIsoSyntax function") fun `Isos will be generated for generic value class with parameters having keywords as names`() { """ |$`package` @@ -45,8 +43,6 @@ class IsoTests { """.compilationSucceeds() } - // In the compiler plugin the companion object is generated automatically when missing, - // so a value class without a companion is now valid (unlike the KSP processor). @Test fun `Iso generation works without an explicit companion object`() { """ 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-optics-algo.md b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/algo.md similarity index 100% rename from arrow-optics-algo.md rename to arrow-libs/optics/arrow-optics-compiler-plugin/vibe/algo.md diff --git a/arrow-optics-impl.md b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/impl.md similarity index 100% rename from arrow-optics-impl.md rename to arrow-libs/optics/arrow-optics-compiler-plugin/vibe/impl.md diff --git a/arrow-optics-impl-review1.md b/arrow-libs/optics/arrow-optics-compiler-plugin/vibe/review.md similarity index 100% rename from arrow-optics-impl-review1.md rename to arrow-libs/optics/arrow-optics-compiler-plugin/vibe/review.md 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/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/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 From 4af7fc43b409aaeffb3ee99453754b99f3c80fb7 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Sat, 27 Jun 2026 09:00:16 +0200 Subject: [PATCH 06/11] Spotless + manual improvements --- .../kotlin/arrow/optics/plugin/OpticsModel.kt | 4 ++ .../optics/plugin/fir/FirOpticsExtractor.kt | 45 +++++++++---------- .../plugin/fir/OpticsCompanionGenerator.kt | 7 +-- .../optics/plugin/fir/OpticsCopyGenerator.kt | 36 +++++++-------- .../optics/plugin/fir/OpticsDslGenerator.kt | 29 +++++------- .../optics/plugin/fir/OpticsPluginWrappers.kt | 1 + .../plugin/ir/OpticsIrGenerationExtension.kt | 7 +-- .../arrow/optics/plugin/ir/OpticsIrHelpers.kt | 1 + build.gradle.kts | 1 + 9 files changed, 63 insertions(+), 68 deletions(-) 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 index cc40020193c..57552ae09be 100644 --- 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 @@ -65,9 +65,13 @@ fun lowercaseFirst(name: Name): Name { */ 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/fir/FirOpticsExtractor.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/fir/FirOpticsExtractor.kt index 0dffdaa7a41..c2633538122 100644 --- 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 @@ -83,8 +83,7 @@ object FirOpticsExtractor { } /** Whether the DSL composition extensions should be generated for [symbol]. */ - fun dslEnabled(symbol: FirRegularClassSymbol, session: FirSession): Boolean = - OpticsTargetKind.DSL in effectiveTargets(symbol, session) + fun dslEnabled(symbol: FirRegularClassSymbol, session: FirSession): Boolean = OpticsTargetKind.DSL in effectiveTargets(symbol, session) /** The effective target set (algo §2.3): requested ∩ kind-allowed, plus COPY when present. */ fun effectiveTargets(symbol: FirRegularClassSymbol, session: FirSession): Set { @@ -175,8 +174,11 @@ object FirOpticsExtractor { 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) + if (at == null || bt == null) { + aArgs[i].kind == bArgs[i].kind // star projections + } else { + aArgs[i].kind == bArgs[i].kind && sameType(at, bt) + } } } @@ -197,22 +199,21 @@ object FirOpticsExtractor { } /** 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, - ) - } + 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 { @@ -228,7 +229,5 @@ object FirOpticsExtractor { } @OptIn(SymbolInternals::class) - private fun FirRegularClassSymbol.getSealedClassInheritors(session: FirSession): List { - return fir.getSealedClassInheritors(session) - } + private fun FirRegularClassSymbol.getSealedClassInheritors(session: FirSession): List = fir.getSealedClassInheritors(session) } 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 ea5d7bd89fe..fc805ab878a 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 @@ -167,7 +167,9 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx typeParams: List, sourceAndFocus: (ConeSubstitutor, List) -> Pair, ) = createMemberFunction( - owner, Key, callableId.callableName, + owner, + Key, + callableId.callableName, returnTypeProvider = { functionTypeParameters -> val funCones = functionTypeParameters.coneTypes() val substitutor = substitutorByMap(typeParams.zip(funCones).toMap(), session) @@ -179,8 +181,7 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx visibility = vis } - private fun List.coneTypes(): List = - map { ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) } + private fun List.coneTypes(): List = map { ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) } override fun generateConstructors(context: MemberGenerationContext): List { val owner = context.owner 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 index 193aacf81e3..fabba1c08f0 100644 --- 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 @@ -30,6 +30,7 @@ import org.jetbrains.kotlin.name.Name */ @OptIn(ExperimentalTopLevelDeclarationsGenerationApi::class) class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + object Key : GeneratedDeclarationKey() private val lookupPredicate = LookupPredicate.create { annotated(setOf(OpticsNames.OPTICS_ANNOTATION_FQNAME)) @@ -42,19 +43,15 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi register(declarationPredicate) } - private val COPY_NAME = Name.identifier("copy") - private val FUNCTION3 = ClassId(FqName("kotlin"), Name.identifier("Function3")) - - private fun copySources(): List = - session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) - .filterIsInstance() - .filter { it.typeParameterSymbols.isEmpty() && it.hasAnnotation(OpticsNames.OPTICS_COPY_ANNOTATION, session) } + private fun copySources(): List = session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) + .filterIsInstance() + .filter { it.typeParameterSymbols.isEmpty() && it.hasAnnotation(OpticsNames.OPTICS_COPY_ANNOTATION, session) } - override fun getTopLevelCallableIds(): Set = - copySources().mapTo(mutableSetOf()) { CallableId(it.classId.packageFqName, COPY_NAME) } + override fun getTopLevelCallableIds(): Set = copySources().mapTo(mutableSetOf()) { + CallableId(it.classId.packageFqName, Name.identifier("copy")) + } - override fun hasPackage(packageFqName: FqName): Boolean = - copySources().any { it.classId.packageFqName == packageFqName } + override fun hasPackage(packageFqName: FqName): Boolean = copySources().any { it.classId.packageFqName == packageFqName } override fun generateFunctions(callableId: CallableId, context: MemberGenerationContext?): List { if (context != null) return emptyList() @@ -69,14 +66,15 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi val blockAttributes = ConeAttributes.create( listOf(CompilerConeAttributes.ExtensionFunctionType, CompilerConeAttributes.ContextFunctionTypeParams(1)), ) - val blockType = FUNCTION3.constructClassLikeType( - arrayOf(copyType, companionType, sourceType, session.builtinTypes.unitType.coneType), - false, - blockAttributes, - ) + val blockType = ClassId(FqName("kotlin"), Name.identifier("Function3")) + .constructClassLikeType( + typeArguments = arrayOf(copyType, companionType, sourceType, session.builtinTypes.unitType.coneType), + isMarkedNullable = false, + blockAttributes, + ) val function = createTopLevelFunction( - Key, - callableId, + key = Key, + callableId = callableId, returnType = sourceType, containingFileName = "${source.classId.shortClassName.asString()}Copy", ) { @@ -87,6 +85,4 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi function.symbol } } - - object Key : GeneratedDeclarationKey() } 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 index 450c51fb2e8..4f5e51c822e 100644 --- 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 @@ -17,7 +17,6 @@ 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.ConeKotlinType import org.jetbrains.kotlin.fir.types.constructClassLikeType import org.jetbrains.kotlin.fir.types.constructType import org.jetbrains.kotlin.fir.types.impl.ConeTypeParameterTypeImpl @@ -34,6 +33,7 @@ import org.jetbrains.kotlin.name.Name */ @OptIn(ExperimentalTopLevelDeclarationsGenerationApi::class) class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + object Key : GeneratedDeclarationKey() private val lookupPredicate = LookupPredicate.create { annotated(setOf(OpticsNames.OPTICS_ANNOTATION_FQNAME)) @@ -46,13 +46,10 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio register(declarationPredicate) } - private val DSL_S = Name.identifier("__S") - /** 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, session) } + private fun annotatedSources(): List = session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) + .filterIsInstance() + .filter { it.typeParameterSymbols.isEmpty() && FirOpticsExtractor.dslEnabled(it, session) } /** * The foci that get DSL composition helpers. Per algo §8.4 a sealed type contributes only its @@ -71,8 +68,7 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio } } - override fun hasPackage(packageFqName: FqName): Boolean = - annotatedSources().any { it.classId.packageFqName == packageFqName } + 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 @@ -87,19 +83,19 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio dslVariantsFor(focus.kind).forEach { dslKind -> val poly = OpticsNames.polyClassFor(dslKind) val property = createTopLevelProperty( - Key, - callableId, + key = Key, + callableId = callableId, returnTypeProvider = { tps -> - val s = sCone(tps) + val s = ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) poly.constructClassLikeType(arrayOf(s, s, focus.focusType, focus.focusType)) }, isVal = true, hasBackingField = false, containingFileName = fileName, ) { - typeParameter(DSL_S) + typeParameter(Name.identifier("__S")) extensionReceiverType { tps -> - val s = sCone(tps) + val s = ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) poly.constructClassLikeType(arrayOf(s, s, sourceType, sourceType)) } visibility = FirOpticsExtractor.effectiveVisibility(source, session) @@ -110,9 +106,4 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio } return result } - - private fun sCone(tps: List): ConeKotlinType = - ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) - - object Key : GeneratedDeclarationKey() } 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 ddfb6b9fe39..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,4 +1,5 @@ @file:OptIn(ExperimentalCompilerApi::class) + package arrow.optics.plugin.fir import arrow.optics.plugin.ir.OpticsIrGenerationExtension 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 index a51b697f9d7..5fbe03606db 100644 --- 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 @@ -1,14 +1,15 @@ @file:OptIn(UnsafeDuringIrConstructionAPI::class) + package arrow.optics.plugin.ir 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.GeneratedDeclarationKey import org.jetbrains.kotlin.descriptors.Modality import org.jetbrains.kotlin.ir.IrElement import org.jetbrains.kotlin.ir.builders.IrBuilderWithScope @@ -122,6 +123,7 @@ private class OpticsBodyGenerator( declaration.backingField = null buildOpticBody(getter, declaration.name) } + OpticsDslGenerator.Key -> { declaration.backingField = null buildDslBody(getter, declaration.name) @@ -168,8 +170,7 @@ private class OpticsBodyGenerator( } } - private fun keyOf(origin: IrDeclarationOrigin): GeneratedDeclarationKey? = - (origin as? IrDeclarationOrigin.GeneratedByPlugin)?.pluginKey + 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) { 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 index 900365b4685..46cb285e21d 100644 --- 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 @@ -1,4 +1,5 @@ @file:OptIn(UnsafeDuringIrConstructionAPI::class) + package arrow.optics.plugin.ir import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext 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/**") } } From 111db2c9696cf962dfb2213fefe46506c075b723 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Mon, 29 Jun 2026 10:30:07 +0200 Subject: [PATCH 07/11] Make it work with IDE --- .gitignore | 1 + .../optics/plugin/fir/FirOpticsExtractor.kt | 43 ++++++++++++++----- .../plugin/fir/OpticsCompanionGenerator.kt | 12 ++++-- .../optics/plugin/fir/OpticsCopyGenerator.kt | 4 +- .../optics/plugin/fir/OpticsDslGenerator.kt | 2 +- 5 files changed, 46 insertions(+), 16 deletions(-) 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/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 index c2633538122..275123af5f2 100644 --- 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 @@ -13,16 +13,15 @@ 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.hasAnnotation import org.jetbrains.kotlin.fir.declarations.primaryConstructorIfAny import org.jetbrains.kotlin.fir.declarations.processAllDeclarations -import org.jetbrains.kotlin.fir.declarations.toAnnotationClassId 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.isSealed import org.jetbrains.kotlin.fir.declarations.utils.modality 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 @@ -33,6 +32,8 @@ 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 @@ -66,7 +67,7 @@ object FirOpticsExtractor { /** 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, session) + val targets = effectiveTargets(symbol) val all = when (classKind(symbol)) { OpticsClassKind.DATA -> constructorFoci(symbol, session, OpticKind.LENS) OpticsClassKind.VALUE -> constructorFoci(symbol, session, OpticKind.ISO) @@ -83,12 +84,14 @@ object FirOpticsExtractor { } /** Whether the DSL composition extensions should be generated for [symbol]. */ - fun dslEnabled(symbol: FirRegularClassSymbol, session: FirSession): Boolean = OpticsTargetKind.DSL in effectiveTargets(symbol, session) + fun dslEnabled(symbol: FirRegularClassSymbol): Boolean = + OpticsTargetKind.DSL in effectiveTargets(symbol) /** The effective target set (algo §2.3): requested ∩ kind-allowed, plus COPY when present. */ - fun effectiveTargets(symbol: FirRegularClassSymbol, session: FirSession): Set { - val requested = requestedTargets(symbol, session) - val hasCopy = symbol.hasAnnotation(OpticsNames.OPTICS_COPY_ANNOTATION, session) + @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) } @@ -100,10 +103,11 @@ object FirOpticsExtractor { * which generation runs — relying on `resolvedAnnotationsWithArguments.argumentMapping` is not, as * the argument mapping may not yet be populated when `getCallableNamesForClass` runs (review §2.6). */ - private fun requestedTargets(symbol: FirRegularClassSymbol, session: FirSession): Set { - val annotation = symbol.resolvedAnnotationsWithClassIds - .firstOrNull { it.toAnnotationClassId(session) == OpticsNames.OPTICS_ANNOTATION } + @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) { @@ -231,3 +235,22 @@ object FirOpticsExtractor { @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 fc805ab878a..e737beaaaff 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 @@ -7,6 +7,7 @@ import org.jetbrains.kotlin.GeneratedDeclarationKey import org.jetbrains.kotlin.descriptors.Visibility import org.jetbrains.kotlin.fir.FirSession 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 @@ -23,6 +24,7 @@ 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 @@ -41,6 +43,8 @@ import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) { + object Key : GeneratedDeclarationKey() + companion object { val OPTICS_ANNOTATION_FQNAME = OpticsNames.OPTICS_ANNOTATION_FQNAME @@ -66,10 +70,11 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx 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 - if (owner.resolvedCompanionObjectSymbol != null) return null + if (owner.companionObjectSymbol != null) return null if (name != SpecialNames.DEFAULT_NAME_FOR_COMPANION_OBJECT) return null return createCompanionObject(owner, Key) { this.visibility = owner.rawStatus.visibility @@ -181,13 +186,12 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx visibility = vis } - private fun List.coneTypes(): List = map { ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) } + 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 index fabba1c08f0..9895ace2765 100644 --- 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 @@ -12,6 +12,7 @@ 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.createTopLevelFunction +import org.jetbrains.kotlin.fir.symbols.SymbolInternals import org.jetbrains.kotlin.fir.symbols.impl.FirNamedFunctionSymbol import org.jetbrains.kotlin.fir.symbols.impl.FirRegularClassSymbol import org.jetbrains.kotlin.fir.types.CompilerConeAttributes @@ -43,9 +44,10 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi register(declarationPredicate) } + @OptIn(SymbolInternals::class) private fun copySources(): List = session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) .filterIsInstance() - .filter { it.typeParameterSymbols.isEmpty() && it.hasAnnotation(OpticsNames.OPTICS_COPY_ANNOTATION, session) } + .filter { it.typeParameterSymbols.isEmpty() && it.annotations.any { it.checkEvenIfUnresolved(OpticsNames.OPTICS_COPY_ANNOTATION) } } override fun getTopLevelCallableIds(): Set = copySources().mapTo(mutableSetOf()) { CallableId(it.classId.packageFqName, Name.identifier("copy")) 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 index 4f5e51c822e..843cb199e55 100644 --- 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 @@ -49,7 +49,7 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio /** 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, session) } + .filter { it.typeParameterSymbols.isEmpty() && FirOpticsExtractor.dslEnabled(it) } /** * The foci that get DSL composition helpers. Per algo §8.4 a sealed type contributes only its From 373437b65aad710ea51dde2ca672044ae299d09f Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Mon, 29 Jun 2026 11:36:12 +0200 Subject: [PATCH 08/11] Make it work with IDE --- .../kotlin/arrow/optics/plugin/OpticsNames.kt | 29 +++++++++++++++++-- .../optics/plugin/fir/FirOpticsExtractor.kt | 18 +++++++----- .../plugin/fir/OpticsCompanionGenerator.kt | 13 ++------- .../optics/plugin/fir/OpticsDslGenerator.kt | 7 ++--- .../plugin/ir/OpticsIrGenerationExtension.kt | 3 +- .../src/main/kotlin/example/commonExample.kt | 3 +- .../src/main/kotlin/example/commonUsage.kt | 2 +- 7 files changed, 47 insertions(+), 28 deletions(-) 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 index c0218596bf3..42917d59e11 100644 --- 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 @@ -18,8 +18,14 @@ object OpticsNames { val OPTICS_COPY_ANNOTATION = OPTICS_ANNOTATION.createNestedClassId(Name.identifier("copy")) - // Underlying poly interfaces (these carry the companion objects with the factories, and the - // generated optic types use them directly — the `Lens`/`Iso`/… type-aliases are never referenced). + // 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")) @@ -46,7 +52,24 @@ object OpticsNames { val ARROW_OPTICS_COPY = CallableId(ARROW_OPTICS_PACKAGE, Name.identifier("copy")) val COPY = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Copy")) - /** Poly-interface ClassId for a DSL outer-optic kind. */ + /** 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 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 index 275123af5f2..710ebde47f9 100644 --- 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 @@ -18,8 +18,6 @@ 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.isSealed -import org.jetbrains.kotlin.fir.declarations.utils.modality import org.jetbrains.kotlin.fir.declarations.utils.visibility import org.jetbrains.kotlin.fir.expressions.FirAnnotation import org.jetbrains.kotlin.fir.expressions.FirAnnotationCall @@ -58,11 +56,17 @@ data class FirFocus( /** Reads `@optics`-annotated FIR class symbols and extracts the foci to generate. */ object FirOpticsExtractor { - fun classKind(symbol: FirRegularClassSymbol): OpticsClassKind = when { - symbol.isData -> OpticsClassKind.DATA - symbol.isInlineOrValue && symbol.classKind == ClassKind.CLASS -> OpticsClassKind.VALUE - symbol.isSealed || symbol.modality == Modality.SEALED -> OpticsClassKind.SEALED - else -> OpticsClassKind.INELIGIBLE + 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. */ 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 e737beaaaff..2c91d87cd5d 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 @@ -106,13 +106,6 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx return FirOpticsExtractor.foci(source, session) } - /** The `arrow.optics` poly-interface backing a focus of the given kind. */ - private fun polyClassOf(kind: OpticKind) = when (kind) { - OpticKind.LENS -> OpticsNames.PLENS - OpticKind.ISO -> OpticsNames.PISO - OpticKind.PRISM -> OpticsNames.PPRISM - } - override fun getCallableNamesForClass(classSymbol: FirClassSymbol<*>, context: MemberGenerationContext): Set { val names = fociFor(classSymbol).mapTo(mutableSetOf()) { it.opticName } if (classSymbol.isGeneratedOpticsCompanion()) names += SpecialNames.INIT @@ -125,9 +118,7 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx 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 = polyClassOf(focus.kind).constructClassLikeType( - arrayOf(sourceType, sourceType, focus.focusType, focus.focusType), - ) + 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 @@ -179,7 +170,7 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx val funCones = functionTypeParameters.coneTypes() val substitutor = substitutorByMap(typeParams.zip(funCones).toMap(), session) val (sourceType, focusType) = sourceAndFocus(substitutor, funCones) - polyClassOf(kind).constructClassLikeType(arrayOf(sourceType, sourceType, focusType, focusType)) + OpticsNames.monoClassOf(kind).constructClassLikeType(arrayOf(sourceType, focusType)) }, ) { typeParams.forEach { tp -> typeParameter(tp.name) } 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 index 843cb199e55..13b9298145e 100644 --- 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 @@ -80,14 +80,13 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio dslFoci(source) .filter { it.opticName == callableId.callableName } .forEach { focus -> - dslVariantsFor(focus.kind).forEach { dslKind -> - val poly = OpticsNames.polyClassFor(dslKind) + for (dslKind in dslVariantsFor(focus.kind)) { val property = createTopLevelProperty( key = Key, callableId = callableId, returnTypeProvider = { tps -> val s = ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) - poly.constructClassLikeType(arrayOf(s, s, focus.focusType, focus.focusType)) + OpticsNames.polyClassFor(dslKind).constructClassLikeType(arrayOf(s, s, focus.focusType, focus.focusType)) }, isVal = true, hasBackingField = false, @@ -96,7 +95,7 @@ class OpticsDslGenerator(session: FirSession) : FirDeclarationGenerationExtensio typeParameter(Name.identifier("__S")) extensionReceiverType { tps -> val s = ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(tps[0].symbol), isMarkedNullable = false) - poly.constructClassLikeType(arrayOf(s, s, sourceType, sourceType)) + OpticsNames.polyClassFor(dslKind).constructClassLikeType(arrayOf(s, s, sourceType, sourceType)) } visibility = FirOpticsExtractor.effectiveVisibility(source, session) } 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 index 5fbe03606db..650a6a2fed6 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -86,7 +87,7 @@ class OpticsIrSymbols(private val ctx: IrPluginContext) { /** For each optic poly-interface, its `plus` composition operator (keyed by the receiver class). */ val polyPlus: Map by lazy { - arrow.optics.plugin.DslKind.entries.associate { kind -> + 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 } diff --git a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt index 295f5775aeb..e39770c66d0 100644 --- a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt +++ b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt @@ -3,8 +3,9 @@ package example import arrow.optics.optics @optics -data class Person(val name: String, val age: Int) +data class Person(val name: String, val age: Int, val address: Address) +@optics data class Address(val street: String, val city: String) @optics diff --git a/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt b/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt index 3e963497ef3..bed62c81098 100644 --- a/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt +++ b/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt @@ -1,3 +1,3 @@ package example -val nameLens = Person.name +val nameLens = Person.address.street From bc5d18baec94c4c3518fe2e8d1cc8afc3980e595 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Mon, 29 Jun 2026 12:19:37 +0200 Subject: [PATCH 09/11] Make 'copy' be a member --- .../kotlin/arrow/optics/plugin/OpticsNames.kt | 3 +- .../optics/plugin/fir/OpticsCopyGenerator.kt | 88 +++++++++---------- .../plugin/ir/OpticsIrGenerationExtension.kt | 15 ++-- .../src/main/kotlin/example/commonExample.kt | 2 +- .../src/main/kotlin/example/commonUsage.kt | 8 ++ 5 files changed, 58 insertions(+), 58 deletions(-) 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 index 42917d59e11..988a5d87f9b 100644 --- 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 @@ -15,8 +15,7 @@ object OpticsNames { 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")) + val OPTICS_COPY_ANNOTATION = OPTICS_ANNOTATION.createNestedClassId(Name.identifier("copy")) // Underlying monomorphic interfaces val MLENS = ClassId(ARROW_OPTICS_PACKAGE, Name.identifier("Lens")) 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 index 9895ace2765..f0be0b75e0e 100644 --- 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 @@ -3,16 +3,14 @@ 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.declarations.hasAnnotation -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.createTopLevelFunction +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 @@ -25,17 +23,13 @@ import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name /** - * Generates the `@optics.copy` builder (algo §9) as a top-level extension: - * `fun Source.copy(block: context(Copy) Source.Companion.(Source) -> Unit): Source`. + * 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. */ -@OptIn(ExperimentalTopLevelDeclarationsGenerationApi::class) class OpticsCopyGenerator(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)) } @@ -44,47 +38,45 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi register(declarationPredicate) } - @OptIn(SymbolInternals::class) - private fun copySources(): List = session.predicateBasedProvider.getSymbolsByPredicate(lookupPredicate) - .filterIsInstance() - .filter { it.typeParameterSymbols.isEmpty() && it.annotations.any { it.checkEvenIfUnresolved(OpticsNames.OPTICS_COPY_ANNOTATION) } } + private val COPY_NAME = Name.identifier("copy") - override fun getTopLevelCallableIds(): Set = copySources().mapTo(mutableSetOf()) { - CallableId(it.classId.packageFqName, Name.identifier("copy")) - } + /** 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 hasPackage(packageFqName: FqName): Boolean = copySources().any { it.classId.packageFqName == packageFqName } + override fun getCallableNamesForClass(classSymbol: FirClassSymbol<*>, context: MemberGenerationContext): Set = + if (isCopySource(classSymbol)) setOf(COPY_NAME) else emptySet() override fun generateFunctions(callableId: CallableId, context: MemberGenerationContext?): List { - if (context != null) return emptyList() - return copySources() - .filter { it.classId.packageFqName == callableId.packageName } - .mapNotNull { source -> - val companion = source.resolvedCompanionObjectSymbol ?: return@mapNotNull null - 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 = createTopLevelFunction( - key = Key, - callableId = callableId, - returnType = sourceType, - containingFileName = "${source.classId.shortClassName.asString()}Copy", - ) { - extensionReceiverType(sourceType) - valueParameter(Name.identifier("block"), blockType) - visibility = FirOpticsExtractor.effectiveVisibility(source, session) - } - function.symbol - } + val source = context?.owner as? FirRegularClassSymbol ?: return emptyList() + if (callableId.callableName != COPY_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, COPY_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/ir/OpticsIrGenerationExtension.kt b/arrow-libs/optics/arrow-optics-compiler-plugin/src/main/kotlin/arrow/optics/plugin/ir/OpticsIrGenerationExtension.kt index 650a6a2fed6..a2c988cc87e 100644 --- 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 @@ -135,20 +135,21 @@ private class OpticsBodyGenerator( } override fun visitSimpleFunction(declaration: IrSimpleFunction) { - if (declaration.correspondingPropertySymbol == null && declaration.body == null) { + if (declaration.correspondingPropertySymbol == null) { when (keyOf(declaration.origin)) { - OpticsCompanionGenerator.Key -> buildOpticBody(declaration, declaration.name) + 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) } - /** `{ val me = this; me.copy { block(this, Source.Companion, me) } }` for a generated `@optics.copy`. */ + /** `{ this.copy { block(this, Source.Companion, this@copy) } }` for a generated `@optics.copy` member. */ private fun buildCopyBody(copyFn: IrSimpleFunction) { - val extReceiver = copyFn.parameters.first { it.kind == IrParameterKind.ExtensionReceiver } + val receiver = copyFn.parameters.first { it.kind == IrParameterKind.DispatchReceiver } val blockParam = copyFn.parameters.first { it.kind == IrParameterKind.Regular } - val sourceType = extReceiver.type + val sourceType = receiver.type val source = sourceType.classOrNull?.owner ?: return val companion = source.companionObject() ?: return val copyType = symbols.copyClass.typeWith(sourceType) @@ -161,11 +162,11 @@ private class OpticsBodyGenerator( invoke.setDispatch(irGet(blockParam)) invoke.setRegular(0, irGet(copyReceiver)) invoke.setRegular(1, irGetObjectValue(companion.defaultType, companion.symbol)) - invoke.setRegular(2, irGet(extReceiver)) + invoke.setRegular(2, irGet(receiver)) +invoke } val call = irCall(symbols.arrowOpticsCopy, sourceType, listOf(sourceType)) - call.setExtension(irGet(extReceiver)) + call.setExtension(irGet(receiver)) call.setRegular(0, lambda) +irReturn(call) } diff --git a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt index e39770c66d0..e36c91560a4 100644 --- a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt +++ b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt @@ -2,7 +2,7 @@ package example import arrow.optics.optics -@optics +@optics @optics.copy data class Person(val name: String, val age: Int, val address: Address) @optics diff --git a/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt b/gradle-test/jvmOnly/src/main/kotlin/example/commonUsage.kt index bed62c81098..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 +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 } +} From 88f21d0e237525c42db7461c733f6d4825b1cb46 Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Mon, 29 Jun 2026 12:28:32 +0200 Subject: [PATCH 10/11] Spotless --- .../kotlin/arrow/optics/plugin/OpticsNames.kt | 1 + .../optics/plugin/fir/FirOpticsExtractor.kt | 5 +++-- .../plugin/fir/OpticsCompanionGenerator.kt | 3 +-- .../optics/plugin/fir/OpticsCopyGenerator.kt | 18 +++++++----------- .../plugin/ir/OpticsIrGenerationExtension.kt | 1 + 5 files changed, 13 insertions(+), 15 deletions(-) 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 index 988a5d87f9b..efecfa74782 100644 --- 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 @@ -50,6 +50,7 @@ object OpticsNames { 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) { 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 index 710ebde47f9..a8f850dde84 100644 --- 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 @@ -88,8 +88,7 @@ object FirOpticsExtractor { } /** Whether the DSL composition extensions should be generated for [symbol]. */ - fun dslEnabled(symbol: FirRegularClassSymbol): Boolean = - OpticsTargetKind.DSL in effectiveTargets(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) @@ -243,6 +242,7 @@ object FirOpticsExtractor { 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 @@ -255,6 +255,7 @@ fun FirAnnotation.checkEvenIfUnresolved(classId: ClassId): Boolean { } 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 2c91d87cd5d..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 @@ -177,8 +177,7 @@ class OpticsCompanionGenerator(session: FirSession) : FirDeclarationGenerationEx visibility = vis } - private fun List.coneTypes(): List = - map { ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) } + private fun List.coneTypes(): List = map { ConeTypeParameterTypeImpl(ConeTypeParameterLookupTag(it.symbol), isMarkedNullable = false) } override fun generateConstructors(context: MemberGenerationContext): List { val owner = context.owner 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 index f0be0b75e0e..4d11578ffcf 100644 --- 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 @@ -38,22 +38,18 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi register(declarationPredicate) } - private val COPY_NAME = Name.identifier("copy") - /** 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) } + 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(COPY_NAME) else emptySet() + 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 != COPY_NAME || !isCopySource(source)) 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) @@ -69,7 +65,7 @@ class OpticsCopyGenerator(session: FirSession) : FirDeclarationGenerationExtensi isMarkedNullable = false, blockAttributes, ) - val function = createMemberFunction(source, Key, COPY_NAME, sourceType) { + 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 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 index a2c988cc87e..71225c7e0c4 100644 --- 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 @@ -138,6 +138,7 @@ private class OpticsBodyGenerator( 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) } From 256d9b51171e6fa67ade6613aad9ee206a3be7bc Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Mena Date: Mon, 29 Jun 2026 12:55:31 +0200 Subject: [PATCH 11/11] Improve test --- gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt index e36c91560a4..1c962b695c9 100644 --- a/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt +++ b/gradle-test/jvmOnly/src/main/kotlin/example/commonExample.kt @@ -10,3 +10,6 @@ data class Address(val street: String, val city: String) @optics internal data class Thing(val essence: String) + +@optics +data class Generic(val value: A)