diff --git a/json/src/com/jetbrains/jsonSchema/impl/JsonSchemaByCommentProvider.kt b/json/src/com/jetbrains/jsonSchema/impl/JsonSchemaByCommentProvider.kt new file mode 100644 index 000000000000..cc13f21cfee4 --- /dev/null +++ b/json/src/com/jetbrains/jsonSchema/impl/JsonSchemaByCommentProvider.kt @@ -0,0 +1,62 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.jsonSchema.impl + +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiComment +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.psi.util.PsiTreeUtil +import java.util.regex.Pattern + + +object JsonSchemaByCommentProvider { + + @JvmStatic + fun getCommentSchema(file: VirtualFile, project: Project): String? { + val psiFile = PsiManager.getInstance(project).findFile(file) ?: return null + return getCommentSchema(psiFile) + } + + @JvmStatic + fun getCommentSchema(psiFile: PsiFile): String? { + for (comment in PsiTreeUtil.findChildrenOfType(psiFile, PsiComment::class.java)) { + val chars = comment.getNode()?.getChars()?.let { StringUtil.newBombedCharSequence(it, 300) } ?: continue + detectInComment(chars)?.let { + return it.subSequence(chars).toString() + } + } + return null + } + + fun detectInComment(chars: CharSequence): TextRange? { + for (schemaComment in availableComments) { + schemaComment.detect(chars)?.let { + return it + } + } + return null + } + + + private val availableComments: List = + listOf(SchemaComment("yaml-language-server: ${'$'}schema=", + Pattern.compile("#\\s*yaml-language-server:\\s*\\\$schema=(?\\S+).*", Pattern.CASE_INSENSITIVE), + forCompletion = false), + SchemaComment("${'$'}schema: ", + Pattern.compile("#\\s*\\\$schema:\\s*(?\\S+).*", Pattern.CASE_INSENSITIVE))) + + + val schemaCommentsForCompletion: List = availableComments.filter { it.forCompletion }.map { it.commentText } + +} + +private class SchemaComment(val commentText: String, val detectPattern: Pattern, val forCompletion: Boolean = true) { + fun detect(chars: CharSequence): TextRange? { + val matcher = detectPattern.matcher(chars) + if (!matcher.matches()) return null + return TextRange.create(matcher.start("id"), matcher.end("id")) + } +} \ No newline at end of file diff --git a/json/src/com/jetbrains/jsonSchema/impl/JsonSchemaServiceImpl.java b/json/src/com/jetbrains/jsonSchema/impl/JsonSchemaServiceImpl.java index 0c06d13c6e03..e6034a5a60b5 100644 --- a/json/src/com/jetbrains/jsonSchema/impl/JsonSchemaServiceImpl.java +++ b/json/src/com/jetbrains/jsonSchema/impl/JsonSchemaServiceImpl.java @@ -193,6 +193,7 @@ public class JsonSchemaServiceImpl implements JsonSchemaService, ModificationTra boolean checkSchemaProperty = true; if (!onlyUserSchemas && providers.stream().noneMatch(p -> p.getSchemaType() == SchemaType.userSchema)) { if (schemaUrl == null) schemaUrl = JsonCachedValues.getSchemaUrlFromSchemaProperty(file, myProject); + if (schemaUrl == null) schemaUrl = JsonSchemaByCommentProvider.getCommentSchema(file, myProject); VirtualFile virtualFile = resolveFromSchemaProperty(schemaUrl, file); if (virtualFile != null) return Collections.singletonList(virtualFile); checkSchemaProperty = false; diff --git a/json/src/com/jetbrains/jsonSchema/widget/JsonSchemaInfoPopupStep.java b/json/src/com/jetbrains/jsonSchema/widget/JsonSchemaInfoPopupStep.java index 43cde22b45ae..1ffb4b941287 100644 --- a/json/src/com/jetbrains/jsonSchema/widget/JsonSchemaInfoPopupStep.java +++ b/json/src/com/jetbrains/jsonSchema/widget/JsonSchemaInfoPopupStep.java @@ -75,7 +75,8 @@ public class JsonSchemaInfoPopupStep extends BaseListPopupStep i if (value == STOP_IGNORE_FILE) { return AllIcons.Actions.AddFile; } - return EMPTY_ICON; + + return AllIcons.FileTypes.JsonSchema; } @Nullable diff --git a/plugins/yaml/resources/META-INF/plugin.xml b/plugins/yaml/resources/META-INF/plugin.xml index af2e41644b4b..0661a69e12f4 100644 --- a/plugins/yaml/resources/META-INF/plugin.xml +++ b/plugins/yaml/resources/META-INF/plugin.xml @@ -116,6 +116,8 @@ implementationClass="org.jetbrains.yaml.schema.YamlJsonSchemaDeprecationInspection"/> + + diff --git a/plugins/yaml/src/org/jetbrains/yaml/YAMLJsonSchemaIdReferenceContributor.kt b/plugins/yaml/src/org/jetbrains/yaml/YAMLJsonSchemaIdReferenceContributor.kt new file mode 100644 index 000000000000..5a303996e9f1 --- /dev/null +++ b/plugins/yaml/src/org/jetbrains/yaml/YAMLJsonSchemaIdReferenceContributor.kt @@ -0,0 +1,89 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.yaml + +import com.intellij.codeInsight.completion.* +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.icons.AllIcons.FileTypes.JsonSchema +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.util.text.StringUtil +import com.intellij.patterns.PatternCondition +import com.intellij.patterns.PlatformPatterns +import com.intellij.psi.* +import com.intellij.util.ProcessingContext +import com.intellij.util.asSafely +import com.jetbrains.jsonSchema.ide.JsonSchemaService +import com.jetbrains.jsonSchema.impl.JsonSchemaByCommentProvider +import org.jetbrains.yaml.psi.YAMLDocument +import org.jetbrains.yaml.psi.YAMLMapping + +class YAMLJsonSchemaIdReferenceContributor : PsiReferenceContributor() { + override fun registerReferenceProviders(registrar: PsiReferenceRegistrar) { + registrar.registerReferenceProvider( + PlatformPatterns.psiComment().with(TopDocumentComment), object : PsiReferenceProvider() { + override fun getReferencesByElement(element: PsiElement, context: ProcessingContext): Array { + val charSequence = StringUtil.newBombedCharSequence(element.node.chars, 300) + val inComment = JsonSchemaByCommentProvider.detectInComment(charSequence) ?: return emptyArray() + return arrayOf(JsonSchemaIdReference(element, inComment)) + } + } + ) + } +} + +private object TopDocumentComment : PatternCondition("inTopOfDocument") { + override fun accepts(t: PsiComment, context: ProcessingContext?): Boolean { + var element: PsiElement = t + element = element.parent.takeIf { it is YAMLMapping } ?: element + element = element.parent.takeIf { it is YAMLDocument } ?: element + return element.parent is PsiFile + } +} + +class JsonSchemaIdReference(element: PsiElement, + rangeInElement: TextRange) : PsiReferenceBase.Immediate(element, rangeInElement, true, element) { + + override fun getVariants(): Array { + val project = this.element.project + val schemaService = JsonSchemaService.Impl.get(project) + return schemaService.allUserVisibleSchemas + .filter { it.provider == null || it.provider?.remoteSource != null } + .map { jsonSchemaInfo -> + val url = jsonSchemaInfo.getUrl(project) + LookupElementBuilder.create(url).withIcon(JsonSchema) + }.toTypedArray() + + } +} + +class YAMLJsonSchemaInCommentCompletionContributor : CompletionContributor() { + init { + extend(CompletionType.BASIC, PlatformPatterns.psiComment().with(TopDocumentComment), + object : CompletionProvider() { + override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) { + + val psiComment = parameters.originalPosition?.asSafely() ?: return + val trimmedBody = psiComment.text.removePrefix("#").trimStart() + + for (schemaComment in JsonSchemaByCommentProvider.schemaCommentsForCompletion) { + if (trimmedBody.isBlank() || + trimmedBody.length != schemaComment.length && schemaComment.startsWith(trimmedBody, true)) { + result.addElement( + LookupElementBuilder.create(schemaComment) + .withIcon(JsonSchema) + .withTypeText("JSON Schema specifying comment", true) + .withInsertHandler( + InsertHandler { context: InsertionContext, item: LookupElement? -> + context.laterRunnable = Runnable { + CodeCompletionHandlerBase(CompletionType.BASIC).invokeCompletion(context.project, context.editor) + } + }) + ) + } + + } + + } + }) + } +} \ No newline at end of file diff --git a/plugins/yaml/testSrc/org/jetbrains/yaml/schema/YamlSchemaPointerTest.kt b/plugins/yaml/testSrc/org/jetbrains/yaml/schema/YamlSchemaPointerTest.kt new file mode 100644 index 000000000000..c5905dbdcffd --- /dev/null +++ b/plugins/yaml/testSrc/org/jetbrains/yaml/schema/YamlSchemaPointerTest.kt @@ -0,0 +1,127 @@ +// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.yaml.schema + +import com.intellij.codeInsight.TargetElementUtil +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.impl.source.resolve.reference.PsiReferenceUtil +import com.intellij.testFramework.UsefulTestCase +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.jetbrains.jsonSchema.JsonSchemaMappingsProjectConfiguration +import com.jetbrains.jsonSchema.UserDefinedJsonSchemaConfiguration +import com.jetbrains.jsonSchema.ide.JsonSchemaService +import com.jetbrains.jsonSchema.impl.JsonSchemaVersion +import junit.framework.TestCase +import org.jetbrains.yaml.JsonSchemaIdReference + +class YamlSchemaPointerTest : BasePlatformTestCase() { + + override fun setUp() { + super.setUp() + val schema = myFixture.configureByText("schema.json", """ + { + "${'$'}id": "https://example.com/schemas/address", + "${'$'}schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"] + } + """.trimIndent()).virtualFile + + JsonSchemaMappingsProjectConfiguration.getInstance(project).setState(mapOf( + UserDefinedJsonSchemaConfiguration("my-schema", JsonSchemaVersion.SCHEMA_7, schema.url, false, emptyList()).let { it.name to it } + )) + JsonSchemaService.Impl.get(project).reset() + psiManager.dropPsiCaches() + } + + fun `test schema configured by ref`() { + myFixture.configureByText("document.yaml", """ + { + "${'$'}schema": https://example.com/schemas/address, + + } + """.trimIndent()).virtualFile + + myFixture.testCompletionVariants("document.yaml", "city") + } + + fun `test schema configured by language server comment`() { + myFixture.configureByText("document.yaml", """ + # yaml-language-server: ${'$'}schema=https://example.com/schemas/address + + """.trimIndent()) + + myFixture.testCompletionVariants("document.yaml", "city") + } + + fun `test schema ref available`() { + myFixture.configureByText("document.yaml", """ + # yaml-language-server: ${'$'}schema=https://example.com/schemas/address + + """.trimIndent()) + val refs = TargetElementUtil.findReference(myFixture.editor)?.let(PsiReferenceUtil::unwrapMultiReference).orEmpty() + TestCase.assertTrue(refs.filterIsInstance().isNotEmpty()) + } + + fun `test schema ls completion`() { + myFixture.configureByText("document.yaml", """ + # yaml-language-server: ${'$'}schema= + + """.trimIndent()) + UsefulTestCase.assertContainsElements(myFixture.getCompletionVariants("document.yaml").orEmpty(), + "http://json-schema.org/draft-06/schema") + } + + fun `test schema ls completion in complex comment`() { + myFixture.configureByText("document.yaml", """ + # Copyright 2019 The Kubernetes Authors. + # SPDX-License-Identifier: Apache-2.0 + # yaml-language-server: ${'$'}schema= + + run: + deadline: 5m + go: '1.20' + + + linters: + enable-all: true + disable: + - cyclop + - exhaustivestruct + - forbidigo + + """.trimIndent()) + UsefulTestCase.assertContainsElements(myFixture.getCompletionVariants("document.yaml").orEmpty(), + "http://json-schema.org/draft-06/schema") + } + + fun `test schema ref completion`() { + myFixture.configureByText("document.yaml", """ + # + + """.trimIndent()) + UsefulTestCase.assertContainsElements(myFixture.getCompletionVariants("document.yaml").orEmpty(), + "\$schema: ") + myFixture.type("\$schema: ") + PsiDocumentManager.getInstance(project).commitAllDocuments() + FileDocumentManager.getInstance().saveDocument(myFixture.editor.document) + UsefulTestCase.assertContainsElements(myFixture.getCompletionVariants("document.yaml").orEmpty(), + "http://json-schema.org/draft-04/schema") + } + + fun `test schema detected`() { + myFixture.configureByText("document.yaml", """ + # ${"\$"}schema: https://myexternal-schema-service.com/schemas/def + + + + """.trimIndent()) + + val schemaFilesForFile = JsonSchemaService.Impl.get(project).getSchemaFilesForFile(myFixture.file.virtualFile) + UsefulTestCase.assertSameElements(schemaFilesForFile.map { it.url }, "https://myexternal-schema-service.com/schemas/def") + } + +} \ No newline at end of file