Skip to content

Commit

Permalink
[IJ Plugin] Inspection: missing directive import (#5494)
Browse files Browse the repository at this point in the history
* Add ApolloMissingGraphQLDefinitionImport (wip)

* Add GraphQLUnresolvedReferenceInspectionSuppressor

* Quickfix for ApolloMissingGraphQLDefinitionImport

* Add description

* Also add a @catch directive to the schema if we're importing @catch

* Add unit test

* Fix blank lines

* Make @link prefix aware and use apollo-ast for the definitions

* Report missing CatchTo import too

* Update test

* Change wording

* Update intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html

Co-authored-by: Martin Bonnin <[email protected]>

* Use definitions to handle the import directive argument type case

* Warn for usage of non-imported kotlin_labs definitions

* Update intellij-plugin/src/main/resources/messages/ApolloBundle.properties

Co-authored-by: Martin Bonnin <[email protected]>

* Update intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html

Co-authored-by: Martin Bonnin <[email protected]>

* Update test after text change

* Don't add @catch on the schema, and add imports at the top of the file

---------

Co-authored-by: Martin Bonnin <[email protected]>
  • Loading branch information
BoD and martinbonnin authored Dec 22, 2023
1 parent 1bb8640 commit 93499a5
Show file tree
Hide file tree
Showing 26 changed files with 605 additions and 2 deletions.
1 change: 1 addition & 0 deletions intellij-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ publishing {

dependencies {
implementation(project(":apollo-gradle-plugin-external"))
implementation(project(":apollo-ast"))
implementation(project(":apollo-tooling"))
implementation(project(":apollo-normalized-cache-sqlite"))
implementation(libs.sqlite.jdbc)
Expand Down
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,178 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.apollo3.ast.GQLDefinition
import com.apollographql.apollo3.ast.GQLDirectiveDefinition
import com.apollographql.apollo3.ast.GQLEnumTypeDefinition
import com.apollographql.apollo3.ast.GQLInputObjectTypeDefinition
import com.apollographql.apollo3.ast.GQLNamed
import com.apollographql.apollo3.ast.GQLScalarTypeDefinition
import com.apollographql.apollo3.ast.rawType
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.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.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.lang.jsgraphql.psi.GraphQLArrayValue
import com.intellij.lang.jsgraphql.psi.GraphQLDirective
import com.intellij.lang.jsgraphql.psi.GraphQLElementFactory
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
import com.intellij.psi.util.parentOfType

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
visitDirective(o, holder, NULLABILITY_DEFINITIONS, NULLABILITY_URL, ProblemHighlightType.LIKE_UNKNOWN_SYMBOL)
visitDirective(o, holder, KOTLIN_LABS_DEFINITIONS, KOTLIN_LABS_URL, ProblemHighlightType.WEAK_WARNING)
}
}
}

private fun visitDirective(
directiveElement: GraphQLDirective,
holder: ProblemsHolder,
definitions: List<GQLDefinition>,
definitionsUrl: String,
highlightType: ProblemHighlightType,
) {
if (directiveElement.name !in definitions.directives().map { it.name }) return
val message = if (highlightType == ProblemHighlightType.WEAK_WARNING) "inspection.missingGraphQLDefinitionImport.reportText.warning" else "inspection.missingGraphQLDefinitionImport.reportText.error"
if (!directiveElement.isImported(definitionsUrl)) {
val typeKind = ApolloBundle.message("inspection.missingGraphQLDefinitionImport.reportText.directive")
holder.registerProblem(
directiveElement,
ApolloBundle.message(message, typeKind, directiveElement.name!!),
highlightType,
ImportDefinitionQuickFix(typeKind = typeKind, elementName = directiveElement.name!!, definitions = definitions, definitionsUrl = definitionsUrl),
)
} else {
val directiveDefinition = definitions.directives().firstOrNull { it.name == directiveElement.name } ?: return
val knownDefinitionNames = definitions.filterIsInstance<GQLNamed>().map { it.name }
val arguments = directiveElement.arguments?.argumentList.orEmpty()
for (argument in arguments) {
val argumentDefinition = directiveDefinition.arguments.firstOrNull { it.name == argument.name } ?: continue
val argumentTypeToImport = argumentDefinition.type.rawType().name.takeIf { it in knownDefinitionNames } ?: continue
if (!isImported(directiveElement, argumentTypeToImport, definitionsUrl)) {
val typeKind = getTypeKind(argumentTypeToImport)
holder.registerProblem(
argument,
ApolloBundle.message(message, typeKind, argumentTypeToImport),
highlightType,
ImportDefinitionQuickFix(typeKind = typeKind, elementName = argumentTypeToImport, definitions = definitions, definitionsUrl = definitionsUrl),
)
}
}
}
}
}

