Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IJ Plugin] Inspection: missing directive import #5494

Merged
merged 18 commits into from
Dec 22, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ class GradleToolingModelService(
})
}

fun triggerFetchToolingModels() {
logd()
startOrAbortFetchToolingModels()
}

private fun startOrAbortFetchToolingModels() {
logd()
abortFetchToolingModels()
Expand Down Expand Up @@ -181,7 +186,7 @@ class GradleToolingModelService(
logd("Fetch tooling model for :${gradleProject.name}")
return@execute try {
val id = ExternalSystemTaskId.create(GRADLE_SYSTEM_ID, ExternalSystemTaskType.RESOLVE_PROJECT, project)
gradleExecutionHelper.getModelBuilder(ApolloGradleToolingModel::class.java, connection,id, executionSettings, ExternalSystemTaskNotificationListenerAdapter.NULL_OBJECT)
gradleExecutionHelper.getModelBuilder(ApolloGradleToolingModel::class.java, connection, id, executionSettings, ExternalSystemTaskNotificationListenerAdapter.NULL_OBJECT)
.withCancellationToken(gradleCancellation!!.token())
.get()
.takeIf {
Expand Down Expand Up @@ -296,3 +301,5 @@ class GradleToolingModelService(
}
}
}

val Project.gradleToolingModelService get() = service<GradleToolingModelService>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.ijplugin.ApolloBundle
import com.apollographql.ijplugin.gradle.gradleToolingModelService
import com.apollographql.ijplugin.project.apolloProjectService
import com.apollographql.ijplugin.telemetry.TelemetryEvent
import com.apollographql.ijplugin.telemetry.telemetryService
import com.apollographql.ijplugin.util.cast
import com.apollographql.ijplugin.util.findChildrenOfType
import com.apollographql.ijplugin.util.findPsiFileByUrl
import com.apollographql.ijplugin.util.quoted
import com.apollographql.ijplugin.util.unquoted
import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo
import com.intellij.codeInsight.intention.preview.IntentionPreviewUtils
import com.intellij.codeInspection.LocalInspectionTool
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.lang.jsgraphql.ide.config.GraphQLConfigProvider
import com.intellij.lang.jsgraphql.psi.GraphQLArrayValue
import com.intellij.lang.jsgraphql.psi.GraphQLDirective
import com.intellij.lang.jsgraphql.psi.GraphQLElement
import com.intellij.lang.jsgraphql.psi.GraphQLElementFactory
import com.intellij.lang.jsgraphql.psi.GraphQLFile
import com.intellij.lang.jsgraphql.psi.GraphQLNamedElement
import com.intellij.lang.jsgraphql.psi.GraphQLSchemaDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLSchemaExtension
import com.intellij.lang.jsgraphql.psi.GraphQLVisitor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiElementVisitor

private const val URL_NULLABILITY = "https://specs.apollo.dev/nullability/v0.1"
private val DIRECTIVES_TO_CHECK = setOf("semanticNonNull", "catch", "ignoreErrors")
BoD marked this conversation as resolved.
Show resolved Hide resolved

class ApolloMissingGraphQLDefinitionImportInspection : LocalInspectionTool() {
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
return object : GraphQLVisitor() {
override fun visitDirective(o: GraphQLDirective) {
super.visitDirective(o)
if (!o.project.apolloProjectService.apolloVersion.isAtLeastV4) return
if (o.name !in DIRECTIVES_TO_CHECK) return
if (!o.isImported()) {
holder.registerProblem(o, ApolloBundle.message("inspection.missingGraphQLDefinitionImport.reportText", ApolloBundle.message("inspection.missingGraphQLDefinitionImport.reportText.directive")), ImportDefinitionQuickFix(ApolloBundle.message("inspection.missingGraphQLDefinitionImport.reportText.directive")))
}
}
}
}
}

private fun GraphQLElement.schemaFiles(): List<GraphQLFile> {
val containingFile = containingFile ?: return emptyList()
val projectConfig = GraphQLConfigProvider.getInstance(project).resolveProjectConfig(containingFile) ?: return emptyList()
return projectConfig.schema.mapNotNull { schema ->
schema.filePath?.let { path -> project.findPsiFileByUrl(schema.dir.url + "/" + path) } as? GraphQLFile
BoD marked this conversation as resolved.
Show resolved Hide resolved
}
}

private fun GraphQLNamedElement.isImported(): Boolean {
for (schemaFile in schemaFiles()) {
if (schemaFile.hasImportFor(this)) return true
}
return false
}

private fun GraphQLFile.linkDirectives(): List<GraphQLDirective> {
val schemaDirectives = typeDefinitions.filterIsInstance<GraphQLSchemaExtension>().flatMap { it.directives } +
typeDefinitions.filterIsInstance<GraphQLSchemaDefinition>().flatMap { it.directives }
return schemaDirectives.filter { directive ->
directive.name == "link" &&
directive.arguments?.argumentList.orEmpty().any { arg -> arg.name == "url" && arg.value?.text == URL_NULLABILITY.quoted() }
}
}

private fun GraphQLFile.hasImportFor(element: GraphQLNamedElement): Boolean {
for (directive in linkDirectives()) {
val importArgValue = directive.arguments?.argumentList.orEmpty().firstOrNull { it.name == "import" }?.value as? GraphQLArrayValue
?: continue
if (importArgValue.valueList.any { it.text == element.nameForImport.quoted() }) {
return true
}
}
return false
BoD marked this conversation as resolved.
Show resolved Hide resolved
}

private val GraphQLNamedElement.nameForImport get() = if (this is GraphQLDirective) "@" + name!! else name!!

private class ImportDefinitionQuickFix(val typeName: String) : LocalQuickFix {
override fun getName() = ApolloBundle.message("inspection.missingGraphQLDefinitionImport.quickFix", typeName)
override fun getFamilyName() = name

override fun availableInBatchMode() = false
override fun generatePreview(project: Project, previewDescriptor: ProblemDescriptor): IntentionPreviewInfo = IntentionPreviewInfo.EMPTY

override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
if (!IntentionPreviewUtils.isIntentionPreviewActive()) project.telemetryService.logEvent(TelemetryEvent.ApolloIjMissingGraphQLDefinitionImportQuickFix())

val element = descriptor.psiElement as GraphQLNamedElement
val schemaFiles = element.schemaFiles()
val linkDirective = schemaFiles.flatMap { it.linkDirectives() }.firstOrNull()

// Also add a @catch directive to the schema if we're importing @catch
val catchDirectiveSchemaExtension = if (element.name == "catch" && schemaFiles.flatMap { it.schemaCatchDirectives() }.isEmpty()) {
createCatchDirectiveSchemaExtension(project)
} else {
null
}

if (linkDirective == null) {
val linkDirectiveSchemaExtension = createLinkDirectiveSchemaExtension(project, listOf(element.nameForImport))
val extraSchemaFile = schemaFiles.firstOrNull { it.name == "extra.graphqls" }
BoD marked this conversation as resolved.
Show resolved Hide resolved
if (extraSchemaFile == null) {
val fileText = linkDirectiveSchemaExtension.text + catchDirectiveSchemaExtension?.let { "\n\n" + it.text }.orEmpty()
GraphQLElementFactory.createFile(project, fileText).also {
// Save the file to the project
it.name = "extra.graphqls"
schemaFiles.first().containingDirectory!!.add(it)

// There's a new schema file, reload the configuration
project.gradleToolingModelService.triggerFetchToolingModels()
}
} else {
extraSchemaFile.add(linkDirectiveSchemaExtension)
catchDirectiveSchemaExtension?.let {
extraSchemaFile.add(GraphQLElementFactory.createWhiteSpace(project, "\n\n"))
extraSchemaFile.add(it)
}
}
} else {
val extraSchemaFile = linkDirective.containingFile
val importedNames = buildList {
addAll(linkDirective.arguments!!.argumentList.firstOrNull { it.name == "import" }?.value?.cast<GraphQLArrayValue>()?.valueList.orEmpty().map { it.text.unquoted() })
add(element.nameForImport)
}
linkDirective.replace(createLinkDirective(project, importedNames))
catchDirectiveSchemaExtension?.let {
extraSchemaFile.add(GraphQLElementFactory.createWhiteSpace(project, "\n\n"))
extraSchemaFile.add(it)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be added at the top of the file instead, like other imports

Copy link
Contributor Author

@BoD BoD Dec 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point 👍

with df43394:

Screen.Recording.2023-12-22.at.12.26.07.mov

}
}
}
}

