diff --git a/modulecheck-core/src/test/kotlin/modulecheck/core/UnusedDependenciesTest.kt b/modulecheck-core/src/test/kotlin/modulecheck/core/UnusedDependenciesTest.kt index 1bc6cee4dc..ccdbcd05da 100644 --- a/modulecheck-core/src/test/kotlin/modulecheck/core/UnusedDependenciesTest.kt +++ b/modulecheck-core/src/test/kotlin/modulecheck/core/UnusedDependenciesTest.kt @@ -834,6 +834,136 @@ class UnusedDependenciesTest : RunnerTest() { .clean() shouldBe """ModuleCheck found 0 issues""" } + @Test + fun `module contributing a named companion object, consumed in the same package should not be unused`() { + // https://github.com/RBusarow/ModuleCheck/issues/705 + + settings.deleteUnused = false + + val lib1 = kotlinProject(":lib1") { + addKotlinSource( + """ + package com.modulecheck.common + + class Lib1Class { + companion object Factory { + fun create() = Lib1Class() + } + } + """.trimIndent() + ) + } + + val lib2 = kotlinProject(":lib2") { + addDependency(ConfigurationName.implementation, lib1) + + buildFile { + """ + plugins { + kotlin("jvm") + } + + dependencies { + implementation(project(path = ":lib1")) + } + """ + } + + addKotlinSource( + """ + package com.modulecheck.common + + fun foo() { + bar(Lib1Class.create()) + } + + fun bar(any: Any) = Unit + """.trimIndent(), + SourceSetName.MAIN + ) + } + + run().isSuccess shouldBe true + + lib2.buildFile shouldHaveText """ + plugins { + kotlin("jvm") + } + + dependencies { + implementation(project(path = ":lib1")) + } + """ + + logger.parsedReport() shouldBe listOf() + } + + @Test + fun `module contributing a named companion object, consumed by the companion name should not be unused`() { + // https://github.com/RBusarow/ModuleCheck/issues/705 + + settings.deleteUnused = false + + val lib1 = kotlinProject(":lib1") { + addKotlinSource( + """ + package com.modulecheck.common + + class Lib1Class { + companion object Factory { + fun create() = Lib1Class() + } + } + """.trimIndent() + ) + } + + val lib2 = kotlinProject(":lib2") { + addDependency(ConfigurationName.implementation, lib1) + + buildFile { + """ + plugins { + kotlin("jvm") + } + + dependencies { + implementation(project(path = ":lib1")) + } + """ + } + + addKotlinSource( + """ + package com.modulecheck.common + + import com.modulecheck.common.Lib1Class.Factory + + fun foo() { + bar(Factory.create()) + } + + fun bar(any: Any) = Unit + """.trimIndent(), + SourceSetName.MAIN + ) + } + + run().isSuccess shouldBe true + + lib2.buildFile shouldHaveText """ + plugins { + kotlin("jvm") + } + + dependencies { + implementation(project(path = ":lib1")) + } + """ + + logger.parsedReport() shouldBe listOf() + } + @Test fun `testImplementation used in test should not be unused`() { diff --git a/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/RealKotlinFile.kt b/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/RealKotlinFile.kt index 9e0427ac40..8891043b14 100644 --- a/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/RealKotlinFile.kt +++ b/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/RealKotlinFile.kt @@ -22,6 +22,8 @@ import modulecheck.parsing.psi.internal.callSiteName import modulecheck.parsing.psi.internal.getByNameOrIndex import modulecheck.parsing.psi.internal.getChildrenOfTypeRecursive import modulecheck.parsing.psi.internal.identifier +import modulecheck.parsing.psi.internal.isCompanionObject +import modulecheck.parsing.psi.internal.isInCompanionObject import modulecheck.parsing.psi.internal.isJvmStatic import modulecheck.parsing.psi.internal.isPartOf import modulecheck.parsing.psi.internal.isPrivateOrInternal @@ -146,18 +148,29 @@ class RealKotlinFile( } } + val psi = this@declaredNames + + fun parseCompanionObjectDeclarations(companionName: String) { + both(nameAsString) + + if (isStatic()) { + both(nameAsString.remove(".$companionName")) + } else if (psi is KtCallableDeclaration) { + kotlin(nameAsString.remove(".$companionName")) + } + } + when { - nameAsString.contains(".Companion") -> { - both(nameAsString) + psi.isCompanionObject() -> { + parseCompanionObjectDeclarations(psi.name ?: "Companion") + } - if (isStatic()) { - both(nameAsString.remove(".Companion")) - } else if (this@declaredNames is KtCallableDeclaration) { - kotlin(nameAsString.remove(".Companion")) - } + psi.isInCompanionObject() -> { + val companion = containingClassOrObject as KtObjectDeclaration + parseCompanionObjectDeclarations(companion.name ?: "Companion") } - isTopLevelKtOrJavaMember() && this@declaredNames !is KtClassOrObject && !isStatic() -> { + isTopLevelKtOrJavaMember() && psi !is KtClassOrObject && !isStatic() -> { kotlin(nameAsString) jvmSimpleNames().forEach { @@ -190,7 +203,7 @@ class RealKotlinFile( val jvmNames = jvmSimpleNames() - if (this@declaredNames is KtFunction && jvmNameOrNull() == null) { + if (psi is KtFunction && psi.jvmNameOrNull() == null) { both(nameAsString) } else { kotlin(nameAsString) @@ -204,8 +217,7 @@ class RealKotlinFile( } } - this@declaredNames is KtParameter || - (this@declaredNames is KtProperty && !isTopLevelKtOrJavaMember()) -> { + psi is KtParameter || (psi is KtProperty && !psi.isTopLevelKtOrJavaMember()) -> { kotlin(nameAsString) diff --git a/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/internal/psiElement.kt b/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/internal/psiElement.kt index c714b8f602..80249c4122 100644 --- a/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/internal/psiElement.kt +++ b/modulecheck-parsing/psi/src/main/kotlin/modulecheck/parsing/psi/internal/psiElement.kt @@ -28,7 +28,6 @@ import org.jetbrains.kotlin.psi.KtAnnotationEntry import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClassLiteralExpression -import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtNameReferenceExpression @@ -50,6 +49,7 @@ import org.jetbrains.kotlin.psi.psiUtil.isObjectLiteral import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf import org.jetbrains.kotlin.utils.addToStdlib.safeAs import java.io.File +import kotlin.contracts.contract inline fun PsiElement.isPartOf() = getNonStrictParentOfType() != null @@ -126,6 +126,7 @@ suspend fun PsiElement.fqNameOrNull( ?.let { return it } ?: text } + is KtNameReferenceExpression -> getReferencedName() is KtUserType -> { val isGenericType = children.any { it is KtTypeArgumentList } @@ -163,6 +164,7 @@ suspend fun PsiElement.fqNameOrNull( text } } + is KtTypeReference -> { val children = children if (children.size == 1) { @@ -173,6 +175,7 @@ suspend fun PsiElement.fqNameOrNull( text } } + is KtNullableType -> return innerType?.fqNameOrNull(project, sourceSetName) is KtAnnotationEntry -> return typeReference?.fqNameOrNull(project, sourceSetName) is KtClassLiteralExpression -> { @@ -180,6 +183,7 @@ suspend fun PsiElement.fqNameOrNull( return children.singleOrNull() ?.fqNameOrNull(project, sourceSetName) } + is KtSuperTypeListEntry -> return typeReference?.fqNameOrNull(project, sourceSetName) else -> return null } @@ -197,6 +201,7 @@ suspend fun PsiElement.fqNameOrNull( when { matchingImportPaths.size == 1 -> return matchingImportPaths[0].fqName + matchingImportPaths.size > 1 -> return matchingImportPaths.firstOrNull { importPath -> project.canResolveFqName(importPath.fqName, sourceSetName) @@ -210,6 +215,7 @@ suspend fun PsiElement.fqNameOrNull( when { matchingImportPaths.size == 1 -> return FqName("${matchingImportPaths[0].fqName.parent()}.$classReference") + matchingImportPaths.size > 1 -> return matchingImportPaths.firstOrNull { importPath -> project.canResolveFqName( @@ -257,10 +263,24 @@ suspend fun PsiElement.fqNameOrNull( fun KtDeclaration.isInObject() = containingClassOrObject?.isObjectLiteral() ?: false -fun KtDeclaration.isInCompanionObject() = containingClassOrObject?.isCompanionObject() ?: false +/** + * @return true if the receiver declaration is inside a companion object + */ +fun KtDeclaration.isInCompanionObject(): Boolean { + return containingClassOrObject?.isCompanionObject() ?: false +} + fun KtDeclaration.isInObjectOrCompanionObject() = isInObject() || isInCompanionObject() -fun KtClassOrObject.isCompanionObject(): Boolean = this is KtObjectDeclaration && isCompanion() +/** + * @return true if the receiver declaration is a companion object + */ +fun KtDeclaration.isCompanionObject(): Boolean { + contract { + returns(true) implies (this@isCompanionObject is KtObjectDeclaration) + } + return this is KtObjectDeclaration && isCompanion() +} fun PsiElement.isQualifiedPropertyOrCallExpression(): Boolean { // properties which are qualified have a direct parent of `KtQualifiedExpression` diff --git a/modulecheck-parsing/psi/src/test/kotlin/modulecheck/parsing/psi/KotlinFileTest.kt b/modulecheck-parsing/psi/src/test/kotlin/modulecheck/parsing/psi/KotlinFileTest.kt index 8aba6a374e..d33f63a9d6 100644 --- a/modulecheck-parsing/psi/src/test/kotlin/modulecheck/parsing/psi/KotlinFileTest.kt +++ b/modulecheck-parsing/psi/src/test/kotlin/modulecheck/parsing/psi/KotlinFileTest.kt @@ -455,6 +455,39 @@ internal class KotlinFileTest : ProjectTest(), NamedSymbolTest { } } + @Test + fun `named companion object and function should also have declarations using original class name`() = + test { + + val project = kotlinProject(":subject") + + val file = project.createFile( + """ + package com.subject + + class SubjectClass { + + companion object Factory { + fun create() = SubjectClass() + } + } + """ + ) + + file shouldBe { + references { + interpretedKotlin("SubjectClass") + interpretedKotlin("com.subject.SubjectClass") + } + declarations { + agnostic("com.subject.SubjectClass") + agnostic("com.subject.SubjectClass.Factory") + agnostic("com.subject.SubjectClass.Factory.create") + kotlin("com.subject.SubjectClass.create") + } + } + } + @Test fun `explicit fully qualified type of public property in public class should be api reference`() = test {