Skip to content

Commit a3e9568

Browse files
authored
Merge pull request #1127 from jqno/kotlin-delegates
Support Kotlin delegates
2 parents 502e1bc + 0836696 commit a3e9568

File tree

51 files changed

+1746
-149
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1746
-149
lines changed

ARCHITECTURE.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,21 @@ This project is a multi-module project to make it easier to deal with shading an
2929

3030
Here's a description of the modules:
3131

32-
| module | purpose |
33-
| ----------------------------- | --------------------------------------------------------------------- |
34-
| docs | project's Jekyll website |
35-
| equalsverifier-core | the actual EqualsVerifier code |
36-
| equalsverifier-21 | tests for record pattern matching |
37-
| equalsverifier-testhelpers | shared types and helpers for use in tests |
38-
| equalsverifier-test | integration tests (without access to Mockito) |
39-
| equalsverifier-test-jpms | tests for the Java module system (with access to Mockito) |
40-
| equalsverifier-test-kotlin | tests for Kotlin classes |
41-
| equalsverifier-test-mockito | tests for instantiation using Mockito |
42-
| equalsverifier-aggregator | generic release assembly description, and shared jacoco configuration |
43-
| equalsverifier-release-main | release assembly for jar with dependencies |
44-
| equalsverifier-release-nodep | release assembly for fat jar (with dependencies shaded in) |
45-
| equalsverifier-release-verify | validation tests for the releases |
32+
| module | purpose |
33+
| ----------------------------- | ---------------------------------------------------------------------------- |
34+
| docs | project's Jekyll website |
35+
| equalsverifier-core | the actual EqualsVerifier code |
36+
| equalsverifier-21 | prefab values for SequencedCollections and tests for record pattern matching |
37+
| equalsverifier-25 | prefab values for ScopedValues |
38+
| equalsverifier-testhelpers | shared types and helpers for use in tests |
39+
| equalsverifier-test | integration tests (without access to Mockito) |
40+
| equalsverifier-test-jpms | tests for the Java module system (with access to Mockito) |
41+
| equalsverifier-test-kotlin | tests for Kotlin classes |
42+
| equalsverifier-test-mockito | tests for instantiation using Mockito |
43+
| equalsverifier-aggregator | generic release assembly description, and shared jacoco configuration |
44+
| equalsverifier-release-main | release assembly for jar with dependencies |
45+
| equalsverifier-release-nodep | release assembly for fat jar (with dependencies shaded in) |
46+
| equalsverifier-release-verify | validation tests for the releases |
4647

4748
## Signed JAR
4849

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
### Added
1818