private fun createLinkDirectiveSchemaExtension(project: Project, importedNames: List<String>): GraphQLSchemaExtension {
val names = if ("@catch" in importedNames && "CatchTo" !in importedNames) importedNames + "CatchTo" else importedNames
return GraphQLElementFactory.createFile(
project,
"""
extend schema
@link(
url: "$URL_NULLABILITY",
import: [${names.joinToString { it.quoted() }}]
)
""".trimIndent()
)
.findChildrenOfType<GraphQLSchemaExtension>().single()
}

private fun createCatchDirectiveSchemaExtension(project: Project): GraphQLSchemaExtension {
return GraphQLElementFactory.createFile(project, "extend schema @catch(to: THROW)")
.findChildrenOfType<GraphQLSchemaExtension>().single()
}

private fun createLinkDirective(project: Project, importedNames: List<String>): GraphQLDirective {
return createLinkDirectiveSchemaExtension(project, importedNames).directives.single()
}

private fun GraphQLFile.schemaCatchDirectives(): List<GraphQLDirective> {
val schemaDirectives = typeDefinitions.filterIsInstance<GraphQLSchemaExtension>().flatMap { it.directives } +
typeDefinitions.filterIsInstance<GraphQLSchemaDefinition>().flatMap { it.directives }
return schemaDirectives.filter { it.name == "catch" }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.apollographql.ijplugin.inspection

import com.intellij.codeInspection.InspectionSuppressor
import com.intellij.codeInspection.SuppressQuickFix
import com.intellij.lang.jsgraphql.psi.GraphQLArgument
import com.intellij.lang.jsgraphql.psi.GraphQLDirective
import com.intellij.lang.jsgraphql.psi.GraphQLDirectivesAware
import com.intellij.psi.PsiElement

private val KNOWN_DIRECTIVES = mapOf(
"link" to setOf("url", "as", "import", "for"),
"semanticNonNull" to setOf("field", "level"),
"catch" to setOf("to", "level"),
"ignoreErrors" to setOf(),
)

/**
* Do not highlight certain known directives as unresolved references.
*
* TODO: remove this once https://github.com/JetBrains/js-graphql-intellij-plugin/pull/698 is merged.
*/
class GraphQLUnresolvedReferenceInspectionSuppressor : InspectionSuppressor {
override fun isSuppressedFor(element: PsiElement, toolId: String): Boolean {
val parent = element.parent
return when (toolId) {
"GraphQLUnresolvedReference" -> parent.isKnownDirective() || parent.isKnownDirectiveArgument()

"GraphQLMissingType" -> element is GraphQLDirectivesAware && element.directives.all { it.isKnownDirective() }

else -> false
}
}

override fun getSuppressActions(psiElement: PsiElement?, s: String): Array<SuppressQuickFix> = SuppressQuickFix.EMPTY_ARRAY
}

private fun PsiElement.isKnownDirective(): Boolean {
return this is GraphQLDirective && name in KNOWN_DIRECTIVES.keys
}

private fun PsiElement.isKnownDirectiveArgument(): Boolean {
return this is GraphQLArgument &&
parent?.parent?.isKnownDirective() == true &&
name in KNOWN_DIRECTIVES[(parent.parent as GraphQLDirective).name].orEmpty()
}
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ sealed class TelemetryEvent(
*/
class ApolloIjInputConstructorChangeToBuilderIntentionApply : TelemetryEvent("akij_input_constructor_change_to_builder_intention_apply", null)

/**
* User applied the 'Import directive' quickfix for the 'Missing GraphQL definition import' inspection of the Apollo Kotlin IntelliJ plugin.
*/
class ApolloIjMissingGraphQLDefinitionImportQuickFix : TelemetryEvent("akij_missing_graphql_definition_import_quickfix", null)
}

class TelemetryEventList {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,28 @@ package com.apollographql.ijplugin.util
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.GeneratedSourcesFilter
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import com.intellij.psi.util.PsiUtilCore
import java.nio.file.Path

fun Project.findPsiFilesByName(fileName: String, searchScope: GlobalSearchScope): List<PsiFile> {
val virtualFiles = FilenameIndex.getVirtualFilesByName(fileName, searchScope)
return PsiUtilCore.toPsiFiles(PsiManager.getInstance(this), virtualFiles)
}

fun Project.findPsiFileByPath(path: String): PsiFile? {
return VirtualFileManager.getInstance().findFileByNioPath(Path.of(path))?.let { PsiManager.getInstance(this).findFile(it) }
}

fun Project.findPsiFileByUrl(url: String): PsiFile? {
return VirtualFileManager.getInstance().findFileByUrl(url)?.let { PsiManager.getInstance(this).findFile(it) }
}


fun Project.findPsiFilesByExtension(extension: String, searchScope: GlobalSearchScope): List<PsiFile> {
val virtualFiles = FilenameIndex.getAllFilesByExt(this, extension, searchScope)
return PsiUtilCore.toPsiFiles(PsiManager.getInstance(this), virtualFiles)
Expand Down
19 changes: 19 additions & 0 deletions intellij-plugin/src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@
editorAttributes="NOT_USED_ELEMENT_ATTRIBUTES"
/>

<!-- Missing GraphQL definition import -->
<!--suppress PluginXmlCapitalization -->
<localInspection
language="GraphQL"
implementationClass="com.apollographql.ijplugin.inspection.ApolloMissingGraphQLDefinitionImportInspection"
groupPathKey="inspection.group.graphql"
groupKey="inspection.group.graphql.apolloKotlin"
key="inspection.missingGraphQLDefinitionImport.displayName"
enabledByDefault="true"
level="ERROR"
editorAttributes="WRONG_REFERENCES_ATTRIBUTES"
/>

<!-- "Missing introspection" inspection -->
<!--suppress PluginXmlCapitalization -->
<localInspection
Expand Down Expand Up @@ -174,6 +187,12 @@
implementationClass="com.apollographql.ijplugin.inspection.GraphQLInspectionSuppressor"
/>

<!-- Suppression of GraphQLUnresolvedReference for certain known directives -->
<lang.inspectionSuppressor
language="GraphQL"
implementationClass="com.apollographql.ijplugin.inspection.GraphQLUnresolvedReferenceInspectionSuppressor"
/>

<!-- Fields insights service (fetch and cache data) -->
<projectService
serviceInterface="com.apollographql.ijplugin.studio.fieldinsights.FieldInsightsService"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<html>
<body>
Reports usages of directives that are not imported.
BoD marked this conversation as resolved.
Show resolved Hide resolved
<p>
Certain directives, such as <code>@semanticNonNull</code> and <code>@catch</code>, are not imported by default.<br>
To be used, they must be imported by your schema, using the <code>@link</code> directive<br>:
<pre>
extend schema
@link(
url: "https://specs.apollo.dev/nullability/v0.1",
import: ["@semanticNonNull"]
)
</pre>
</p>
<p>
<a href="https://specs.apollo.dev/link/v1.0/">More information about the <code>@link</code> directive</a>
</p>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ inspection.suppress.field=Suppress for field
intention.InputConstructorChangeToBuilder.name.editor=Change to builder construction
intention.InputConstructorChangeToBuilder.name.settings=Change input class constructor to builder

inspection.missingGraphQLDefinitionImport.displayName=Missing GraphQL import
inspection.missingGraphQLDefinitionImport.reportText=The {0} is not imported
BoD marked this conversation as resolved.
Show resolved Hide resolved
inspection.missingGraphQLDefinitionImport.reportText.directive=directive
inspection.missingGraphQLDefinitionImport.reportText.enum=enum
inspection.missingGraphQLDefinitionImport.quickFix=Import {0}

inspection.more=More...


notification.group.apollo.main=Apollo
notification.group.apollo.telemetry=Apollo (telemetry)

Expand Down Expand Up @@ -206,4 +215,3 @@ normalizedCacheViewer.pullFromDevice.apolloDebugNormalizedCache.records={0,choic

tree.dynamicNode.loading=Loading...

inspection.more=More...
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.ijplugin.ApolloTestCase
import com.intellij.testFramework.TestDataPath
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@TestDataPath("\$CONTENT_ROOT/testData/inspection")
@RunWith(JUnit4::class)
class ApolloMissingGraphQLDefinitionImportInspectionTest : ApolloTestCase() {

override fun getTestDataPath() = "src/test/testData/inspection"

@Throws(Exception::class)
override fun setUp() {
super.setUp()
myFixture.enableInspections(ApolloMissingGraphQLDefinitionImportInspection())
}

@Test
fun testInspection() {
myFixture.copyFileToProject("MissingGraphQLDefinitionImport.graphqls", "MissingGraphQLDefinitionImport.graphqls")
myFixture.copyFileToProject("MissingGraphQLDefinitionImport.config.yml", "graphql.config.yml")
myFixture.configureByFile("MissingGraphQLDefinitionImport.graphql")

var highlightInfos = doHighlighting()
assertTrue(highlightInfos.any { it.description == "The directive is not imported" })
val quickFixAction = myFixture.findSingleIntention("Import directive")
assertNotNull(quickFixAction)

// Apply quickfix
myFixture.launchAction(quickFixAction)
highlightInfos = doHighlighting()
assertTrue(highlightInfos.none { it.description == "The directive is not imported" })

myFixture.openFileInEditor(myFixture.findFileInTempDir("extra.graphqls"))
myFixture.checkResultByFile("MissingGraphQLDefinitionImport_extra_after.graphqls", true)
}
}