private fun getTypeKind(typeName: String): String {
val typeDefinition = NULLABILITY_DEFINITIONS.firstOrNull { it is GQLNamed && it.name == typeName } ?: return "unknown"
return ApolloBundle.message(
when (typeDefinition) {
is GQLDirectiveDefinition -> "inspection.missingGraphQLDefinitionImport.reportText.directive"
is GQLEnumTypeDefinition -> "inspection.missingGraphQLDefinitionImport.reportText.enum"
is GQLInputObjectTypeDefinition -> "inspection.missingGraphQLDefinitionImport.reportText.input"
is GQLScalarTypeDefinition -> "inspection.missingGraphQLDefinitionImport.reportText.scalar"
else -> return "unknown"
}
)
}

private class ImportDefinitionQuickFix(
val typeKind: String,
val elementName: String,
private val definitions: List<GQLDefinition>,
private val definitionsUrl: String,
) : LocalQuickFix {
override fun getName() = ApolloBundle.message("inspection.missingGraphQLDefinitionImport.quickFix", typeKind, "'$elementName'")
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.parentOfType<GraphQLDirective>(withSelf = true)!!
val schemaFiles = element.schemaFiles()
val linkDirective = schemaFiles.flatMap { it.linkDirectives(definitionsUrl) }.firstOrNull()

if (linkDirective == null) {
val linkDirectiveSchemaExtension = createLinkDirectiveSchemaExtension(project, setOf(element.nameForImport), definitions, definitionsUrl)
val extraSchemaFile = schemaFiles.firstOrNull { it.name == "extra.graphqls" }
if (extraSchemaFile == null) {
GraphQLElementFactory.createFile(project, linkDirectiveSchemaExtension.text).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 {
val addedElement = extraSchemaFile.addBefore(linkDirectiveSchemaExtension, extraSchemaFile.firstChild)
extraSchemaFile.addAfter(GraphQLElementFactory.createWhiteSpace(project, "\n\n"), addedElement)
}
} else {
val importedNames = buildSet {
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, definitions, definitionsUrl))
}
}
}

private fun createLinkDirectiveSchemaExtension(
project: Project,
importedNames: Set<String>,
definitions: List<GQLDefinition>,
definitionsUrl: String,
): GraphQLSchemaExtension {
// If any of the imported name is a directive, add its argument types to the import list
val knownDefinitionNames = definitions.filterIsInstance<GQLNamed>().map { it.name }
val additionalNames = importedNames.flatMap { importedName ->
definitions.directives().firstOrNull { "@${it.name}" == importedName }
?.arguments
?.map { it.type.rawType().name }
?.filter { it in knownDefinitionNames }.orEmpty()
}.toSet()

return GraphQLElementFactory.createFile(
project,
"""
extend schema
@link(
url: "$definitionsUrl",
import: [${(importedNames + additionalNames).joinToString { it.quoted() }}]
)
""".trimIndent()
)
.findChildrenOfType<GraphQLSchemaExtension>().single()
}

private fun createLinkDirective(
project: Project,
importedNames: Set<String>,
definitions: List<GQLDefinition>,
definitionsUrl: String,
): GraphQLDirective {
return createLinkDirectiveSchemaExtension(project, importedNames, definitions, definitionsUrl).directives.single()
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.ijplugin.util.findPsiFileByUrl
import com.intellij.lang.jsgraphql.ide.config.GraphQLConfigProvider
import com.intellij.lang.jsgraphql.psi.GraphQLDirective
import com.intellij.lang.jsgraphql.psi.GraphQLElement
import com.intellij.lang.jsgraphql.psi.GraphQLFieldDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLFile
import com.intellij.lang.jsgraphql.psi.GraphQLIdentifier
import com.intellij.lang.jsgraphql.psi.GraphQLInterfaceTypeDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLNamedTypeDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLObjectTypeDefinition
import com.intellij.lang.jsgraphql.psi.GraphQLValue
import com.intellij.psi.util.parentOfType

/**
Expand Down Expand Up @@ -66,3 +72,17 @@ private fun matchingFieldCoordinates(
if (implementedInterfaceTypeDefinitions.isEmpty()) return fieldCoordinates
return fieldCoordinates + implementedInterfaceTypeDefinitions.flatMap { matchingFieldCoordinates(fieldDefinition, it) }
}

/**
* Return the schema files associated with the given element.
*/
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
}
}