1919
- Prefab values for Java 25's `ScopedValue`.
20+
- Additional support for Kotlin delegate fields: ([Issue 1097](https://github.com/jqno/equalsverifier/issues/1097))
21+
- In error messages, you will now see readable Kotlin field names instead of bytecode field names like `foo$delegate`.
22+
- In `#withIgnoredFields()`, `#withOnlyTheseFields()` and `#withPrefabValuesForField()`, you can now refer to fields by their Kotlin name instead of their bytecode name.
23+
- EqualsVerifier can construct prefab values for lazy delegates.
24+
- EqualsVerifier can deal with situations where there are multiple fields delegating to the same class.
25+
- For more information, see the [new manual page about Kotlin classes](/equalsverifier/manual/kotlin)
2026

2127
## [4.1.1] - 2025-09-22
2228

docs/_manual/16-kotlin.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
title: "Kotlin support"
3+
permalink: /manual/kotlin/
4+
---
5+
EqualsVerifier has some support for Kotlin classes. In principle, Kotlin classes are the same as Java classes on the bytecode level, so EqualsVerifier can read them in the same way. However, the Kotlin compiler does some tricks in order to be able to represent its more advanced language features. This means that in some cases, EqualsVerifier needs to treat Kotlin classes a little differently. This is possible in many, but unfortunately not all, cases.
6+
7+
## Simple classes
8+
9+
Since EqualsVerifier works primarily with Java reflection, you'll have to provide EqualsVerifier with Java-style reflection types:
10+
11+
```kotlin
12+
EqualsVerifier.forClass(Foo::class.java)
13+
.verify()
14+
```
15+
16+
For most simple classes and data classes, that's enough.
17+
18+
## Delegates
19+
20+
Kotlin supports various forms of delegate fields. In a Kotlin class definition, delegates may look like this:
21+
22+
```kotlin
23+
data class StringContainer(s: String)
24+
25+
class Foo(container: StringContainer) {
26+
private val bar: String by container::s // object delegation
27+
private val baz: String by lazy { ... } // lazy delegation
28+
}
29+
```
30+
31+
But EqualsVerifier works with Java reflection, which looks at the fields as they exist in the compiled bytecode. And at that level, these fields don't exist! When decompiled to Java, it looks like this:
32+
33+
```java
34+
class Foo {
35+
private final StringContainer bar$receiver;
36+
private final kotlin.Lazy<String> baz$delegate;
37+
}
38+
```
39+
40+
EqualsVerifier can deal with most forms of Kotlin delegation, but in order to do so, it does require the `org.jetbrains.kotlin:kotlin-reflect` library to be available. You might have to add it to your `pom.xml` or your Gradle scripts. In some cases, EqualsVerifier can fall back to using bytecode names, but not in all cases. If EqualsVerifier needs it, it will let you know if it's not available.
41+
42+
EqualsVerifier can detect when a field is a Kotlin delegate, and it can translate between the Kotlin names and the bytecode names. So, in error messages, instead of `bar$receiver`, you will see `bar`. And instead of having to call `withIgnoredFields("baz$delegate")`, you can simply call `withIgnoredFields("baz")`. You can still call `withIgnoredFields("baz$delegate")` if you prefer, for example because you don't want to add `kotlin-reflect` to the project.
43+
44+
Unfortunately, when adding [prefab values](/equalsverifier/manual/prefab-values), you still have to provide values of the underlying type:
45+
46+
```kotlin
47+
EqualsVerifier.forClass(Foo::class.java)
48+
.withPrefabValuesForField(Foo::bar.name, StringContainer("a"), StringContainer("b"))
49+
.withPrefabValuesForField(Foo::baz.name, lazy { "a" }, lazy { "b" })
50+
.verify()
51+
```
52+
53+
Note also that some of the checks that EqualsVerifier normally does, like reflexivity checks, don't work for delegates. For example, the use of the triple-equals operator `===` will not be detected in this case:
54+
55+
```kotlin
56+
class Foo(container: StringContainer) {
57+
private val foo: String by container::s
58+
59+
override fun equals(other: Any?): Boolean =
60+
(other is Foo) && foo === other.foo
61+
}
62+
```
63+
64+
EqualsVerifier will generate two non-equal instances of `StringContainer`, but the Kotlin compiler generates bytecode that directly compares its `s` field in the `equals` method, without calling `equals` on `StringContainer` itself. Because of architectural decisions made before Kotlin even existed and which are hard to change now, EqualsVerifier gives both `StringContainers` the same String instance for their `s` fields, and because of that EqualsVerifier can't detect the difference between `==` or `===` (or in Java terms, the difference between `equals()` and `==`.)
65+
66+
## Interface delegation
67+
68+
A special case of delegation in Kotlin is interface delegation:
69+
70+
```kotlin
71+
interface Foo {
72+
val foo: Int
73+
}
74+
75+
data class FooImpl(override val foo: Int) : Foo
76+
77+
class InterfaceDelegation(fooValue: Int) : Foo by FooImpl(fooValue) {
78+
79+
override fun equals(other: Any?): Boolean =
80+
(other is InterfaceDelegation) && foo == other.foo
81+
82+
override fun hashCode(): Int = foo
83+
}
84+
```
85+
86+
In this case, the `FooImpl` instance will be stored in a field named `$$delegate_0`. Unfortunately, there is no way to reliably translate between this bytecode field and its Kotlin-level corresponding `foo` property. As a result, error messages will show these, more cryptic field names. Also, EqualsVerifier might ask more quickly for prefab values for the type that is delegated to (in this case: `FooImpl`).

docs/_pages/manual.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ These pages will quickly get you up and running with EqualsVerifier, and help yo
2020
* [Dealing with legacy systems](/equalsverifier/manual/legacy-systems)
2121
* [The Java Platform Module System](/equalsverifier/manual/jpms)
2222
* [What are these prefab values?](/equalsverifier/manual/prefab-values)
23+
* [Kotlin support](/equalsverifier/manual/kotlin)
2324
* [Additional resources](/equalsverifier/resources)

equalsverifier-core/pom.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@
9090
<version>${version.mockito}</version>
9191
<optional>true</optional>
9292
</dependency>
93+
<dependency>
94+
<groupId>org.jetbrains.kotlin</groupId>
95+
<artifactId>kotlin-stdlib</artifactId>
96+
<version>${version.kotlin}</version>
97+
<optional>true</optional>
98+
</dependency>
99+
<dependency>
100+
<groupId>org.jetbrains.kotlin</groupId>
101+
<artifactId>kotlin-reflect</artifactId>
102+
<version>${version.kotlin}</version>
103+
<optional>true</optional>
104+
</dependency>
93105

94106
<!-- Shared test dependencies -->
95107
<dependency>

equalsverifier-core/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
// Optional dependencies
1717
requires static org.mockito;
18+
requires static kotlin.reflect;
1819

1920
// Built-in prefab values
2021
requires static java.desktop;

equalsverifier-core/src/main/java/nl/jqno/equalsverifier/api/SingleTypeEqualsVerifierApi.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import nl.jqno.equalsverifier.internal.instantiation.UserPrefabValueProvider;
1414
import nl.jqno.equalsverifier.internal.instantiation.vintage.FactoryCache;
1515
import nl.jqno.equalsverifier.internal.reflection.FieldCache;
16+
import nl.jqno.equalsverifier.internal.reflection.kotlin.KotlinProbe;
17+
import nl.jqno.equalsverifier.internal.reflection.kotlin.KotlinScreen;
1618
import nl.jqno.equalsverifier.internal.util.*;
1719
import nl.jqno.equalsverifier.internal.util.Formatter;
1820
import org.objenesis.Objenesis;
@@ -168,9 +170,12 @@ public <S> SingleTypeEqualsVerifierApi<T> withResettablePrefabValues(
168170
*/
169171
@CheckReturnValue
170172
public <S> SingleTypeEqualsVerifierApi<T> withPrefabValuesForField(String fieldName, S red, S blue) {
171-
Validations.validateFieldNamesExist(type, Arrays.asList(fieldName), actualFields);
172-
PrefabValuesApi.addPrefabValuesForField(fieldCache, objenesis, type, fieldName, red, blue);
173-
return withNonnullFields(fieldName);
173+
String translated = KotlinScreen.isKotlin(type) && KotlinScreen.canProbe()
174+
? KotlinProbe.translateKotlinToBytecodeFieldName(type, fieldName)
175+
: fieldName;
176+
Validations.validateFieldNamesExist(type, List.of(translated), actualFields);
177+
PrefabValuesApi.addPrefabValuesForField(fieldCache, objenesis, type, translated, red, blue);
178+
return withNonnullFields(translated);
174179
}
175180

176181
/** {@inheritDoc} */
@@ -241,10 +246,13 @@ public SingleTypeEqualsVerifierApi<T> withOnlyTheseFields(String... fields) {
241246
private SingleTypeEqualsVerifierApi<T> withFieldsAddedAndValidated(
242247
Set<String> collection,
243248
List<String> specifiedFields) {
244-
collection.addAll(specifiedFields);
249+
List<String> translated = KotlinScreen.isKotlin(type) && KotlinScreen.canProbe()
250+
? KotlinProbe.translateKotlinToBytecodeFieldNames(type, specifiedFields)
251+
: specifiedFields;
252+
collection.addAll(translated);
245253

246254
Validations.validateFields(allIncludedFields, allExcludedFields);
247-
Validations.validateFieldNamesExist(type, specifiedFields, actualFields);
255+
Validations.validateFieldNamesExist(type, translated, actualFields);
248256
Validations.validateWarningsAndFields(warningsToSuppress, allIncludedFields, allExcludedFields);
249257
return this;
250258
}

equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/PrefabValuesApi.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public static <T> void addPrefabValuesForField(
6868
T blue) {
6969
Validations.validateRedAndBluePrefabValues(fieldName, red, blue);
7070
Field f = Validations.validateFieldTypeMatches(type, fieldName, red.getClass());
71+
Validations.validateCanProbeKotlinLazyDelegate(type, f);
7172
TypeTag tag = TypeTag.of(f, new TypeTag(type));
7273

7374
if (red.getClass().isArray()) {

equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/AbstractDelegationChecker.java

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import static nl.jqno.equalsverifier.internal.util.Assert.fail;
44

5+
import java.util.regex.Matcher;
6+
import java.util.regex.Pattern;
7+
58
import nl.jqno.equalsverifier.internal.instantiation.SubjectCreator;
69
import nl.jqno.equalsverifier.internal.instantiation.ValueProvider;
710
import nl.jqno.equalsverifier.internal.reflection.*;
@@ -128,14 +131,33 @@ private Formatter buildAbstractDelegationErrorMessage(
128131
boolean prefabPossible,
129132
String method,
130133
String originalMessage) {
131-
Formatter prefabFormatter = Formatter.of("\nAdd prefab values for %%.", c.getName());
132-
134+
String prefabbable = determinePrefabValueTypeForErrorMessage(prefabPossible, c.getName(), originalMessage);
135+
Formatter prefabFormatter = Formatter.of("\n\nAdd prefab values for %%.", prefabbable);
133136
return Formatter
134137
.of(
135-
"Abstract delegation: %%'s %% method delegates to an abstract method:\n %%%%",
138+
"Abstract delegation: %%'s %% method delegates to an abstract method:\n %%%%",
136139
c.getSimpleName(),
137140
method,
138141
originalMessage,
139-
prefabPossible ? prefabFormatter.format() : "");
142+
prefabbable != null ? prefabFormatter.format() : "");
143+
}
144+
145+
// This logic is needed for Kotlin delegator edge cases
146+
private String determinePrefabValueTypeForErrorMessage(
147+
boolean prefabPossible,
148+
String className,
149+
String originalMessage) {
150+
if (prefabPossible) {
151+
return className;
152+
}
153+
Matcher m = Pattern.compile("Receiver class .* ([^\\s]+)\\.$").matcher(originalMessage);
154+
if (m.find()) {
155+
String receiver = m.group(1);
156+
if (!className.equals(receiver)) {
157+
return receiver;
158+
}
159+
}
160+
161+
return null;
140162
}
141163
}

equalsverifier-core/src/main/java/nl/jqno/equalsverifier/internal/checkers/fieldchecks/ArrayFieldCheck.java

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ public void execute(FieldProbe fieldProbe) {
3333
T changed = replaceInnermostArrayValue(reference, fieldProbe);
3434

3535
if (arrayType.getComponentType().isArray()) {
36-
assertDeep(fieldProbe.getName(), reference, changed);
36+
assertDeep(fieldProbe.getDisplayName(), reference, changed);
3737
}
3838
else {
39-
assertArray(fieldProbe.getName(), reference, changed);
39+
assertArray(fieldProbe.getDisplayName(), reference, changed);
4040
}
4141
}
4242

@@ -63,32 +63,35 @@ private Object arrayCopy(Object array) {
6363
return result;
6464
}
6565

66-
private void assertDeep(String fieldName, Object reference, Object changed) {
66+
private void assertDeep(String fieldDisplayName, Object reference, Object changed) {
6767
Formatter eqEqFormatter = Formatter
6868
.of(
6969
"Multidimensional array: ==, regular equals() or Arrays.equals() used"
7070
+ " instead of Arrays.deepEquals() for field %%.",
71-
fieldName);
71+
fieldDisplayName);
7272
assertEquals(eqEqFormatter, reference, changed);
7373

7474
Formatter regularFormatter = Formatter
7575
.of(
7676
"Multidimensional array: regular hashCode() or Arrays.hashCode() used"
7777
+ " instead of Arrays.deepHashCode() for field %%.",
78-
fieldName);
78+
fieldDisplayName);
7979
assertEquals(
8080
regularFormatter,
8181
cachedHashCodeInitializer.getInitializedHashCode(reference),
8282
cachedHashCodeInitializer.getInitializedHashCode(changed));
8383
}
8484

85-
private void assertArray(String fieldName, Object reference, Object changed) {
85+
private void assertArray(String fieldDisplayName, Object reference, Object changed) {
8686
assertEquals(
87-
Formatter.of("Array: == or regular equals() used instead of Arrays.equals() for field %%.", fieldName),
87+
Formatter
88+
.of(
89+
"Array: == or regular equals() used instead of Arrays.equals() for field %%.",
90+
fieldDisplayName),
8891
reference,
8992
changed);
9093
assertEquals(
91-
Formatter.of("Array: regular hashCode() used instead of Arrays.hashCode() for field %%.", fieldName),
94+
Formatter.of("Array: regular hashCode() used instead of Arrays.hashCode() for field %%.", fieldDisplayName),
9295
cachedHashCodeInitializer.getInitializedHashCode(reference),
9396
cachedHashCodeInitializer.getInitializedHashCode(changed));
9497
}

0 commit comments

Comments
 (0)