From 93499a51f83c57cb3a90fdbbb5094180194b4940 Mon Sep 17 00:00:00 2001 From: Benoit Lubek Date: Fri, 22 Dec 2023 14:12:01 +0100 Subject: [PATCH] [IJ Plugin] Inspection: missing directive import (#5494) * 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 * 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 * Update intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html Co-authored-by: Martin Bonnin * 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 --- intellij-plugin/build.gradle.kts | 1 + .../gradle/GradleToolingModelService.kt | 9 +- ...issingGraphQLDefinitionImportInspection.kt | 178 ++++++++++++++++++ .../ijplugin/inspection/GraphQL.kt | 20 ++ ...UnresolvedReferenceInspectionSuppressor.kt | 47 +++++ .../apollographql/ijplugin/inspection/Link.kt | 88 +++++++++ .../ijplugin/telemetry/TelemetrySession.kt | 4 + .../com/apollographql/ijplugin/util/Files.kt | 11 ++ .../src/main/resources/META-INF/plugin.xml | 19 ++ .../ApolloMissingGraphQLDefinitionImport.html | 25 +++ .../messages/ApolloBundle.properties | 13 +- ...ngGraphQLDefinitionImportInspectionTest.kt | 85 +++++++++ .../missing-CatchTo-extra.graphqls | 5 + .../missing-CatchTo-extra_after.graphqls | 5 + .../missing-CatchTo.config.yml | 4 + .../missing-CatchTo.graphql | 8 + .../missing-CatchTo.graphqls | 12 ++ .../missing-catch-extra_after.graphqls | 5 + .../missing-catch.config.yml | 4 + .../missing-catch.graphql | 8 + .../missing-catch.graphqls | 12 ++ .../missing-targetName-extra.graphqls | 7 + .../missing-targetName-extra_after.graphqls | 13 ++ .../missing-targetName.config.yml | 4 + .../missing-targetName.graphql | 8 + .../missing-targetName.graphqls | 12 ++ 26 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspection.kt create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQLUnresolvedReferenceInspectionSuppressor.kt create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/Link.kt create mode 100644 intellij-plugin/src/main/resources/inspectionDescriptions/ApolloMissingGraphQLDefinitionImport.html create mode 100644 intellij-plugin/src/test/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspectionTest.kt create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.config.yml create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphql create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphqls create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.config.yml create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphql create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphqls create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.config.yml create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphql create mode 100644 intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphqls diff --git a/intellij-plugin/build.gradle.kts b/intellij-plugin/build.gradle.kts index 86fdfad6860..bc4d86669bb 100644 --- a/intellij-plugin/build.gradle.kts +++ b/intellij-plugin/build.gradle.kts @@ -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) diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/gradle/GradleToolingModelService.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/gradle/GradleToolingModelService.kt index d3fe8e4a341..ec7e0a07c9e 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/gradle/GradleToolingModelService.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/gradle/GradleToolingModelService.kt @@ -117,6 +117,11 @@ class GradleToolingModelService( }) } + fun triggerFetchToolingModels() { + logd() + startOrAbortFetchToolingModels() + } + private fun startOrAbortFetchToolingModels() { logd() abortFetchToolingModels() @@ -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 { @@ -296,3 +301,5 @@ class GradleToolingModelService( } } } + +val Project.gradleToolingModelService get() = service() diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspection.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspection.kt new file mode 100644 index 00000000000..fd0cf8a96bc --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspection.kt @@ -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, + 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().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, + 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(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()?.valueList.orEmpty().map { it.text.unquoted() }) + add(element.nameForImport) + } + linkDirective.replace(createLinkDirective(project, importedNames, definitions, definitionsUrl)) + } + } +} + +private fun createLinkDirectiveSchemaExtension( + project: Project, + importedNames: Set, + definitions: List, + definitionsUrl: String, +): GraphQLSchemaExtension { + // If any of the imported name is a directive, add its argument types to the import list + val knownDefinitionNames = definitions.filterIsInstance().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().single() +} + +private fun createLinkDirective( + project: Project, + importedNames: Set, + definitions: List, + definitionsUrl: String, +): GraphQLDirective { + return createLinkDirectiveSchemaExtension(project, importedNames, definitions, definitionsUrl).directives.single() +} diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQL.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQL.kt index e38268c1eb9..c87952ba3d5 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQL.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQL.kt @@ -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 /** @@ -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 { + 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 diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQLUnresolvedReferenceInspectionSuppressor.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQLUnresolvedReferenceInspectionSuppressor.kt new file mode 100644 index 00000000000..738c6e32240 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/GraphQLUnresolvedReferenceInspectionSuppressor.kt @@ -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 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.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() +} diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/Link.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/Link.kt new file mode 100644 index 00000000000..7e923cd31f9 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/inspection/Link.kt @@ -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 by lazy { + nullabilityDefinitions(NULLABILITY_VERSION) +} + +const val KOTLIN_LABS_URL = "https://specs.apollo.dev/kotlin_labs/$KOTLIN_LABS_VERSION" + +val KOTLIN_LABS_DEFINITIONS: List by lazy { + kotlinLabsDefinitions(KOTLIN_LABS_VERSION) +} + +const val CATCH = "catch" + +fun List.directives(): List { + return filterIsInstance() +} + +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 { + val schemaDirectives = typeDefinitions.filterIsInstance().flatMap { it.directives } + + typeDefinitions.filterIsInstance().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 diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/telemetry/TelemetrySession.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/telemetry/TelemetrySession.kt index 2074f8d202e..e5d05cf9ca1 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/telemetry/TelemetrySession.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/telemetry/TelemetrySession.kt @@ -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 { diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/util/Files.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/util/Files.kt index fb603515660..488f4efa56c 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/util/Files.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/util/Files.kt @@ -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 { 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 { val virtualFiles = FilenameIndex.getAllFilesByExt(this, extension, searchScope) return PsiUtilCore.toPsiFiles(PsiManager.getInstance(this), virtualFiles) diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin/src/main/resources/META-INF/plugin.xml index 56076fde257..0a0787bb420 100644 --- a/intellij-plugin/src/main/resources/META-INF/plugin.xml +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -124,6 +124,19 @@ editorAttributes="NOT_USED_ELEMENT_ATTRIBUTES" /> + + + + + + + + +Reports usages of types and directives that are not imported or imported implicitly. +

+ Before being referenced, directives and types supported by Apollo Kotlin must be imported by your schema using the @link directive
. + For instance, to use the @semanticNonNull directive, import it from the + nullability definitions: +

+        extend schema
+        @link(
+          url: "https://specs.apollo.dev/nullability/v0.1",
+          import: ["@semanticNonNull"]
+        )
+    
+

+

+ Note: the kotlin_labs directives such as @typePolicy + are currently implicitly imported, so an explicit import is not required, but encouraged. This inspection will report + their non-imported usage as a warning. +

+

+ More information about the @link directive +

+ + diff --git a/intellij-plugin/src/main/resources/messages/ApolloBundle.properties b/intellij-plugin/src/main/resources/messages/ApolloBundle.properties index a804beae195..7cec1a9dada 100644 --- a/intellij-plugin/src/main/resources/messages/ApolloBundle.properties +++ b/intellij-plugin/src/main/resources/messages/ApolloBundle.properties @@ -158,6 +158,18 @@ 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.error=Unresolved {0}: {1} +inspection.missingGraphQLDefinitionImport.reportText.warning=Usage of implicit {0}: {1} +inspection.missingGraphQLDefinitionImport.reportText.directive=directive +inspection.missingGraphQLDefinitionImport.reportText.enum=enum +inspection.missingGraphQLDefinitionImport.reportText.input=input object +inspection.missingGraphQLDefinitionImport.reportText.scalar=scalar +inspection.missingGraphQLDefinitionImport.quickFix=Import {0} {1} + +inspection.more=More... + + notification.group.apollo.main=Apollo notification.group.apollo.telemetry=Apollo (telemetry) @@ -206,4 +218,3 @@ normalizedCacheViewer.pullFromDevice.apolloDebugNormalizedCache.records={0,choic tree.dynamicNode.loading=Loading... -inspection.more=More... diff --git a/intellij-plugin/src/test/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspectionTest.kt b/intellij-plugin/src/test/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspectionTest.kt new file mode 100644 index 00000000000..441bd358e26 --- /dev/null +++ b/intellij-plugin/src/test/kotlin/com/apollographql/ijplugin/inspection/ApolloMissingGraphQLDefinitionImportInspectionTest.kt @@ -0,0 +1,85 @@ +package com.apollographql.ijplugin.inspection + +import com.apollographql.ijplugin.ApolloTestCase +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.testFramework.TestDataPath +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@TestDataPath("\$CONTENT_ROOT/testData/inspection/MissingGraphQLDefinitionImport") +@RunWith(JUnit4::class) +class ApolloMissingGraphQLDefinitionImportInspectionTest : ApolloTestCase() { + + override fun getTestDataPath() = "src/test/testData/inspection/MissingGraphQLDefinitionImport" + + @Throws(Exception::class) + override fun setUp() { + super.setUp() + myFixture.enableInspections(ApolloMissingGraphQLDefinitionImportInspection()) + } + + @Test + fun missingCatch() { + myFixture.copyFileToProject("missing-catch.graphqls", "missing-catch.graphqls") + myFixture.copyFileToProject("missing-catch.config.yml", "graphql.config.yml") + myFixture.configureByFile("missing-catch.graphql") + + var highlightInfos = doHighlighting() + assertTrue(highlightInfos.any { it.description == "Unresolved directive: catch" }) + val quickFixAction = myFixture.findSingleIntention("Import directive 'catch'") + assertNotNull(quickFixAction) + + // Apply quickfix + myFixture.launchAction(quickFixAction) + highlightInfos = doHighlighting() + assertTrue(highlightInfos.none { it.description == "Unresolved directive: catch" }) + + myFixture.openFileInEditor(myFixture.findFileInTempDir("extra.graphqls")) + myFixture.checkResultByFile("missing-catch-extra_after.graphqls", true) + } + + + @Test + fun missingCatchTo() { + myFixture.copyFileToProject("missing-CatchTo.graphqls", "missing-CatchTo.graphqls") + myFixture.copyFileToProject("missing-CatchTo-extra.graphqls", "extra.graphqls") + myFixture.copyFileToProject("missing-CatchTo.config.yml", "graphql.config.yml") + myFixture.configureByFile("missing-CatchTo.graphql") + + var highlightInfos = doHighlighting() + assertTrue(highlightInfos.any { it.description == "Unresolved enum: CatchTo" }) + val quickFixAction = myFixture.findSingleIntention("Import enum 'CatchTo'") + assertNotNull(quickFixAction) + + // Apply quickfix + myFixture.launchAction(quickFixAction) + highlightInfos = doHighlighting() + assertTrue(highlightInfos.none { it.description == "Unresolved enum: CatchTo" }) + + myFixture.openFileInEditor(myFixture.findFileInTempDir("extra.graphqls")) + myFixture.checkResultByFile("missing-CatchTo-extra_after.graphqls", true) + } + + @Test + fun missingTargetNameTo() { + myFixture.copyFileToProject("missing-targetName.graphqls", "missing-targetName.graphqls") + myFixture.copyFileToProject("missing-targetName-extra.graphqls", "extra.graphqls") + myFixture.copyFileToProject("missing-targetName.config.yml", "graphql.config.yml") + myFixture.configureByFile("missing-targetName.graphql") + + var highlightInfos = doHighlighting() + assertTrue(highlightInfos.any { it.description == "Usage of implicit directive: targetName" && it.severity == HighlightSeverity.WEAK_WARNING }) + val quickFixAction = myFixture.findSingleIntention("Import directive 'targetName'") + assertNotNull(quickFixAction) + + // Apply quickfix + myFixture.launchAction(quickFixAction) + highlightInfos = doHighlighting() + assertTrue(highlightInfos.none { it.description == "Usage of implicit directive: targetName" }) + + myFixture.openFileInEditor(myFixture.findFileInTempDir("extra.graphqls")) + myFixture.checkResultByFile("missing-targetName-extra_after.graphqls", true) + } + +} diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls new file mode 100644 index 00000000000..e2f546afeb2 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra.graphqls @@ -0,0 +1,5 @@ +extend schema +@link( + url: "https://specs.apollo.dev/nullability/v0.1", + import: ["@catch"] +) diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls new file mode 100644 index 00000000000..616469b20a8 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo-extra_after.graphqls @@ -0,0 +1,5 @@ +extend schema +@link( + url: "https://specs.apollo.dev/nullability/v0.1", + import: ["@catch", "CatchTo"] +) diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.config.yml b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.config.yml new file mode 100644 index 00000000000..82a7169b687 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.config.yml @@ -0,0 +1,4 @@ +schema: + - missing-CatchTo.graphqls + - extra.graphqls +documents: '**/*.graphql' diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphql b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphql new file mode 100644 index 00000000000..dff674b7c3f --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphql @@ -0,0 +1,8 @@ +query MyQuery { + person { + identity { + firstName @catch(to: THROW) + lastName + } + } +} diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphqls new file mode 100644 index 00000000000..a1ffb654b70 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-CatchTo.graphqls @@ -0,0 +1,12 @@ +type Query { + person: Person +} + +type Person { + identity: Identity +} + +type Identity { + firstName: String + lastName: String +} diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls new file mode 100644 index 00000000000..3cbe73d3883 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch-extra_after.graphqls @@ -0,0 +1,5 @@ +extend schema +@link( + url: "https://specs.apollo.dev/nullability/v0.1", + import: ["@catch", "CatchTo"] +) \ No newline at end of file diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.config.yml b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.config.yml new file mode 100644 index 00000000000..86b7bc4aa42 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.config.yml @@ -0,0 +1,4 @@ +schema: + - missing-catch.graphqls + - extra.graphqls +documents: '**/*.graphql' diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphql b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphql new file mode 100644 index 00000000000..f97085cc6e7 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphql @@ -0,0 +1,8 @@ +query MyQuery { + person { + identity { + firstName @catch + lastName + } + } +} diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphqls new file mode 100644 index 00000000000..a1ffb654b70 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-catch.graphqls @@ -0,0 +1,12 @@ +type Query { + person: Person +} + +type Person { + identity: Identity +} + +type Identity { + firstName: String + lastName: String +} diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls new file mode 100644 index 00000000000..f61b7bf5e45 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra.graphqls @@ -0,0 +1,7 @@ +extend schema +@link( + url: "https://specs.apollo.dev/nullability/v0.1", + import: ["@catch", "CatchTo"] +) + +extend schema @catch(to: THROW) \ No newline at end of file diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls new file mode 100644 index 00000000000..d070ad01688 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName-extra_after.graphqls @@ -0,0 +1,13 @@ +extend schema +@link( + url: "https://specs.apollo.dev/kotlin_labs/v0.2", + import: ["@targetName"] +) + +extend schema +@link( + url: "https://specs.apollo.dev/nullability/v0.1", + import: ["@catch", "CatchTo"] +) + +extend schema @catch(to: THROW) \ No newline at end of file diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.config.yml b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.config.yml new file mode 100644 index 00000000000..04af04620db --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.config.yml @@ -0,0 +1,4 @@ +schema: + - missing-targetName.graphqls + - extra.graphqls +documents: '**/*.graphql' diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphql b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphql new file mode 100644 index 00000000000..00a6eb744bb --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphql @@ -0,0 +1,8 @@ +query MyQuery { + person { + identity { + firstName @catch(to: THROW) + lastName @targetName(name: "last_name") + } + } +} diff --git a/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphqls b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphqls new file mode 100644 index 00000000000..a1ffb654b70 --- /dev/null +++ b/intellij-plugin/src/test/testData/inspection/MissingGraphQLDefinitionImport/missing-targetName.graphqls @@ -0,0 +1,12 @@ +type Query { + person: Person +} + +type Person { + identity: Identity +} + +type Identity { + firstName: String + lastName: String +}