From 8ab271bc5c12c0ee231e43f8c6ba5fff45b5002b Mon Sep 17 00:00:00 2001 From: Piotr Kasprowicz Date: Tue, 10 Feb 2026 19:53:04 +0100 Subject: [PATCH] [kotlin] KTIJ-37502 Fix FP to properly match stdlib/custom methods by using CallableIds GitOrigin-RevId: 29871776c86ec26aa7fb8f0dcd4f1204b76cdea9 --- ...erboseNullabilityAndEmptinessInspection.kt | 109 ++++++++---------- .../tests/K2LocalInspectionTestGenerated.java | 34 ++++++ .../LocalInspectionTestGenerated.java | 34 ++++++ .../custom/customIsBlank.kt | 8 ++ .../custom/customIsEmpty.kt | 8 ++ .../custom/customIsNotBlank.kt | 8 ++ .../custom/customIsNotEmpty.kt | 8 ++ 7 files changed, 149 insertions(+), 60 deletions(-) create mode 100644 plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsBlank.kt create mode 100644 plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsEmpty.kt create mode 100644 plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotBlank.kt create mode 100644 plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotEmpty.kt diff --git a/plugins/kotlin/code-insight/inspections-k2/src/org/jetbrains/kotlin/idea/k2/codeinsight/inspections/VerboseNullabilityAndEmptinessInspection.kt b/plugins/kotlin/code-insight/inspections-k2/src/org/jetbrains/kotlin/idea/k2/codeinsight/inspections/VerboseNullabilityAndEmptinessInspection.kt index a10b45b8c1d5..64ad2b75f36f 100644 --- a/plugins/kotlin/code-insight/inspections-k2/src/org/jetbrains/kotlin/idea/k2/codeinsight/inspections/VerboseNullabilityAndEmptinessInspection.kt +++ b/plugins/kotlin/code-insight/inspections-k2/src/org/jetbrains/kotlin/idea/k2/codeinsight/inspections/VerboseNullabilityAndEmptinessInspection.kt @@ -11,16 +11,18 @@ import org.jetbrains.kotlin.analysis.api.KaNonPublicApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull import org.jetbrains.kotlin.analysis.api.resolution.symbol -import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol import org.jetbrains.kotlin.analysis.api.types.KaUsualClassType +import org.jetbrains.kotlin.builtins.StandardNames import org.jetbrains.kotlin.idea.base.psi.safeDeparenthesize import org.jetbrains.kotlin.idea.base.resources.KotlinBundle import org.jetbrains.kotlin.idea.codeinsight.api.applicable.inspections.KotlinApplicableInspectionBase import org.jetbrains.kotlin.idea.codeinsight.api.applicable.inspections.KotlinModCommandQuickFix import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.name.StandardClassIds import org.jetbrains.kotlin.psi.KtBinaryExpression import org.jetbrains.kotlin.psi.KtCallExpression @@ -67,12 +69,12 @@ internal class VerboseNullabilityAndEmptinessInspection : override fun isApplicableByPsi(element: KtBinaryExpression): Boolean { val nullCheckExpression = findNullCheckExpression(element) val nullCheck = getNullCheck(nullCheckExpression) ?: return false - + val binaryExpression = findBinaryExpression(nullCheckExpression) ?: return false val operationToken = binaryExpression.operationToken if (!isValidOperatorCombination(nullCheck.isEqualNull, operationToken)) return false - + val contentCheckExpression = findContentCheckExpression(nullCheckExpression, binaryExpression) ?: return false val contentCheck = getContentCheck(contentCheckExpression) ?: return false @@ -80,13 +82,13 @@ internal class VerboseNullabilityAndEmptinessInspection : val replacementName = contentCheck.data.replacementName if (isInsideFunctionImplementation(element, replacementName)) return false - + return super.isApplicableByPsi(element) } private fun isValidOperatorCombination(isEqualNull: Boolean, operationToken: IElementType): Boolean { return (!isEqualNull && operationToken == KtTokens.ANDAND) || // a != null && a.isNotEmpty() - (isEqualNull && operationToken == KtTokens.OROR) // a == null || a.isEmpty() + (isEqualNull && operationToken == KtTokens.OROR) // a == null || a.isEmpty() } override fun KaSession.prepareContext(element: KtBinaryExpression): Context? { @@ -117,8 +119,8 @@ internal class VerboseNullabilityAndEmptinessInspection : val contentCheckFunction = resolveToFunctionSymbol(contentCheck.call) ?: return null if (!checkTargetFunctionReceiver(contentCheck.call)) return null - val overriddenFunctions = getAllOverriddenSymbols(contentCheckFunction).filterIsInstance() + contentCheckFunction - if (overriddenFunctions.none { getCallableFqName(it) in contentCheck.data.callableNames }) return null + val overriddenFunctions = contentCheckFunction.allOverriddenSymbols + contentCheckFunction + if (overriddenFunctions.none { it.callableId in contentCheck.data.callableIds }) return null val hasExplicitReceiver = contentCheck.target.singleOrNull()?.expression !is KtCallExpression val replacementName = contentCheck.data.replacementName @@ -197,8 +199,7 @@ internal class VerboseNullabilityAndEmptinessInspection : */ private fun KaSession.checkTargetFunctionReceiver(expression: KtCallExpression): Boolean { val call = expression.resolveToCall()?.successfulFunctionCallOrNull() ?: return false - val receiverSymbol = call.partiallyAppliedSymbol - val type = receiverSymbol.dispatchReceiver?.type ?: receiverSymbol.extensionReceiver?.type ?: return false + val type = call.dispatchReceiver?.type ?: call.extensionReceiver?.type ?: return false val isNotNullable = !type.isMarkedNullable val isNotPrimitiveArray = type !is KaUsualClassType || !type.isPrimitiveArray() @@ -322,7 +323,7 @@ internal class VerboseNullabilityAndEmptinessInspection : return ContentCheck(target, callExpression, contentFunction, contentFunction.isPositiveCheck xor isNegated) }) - internal class ContentFunction(val isPositiveCheck: Boolean, val replacementName: String, vararg val callableNames: String) + internal class ContentFunction(val isPositiveCheck: Boolean, val replacementName: String, val callableIds: Set) } private typealias TargetChain = List @@ -341,28 +342,42 @@ data class TargetChunk(val expression: KtExpression) { } } -private val contentCheckingFunctions: Map = mapOf( - "isEmpty" to VerboseNullabilityAndEmptinessInspection.ContentFunction( - isPositiveCheck = false, replacementName = "isNullOrEmpty", - "kotlin.collections.Collection.isEmpty", - "kotlin.collections.Map.isEmpty", - "kotlin.collections.isEmpty", - "kotlin.text.isEmpty" - ), - "isBlank" to VerboseNullabilityAndEmptinessInspection.ContentFunction( - isPositiveCheck = false, replacementName = "isNullOrBlank", - "kotlin.text.isBlank" - ), - "isNotEmpty" to VerboseNullabilityAndEmptinessInspection.ContentFunction( - isPositiveCheck = true, replacementName = "isNullOrEmpty", - "kotlin.collections.isNotEmpty", - "kotlin.text.isNotEmpty" - ), - "isNotBlank" to VerboseNullabilityAndEmptinessInspection.ContentFunction( - isPositiveCheck = true, replacementName = "isNullOrBlank", - "kotlin.text.isNotBlank" +private val contentCheckingFunctions: Map = buildMap { + val isEmptyName = Name.identifier("isEmpty") + val isNotEmptyName = Name.identifier("isNotEmpty") + put( + "isEmpty", VerboseNullabilityAndEmptinessInspection.ContentFunction( + isPositiveCheck = false, replacementName = "isNullOrEmpty", + setOf( + CallableId(ClassId.topLevel(StandardNames.FqNames.collection), isEmptyName), + CallableId(ClassId.topLevel(StandardNames.FqNames.map), isEmptyName), + CallableId(StandardClassIds.BASE_COLLECTIONS_PACKAGE, isEmptyName), + CallableId(StandardClassIds.BASE_TEXT_PACKAGE, isEmptyName) + ) + ) ) -) + put( + "isBlank", VerboseNullabilityAndEmptinessInspection.ContentFunction( + isPositiveCheck = false, replacementName = "isNullOrBlank", + setOf(CallableId(StandardClassIds.BASE_TEXT_PACKAGE, Name.identifier("isBlank"))) + ) + ) + put( + "isNotEmpty", VerboseNullabilityAndEmptinessInspection.ContentFunction( + isPositiveCheck = true, replacementName = "isNullOrEmpty", + setOf( + CallableId(StandardClassIds.BASE_COLLECTIONS_PACKAGE, isNotEmptyName), + CallableId(StandardClassIds.BASE_TEXT_PACKAGE, isNotEmptyName) + ) + ) + ) + put( + "isNotBlank", VerboseNullabilityAndEmptinessInspection.ContentFunction( + isPositiveCheck = true, replacementName = "isNullOrBlank", + setOf(CallableId(StandardClassIds.BASE_TEXT_PACKAGE, Name.identifier("isNotBlank"))) + ) + ) +} /** * Finds the topmost expression that contains the null check, including any negation operators. @@ -461,7 +476,7 @@ private fun KaSession.resolve(chunk: TargetChunk): Any? { is KtCallExpression -> { val call = chunk.expression.resolveToCall()?.successfulFunctionCallOrNull() ?: return null - getSingleReceiver(call.partiallyAppliedSymbol.dispatchReceiver, call.partiallyAppliedSymbol.extensionReceiver) + getSingleReceiver(call.dispatchReceiver, call.extensionReceiver) } is KtNameReferenceExpression -> { @@ -562,33 +577,7 @@ private fun KaSession.hasSmartCast(chunk: TargetChunk): Boolean { */ private fun KaSession.resolveToFunctionSymbol(expression: KtCallExpression): KaFunctionSymbol? { val call = expression.resolveToCall()?.successfulFunctionCallOrNull() ?: return null - return call.partiallyAppliedSymbol.symbol -} - -/** - * Gets all overridden symbols for a function. Currently returns only the function itself. - * This is a placeholder for potential future enhancement to handle inheritance hierarchies. - */ -private fun getAllOverriddenSymbols(function: KaFunctionSymbol): Sequence { - return sequenceOf(function) -} - -/** - * Attempts to get the fully qualified name of a callable function. - * Handles built-in Kotlin functions by constructing FQ names manually. - */ -private fun getCallableFqName(function: KaFunctionSymbol): String? { - return (function as? KaNamedFunctionSymbol)?.let { namedFunction -> - // Try to get the full qualified name using a simple approach - namedFunction.name.asString().let { name -> - // For built-in functions, we need to construct the FQ name manually - when (name) { - in setOf("isEmpty", "isNotEmpty") -> "kotlin.collections.$name" - in setOf("isBlank", "isNotBlank") -> "kotlin.text.$name" - else -> name - } - } - } + return call.symbol } /** diff --git a/plugins/kotlin/code-insight/inspections-k2/tests/test/org/jetbrains/kotlin/idea/k2/inspections/tests/K2LocalInspectionTestGenerated.java b/plugins/kotlin/code-insight/inspections-k2/tests/test/org/jetbrains/kotlin/idea/k2/inspections/tests/K2LocalInspectionTestGenerated.java index ca9ffbd31925..9f54f50584af 100644 --- a/plugins/kotlin/code-insight/inspections-k2/tests/test/org/jetbrains/kotlin/idea/k2/inspections/tests/K2LocalInspectionTestGenerated.java +++ b/plugins/kotlin/code-insight/inspections-k2/tests/test/org/jetbrains/kotlin/idea/k2/inspections/tests/K2LocalInspectionTestGenerated.java @@ -16175,6 +16175,40 @@ public abstract class K2LocalInspectionTestGenerated extends AbstractK2LocalInsp } } + @RunWith(JUnit3RunnerWithInners.class) + @TestMetadata("../../../idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom") + public static class Custom extends AbstractK2LocalInspectionTest { + @java.lang.Override + @org.jetbrains.annotations.NotNull + public final KotlinPluginMode getPluginMode() { + return KotlinPluginMode.K2; + } + + private void runTest(String testDataFilePath) throws Exception { + KotlinTestUtils.runTest(this::doTest, this, testDataFilePath); + } + + @TestMetadata("customIsBlank.kt") + public void testCustomIsBlank() throws Exception { + runTest("../../../idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsBlank.kt"); + } + + @TestMetadata("customIsEmpty.kt") + public void testCustomIsEmpty() throws Exception { + runTest("../../../idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsEmpty.kt"); + } + + @TestMetadata("customIsNotBlank.kt") + public void testCustomIsNotBlank() throws Exception { + runTest("../../../idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotBlank.kt"); + } + + @TestMetadata("customIsNotEmpty.kt") + public void testCustomIsNotEmpty() throws Exception { + runTest("../../../idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotEmpty.kt"); + } + } + @RunWith(JUnit3RunnerWithInners.class) @TestMetadata("../../../idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/hashSet") public static class HashSet extends AbstractK2LocalInspectionTest { diff --git a/plugins/kotlin/idea/tests/test/org/jetbrains/kotlin/idea/inspections/LocalInspectionTestGenerated.java b/plugins/kotlin/idea/tests/test/org/jetbrains/kotlin/idea/inspections/LocalInspectionTestGenerated.java index 5796913cb094..d4367a6fb6ab 100644 --- a/plugins/kotlin/idea/tests/test/org/jetbrains/kotlin/idea/inspections/LocalInspectionTestGenerated.java +++ b/plugins/kotlin/idea/tests/test/org/jetbrains/kotlin/idea/inspections/LocalInspectionTestGenerated.java @@ -19825,6 +19825,40 @@ public abstract class LocalInspectionTestGenerated extends AbstractK1LocalInspec } } + @RunWith(JUnit3RunnerWithInners.class) + @TestMetadata("testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom") + public static class Custom extends AbstractK1LocalInspectionTest { + @java.lang.Override + @org.jetbrains.annotations.NotNull + public final KotlinPluginMode getPluginMode() { + return KotlinPluginMode.K1; + } + + private void runTest(String testDataFilePath) throws Exception { + KotlinTestUtils.runTest(this::doTest, this, testDataFilePath); + } + + @TestMetadata("customIsBlank.kt") + public void testCustomIsBlank() throws Exception { + runTest("testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsBlank.kt"); + } + + @TestMetadata("customIsEmpty.kt") + public void testCustomIsEmpty() throws Exception { + runTest("testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsEmpty.kt"); + } + + @TestMetadata("customIsNotBlank.kt") + public void testCustomIsNotBlank() throws Exception { + runTest("testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotBlank.kt"); + } + + @TestMetadata("customIsNotEmpty.kt") + public void testCustomIsNotEmpty() throws Exception { + runTest("testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotEmpty.kt"); + } + } + @RunWith(JUnit3RunnerWithInners.class) @TestMetadata("testData/inspectionsLocal/verboseNullabilityAndEmptiness/hashSet") public static class HashSet extends AbstractK1LocalInspectionTest { diff --git a/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsBlank.kt b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsBlank.kt new file mode 100644 index 000000000000..b9c43e91a72e --- /dev/null +++ b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsBlank.kt @@ -0,0 +1,8 @@ +// PROBLEM: none +class TextField { + fun isBlank(): Boolean = false +} + +fun test2(field: TextField?) { + if (field == null || field.isBlank()) {} +} \ No newline at end of file diff --git a/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsEmpty.kt b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsEmpty.kt new file mode 100644 index 000000000000..8e0d54cd7e72 --- /dev/null +++ b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsEmpty.kt @@ -0,0 +1,8 @@ +// PROBLEM: none +class Comment { + fun isEmpty(): Boolean = false +} + +fun test1(c: Comment?) { + if (c == null || c.isEmpty()) {} +} \ No newline at end of file diff --git a/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotBlank.kt b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotBlank.kt new file mode 100644 index 000000000000..d68cfd54f597 --- /dev/null +++ b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotBlank.kt @@ -0,0 +1,8 @@ +// PROBLEM: none +class Message { + fun isNotBlank(): Boolean = true +} + +fun test4(msg: Message?) { + if (msg != null && msg.isNotBlank()) {} +} \ No newline at end of file diff --git a/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotEmpty.kt b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotEmpty.kt new file mode 100644 index 000000000000..cf0afff0c8de --- /dev/null +++ b/plugins/kotlin/idea/tests/testData/inspectionsLocal/verboseNullabilityAndEmptiness/custom/customIsNotEmpty.kt @@ -0,0 +1,8 @@ +// PROBLEM: none +class Buffer { + fun isNotEmpty(): Boolean = true +} + +fun test3(buf: Buffer?) { + if (buf != null && buf.isNotEmpty()) {} +} \ No newline at end of file