Skip to content

Commit

Permalink
[IJ/AS Plugin] Add unused operation and unused field inspections (#5069)
Browse files Browse the repository at this point in the history
  • Loading branch information
BoD authored Jul 6, 2023
1 parent ac3f2a1 commit a788b77
Show file tree
Hide file tree
Showing 26 changed files with 515 additions and 55 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.ijplugin.ApolloBundle
import com.apollographql.ijplugin.navigation.compat.KotlinFindUsagesHandlerFactoryCompat
import com.apollographql.ijplugin.navigation.findKotlinFieldDefinitions
import com.apollographql.ijplugin.navigation.findKotlinFragmentSpreadDefinitions
import com.apollographql.ijplugin.navigation.findKotlinInlineFragmentDefinitions
import com.apollographql.ijplugin.project.apolloProjectService
import com.apollographql.ijplugin.util.isProcessCanceled
import com.intellij.codeInspection.LocalInspectionTool
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.lang.jsgraphql.psi.GraphQLField
import com.intellij.lang.jsgraphql.psi.GraphQLFragmentSpread
import com.intellij.lang.jsgraphql.psi.GraphQLIdentifier
import com.intellij.lang.jsgraphql.psi.GraphQLInlineFragment
import com.intellij.lang.jsgraphql.psi.GraphQLSelection
import com.intellij.lang.jsgraphql.psi.GraphQLTypeName
import com.intellij.lang.jsgraphql.psi.GraphQLTypedOperationDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLVisitor
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.util.findParentOfType

class ApolloUnusedFieldInspection : LocalInspectionTool() {
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
var isUnusedOperation = false
return object : GraphQLVisitor() {
override fun visitIdentifier(o: GraphQLIdentifier) {
if (isProcessCanceled()) return
if (!o.project.apolloProjectService.apolloVersion.isAtLeastV3) return
if (isUnusedOperation) return
val operation = o.findParentOfType<GraphQLTypedOperationDefinition>()
if (operation != null && ApolloUnusedOperationInspection.isUnusedOperation(operation)) {
// The whole operation is unused, no need to check the fields
isUnusedOperation = true
return
}

var isFragment = false
val ktDefinitions = when (val parent = o.parent) {
is GraphQLField -> findKotlinFieldDefinitions(parent)
is GraphQLFragmentSpread -> {
isFragment = true
findKotlinFragmentSpreadDefinitions(parent)
}
is GraphQLTypeName -> {
val inlineFragment = parent.parent?.parent as? GraphQLInlineFragment ?: return
isFragment = true
findKotlinInlineFragmentDefinitions(inlineFragment)
}
else -> return
}.ifEmpty { return }

val kotlinFindUsagesHandlerFactory = KotlinFindUsagesHandlerFactoryCompat(o.project)
val hasUsageProcessor = HasUsageProcessor()
for (kotlinDefinition in ktDefinitions) {
if (kotlinFindUsagesHandlerFactory.canFindUsages(kotlinDefinition)) {
val kotlinFindUsagesHandler = kotlinFindUsagesHandlerFactory.createFindUsagesHandler(kotlinDefinition, false)
?: return
val findUsageOptions = kotlinFindUsagesHandlerFactory.findPropertyOptions ?: return
kotlinFindUsagesHandler.processElementUsages(kotlinDefinition, hasUsageProcessor, findUsageOptions)
if (hasUsageProcessor.foundUsage) return
}
}
holder.registerProblem(
if (isFragment) o.findParentOfType<GraphQLSelection>()!! else o,
ApolloBundle.message("inspection.unusedField.reportText"),
DeleteElementQuickFix("inspection.unusedField.quickFix") { it.findParentOfType<GraphQLSelection>(strict = false)!! },
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.ijplugin.ApolloBundle
import com.apollographql.ijplugin.navigation.compat.KotlinFindUsagesHandlerFactoryCompat
import com.apollographql.ijplugin.navigation.findKotlinOperationDefinitions
import com.apollographql.ijplugin.project.apolloProjectService
import com.apollographql.ijplugin.util.isProcessCanceled
import com.intellij.codeInspection.LocalInspectionTool
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.lang.jsgraphql.psi.GraphQLTypedOperationDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLVisitor
import com.intellij.psi.PsiElementVisitor

class ApolloUnusedOperationInspection : LocalInspectionTool() {
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
return object : GraphQLVisitor() {
override fun visitTypedOperationDefinition(o: GraphQLTypedOperationDefinition) {
if (isUnusedOperation(o)) {
holder.registerProblem(
o,
ApolloBundle.message("inspection.unusedOperation.reportText"),
DeleteElementQuickFix("inspection.unusedOperation.quickFix") { it }
)
}
}
}
}

companion object {
fun isUnusedOperation(operationDefinition: GraphQLTypedOperationDefinition): Boolean {
if (isProcessCanceled()) return false
if (!operationDefinition.project.apolloProjectService.apolloVersion.isAtLeastV3) return false
val ktClasses = findKotlinOperationDefinitions(operationDefinition).ifEmpty {
// Didn't find any generated class: maybe in the middle of writing a new operation, let's not report an error yet.
return false
}
val kotlinFindUsagesHandlerFactory = KotlinFindUsagesHandlerFactoryCompat(operationDefinition.project)
val hasUsageProcessor = HasUsageProcessor()
for (kotlinDefinition in ktClasses) {
if (kotlinFindUsagesHandlerFactory.canFindUsages(kotlinDefinition)) {
val kotlinFindUsagesHandler = kotlinFindUsagesHandlerFactory.createFindUsagesHandler(kotlinDefinition, false)
?: return false
val findUsageOptions = kotlinFindUsagesHandlerFactory.findClassOptions ?: return false
kotlinFindUsagesHandler.processElementUsages(kotlinDefinition, hasUsageProcessor, findUsageOptions)
if (hasUsageProcessor.foundUsage) return false
}
}
return true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.ijplugin.ApolloBundle
import com.intellij.codeInsight.intention.FileModifier.SafeFieldForPreview
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiElement

class DeleteElementQuickFix(
private val label: String,

@SafeFieldForPreview
private val elementToDelete: (PsiElement) -> PsiElement,
) : LocalQuickFix {
override fun getName() = ApolloBundle.message(label)

override fun getFamilyName() = name

override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
elementToDelete(descriptor.psiElement).delete()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.ijplugin.util.isGenerated
import com.intellij.usageView.UsageInfo
import com.intellij.util.Processor

class HasUsageProcessor : Processor<UsageInfo> {
var foundUsage = false
private set

override fun process(usageInfo: UsageInfo): Boolean {
if (usageInfo.virtualFile?.isGenerated(usageInfo.project) == false) {
foundUsage = true
return false
}
return true
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.apollographql.ijplugin.navigation

import com.apollographql.ijplugin.util.capitalizeFirstLetter
import com.apollographql.ijplugin.util.decapitalizeFirstLetter
import com.apollographql.ijplugin.util.findChildrenOfType
import com.intellij.lang.jsgraphql.psi.GraphQLElement
import com.intellij.lang.jsgraphql.psi.GraphQLEnumTypeDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLEnumValue
import com.intellij.lang.jsgraphql.psi.GraphQLField
import com.intellij.lang.jsgraphql.psi.GraphQLFragmentDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLFragmentSpread
import com.intellij.lang.jsgraphql.psi.GraphQLInlineFragment
import com.intellij.lang.jsgraphql.psi.GraphQLInputObjectTypeDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLInputValueDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLTypedOperationDefinition
Expand All @@ -17,9 +20,13 @@ import com.intellij.psi.search.GlobalSearchScope
import com.intellij.psi.search.PsiShortNamesCache
import com.intellij.psi.util.parentOfType
import org.jetbrains.kotlin.asJava.classes.KtUltraLightClass
import org.jetbrains.kotlin.idea.base.utils.fqname.fqName
import org.jetbrains.kotlin.nj2k.postProcessing.type
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtEnumEntry
import org.jetbrains.kotlin.psi.KtNamedDeclaration
import org.jetbrains.kotlin.psi.KtParameter
import org.jetbrains.kotlin.psi.KtProperty

fun findKotlinOperationDefinitions(operationDefinition: GraphQLTypedOperationDefinition): List<KtClass> {
val operationName = operationDefinition.name ?: return emptyList()
Expand All @@ -36,23 +43,102 @@ fun findKotlinOperationDefinitions(operationDefinition: GraphQLTypedOperationDef
}

fun findKotlinFieldDefinitions(graphQLField: GraphQLField): List<PsiElement> {
// TODO We can disambiguate fields with the same name by using the path to the field
return (
// Try operation first
graphQLField.parentOfType<GraphQLTypedOperationDefinition>()?.let { operationDefinition ->
findKotlinOperationDefinitions(operationDefinition)
val path = graphQLField.pathFromRoot()
val ktClasses = findKotlinClassOfParent(graphQLField)
return ktClasses?.mapNotNull { ktClass ->
// Try Data class first (operations)
var c = ktClass.findChildrenOfType<KtClass> { it.name == "Data" }.firstOrNull()
// Fallback to class itself (fragments)
?: ktClass
var ktFieldDefinition: KtNamedDeclaration? = null
for ((i, pathElement) in path.withIndex()) {
// Look for the element in the constructor parameters (for data classes) and in the properties (for interfaces)
val properties = c.primaryConstructor?.valueParameters.orEmpty() + c.getProperties()
ktFieldDefinition = properties.firstOrNull { it.name == pathElement } ?: continue
val parameterType = ktFieldDefinition.type()
val parameterTypeFqName =
// Try Lists first
parameterType?.arguments?.firstOrNull()?.type?.fqName
// Fallback to regular type
?: parameterType?.fqName
?: break
if (i != path.lastIndex) {
c = ktClass.findChildrenOfType<KtClass> { it.fqName == parameterTypeFqName }.firstOrNull() ?: return@mapNotNull null
}
// Fallback to fragment
?: graphQLField.parentOfType<GraphQLFragmentDefinition>()?.let { fragmentDefinition ->
findKotlinFragmentClassDefinitions(fragmentDefinition)
}
)
}
ktFieldDefinition
}
// Fallback to just finding any property with the name (for responseBased)
?: ktClasses?.flatMap { ktClass ->
ktClass.findChildrenOfType<KtProperty> { it.name == graphQLField.name }
}
?: emptyList()
}

/**
* Ex:
* ```graphql
* a {
* b
* ... on MyType {
* c
* }
* }
* ```
* returns `["a", "b", "onMyType", "c"]`
*/
private fun GraphQLField.pathFromRoot(): List<String> {
val path = mutableListOf<String>()
var element: GraphQLElement = this
while (true) {
element = when (element) {
is GraphQLInlineFragment -> {
path.add(0,element.kotlinFieldName() ?: break)
element.parent?.parent?.parent?.parent as? GraphQLElement ?: break
}

is GraphQLField -> {
path.add(0,element.name!!)
element.parent?.parent?.parent as? GraphQLElement ?: break
}

else -> break
}
if (element !is GraphQLField && element !is GraphQLInlineFragment) break
}
return path
}

fun findKotlinFragmentSpreadDefinitions(graphQLFragmentSpread: GraphQLFragmentSpread): List<PsiElement> {
return findKotlinClassOfParent(graphQLFragmentSpread)
?.flatMap { psiClass ->
psiClass.findChildrenOfType<KtParameter> { it.name == graphQLField.name }
psiClass.findChildrenOfType<KtParameter> { it.name == graphQLFragmentSpread.name?.decapitalizeFirstLetter() }
}
?: emptyList()
}

fun findKotlinInlineFragmentDefinitions(graphQLFragmentSpread: GraphQLInlineFragment): List<PsiElement> {
return findKotlinClassOfParent(graphQLFragmentSpread)
?.flatMap { psiClass ->
psiClass.findChildrenOfType<KtParameter> { it.name == graphQLFragmentSpread.kotlinFieldName() }
}
?: emptyList()
}

private fun GraphQLInlineFragment.kotlinFieldName() = typeCondition?.typeName?.name?.capitalizeFirstLetter()?.let { "on$it" }

private fun findKotlinClassOfParent(gqlElement: GraphQLElement): List<KtClass>? {
// Try operation first
return gqlElement.parentOfType<GraphQLTypedOperationDefinition>()?.let { operationDefinition ->
findKotlinOperationDefinitions(operationDefinition)
}
// Fallback to fragment
?: gqlElement.parentOfType<GraphQLFragmentDefinition>()?.let { fragmentDefinition ->
findKotlinFragmentClassDefinitions(fragmentDefinition)
}
}


fun findKotlinFragmentClassDefinitions(fragmentSpread: GraphQLFragmentSpread): List<KtClass> {
val fragmentName = fragmentSpread.nameIdentifier.referenceName ?: return emptyList()
return findKotlinClass(fragmentSpread.project, fragmentName) { it.isApolloFragment() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ class KotlinFindUsagesHandlerFactoryCompat(project: Project) : FindUsagesHandler
// Try with the recent version first (changed package since platform 231)
Class.forName(POST_231_CLASS_NAME)
}
.onFailure { logw(it, "Could not load $POST_231_CLASS_NAME") }
.recoverCatching {
// Fallback to the old version
Class.forName(PRE_231_CLASS_NAME)
}
.onFailure { logw(it, "Could not load <231 KotlinFindUsagesHandlerFactory") }
.onFailure { logw(it, "Could not load either $POST_231_CLASS_NAME nor $PRE_231_CLASS_NAME") }
.getOrNull()

private val delegate: FindUsagesHandlerFactory? = delegateClass?.let { it.getConstructor(Project::class.java).newInstance(project) as FindUsagesHandlerFactory }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ fun Project.findPsiFilesByName(fileName: String, searchScope: GlobalSearchScope)
}

fun VirtualFile.isGenerated(project: Project): Boolean {
return GeneratedSourcesFilter.isGeneratedSourceByAnyFilter(this, project) || isApolloGenerated()
return GeneratedSourcesFilter.isGeneratedSourceByAnyFilter(this, project) || isApolloGenerated() || name.endsWith(".keystream")
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.apollographql.ijplugin.util

import com.intellij.openapi.progress.JobCanceledException
import com.intellij.openapi.diagnostic.ControlFlowException
import com.intellij.psi.PsiElement
import com.intellij.psi.util.PsiTreeUtil
import org.jetbrains.kotlin.idea.references.KtSimpleNameReference
Expand Down Expand Up @@ -51,9 +51,8 @@ fun PsiElement.resolveKtName(): PsiElement? = runCatching {
references.firstIsInstanceOrNull<KtSimpleNameReference>()?.resolve()
}.onFailure { t ->
// Sometimes KotlinIdeaResolutionException is thrown
@Suppress("UnstableApiUsage")
// JobCanceledException is a normal thing to happen though so no need to log
if (t !is JobCanceledException) logw(t, "Could not resolve $this")
// But ControlFlowException is a normal thing to happen, so no need to log
if (t !is ControlFlowException) logw(t, "Could not resolve $this")
}.getOrNull()

fun PsiElement.asKtClass(): KtClass? = cast<KtClass>() ?: cast<KtConstructor<*>>()?.containingClass()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ fun String.unquoted(): String {
}

fun String.capitalizeFirstLetter() = replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }
fun String.decapitalizeFirstLetter() = replaceFirstChar { if (it.isUpperCase()) it.lowercase(Locale.ROOT) else it.toString() }
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
package com.apollographql.ijplugin.util

import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.progress.ProgressManager

fun runWriteActionInEdt(action: () -> Unit) {
ApplicationManager.getApplication().invokeLater {
ApplicationManager.getApplication().runWriteAction<Unit>(action)
}
}

fun isProcessCanceled(): Boolean {
try {
ProgressManager.checkCanceled()
} catch (e: ProcessCanceledException) {
return true
}
return false
}
Loading

0 comments on commit a788b77

Please sign in to comment.