Skip to content

Commit

Permalink
[kotlin] Implement evaluatable getters filtering using Kotlin class m…
Browse files Browse the repository at this point in the history
…etadata

Previously, evaluatable getters in the variables view of the Kotlin debugger relied on a filtering heuristic that inspected bytecode of each getter. Unfortunately, such filtering was incorrect in some cases. Moreover the filtering was disabled completely in the Android Studio debugger, because method bytecode is not available there. This commit reimplements filtering of evaluatable getters by using Kotlin class metadata, fetched by `MetadataDebugHelper`.
  • Loading branch information
nikita-nazarov committed Feb 24, 2025
1 parent 46f94c7 commit 3d323aa
Show file tree
Hide file tree
Showing 18 changed files with 356 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.rt.debugger.coroutines;
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.rt.debugger;

import java.util.List;

public final class JsonUtils {
private JsonUtils() { }

static String dumpCoroutineStackTraceDumpToJson(
public static String dumpCoroutineStackTraceDumpToJson(
List<StackTraceElement> continuationStackElements,
List<List<String>> variableNames,
List<List<String>> fieldNames,
Expand All @@ -23,6 +23,19 @@ static String dumpCoroutineStackTraceDumpToJson(
return result.toString();
}

public static String escapeJsonString(String s) {
StringBuilder builder = new StringBuilder();
int length = s.length();
for (int i = 0; i < length; i++) {
char c = s.charAt(i);
if (c == '"' || c == '\\' || c == '/') {
builder.append('\\');
}
builder.append(c);
}
return builder.toString();
}

private static void dumpContinuationStacks(StringBuilder result,
List<StackTraceElement> continuationStackElements,
List<List<String>> variableNames,
Expand Down Expand Up @@ -95,17 +108,4 @@ private static void dumpStackTraceElement(StringBuilder result, StackTraceElemen

result.append("}");
}

private static String escapeJsonString(String s) {
StringBuilder builder = new StringBuilder();
int length = s.length();
for (int i = 0; i < length; i++) {
char c = s.charAt(i);
if (c == '"' || c == '\\' || c == '/') {
builder.append('\\');
}
builder.append(c);
}
return builder.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import java.util.Arrays;

public class MetadataDebugHelper {
public static final String METADATA_SEPARATOR = "\n";

private static final String METADATA_CLASS_NAME = "kotlin.Metadata";

private static final String KIND = "kind";
Expand Down Expand Up @@ -82,6 +84,29 @@ public static String getDebugMetadataAsJson(Class<?> cls) {
}
}

/*
* This function is used similarly to `getDebugMetadataAsJson`, when there is a need to
* fetch metadata for multiple classes.
*
* The return value is a concatenation of metadata JSON representations of given classes
* separated by MetadataDebugHelper.METADATA_SEPARATOR.
*/
public static String getDebugMetadataListAsJson(Class<?>... classes) {
StringBuilder sb = new StringBuilder();
for (Class<?> cls : classes) {
String metadataAsJson = getDebugMetadataAsJson(cls);
if (metadataAsJson == null) {
return null;
}

if (sb.length() != 0) {
sb.append(METADATA_SEPARATOR);
}
sb.append(metadataAsJson);
}
return sb.toString();
}

private static void appendAsJsonValue(StringBuilder sb, String name, Object value) {
appendAsJsonValueNoComma(sb, name, value).append(',');
}
Expand All @@ -91,18 +116,7 @@ private static StringBuilder appendAsJsonValueNoComma(StringBuilder sb, String n
}

private static String toJson(String str) {
StringBuilder sb = new StringBuilder();
sb.append('"');
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (c == '"') {
sb.append("\\\"");
} else {
sb.append(c);
}
}
sb.append('"');
return sb.toString();
return "\"" + JsonUtils.escapeJsonString(str) + "\"";
}

private static String[] toJson(String[] array) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.rt.debugger.coroutines;

import com.intellij.rt.debugger.JsonUtils;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
Expand Down
3 changes: 3 additions & 0 deletions plugins/kotlin/jvm-debugger/core/kotlin.jvm-debugger.core.iml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@
<orderEntry type="module" module-name="intellij.platform.statistics" />
<orderEntry type="module" module-name="intellij.platform.util" />
<orderEntry type="module" module-name="kotlin.base.resources" />
<orderEntry type="library" name="gson" level="project" />
<orderEntry type="module" module-name="intellij.java.debugger.impl.shared" />
<orderEntry type="module" module-name="intellij.platform.util.jdom" />
<orderEntry type="module" module-name="intellij.java.rt" />
<orderEntry type="library" name="kotlin-metadata" level="project" />
</component>
</module>
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.

package org.jetbrains.kotlin.idea.debugger.core

import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.intellij.debugger.engine.evaluation.EvaluationContext
import com.intellij.debugger.engine.evaluation.EvaluationContextImpl
import com.intellij.debugger.impl.DebuggerUtilsImpl
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.getOrCreateUserData
import com.intellij.rt.debugger.MetadataDebugHelper
import com.intellij.rt.debugger.JsonUtils
import com.sun.jdi.ReferenceType
import com.sun.jdi.StringReference
import com.sun.jdi.Value
import org.jetbrains.kotlin.idea.debugger.base.util.wrapEvaluateException
import org.jetbrains.kotlin.idea.debugger.base.util.wrapIllegalArgumentException
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.metadata.jvm.KotlinClassMetadata

object KotlinMetadataCacheService {
private val METADATA_CACHE_KEY = Key.create<KotlinMetadataCache?>("METADATA_CACHE_KEY")

fun getKotlinMetadata(refType: ReferenceType, context: EvaluationContext): KotlinClassMetadata? {
return getCache(context)?.fetchKotlinMetadata(refType, context)
}

fun getKotlinMetadataList(refTypes: Collection<ReferenceType>, context: EvaluationContext): List<KotlinClassMetadata>? {
return getCache(context)?.fetchKotlinMetadataList(refTypes, context)
}

private fun getCache(context: EvaluationContext): KotlinMetadataCache? {
val vmProxy = (context as? EvaluationContextImpl)?.virtualMachineProxy ?: return null
return vmProxy.getOrCreateUserData(METADATA_CACHE_KEY) {
KotlinMetadataCache()
}
}
}

private class KotlinMetadataCache {
private class MetadataAdapter(
val kind: Int,
val metadataVersion: Array<Int>,
val data1: Array<String>,
val data2: Array<String>,
val extraString: String,
val packageName: String,
val extraInt: Int,
) {
@OptIn(ExperimentalEncodingApi::class)
fun toMetadata(): Metadata {
return Metadata(
kind = kind,
metadataVersion = metadataVersion.toIntArray(),
data1 = data1.map { String(Base64.Default.decode(it)) }.toTypedArray(),
data2 = data2,
extraString = extraString,
packageName = packageName,
extraInt = extraInt
)
}
}

private val cache = mutableMapOf<ReferenceType, KotlinClassMetadata>()

fun fetchKotlinMetadata(refType: ReferenceType, context: EvaluationContext): KotlinClassMetadata? {
cache[refType]?.let { return it }
val metadataAsJson = callMethodFromHelper(
context, "getDebugMetadataAsJson", listOf(refType.classObject())
) ?: return null
val metadata = parseMetadataFromJson(metadataAsJson) ?: return null
cache[refType] = metadata
return metadata
}

fun fetchKotlinMetadataList(refTypes: Collection<ReferenceType>, context: EvaluationContext): List<KotlinClassMetadata>? {
val result = mutableListOf<KotlinClassMetadata>()
val toFetch = mutableListOf<ReferenceType>()
for (refType in refTypes) {
val metadata = cache[refType]
if (metadata != null) {
result.add(metadata)
} else {
toFetch.add(refType)
}
}

if (toFetch.isEmpty()) {
return result
}

val classObjects = toFetch.map { it.classObject() }
val concatenatedJsonMetadatas = callMethodFromHelper(
context, "getDebugMetadataListAsJson", classObjects
) ?: return null
val splitJsonMetadatas = concatenatedJsonMetadatas.split(MetadataDebugHelper.METADATA_SEPARATOR)
if (splitJsonMetadatas.size != toFetch.size) {
return null
}

for ((metadataAsJson, refType) in splitJsonMetadatas.zip(toFetch)) {
val metadata = parseMetadataFromJson(metadataAsJson) ?: return null
cache[refType] = metadata
result.add(metadata)
}
return result
}

private fun parseMetadataFromJson(metadataAsJson: String): KotlinClassMetadata? {
val metadata = wrapJsonSyntaxException {
Gson().fromJson(metadataAsJson, MetadataAdapter::class.java).toMetadata()
} ?: return null
val parsedMetadata = wrapIllegalArgumentException {
KotlinClassMetadata.readStrict(metadata)
} ?: return null
return parsedMetadata
}

private fun callMethodFromHelper(context: EvaluationContext, methodName: String, args: List<Value>): String? {
return wrapEvaluateException {
val value = DebuggerUtilsImpl.invokeHelperMethod(
context as EvaluationContextImpl,
MetadataDebugHelper::class.java,
methodName,
args,
true,
JsonUtils::class.java.name
)
(value as? StringReference)?.value()
}
}
}

private inline fun <T> wrapJsonSyntaxException(block: () -> T): T? {
return try {
block()
} catch (_: JsonSyntaxException) {
null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,22 @@ import com.intellij.debugger.ui.tree.render.ClassRenderer
import com.intellij.debugger.ui.tree.render.DescriptorLabelListener
import com.intellij.openapi.project.Project
import com.sun.jdi.*
import org.jetbrains.kotlin.idea.debugger.base.util.safeFields
import org.jetbrains.kotlin.idea.debugger.base.util.safeType
import org.jetbrains.kotlin.idea.debugger.base.util.isLateinitVariableGetter
import org.jetbrains.kotlin.idea.debugger.base.util.isSimpleGetter
import org.jetbrains.kotlin.idea.debugger.core.render.GetterDescriptor
import org.jetbrains.kotlin.idea.debugger.base.util.safeFields
import org.jetbrains.kotlin.idea.debugger.base.util.safeType
import org.jetbrains.kotlin.idea.debugger.core.KotlinDebuggerCoreBundle
import org.jetbrains.kotlin.idea.debugger.core.KotlinMetadataCacheService
import org.jetbrains.kotlin.idea.debugger.core.isInKotlinSources
import org.jetbrains.kotlin.idea.debugger.core.isInKotlinSourcesAsync
import org.jetbrains.kotlin.idea.debugger.core.render.GetterDescriptor
import org.jetbrains.kotlin.utils.addIfNotNull
import java.util.concurrent.CompletableFuture
import java.util.function.Function
import kotlin.metadata.KmProperty
import kotlin.metadata.isNotDefault
import kotlin.metadata.jvm.KotlinClassMetadata
import kotlin.metadata.jvm.getterSignature

class KotlinClassRenderer : ClassRenderer() {
init {
Expand All @@ -53,7 +59,10 @@ class KotlinClassRenderer : ClassRenderer() {
val nodeDescriptorFactory = builder.descriptorManager
val refType = value.referenceType()
val gettersFuture = DebuggerUtilsAsync.allMethods(refType)
.thenApply { methods -> methods.getters().createNodes(value, parentDescriptor.project, evaluationContext, nodeManager) }
.thenApply { methods ->
val getters = fetchGettersUsingMetadata(evaluationContext, methods) ?: methods.getters()
getters.createNodes(value, parentDescriptor.project, evaluationContext, nodeManager)
}
DebuggerUtilsAsync.allFields(refType).thenCombine(gettersFuture) { fields, getterNodes ->
if (fields.none { FieldVisibilityProvider.shouldDisplayField(it) } && getterNodes.isEmpty()) {
builder.setChildren(listOf(nodeManager.createMessageNode(KotlinDebuggerCoreBundle.message("message.class.has.no.properties"))))
Expand All @@ -78,6 +87,46 @@ class KotlinClassRenderer : ClassRenderer() {
}
}

private fun fetchGettersUsingMetadata(
context: EvaluationContext,
methods: List<Method>
): List<Method>? {
val gettersToShow = calculateGettersToShowUsingMetadata(methods, context) ?: return null
return methods
.filter { it.name() in gettersToShow }
.distinctBy { it.name() }
}

private fun calculateGettersToShowUsingMetadata(
methods: List<Method>,
context: EvaluationContext
): Set<String>? {
val uniqueKotlinDeclaringTypes = methods
.map { it.declaringType() }
.filter { it.isInKotlinSources() }
.toSet()
val metadataList = KotlinMetadataCacheService.getKotlinMetadataList(
uniqueKotlinDeclaringTypes, context
) ?: return null
val gettersToShow = mutableSetOf<String>()
for (metadata in metadataList) {
if (metadata !is KotlinClassMetadata.Class) {
continue
}

for (property in metadata.kmClass.properties) {
if (property.shouldBeVisibleInVariablesView()) {
gettersToShow.addIfNotNull(property.getterSignature?.name)
}
}
}
return gettersToShow
}

private fun KmProperty.shouldBeVisibleInVariablesView(): Boolean {
return getter.isNotDefault
}

override fun calcLabel(
descriptor: ValueDescriptor,
evaluationContext: EvaluationContext?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.intellij.debugger.engine.JavaValue
import com.intellij.debugger.impl.DebuggerUtilsImpl.logError
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.rt.debugger.coroutines.CoroutinesDebugHelper
import com.intellij.rt.debugger.JsonUtils
import com.sun.jdi.ArrayReference
import com.sun.jdi.ObjectReference
import com.sun.jdi.StringReference
Expand Down Expand Up @@ -71,9 +72,13 @@ private fun collectCoroutineAndCreationStack(
continuation: ObjectReference,
context: DefaultExecutionContext
): Pair<List<MirrorOfStackFrame>, List<StackTraceElement>?>? {
val array = callMethodFromHelper(CoroutinesDebugHelper::class.java, context, "getCoroutineStackTraceDump", listOf(continuation),
"com.intellij.rt.debugger.coroutines.JsonUtils")
?: return fallbackToOldFetchContinuationStack(continuation, context)
val array = callMethodFromHelper(
CoroutinesDebugHelper::class.java,
context,
"getCoroutineStackTraceDump",
listOf(continuation),
JsonUtils::class.java.name
) ?: return fallbackToOldFetchContinuationStack(continuation, context)
return parseResultFromHelper(array)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,11 @@ public void testDelegatedPropertyInClassWoRenderer() throws Exception {
runTest("../testData/evaluation/singleBreakpoint/frame/delegatedPropertyInClassWoRenderer.kt");
}

@TestMetadata("evaluatableGetters.kt")
public void testEvaluatableGetters() throws Exception {
runTest("../testData/evaluation/singleBreakpoint/frame/evaluatableGetters.kt");
}

@TestMetadata("frameAnonymousObject.kt")
public void testFrameAnonymousObject() throws Exception {
runTest("../testData/evaluation/singleBreakpoint/frame/frameAnonymousObject.kt");
Expand Down
Loading

0 comments on commit 3d323aa

Please sign in to comment.