fun GraphQLDirective.argumentValue(argumentName: String): GraphQLValue? =
arguments?.argumentList.orEmpty().firstOrNull { it.name == argumentName }?.value
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.apollographql.ijplugin.inspection

import com.apollographql.apollo3.ast.GQLDirectiveDefinition
import com.apollographql.apollo3.ast.linkDefinitions
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: List<GQLDirectiveDefinition> by lazy {
linkDefinitions().directives() + NULLABILITY_DEFINITIONS.directives()
}

/**
* 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() }

// We need to suppress this one too because the plugin doesn't know that @link is repeatable
"GraphQLDuplicateDirective" -> element is GraphQLDirective && element.name == "link"

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.map { it.name } || this.isImported(NULLABILITY_URL))
}

private fun PsiElement.isKnownDirectiveArgument(): Boolean {
return this is GraphQLArgument &&
parent?.parent?.isKnownDirective() == true &&
name in KNOWN_DIRECTIVES.firstOrNull { it.name == (parent.parent as GraphQLDirective).nameWithoutPrefix }?.arguments?.map { it.name }.orEmpty()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
@file:OptIn(ApolloInternal::class)

package com.apollographql.ijplugin.inspection

import com.apollographql.apollo3.annotations.ApolloInternal
import com.apollographql.apollo3.ast.GQLDefinition
import com.apollographql.apollo3.ast.GQLDirectiveDefinition
import com.apollographql.apollo3.ast.KOTLIN_LABS_VERSION
import com.apollographql.apollo3.ast.NULLABILITY_VERSION
import com.apollographql.apollo3.ast.kotlinLabsDefinitions
import com.apollographql.apollo3.ast.nullabilityDefinitions
import com.apollographql.ijplugin.util.quoted
import com.apollographql.ijplugin.util.unquoted
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.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.GraphQLStringValue

const val NULLABILITY_URL = "https://specs.apollo.dev/nullability/$NULLABILITY_VERSION"

val NULLABILITY_DEFINITIONS: List<GQLDefinition> by lazy {
nullabilityDefinitions(NULLABILITY_VERSION)
}

const val KOTLIN_LABS_URL = "https://specs.apollo.dev/kotlin_labs/$KOTLIN_LABS_VERSION"

val KOTLIN_LABS_DEFINITIONS: List<GQLDefinition> by lazy {
kotlinLabsDefinitions(KOTLIN_LABS_VERSION)
}

const val CATCH = "catch"

fun List<GQLDefinition>.directives(): List<GQLDirectiveDefinition> {
return filterIsInstance<GQLDirectiveDefinition>()
}

fun GraphQLNamedElement.isImported(definitionsUrl: String): Boolean {
for (schemaFile in schemaFiles()) {
if (schemaFile.hasImportFor(this.name!!, this is GraphQLDirective, definitionsUrl)) return true
}
return false
}

fun isImported(element: GraphQLElement, enumName: String, definitionsUrl: String): Boolean {
for (schemaFile in element.schemaFiles()) {
if (schemaFile.hasImportFor(enumName, false, definitionsUrl)) return true
}
return false
}

fun GraphQLFile.linkDirectives(definitionsUrl: String): 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 == definitionsUrl.quoted() }
}
}

private fun GraphQLFile.hasImportFor(name: String, isDirective: Boolean, definitionsUrl: String): Boolean {
for (directive in linkDirectives(definitionsUrl)) {
val importArgValue = directive.argumentValue("import") as? GraphQLArrayValue
if (importArgValue == null) {
// Default import is everything - see https://specs.apollo.dev/link/v1.0/#@link.url
val asArgValue = directive.argumentValue("as") as? GraphQLStringValue
// Default prefix is the name part of the url
val prefix = (asArgValue?.text?.unquoted() ?: "nullability") + "__"
if (name.startsWith(prefix)) return true
} else {
if (importArgValue.valueList.any { it.text == name.nameForImport(isDirective).quoted() }) {
return true
}
}
}
return false
}

val String.nameWithoutPrefix get() = substringAfter("__")

val GraphQLNamedElement.nameWithoutPrefix get() = name!!.nameWithoutPrefix

fun String.nameForImport(isDirective: Boolean) = "${if (isDirective) "@" else ""}${this.nameWithoutPrefix}"

val GraphQLNamedElement.nameForImport get() = if (this is GraphQLDirective) "@$nameWithoutPrefix" else nameWithoutPrefix
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
Loading

0 comments on commit 93499a5

Please sign in to comment.