Skip to content

[WIP] Introduce JSON validation through schemas #315

New issue

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

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

Already on GitHub? # 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