diff --git a/.gitignore b/.gitignore index 893c3fd63..14ffaed78 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ equalsverifier-release-verify/src/test/resources bin target .DS_Store +kls_database.db # IntelliJ files .idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 9be88b19e..fc3f14bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Improved Kotlin support. ([Issue 506](https://github.com/jqno/equalsverifier/issues/506#issuecomment-2563664670)) + ## [3.18] - 2024-12-24 ### Added diff --git a/README.md b/README.md index 7958b7efe..4de8638df 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ Here's a description of the modules: | equalsverifier-release-main | release assembly for jar with dependencies | | equalsverifier-release-nodep | release assembly for fat jar (with dependencies shaded in) | | equalsverifier-release-verify | validation tests for the releases | +| equalsverifier-test-kotlin | tests for Kotlin classes | ## Signed JAR diff --git a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldInspector.java b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldInspector.java index 19cedf166..a7451ff83 100644 --- a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldInspector.java +++ b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldInspector.java @@ -7,13 +7,16 @@ public class FieldInspector { private final Class type; + private final boolean isKotlin; - public FieldInspector(Class type) { + public FieldInspector(Class type, boolean isKotlin) { this.type = type; + this.isKotlin = isKotlin; } public void check(FieldCheck check) { - for (FieldProbe fieldProbe : FieldIterable.of(type)) { + FieldIterable it = isKotlin ? FieldIterable.ofKotlin(type) : FieldIterable.of(type); + for (FieldProbe fieldProbe : it) { check.execute(fieldProbe); } } diff --git a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldsChecker.java b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldsChecker.java index 4199ecfd2..4a1aa1c6c 100644 --- a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldsChecker.java +++ b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/FieldsChecker.java @@ -55,7 +55,7 @@ public FieldsChecker(Context context) { @Override public void check() { - FieldInspector inspector = new FieldInspector<>(context.getType()); + FieldInspector inspector = new FieldInspector<>(context.getType(), config.isKotlin()); if (!context.getClassProbe().isEqualsInheritedFromObject()) { inspector.check(arrayFieldCheck); diff --git a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/NullChecker.java b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/NullChecker.java index 736a06c6a..3635e3f6f 100644 --- a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/NullChecker.java +++ b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/NullChecker.java @@ -18,7 +18,7 @@ public void check() { return; } - FieldInspector inspector = new FieldInspector<>(context.getType()); + FieldInspector inspector = new FieldInspector<>(context.getType(), context.getConfiguration().isKotlin()); inspector.check(new NullPointerExceptionFieldCheck<>(context)); } } diff --git a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/FieldIterable.java b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/FieldIterable.java index a68d78735..7f554aaab 100644 --- a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/FieldIterable.java +++ b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/FieldIterable.java @@ -1,9 +1,8 @@ package nl.jqno.equalsverifier.internal.reflection; import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; /** * Iterable to iterate over all declared fields in a class and, if needed, over all declared fields of its superclasses. @@ -13,12 +12,14 @@ public final class FieldIterable implements Iterable { private final Class type; private final boolean includeSuperclasses; private final boolean includeStatic; + private final boolean isKotlin; /** Private constructor. Call {@link #of(Class)} or {@link #ofIgnoringSuper(Class)} instead. */ - private FieldIterable(Class type, boolean includeSuperclasses, boolean includeStatic) { + private FieldIterable(Class type, boolean includeSuperclasses, boolean includeStatic, boolean isKotlin) { this.type = type; this.includeSuperclasses = includeSuperclasses; this.includeStatic = includeStatic; + this.isKotlin = isKotlin; } /** @@ -29,7 +30,18 @@ private FieldIterable(Class type, boolean includeSuperclasses, boolean includ * @return A FieldIterable. */ public static FieldIterable of(Class type) { - return new FieldIterable(type, true, true); + return new FieldIterable(type, true, true, false); + } + + /** + * Factory method for a FieldIterable that iterates over all declared fields of {@code type} and over the declared + * fields of all of its superclasses, but that ignores overridden Kotlin backing fields in superclasses. + * + * @param type The class that contains the fields over which to iterate. + * @return A FieldIterable. + */ + public static FieldIterable ofKotlin(Class type) { + return new FieldIterable(type, true, true, true); } /** @@ -40,7 +52,7 @@ public static FieldIterable of(Class type) { * @return A FieldIterable. */ public static FieldIterable ofIgnoringSuper(Class type) { - return new FieldIterable(type, false, true); + return new FieldIterable(type, false, true, false); } /** @@ -51,7 +63,7 @@ public static FieldIterable ofIgnoringSuper(Class type) { * @return A FieldIterable. */ public static FieldIterable ofIgnoringStatic(Class type) { - return new FieldIterable(type, true, false); + return new FieldIterable(type, true, false, false); } /** @@ -62,7 +74,7 @@ public static FieldIterable ofIgnoringStatic(Class type) { * @return A FieldIterable. */ public static FieldIterable ofIgnoringSuperAndStatic(Class type) { - return new FieldIterable(type, false, false); + return new FieldIterable(type, false, false, false); } /** @@ -76,6 +88,15 @@ public Iterator iterator() { } private List createFieldList() { + if (isKotlin) { + return createKotlinFieldList(); + } + else { + return createJavaFieldList(); + } + } + + private List createJavaFieldList() { List result = new ArrayList<>(); result.addAll(addFieldsFor(type)); @@ -89,6 +110,24 @@ private List createFieldList() { return result; } + private List createKotlinFieldList() { + List result = new ArrayList<>(); + + result.addAll(addFieldsFor(type)); + Set names = result.stream().map(FieldProbe::getName).collect(Collectors.toSet()); + + if (includeSuperclasses) { + for (Class c : SuperclassIterable.of(type)) { + List superFields = + addFieldsFor(c).stream().filter(p -> !names.contains(p.getName())).collect(Collectors.toList()); + result.addAll(superFields); + superFields.stream().map(FieldProbe::getName).forEach(names::add); + } + } + + return result; + } + private List addFieldsFor(Class c) { List fields = new ArrayList<>(); List statics = new ArrayList<>(); diff --git a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/annotations/SupportedAnnotations.java b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/annotations/SupportedAnnotations.java index 4148769cc..bb3343b9a 100644 --- a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/annotations/SupportedAnnotations.java +++ b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/reflection/annotations/SupportedAnnotations.java @@ -194,7 +194,9 @@ public boolean validate( Set ignoredAnnotations) { return "LAZY".equals(properties.getEnumValue("fetch")); } - }; + }, + + KOTLIN(false, "kotlin.Metadata"); private final boolean inherits; private final Set partialClassNames; diff --git a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/util/Configuration.java b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/util/Configuration.java index 11a29bead..a825c5b8d 100644 --- a/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/util/Configuration.java +++ b/equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/util/Configuration.java @@ -24,6 +24,7 @@ public final class Configuration { private final boolean usingGetClass; private final EnumSet warningsToSuppress; private final Function fieldnameToGetter; + private final boolean isKotlin; private final TypeTag typeTag; private final AnnotationCache annotationCache; @@ -46,6 +47,7 @@ private Configuration( boolean usingGetClass, EnumSet warningsToSuppress, Function fieldnameToGetter, + boolean isKotlin, List equalExamples, List unequalExamples) { this.type = type; @@ -60,6 +62,7 @@ private Configuration( this.usingGetClass = usingGetClass; this.warningsToSuppress = warningsToSuppress; this.fieldnameToGetter = fieldnameToGetter; + this.isKotlin = isKotlin; this.equalExamples = equalExamples; this.unequalExamples = unequalExamples; } @@ -91,6 +94,7 @@ public static Configuration build( actualFields); Function converter = fieldnameToGetter != null ? fieldnameToGetter : DEFAULT_FIELDNAME_TO_GETTER_CONVERTER; + boolean isKotlin = annotationCache.hasClassAnnotation(type, SupportedAnnotations.KOTLIN); return new Configuration<>(type, typeTag, @@ -104,6 +108,7 @@ public static Configuration build( usingGetClass, warningsToSuppress, converter, + isKotlin, equalExamples, unequalExamples); } @@ -200,6 +205,10 @@ public Function getFieldnameToGetter() { return fieldnameToGetter; } + public boolean isKotlin() { + return isKotlin; + } + public List getEqualExamples() { return Collections.unmodifiableList(equalExamples); } diff --git a/equalsverifier-test-kotlin/pom.xml b/equalsverifier-test-kotlin/pom.xml new file mode 100644 index 000000000..8d81fa151 --- /dev/null +++ b/equalsverifier-test-kotlin/pom.xml @@ -0,0 +1,115 @@ + + + + 4.0.0 + + nl.jqno.equalsverifier + equalsverifier-parent + 3.18.1-SNAPSHOT + + jar + + equalsverifier-test-kotlin + EqualsVerifier | test Kotlin + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${version.kotlin} + + + compile + compile + + compile + + + + src/main/kotlin + + + + + test-compile + test-compile + + test-compile + + + + src/test/kotlin + + + + + + 1.8 + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + none + + + default-testCompile + none + + + compile + compile + + compile + + + + testCompile + test-compile + + testCompile + + + + + + + + + + nl.jqno.equalsverifier + equalsverifier-core + ${project.version} + test + + + org.junit.jupiter + junit-jupiter + ${version.junit-jupiter} + test + + + org.assertj + assertj-core + ${version.assertj} + test + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${version.kotlin} + test + + + org.jetbrains.kotlin + kotlin-reflect + ${version.kotlin} + test + + + + diff --git a/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinCompilerGeneratedAnnotationTest.kt b/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinCompilerGeneratedAnnotationTest.kt new file mode 100644 index 000000000..cd4fa2033 --- /dev/null +++ b/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinCompilerGeneratedAnnotationTest.kt @@ -0,0 +1,31 @@ +package nl.jqno.equalsverifier.kotlin + +import nl.jqno.equalsverifier.EqualsVerifier +import nl.jqno.equalsverifier.internal.reflection.annotations.AnnotationCache +import nl.jqno.equalsverifier.internal.reflection.annotations.AnnotationCacheBuilder +import nl.jqno.equalsverifier.internal.reflection.annotations.SupportedAnnotations +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class KotlinCompilerGeneratedAnnotationTest { + + val cache = AnnotationCache() + val cacheBuilder = AnnotationCacheBuilder(SupportedAnnotations.values(), HashSet()) + + @Test + fun `Kotlin classes are recognised as such`() { + assertThat(checkAnnotationFor(KotlinClass::class.java)).isTrue() + } + + @Test + fun `Java classes are not recognised as Kotlin classes`() { + assertThat(checkAnnotationFor(EqualsVerifier::class.java)).isFalse() + } + + private fun checkAnnotationFor(type: Class<*>): Boolean { + cacheBuilder.build(type, cache) + return cache.hasClassAnnotation(type, SupportedAnnotations.KOTLIN) + } +} + +class KotlinClass diff --git a/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinFieldIterableTest.kt b/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinFieldIterableTest.kt new file mode 100644 index 000000000..272b904d1 --- /dev/null +++ b/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinFieldIterableTest.kt @@ -0,0 +1,38 @@ +package nl.jqno.equalsverifier.kotlin + +import org.junit.jupiter.api.Test +import java.lang.reflect.Field +import nl.jqno.equalsverifier.internal.reflection.FieldIterable +import org.assertj.core.api.Assertions.assertThat + +class KotlinFieldIterableTest { + val actual = HashSet() + + @Test + fun `FieldIterable ofKotlin ignores superclass backing fields`() { + actual.addAll(FieldIterable.ofKotlin(ImplementingDataClass::class.java).map { it.getField() }) + + assertThat(actual) + .isEqualTo(setOf( + Base::class.java.getDeclaredField("base"), + ImplementingDataClass::class.java.getDeclaredField("toOverride"))) + } + + @Test + fun `FieldIterable of does not ignore superclass backing fields`() { + actual.addAll(FieldIterable.of(ImplementingDataClass::class.java).map { it.getField() }) + + assertThat(actual) + .isEqualTo(setOf( + Base::class.java.getDeclaredField("base"), + Base::class.java.getDeclaredField("toOverride"), + ImplementingDataClass::class.java.getDeclaredField("toOverride"))) + } + + sealed class Base( + internal open val base: Int, + internal open val toOverride: Int, + ) + + data class ImplementingDataClass(override val toOverride: Int) : Base(42, toOverride) +} diff --git a/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinIntegrationTest.kt b/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinIntegrationTest.kt new file mode 100644 index 000000000..071ba26fd --- /dev/null +++ b/equalsverifier-test-kotlin/src/test/kotlin/nl/jqno/equalsverifier/kotlin/KotlinIntegrationTest.kt @@ -0,0 +1,39 @@ +package nl.jqno.equalsverifier.kotlin + +import java.util.Objects +import nl.jqno.equalsverifier.EqualsVerifier +import org.junit.jupiter.api.Test + +class KotlinIntegrationTest { + @Test + fun `super's backing field does not affect implementing data class`() { + EqualsVerifier.forClass(ImplementingDataClass::class.java) + .withIgnoredFields("base") + .verify() + } + + @Test + fun `super's backing field does not affect implementing regular class`() { + EqualsVerifier.forClass(ImplementingRegularClass::class.java) + .verify() + } + + sealed class Base( + internal open val base: Int, + internal open val toOverride: Int, + ) + + data class ImplementingDataClass(override val toOverride: Int) : Base(42, toOverride) + + class ImplementingRegularClass(override val toOverride: Int) : Base(42, toOverride) { + override fun equals(other: Any?): Boolean { + return other is ImplementingRegularClass + && base == other.base + && toOverride == other.toOverride + } + + override fun hashCode(): Int { + return Objects.hash(base, toOverride) + } + } +} diff --git a/pom.xml b/pom.xml index b7275309a..aa942fdc0 100644 --- a/pom.xml +++ b/pom.xml @@ -89,6 +89,7 @@ 1.0 2.13.0 5.11.4 + 2.1.0 19.0.2.1 2.0.16 @@ -576,6 +577,7 @@ equalsverifier-16 equalsverifier-17 equalsverifier-21 + equalsverifier-test-kotlin equalsverifier-aggregator equalsverifier-release-main equalsverifier-release-nodep