diff --git a/agent/android/build.gradle b/agent/android/build.gradle index ffe7223a2..b27dc8875 100644 --- a/agent/android/build.gradle +++ b/agent/android/build.gradle @@ -85,4 +85,6 @@ dependencies { exclude group: 'com.android.support', module: 'support-annotations' }) androidTestImplementation 'junit:junit:4.13.1' + + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" } diff --git a/agent/android/src/main/kotlin/io/mockk/ValueClassSupport.kt b/agent/android/src/main/kotlin/io/mockk/ValueClassSupport.kt new file mode 100644 index 000000000..77e432048 --- /dev/null +++ b/agent/android/src/main/kotlin/io/mockk/ValueClassSupport.kt @@ -0,0 +1,76 @@ +package io.mockk + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.isAccessible + +private val valueClassFieldCache = mutableMapOf, KProperty1>() + +/** + * Get boxed value of any value class + * + * @return boxed value of value class, if this is value class, else just itself + */ +fun T.boxedValue(): Any? { + if (!this::class.isValueClass()) return this + + // get backing field + val backingField = this::class.valueField() + + // get boxed value + @Suppress("UNCHECKED_CAST") + return (backingField as KProperty1).get(this) +} + +/** + * Get class of boxed value of any value class + * + * @return class of boxed value, if this is value class, else just class of itself + */ +fun T.boxedClass(): KClass<*> { + if (!this::class.isValueClass()) return this::class + + // get backing field + val backingField = this::class.valueField() + + // get boxed value + return backingField.returnType.classifier as KClass<*> +} + + +private fun KClass.valueField(): KProperty1 { + @Suppress("UNCHECKED_CAST") + return valueClassFieldCache.getOrPut(this) { + require(isValue) { "$this is not a value class" } + + // value classes always have a primary constructor... + val constructor = primaryConstructor!! + // ...and exactly one constructor parameter + val constructorParameter = constructor.parameters.first() + // ...with a backing field + val backingField = declaredMemberProperties + .first { it.name == constructorParameter.name } + .apply { isAccessible = true } + + backingField + } as KProperty1 +} + +private fun KClass.isValueClass() = try { + this.isValue +} catch (_: UnsupportedOperationException) { + false +} + +/** + * POLYFILL for kotlin version < 1.5 + * will be shadowed by implementation in kotlin SDK 1.5+ + * + * @return true if this is an inline class, else false + */ +private val KClass.isValue: Boolean + get() = !isData && + primaryConstructor?.parameters?.size == 1 && + java.declaredMethods.any { it.name == "box-impl" } diff --git a/agent/android/src/main/kotlin/io/mockk/proxy/android/advice/Advice.kt b/agent/android/src/main/kotlin/io/mockk/proxy/android/advice/Advice.kt index 0116f9701..e1b9e18ab 100644 --- a/agent/android/src/main/kotlin/io/mockk/proxy/android/advice/Advice.kt +++ b/agent/android/src/main/kotlin/io/mockk/proxy/android/advice/Advice.kt @@ -5,6 +5,7 @@ package io.mockk.proxy.android.advice +import io.mockk.boxedValue import io.mockk.proxy.MockKAgentException import io.mockk.proxy.android.AndroidMockKMap import io.mockk.proxy.android.MethodDescriptor @@ -80,6 +81,7 @@ internal class Advice( superMethodCall, arguments ) + ?.boxedValue() // unbox value class objects } } diff --git a/agent/jvm/build.gradle b/agent/jvm/build.gradle index 8b4d1cac6..6f5abaa7d 100644 --- a/agent/jvm/build.gradle +++ b/agent/jvm/build.gradle @@ -16,6 +16,8 @@ dependencies { api "net.bytebuddy:byte-buddy:$byte_buddy_version" api "net.bytebuddy:byte-buddy-agent:$byte_buddy_version" + + implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" } task copyMockKDispatcher(type: Copy) { diff --git a/agent/jvm/src/main/kotlin/io/mockk/ValueClassSupport.kt b/agent/jvm/src/main/kotlin/io/mockk/ValueClassSupport.kt new file mode 100644 index 000000000..77e432048 --- /dev/null +++ b/agent/jvm/src/main/kotlin/io/mockk/ValueClassSupport.kt @@ -0,0 +1,76 @@ +package io.mockk + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.isAccessible + +private val valueClassFieldCache = mutableMapOf, KProperty1>() + +/** + * Get boxed value of any value class + * + * @return boxed value of value class, if this is value class, else just itself + */ +fun T.boxedValue(): Any? { + if (!this::class.isValueClass()) return this + + // get backing field + val backingField = this::class.valueField() + + // get boxed value + @Suppress("UNCHECKED_CAST") + return (backingField as KProperty1).get(this) +} + +/** + * Get class of boxed value of any value class + * + * @return class of boxed value, if this is value class, else just class of itself + */ +fun T.boxedClass(): KClass<*> { + if (!this::class.isValueClass()) return this::class + + // get backing field + val backingField = this::class.valueField() + + // get boxed value + return backingField.returnType.classifier as KClass<*> +} + + +private fun KClass.valueField(): KProperty1 { + @Suppress("UNCHECKED_CAST") + return valueClassFieldCache.getOrPut(this) { + require(isValue) { "$this is not a value class" } + + // value classes always have a primary constructor... + val constructor = primaryConstructor!! + // ...and exactly one constructor parameter + val constructorParameter = constructor.parameters.first() + // ...with a backing field + val backingField = declaredMemberProperties + .first { it.name == constructorParameter.name } + .apply { isAccessible = true } + + backingField + } as KProperty1 +} + +private fun KClass.isValueClass() = try { + this.isValue +} catch (_: UnsupportedOperationException) { + false +} + +/** + * POLYFILL for kotlin version < 1.5 + * will be shadowed by implementation in kotlin SDK 1.5+ + * + * @return true if this is an inline class, else false + */ +private val KClass.isValue: Boolean + get() = !isData && + primaryConstructor?.parameters?.size == 1 && + java.declaredMethods.any { it.name == "box-impl" } diff --git a/agent/jvm/src/main/kotlin/io/mockk/proxy/jvm/advice/Interceptor.kt b/agent/jvm/src/main/kotlin/io/mockk/proxy/jvm/advice/Interceptor.kt index b563ece75..dcb8019c5 100644 --- a/agent/jvm/src/main/kotlin/io/mockk/proxy/jvm/advice/Interceptor.kt +++ b/agent/jvm/src/main/kotlin/io/mockk/proxy/jvm/advice/Interceptor.kt @@ -1,5 +1,6 @@ package io.mockk.proxy.jvm.advice +import io.mockk.boxedValue import io.mockk.proxy.MockKInvocationHandler import java.lang.reflect.Method import java.util.concurrent.Callable @@ -18,6 +19,7 @@ internal class Interceptor( method ) return handler.invocation(self, method, callOriginalMethod, arguments) + ?.boxedValue() // unbox value class objects } } diff --git a/build.gradle b/build.gradle index 537f40ca2..c7f1761c2 100644 --- a/build.gradle +++ b/build.gradle @@ -36,11 +36,10 @@ subprojects { subProject -> jcenter() } - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { kotlinOptions { jvmTarget = "1.8" - languageVersion = "1.3" + apiVersion = "1.3" useIR = findProperty("kotlin.ir.enabled")?.toBoolean() == true } } diff --git a/client-tests/jvm/build.gradle b/client-tests/jvm/build.gradle index 671791949..8a8b09f7c 100644 --- a/client-tests/jvm/build.gradle +++ b/client-tests/jvm/build.gradle @@ -17,13 +17,6 @@ apply plugin: 'kotlin' sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 -compileTestKotlin { - kotlinOptions { - apiVersion = '1.3' - languageVersion = '1.3' - } -} - configurations.all { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' resolutionStrategy.cacheDynamicVersionsFor 0, 'seconds' diff --git a/dsl/common/build.gradle b/dsl/common/build.gradle index c6efe601c..17c431fed 100644 --- a/dsl/common/build.gradle +++ b/dsl/common/build.gradle @@ -10,13 +10,3 @@ apply from: "${gradles}/upload.gradle" sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 -compileKotlinCommon { - kotlinOptions { - apiVersion = '1.3' - languageVersion = '1.3' - } -} - -jar { -} - diff --git a/gradle.properties b/gradle.properties index 67d38280d..b819dccb3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,4 @@ org.gradle.configureondemand=false org.gradle.jvmargs=-XX:MaxMetaspaceSize=768m localrepo=build/mockk-repo # localrepo=/Users/raibaz/.m2/repository +# kotlin.version=1.5.10 \ No newline at end of file diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 7ed4e5fa4..6a0c27d18 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -1,7 +1,7 @@ apply plugin: 'jacoco' jacoco { - toolVersion = "0.8.6" + toolVersion = "0.8.7" } afterEvaluate { diff --git a/mockk/common/build.gradle b/mockk/common/build.gradle index 5dfb07f45..d069b6dcf 100644 --- a/mockk/common/build.gradle +++ b/mockk/common/build.gradle @@ -13,10 +13,3 @@ targetCompatibility = JavaVersion.VERSION_1_8 dependencies { api project(":mockk-dsl") } - -compileKotlinCommon { - kotlinOptions { - apiVersion = '1.3' - languageVersion = '1.3' - } -} diff --git a/mockk/common/src/main/kotlin/io/mockk/impl/recording/states/RecordingState.kt b/mockk/common/src/main/kotlin/io/mockk/impl/recording/states/RecordingState.kt index 401ae710f..09189bebb 100644 --- a/mockk/common/src/main/kotlin/io/mockk/impl/recording/states/RecordingState.kt +++ b/mockk/common/src/main/kotlin/io/mockk/impl/recording/states/RecordingState.kt @@ -139,6 +139,7 @@ abstract class RecordingState(recorder: CommonCallRecorder) : CallRecordingState * * Max 40 calls looks like reasonable compromise */ + @Suppress("DEPRECATION_ERROR") override fun estimateCallRounds(): Int { val regularArguments = builder().signedCalls .flatMap { it.args } diff --git a/mockk/common/src/main/kotlin/io/mockk/impl/verify/VerificationHelpers.kt b/mockk/common/src/main/kotlin/io/mockk/impl/verify/VerificationHelpers.kt index 0dcb884e2..041df875c 100644 --- a/mockk/common/src/main/kotlin/io/mockk/impl/verify/VerificationHelpers.kt +++ b/mockk/common/src/main/kotlin/io/mockk/impl/verify/VerificationHelpers.kt @@ -25,6 +25,7 @@ object VerificationHelpers { } fun stackTrace(prefix: Int, stackTrace: List): String { + @Suppress("DEPRECATION_ERROR") fun columnSize(block: StackElement.() -> String) = stackTrace.map(block).map { it.length }.max() ?: 0 diff --git a/mockk/common/src/test/kotlin/io/mockk/it/ValueClassTest.kt b/mockk/common/src/test/kotlin/io/mockk/it/ValueClassTest.kt new file mode 100644 index 000000000..6acf64f9f --- /dev/null +++ b/mockk/common/src/test/kotlin/io/mockk/it/ValueClassTest.kt @@ -0,0 +1,56 @@ +package io.mockk.it + +import io.mockk.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class ValueClassTest { + + private val mock = mockk() + + @Test + fun valueClassObjectAsReturnValue() { + every { mock.requestValue() } returns DummyValue(42) + + assertEquals(DummyValue(42), mock.requestValue()) + + verify { mock.requestValue() } + } + + @Test + fun valueClassObjectAsFunctionArgumentAndReturnValue() { + every { mock.processValue(DummyValue(1)) } returns DummyValue(42) + + assertEquals(DummyValue(42), mock.processValue(DummyValue(1))) + + verify { mock.processValue(DummyValue(1)) } + } + + @Test + fun valueClassObjectAsFunctionArgumentAndAnswerValue() { + every { mock.processValue(DummyValue(1)) } answers { DummyValue(42) } + + assertEquals(DummyValue(42), mock.processValue(DummyValue(1))) + + verify { mock.processValue(DummyValue(1)) } + } + + @Test + fun anyValueClassMatcherAsFunctionArgumentAndValueClassObjectAsReturnValue() { + every { mock.processValue(any()) } returns DummyValue(42) + + assertEquals(DummyValue(42), mock.processValue(DummyValue(1))) + + verify { mock.processValue(DummyValue(1)) } + } +} + +// TODO should be value class in kotlin 1.5+ +private inline class DummyValue(val value: Int) + +private class DummyService { + + fun requestValue() = DummyValue(0) + + fun processValue(value: DummyValue) = DummyValue(0) +} diff --git a/mockk/jvm/src/main/kotlin/io/mockk/ValueClassSupport.kt b/mockk/jvm/src/main/kotlin/io/mockk/ValueClassSupport.kt new file mode 100644 index 000000000..77e432048 --- /dev/null +++ b/mockk/jvm/src/main/kotlin/io/mockk/ValueClassSupport.kt @@ -0,0 +1,76 @@ +package io.mockk + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.isAccessible + +private val valueClassFieldCache = mutableMapOf, KProperty1>() + +/** + * Get boxed value of any value class + * + * @return boxed value of value class, if this is value class, else just itself + */ +fun T.boxedValue(): Any? { + if (!this::class.isValueClass()) return this + + // get backing field + val backingField = this::class.valueField() + + // get boxed value + @Suppress("UNCHECKED_CAST") + return (backingField as KProperty1).get(this) +} + +/** + * Get class of boxed value of any value class + * + * @return class of boxed value, if this is value class, else just class of itself + */ +fun T.boxedClass(): KClass<*> { + if (!this::class.isValueClass()) return this::class + + // get backing field + val backingField = this::class.valueField() + + // get boxed value + return backingField.returnType.classifier as KClass<*> +} + + +private fun KClass.valueField(): KProperty1 { + @Suppress("UNCHECKED_CAST") + return valueClassFieldCache.getOrPut(this) { + require(isValue) { "$this is not a value class" } + + // value classes always have a primary constructor... + val constructor = primaryConstructor!! + // ...and exactly one constructor parameter + val constructorParameter = constructor.parameters.first() + // ...with a backing field + val backingField = declaredMemberProperties + .first { it.name == constructorParameter.name } + .apply { isAccessible = true } + + backingField + } as KProperty1 +} + +private fun KClass.isValueClass() = try { + this.isValue +} catch (_: UnsupportedOperationException) { + false +} + +/** + * POLYFILL for kotlin version < 1.5 + * will be shadowed by implementation in kotlin SDK 1.5+ + * + * @return true if this is an inline class, else false + */ +private val KClass.isValue: Boolean + get() = !isData && + primaryConstructor?.parameters?.size == 1 && + java.declaredMethods.any { it.name == "box-impl" } diff --git a/mockk/jvm/src/main/kotlin/io/mockk/impl/InternalPlatform.kt b/mockk/jvm/src/main/kotlin/io/mockk/impl/InternalPlatform.kt index 785363a0d..13b72b310 100644 --- a/mockk/jvm/src/main/kotlin/io/mockk/impl/InternalPlatform.kt +++ b/mockk/jvm/src/main/kotlin/io/mockk/impl/InternalPlatform.kt @@ -3,6 +3,8 @@ package io.mockk.impl import io.mockk.InternalPlatformDsl import io.mockk.MockKException import io.mockk.StackElement +import io.mockk.boxedClass +import io.mockk.boxedValue import io.mockk.impl.platform.CommonIdentityHashMapOf import io.mockk.impl.platform.CommonRef import io.mockk.impl.platform.JvmWeakConcurrentMap @@ -58,10 +60,11 @@ actual object InternalPlatform { actual fun synchronizedMutableMap(): MutableMap = Collections.synchronizedMap(hashMapOf()) actual fun packRef(arg: Any?): Any? { - return if (arg == null || isPassedByValue(arg::class)) - arg - else - ref(arg) + return when { + arg == null -> null + isPassedByValue(arg.boxedClass()) -> arg.boxedValue() + else -> ref(arg) + } } actual fun prettifyRecordingException(ex: Throwable): Throwable {