Skip to content

[WIP] Introduce JSON validation through schemas #315

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

Draft
wants to merge 12 commits into
base: dev
Choose a base branch
from
Draft
4 changes: 2 additions & 2 deletions src/main/kotlin/com/demonwav/mcdev/i18n/I18nAnnotator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import com.demonwav.mcdev.i18n.intentions.RemoveUnmatchedEntryIntention
import com.demonwav.mcdev.i18n.intentions.TrimKeyIntention
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nEntry
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nTypes
import com.demonwav.mcdev.util.mcDomain
import com.demonwav.mcdev.util.resourceDomain
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.Annotator
import com.intellij.openapi.util.TextRange
Expand Down Expand Up @@ -50,7 +50,7 @@ class I18nAnnotator : Annotator {
}

private fun checkEntryMatchesDefault(entry: I18nEntry, annotations: AnnotationHolder) {
if (entry.project.findDefaultLangEntries(domain = entry.containingFile.virtualFile.mcDomain).any { it.key == entry.key }) {
if (entry.project.findDefaultLangEntries(domain = entry.containingFile.virtualFile.resourceDomain).any { it.key == entry.key }) {
return
}
annotations.createWarningAnnotation(entry.textRange, "Translation key not included in default localization file.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import com.demonwav.mcdev.i18n.lang.gen.psi.I18nTypes
import com.demonwav.mcdev.i18n.sorting.I18nSorter
import com.demonwav.mcdev.i18n.sorting.Ordering
import com.demonwav.mcdev.util.applyWriteAction
import com.demonwav.mcdev.util.mcDomain
import com.demonwav.mcdev.util.resourceDomain
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.fileEditor.FileEditor
Expand Down Expand Up @@ -80,7 +80,7 @@ class I18nEditorNotificationProvider(private val project: Project) : EditorNotif
}

private fun getMissingEntries(file: VirtualFile): Map<String, I18nEntry> {
val defaultEntries = project.findDefaultLangEntries(scope = Scope.PROJECT, domain = file.mcDomain)
val defaultEntries = project.findDefaultLangEntries(scope = Scope.PROJECT, domain = file.resourceDomain)
val entries = project.findLangEntries(file = file, scope = Scope.PROJECT)
val keys = entries.map { it.key }
val missingEntries = defaultEntries.associate { it.key to it }.toMutableMap()
Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/com/demonwav/mcdev/i18n/I18nElementFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import com.demonwav.mcdev.i18n.lang.I18nFileType
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nEntry
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nTypes
import com.demonwav.mcdev.util.applyWriteAction
import com.demonwav.mcdev.util.mcDomain
import com.demonwav.mcdev.util.resourceDomain
import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.module.Module
Expand Down Expand Up @@ -50,7 +50,7 @@ object I18nElementFactory {

val files = FileTypeIndex.getFiles(I18nFileType, GlobalSearchScope.moduleScope(module))
if (files.count { it.nameWithoutExtension.toLowerCase(Locale.ROOT) == I18nConstants.DEFAULT_LOCALE } > 1) {
val choices = files.mapNotNull { it.mcDomain }.distinct().sorted()
val choices = files.mapNotNull { it.resourceDomain }.distinct().sorted()
val swingList = JBList(choices)
DataManager.getInstance().dataContextFromFocus.doWhenDone(Consumer<DataContext> {
JBPopupFactory.getInstance()
Expand Down
8 changes: 4 additions & 4 deletions src/main/kotlin/com/demonwav/mcdev/i18n/i18n-utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ package com.demonwav.mcdev.i18n
import com.demonwav.mcdev.i18n.lang.I18nFile
import com.demonwav.mcdev.i18n.lang.I18nFileType
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nEntry
import com.demonwav.mcdev.util.mcDomain
import com.demonwav.mcdev.util.resourceDomain
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiManager
Expand Down Expand Up @@ -47,7 +47,7 @@ fun Project.findLangEntries(scope: Scope = Scope.GLOBAL, key: String? = null, fi
{
it.virtualFile != null
&& (file == null || it.virtualFile.path == file.path)
&& (domain == null || it.virtualFile.mcDomain == domain)
&& (domain == null || it.virtualFile.resourceDomain == domain)
},
{ key == null || it.key == key }
)
Expand All @@ -58,7 +58,7 @@ fun Project.findDefaultLangEntries(scope: Scope = Scope.GLOBAL, key: String? = n
{
it.virtualFile != null && it.virtualFile.nameWithoutExtension.toLowerCase(Locale.ROOT) == I18nConstants.DEFAULT_LOCALE
&& (file == null || it.virtualFile.path == file.path)
&& (domain == null || it.virtualFile.mcDomain == domain)
&& (domain == null || it.virtualFile.resourceDomain == domain)
},
{ key == null || it.key == key }
)
Expand All @@ -69,4 +69,4 @@ fun Project.findDefaultLangFile(domain: String? = null) =
I18nConstants.DEFAULT_LOCALE_FILE,
false,
GlobalSearchScope.projectScope(this)
).firstOrNull { domain == null || it.mcDomain == domain }
).firstOrNull { domain == null || it.resourceDomain == domain }
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import com.demonwav.mcdev.i18n.findDefaultLangEntries
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nEntry
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nTypes
import com.demonwav.mcdev.util.getSimilarity
import com.demonwav.mcdev.util.mcDomain
import com.demonwav.mcdev.util.resourceDomain
import com.intellij.codeInsight.completion.CompletionContributor
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionResultSet
Expand Down Expand Up @@ -55,7 +55,7 @@ class I18nCompletionContributor : CompletionContributor() {

if (KEY_PATTERN.accepts(position) || DUMMY_PATTERN.accepts(position)) {
val text = position.text.let { it.substring(0, it.length - CompletionUtil.DUMMY_IDENTIFIER.length) }
val domain = file.mcDomain
val domain = file.resourceDomain
handleKey(text, position, domain, result)
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/main/kotlin/com/demonwav/mcdev/i18n/sorting/I18nSorter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import com.demonwav.mcdev.i18n.findDefaultLangFile
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nEntry
import com.demonwav.mcdev.i18n.lang.gen.psi.I18nTypes
import com.demonwav.mcdev.util.lexicographical
import com.demonwav.mcdev.util.mcDomain
import com.demonwav.mcdev.util.resourceDomain
import com.demonwav.mcdev.util.runWriteAction
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiElement
Expand All @@ -32,7 +32,7 @@ object I18nSorter {
private val descendingComparator = ascendingComparator.reversed()

fun query(project: Project, file: PsiFile, defaultSelection: Ordering = Ordering.ASCENDING) {
val defaultFileMissing = project.findDefaultLangFile(file.virtualFile.mcDomain ?: return) == null
val defaultFileMissing = project.findDefaultLangFile(file.virtualFile.resourceDomain ?: return) == null
val isDefaultFile = file.name == I18nConstants.DEFAULT_LOCALE_FILE
val (order, comments) = TranslationSortOrderDialog.show(defaultFileMissing || isDefaultFile, defaultSelection)
if (order == null) {
Expand All @@ -47,7 +47,7 @@ object I18nSorter {
Ordering.ASCENDING -> I18nElementFactory.assembleElements(project, it.sortedWith(ascendingComparator), keepComments)
Ordering.DESCENDING -> I18nElementFactory.assembleElements(project, it.sortedWith(descendingComparator), keepComments)
Ordering.TEMPLATE -> sortByTemplate(project, TemplateManager.getProjectTemplate(project), it, keepComments)
else -> sortByTemplate(project, buildDefaultTemplate(project, file.virtualFile.mcDomain) ?: return, it, keepComments)
else -> sortByTemplate(project, buildDefaultTemplate(project, file.virtualFile.resourceDomain) ?: return, it, keepComments)
}
}

Expand Down
58 changes: 58 additions & 0 deletions src/main/kotlin/com/demonwav/mcdev/json/schema_providers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Minecraft Dev for IntelliJ
*
* https://minecraftdev.org
*
* Copyright (c) 2018 minecraft-dev
*
* MIT License
*/

package com.demonwav.mcdev.json

import com.demonwav.mcdev.util.resourceDomain
import com.demonwav.mcdev.util.resourcePath
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.jetbrains.jsonSchema.extension.JsonSchemaFileProvider
import com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory
import com.jetbrains.jsonSchema.extension.SchemaType

class SchemaProviderFactory : JsonSchemaProviderFactory {
override fun getProviders(project: Project) =
listOf(
SoundsSchemaProvider(),
PathBasedSchemaProvider("Minecraft Blockstates JSON", "blockstates", "blockstates/"),
PathBasedSchemaProvider("Minecraft Item Model JSON", "model_item", "models/item/"),
PathBasedSchemaProvider("Minecraft Block Model JSON", "model_block", "models/block/"),
PathBasedSchemaProvider("Minecraft Loot Table JSON", "loot_table", "loot_tables/"),
PathBasedSchemaProvider("Minecraft Advancement JSON", "advancement", "advancements/")
)
}

class SoundsSchemaProvider : JsonSchemaFileProvider {
companion object {
val FILE = JsonSchemaProviderFactory.getResourceFile(SchemaProviderFactory::class.java, "/jsonSchemas/sounds.schema.json")
}

override fun getName() = "Minecraft Sounds JSON"

override fun isAvailable(file: VirtualFile) = file.resourceDomain != null && file.resourcePath == "sounds.json"

override fun getSchemaType(): SchemaType = SchemaType.embeddedSchema

override fun getSchemaFile(): VirtualFile = FILE
}

class PathBasedSchemaProvider(name: String, schema: String, private val path: String) : JsonSchemaFileProvider {
private val _name = name
private val file = JsonSchemaProviderFactory.getResourceFile(SchemaProviderFactory::class.java, "/jsonSchemas/$schema.schema.json")

override fun getName() = this._name

override fun isAvailable(file: VirtualFile) = file.resourceDomain != null && file.resourcePath?.startsWith(path) == true

override fun getSchemaType(): SchemaType = SchemaType.embeddedSchema

override fun getSchemaFile(): VirtualFile = file
}
10 changes: 7 additions & 3 deletions src/main/kotlin/com/demonwav/mcdev/util/files.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ val VirtualFile.manifest: Manifest?
}

// Technically resource domains are much more restricted ([a-z0-9_-]+) in modern versions, but we want to support as much as possible
private val DOMAIN_PATTERN = Regex("^.*?/assets/([^/]+)/lang.*?$")
private val RESOURCE_PATTERN = Regex("^.*?/assets/([^/]+)/(.*?)\$")

val VirtualFile.resourceDomain: String?
get() = RESOURCE_PATTERN.matchEntire(this.path)?.groupValues?.get(1)

val VirtualFile.resourcePath: String?
get() = RESOURCE_PATTERN.matchEntire(this.path)?.groupValues?.get(2)

val VirtualFile.mcDomain: String?
get() = DOMAIN_PATTERN.matchEntire(this.path)?.groupValues?.get(1)

operator fun Manifest.get(attribute: String): String? = mainAttributes.getValue(attribute)
operator fun Manifest.get(attribute: Attributes.Name): String? = mainAttributes.getValue(attribute)
4 changes: 4 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,10 @@
<projectResolve implementation="com.demonwav.mcdev.platform.forge.gradle.ForgePatcherProjectResolverExtension"/>
</extensions>

<extensions defaultExtensionNs="JavaScript.JsonSchema">
<ProviderFactory implementation="com.demonwav.mcdev.json.SchemaProviderFactory"/>
</extensions>

<application-components>
<component>
<implementation-class>com.demonwav.mcdev.i18n.I18nFileListener</implementation-class>
Expand Down
112 changes: 112 additions & 0 deletions src/main/resources/jsonSchemas/advancement.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Minecraft Advancement JSON",
"type": "object",
"properties": {
"display": {
"type": "object",
"properties": {
"icon": {
"type": "object",
"properties": {
"item": { "type": "string" },
"data": { "type": "integer", "minimum": 0, "maximum": 32767 }
},
"required": [ "item" ]
},
"title": { "$ref": "common.json#/textComponent" },
"frame": {
"anyOf": [
{ "type": "string" },
{ "enum": [ "task", "goal", "challenge" ], "default": "task" }
]
},
"background": { "type": "string" },
"description": { "$ref": "common.json#/textComponent" },
"show_toast": { "type": "boolean" },
"announce_to_chat": { "type": "boolean" },
"hidden": { "type": "boolean" }
},
"required": [ "title", "description", "icon" ]
},
"parent": { "type": "string" },
"criteria": {
"type": "object",
"additionalProperties": { "$ref": "#/definitions/trigger" },
"minProperties": 1
},
"requirements": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
},
"rewards": {
"type": "object",
"properties": {
"recipes": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"loot": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"experience": { "type": "integer" },
"function": { "type": "string" }
}
}
},
"require": [ "criteria" ],
"definitions": {
"trigger": {
"type": "object",
"properties": {
"trigger": {
"anyOf": [
{ "type": "string" },
{
"type": "string",
"enum": [
"minecraft:bred_animals",
"minecraft:brewed_potion",
"minecraft:changed_dimension",
"minecraft:channeled_lightning",
"minecraft:construct_beacon",
"minecraft:consume_item",
"minecraft:cured_zombie_villager",
"minecraft:effects_changed",
"minecraft:enchanted_item",
"minecraft:enter_block",
"minecraft:entity_hurt_player",
"minecraft:entity_killed_player",
"minecraft:filled_bucket",
"minecraft:fishing_rod_hooked",
"minecraft:impossible",
"minecraft:inventory_changed",
"minecraft:item_durability_changed",
"minecraft:levitation",
"minecraft:location",
"minecraft:nether_travel",
"minecraft:nether_travel",
"minecraft:placed_block",
"minecraft:player_hurt_entity",
"minecraft:player_killed_entity",
"minecraft:recipe_unlocked",
"minecraft:slept_in_bed",
"minecraft:summoned_entity",
"minecraft:tame_animal",
"minecraft:tick",
"minecraft:used_ender_eye",
"minecraft:used_totem",
"minecraft:villager_trade"
]
}
]
}
},
"required": [ "trigger" ]
}
}
}
18 changes: 18 additions & 0 deletions src/main/resources/jsonSchemas/blockstates.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Minecraft Blockstates JSON",
"oneOf": [
{
"allOf": [
{ "required": [ "forge_marker" ] },
{ "$ref": "blockstates_forge.schema.json" }
]
},
{
"allOf": [
{ "not": { "required": [ "forge_marker" ] } },
{ "$ref": "blockstates_vanilla.schema.json" }
]
}
]
}
23 changes: 23 additions & 0 deletions src/main/resources/jsonSchemas/blockstates_common.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Minecraft Blockstates JSON",
"baseVariant": {
"type": "object",
"properties": {
"model": { "type": "string" },
"textures": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"x": {
"type": "number",
"multipleOf": 22.5
},
"y": {
"type": "number",
"multipleOf": 22.5
},
"uvlock": { "type": "boolean" }
}
}
}
Loading