@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
644 lines (643 loc) • 30.4 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DataFormIssueType = void 0;
const Utilities_1 = __importDefault(require("../core/Utilities"));
const DataFormUtilities_1 = __importDefault(require("./DataFormUtilities"));
const FieldUtilities_1 = __importDefault(require("./FieldUtilities"));
const ICondition_1 = require("./ICondition");
const IField_1 = require("./IField");
/**
* Cleans a field ID by removing the embedded enum syntax that appears in some form files.
* For example: 'render_distance_type"<"fixed", "render"' becomes 'render_distance_type'
* This syntax is used in forms to specify enum choices but should not appear in property names.
*/
function cleanFieldId(fieldId) {
const quoteIndex = fieldId.indexOf('"<"');
if (quoteIndex !== -1) {
return fieldId.substring(0, quoteIndex);
}
return fieldId;
}
/**
* Maximum recursion depth for validation to prevent exponential time complexity
* when validating deeply nested structures. Beyond this depth, validation will
* skip recursive subForm validation.
*/
const MAX_VALIDATION_DEPTH = 10;
var DataFormIssueType;
(function (DataFormIssueType) {
DataFormIssueType[DataFormIssueType["unexpectedStringUsedWhenObjectExpected"] = 101] = "unexpectedStringUsedWhenObjectExpected";
DataFormIssueType[DataFormIssueType["unexpectedBooleanUsedWhenObjectExpected"] = 102] = "unexpectedBooleanUsedWhenObjectExpected";
DataFormIssueType[DataFormIssueType["unexpectedNumberUsedWhenObjectExpected"] = 103] = "unexpectedNumberUsedWhenObjectExpected";
DataFormIssueType[DataFormIssueType["dataTypeMismatch"] = 110] = "dataTypeMismatch";
DataFormIssueType[DataFormIssueType["valueBelowMinimum"] = 111] = "valueBelowMinimum";
DataFormIssueType[DataFormIssueType["valueAboveMaximum"] = 112] = "valueAboveMaximum";
DataFormIssueType[DataFormIssueType["stringTooShort"] = 113] = "stringTooShort";
DataFormIssueType[DataFormIssueType["stringTooLong"] = 114] = "stringTooLong";
DataFormIssueType[DataFormIssueType["valueNotInChoices"] = 115] = "valueNotInChoices";
DataFormIssueType[DataFormIssueType["patternMismatch"] = 116] = "patternMismatch";
DataFormIssueType[DataFormIssueType["arrayLengthMismatch"] = 117] = "arrayLengthMismatch";
DataFormIssueType[DataFormIssueType["pointSizeMismatch"] = 118] = "pointSizeMismatch";
DataFormIssueType[DataFormIssueType["keyNotAllowed"] = 119] = "keyNotAllowed";
DataFormIssueType[DataFormIssueType["unexpectedProperty"] = 120] = "unexpectedProperty";
DataFormIssueType[DataFormIssueType["missingRequiredField"] = 121] = "missingRequiredField";
})(DataFormIssueType || (exports.DataFormIssueType = DataFormIssueType = {}));
class DataFormValidator {
static async validate(data, form, issues, path, context) {
// Initialize context on first call (top-level validation)
if (!context) {
context = {
depth: 0,
subFormCache: new Map(),
};
}
// Check recursion depth limit - skip deep validation to prevent exponential complexity
if (context.depth >= MAX_VALIDATION_DEPTH) {
return issues || [];
}
if (path === undefined) {
path = "";
}
else {
path = path + ".";
}
if (!issues) {
issues = [];
}
if (typeof data === "string" || typeof data === "number" || typeof data === "boolean") {
const scalarField = DataFormUtilities_1.default.getScalarField(form);
if (!scalarField) {
if (typeof data === "string") {
issues.push(DataFormValidator.getValidationIssue(DataFormIssueType.unexpectedStringUsedWhenObjectExpected));
}
else if (typeof data === "number") {
issues.push(DataFormValidator.getValidationIssue(DataFormIssueType.unexpectedNumberUsedWhenObjectExpected));
}
else if (typeof data === "boolean") {
issues.push(DataFormValidator.getValidationIssue(DataFormIssueType.unexpectedBooleanUsedWhenObjectExpected));
}
return issues;
}
await this.validateField(data, scalarField, issues, path + "<default value>", context);
}
const fields = form.fields;
// Guard against forms with undefined or null fields array
if (fields && Array.isArray(fields)) {
for (const field of fields) {
if (field.id) {
// Clean the field ID to remove embedded enum syntax like 'render_distance_type"<"fixed", "render"'
const cleanedFieldId = cleanFieldId(field.id);
// Check if the cleaned field ID is a regex pattern (contains metacharacters like [, ], +, *, etc.)
if (this.isPatternFieldId(cleanedFieldId) &&
typeof data === "object" &&
data !== null &&
!Array.isArray(data)) {
// For pattern-based field IDs, find all matching keys in the data
await this.validatePatternField(data, field, issues, path, context, cleanedFieldId);
}
else {
// Use the cleaned field ID for data lookup
const fieldData = data[cleanedFieldId];
await this.validateField(fieldData, field, issues, path + cleanedFieldId, context);
}
}
}
}
// Check for unexpected properties not defined in the form
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
await this.validateUnexpectedProperties(data, form, issues, path);
}
return issues;
}
/**
* Validates that an object doesn't have properties not defined in the form.
* This check only runs when form.strictAdditionalProperties is true, as most Minecraft
* content formats allow additional properties not explicitly defined in the schema.
*/
static async validateUnexpectedProperties(data, form, issues, path) {
// Skip if the form allows additional properties (default behavior for Minecraft content)
if (!form.strictAdditionalProperties) {
return;
}
// Skip if the form explicitly allows custom fields
if (form.customField) {
return;
}
// Skip if form.fields is not available
if (!form.fields || !Array.isArray(form.fields)) {
return;
}
const definedFieldIds = new Set();
for (const field of form.fields) {
if (field.id) {
definedFieldIds.add(field.id);
}
if (field.altId) {
definedFieldIds.add(field.altId);
}
}
const dataKeys = Object.keys(data);
for (const key of dataKeys) {
if (!definedFieldIds.has(key)) {
issues.push({
message: `At ${path}${key}, unexpected property '${key}' is not defined in the schema.`,
type: DataFormIssueType.unexpectedProperty,
});
}
}
}
static async validateField(data, field, issues, path, context) {
const allFields = DataFormUtilities_1.default.getFieldAndAlternates(field);
let dataMismatchErrors = "";
let hasOneMatch = false;
let isRequired = false;
let matchingField;
for (const altField of allFields) {
if (data !== undefined && data !== null) {
const mismatchError = DataFormValidator.getDataMismatchError(data, altField.dataType);
if (!mismatchError) {
hasOneMatch = true;
matchingField = altField;
}
else {
dataMismatchErrors += mismatchError;
}
}
if (altField.isRequired) {
isRequired = true;
}
}
if (data === undefined || data === null) {
if (isRequired) {
// Use cleaned field ID in error message
const cleanedId = field.id ? cleanFieldId(field.id) : field.id;
issues.push({
message: "At " + path + ", data is missing required field '" + cleanedId + "'.",
type: DataFormIssueType.missingRequiredField,
});
}
return;
}
if (!hasOneMatch) {
if (allFields.length > 1) {
issues.push({
message: "At " + path + ", data does not match any one of the expected types: " + dataMismatchErrors,
type: DataFormIssueType.dataTypeMismatch,
});
}
else {
issues.push({
message: "At " + path + ", data does not match expected type. " + dataMismatchErrors,
type: DataFormIssueType.dataTypeMismatch,
});
}
return; // Don't continue validation if type doesn't match
}
// Use the matching field for remaining validations
const activeField = matchingField || field;
// Validate numeric ranges
if (typeof data === "number") {
this.validateNumericRange(data, activeField, issues, path);
}
// Validate string length
if (typeof data === "string") {
this.validateStringLength(data, activeField, issues, path);
}
// Validate choices/enum values
this.validateChoices(data, activeField, issues, path);
// Validate patterns from validity conditions
this.validatePatterns(data, activeField, issues, path);
// Validate array lengths
if (Array.isArray(data)) {
this.validateArrayLength(data, activeField, issues, path);
}
// Validate point sizes (point2, point3, etc.)
this.validatePointSize(data, activeField, issues, path);
// Validate keyed collection keys
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
await this.validateKeyedCollection(data, activeField, issues, path, context);
}
if (typeof data === "object" && data === null && field.id && Utilities_1.default.isUsableAsObjectKey(field.id)) {
const fieldData = data[field.id];
const subForm = await this.getCachedSubForm(field, context);
if (subForm && fieldData !== undefined && fieldData !== null) {
await this.validate(fieldData, subForm, issues, path, {
depth: context.depth + 1,
subFormCache: context.subFormCache,
});
}
}
}
static getDataMismatchError(data, type) {
if (Array.isArray(data)) {
// Determine array element type by checking first element
const elementType = data.length > 0 ? typeof data[0] : undefined;
if (elementType === "string") {
if (type === IField_1.FieldDataType.stringArray ||
type === IField_1.FieldDataType.checkboxListAsStringArray ||
type === IField_1.FieldDataType.longFormStringArray) {
return undefined;
}
return ("Data '" +
data +
"' is of type string array, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
else if (elementType === "number") {
// Number arrays can match numberArray, floatRange, intRange, percentRange, point2, point3, intPoint3, version
if (type === IField_1.FieldDataType.numberArray ||
type === IField_1.FieldDataType.floatRange ||
type === IField_1.FieldDataType.intRange ||
type === IField_1.FieldDataType.percentRange ||
type === IField_1.FieldDataType.point2 ||
type === IField_1.FieldDataType.point3 ||
type === IField_1.FieldDataType.intPoint3 ||
type === IField_1.FieldDataType.version ||
type === IField_1.FieldDataType.location ||
type === IField_1.FieldDataType.locationOffset) {
return undefined;
}
return ("Data '" +
data +
"' is of type number array, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
else if (elementType === "object") {
// Array of objects - valid for objectArray, minecraftEventTriggerArray, etc.
if (type === IField_1.FieldDataType.objectArray ||
type === IField_1.FieldDataType.minecraftEventTriggerArray ||
type === IField_1.FieldDataType.arrayOfKeyedStringCollection) {
return undefined;
}
return ("Data is of type object array, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
else if (Array.isArray(data[0])) {
// Two-dimensional array
if (type === IField_1.FieldDataType.twoDStringArray) {
return undefined;
}
return ("Data is of type 2D array, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
// Empty array - allow it for any array type
if (data.length === 0) {
return undefined;
}
}
else if (typeof data === "string") {
// Strings can match many types including enums, molang expressions, UUIDs, etc.
if (type === IField_1.FieldDataType.localizableString ||
type === IField_1.FieldDataType.longFormString ||
type === IField_1.FieldDataType.version ||
type === IField_1.FieldDataType.stringLookup ||
type === IField_1.FieldDataType.string ||
type === IField_1.FieldDataType.stringEnum ||
type === IField_1.FieldDataType.molang ||
type === IField_1.FieldDataType.uuid ||
type === IField_1.FieldDataType.minecraftEventReference) {
return undefined;
}
return ("Data '" +
data +
"' is of type string, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
else if (typeof data === "number") {
// A single number can match many types including range types (collapsed ranges)
// According to IField.ts docs, intRange/floatRange/percentRange can be a single number
if (type === IField_1.FieldDataType.float ||
type === IField_1.FieldDataType.intBoolean ||
type === IField_1.FieldDataType.int ||
type === IField_1.FieldDataType.intEnum ||
type === IField_1.FieldDataType.intValueLookup ||
type === IField_1.FieldDataType.number ||
type === IField_1.FieldDataType.long ||
type === IField_1.FieldDataType.intRange ||
type === IField_1.FieldDataType.floatRange ||
type === IField_1.FieldDataType.percentRange) {
return undefined;
}
return ("Data '" +
data +
"' is of type number, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
else if (typeof data === "boolean") {
if (type === IField_1.FieldDataType.boolean) {
return undefined;
}
return ("Data '" +
data +
"' is of type boolean, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
else if (typeof data === "object") {
// Objects can match many types - keyed collections, range types (with min/max), filters, event triggers, etc.
if (type === IField_1.FieldDataType.object ||
type === IField_1.FieldDataType.keyedBooleanCollection ||
type === IField_1.FieldDataType.keyedKeyedStringArrayCollection ||
type === IField_1.FieldDataType.keyedNumberArrayCollection ||
type === IField_1.FieldDataType.keyedNumberCollection ||
type === IField_1.FieldDataType.keyedObjectCollection ||
type === IField_1.FieldDataType.keyedStringArrayCollection ||
type === IField_1.FieldDataType.keyedStringCollection ||
// Range types can also be objects with min/max properties
type === IField_1.FieldDataType.intRange ||
type === IField_1.FieldDataType.floatRange ||
type === IField_1.FieldDataType.percentRange ||
// Minecraft-specific object types
type === IField_1.FieldDataType.minecraftFilter ||
type === IField_1.FieldDataType.minecraftEventTrigger ||
type === IField_1.FieldDataType.minecraftEventReference ||
// Point types can be objects with x/y/z properties
type === IField_1.FieldDataType.point2 ||
type === IField_1.FieldDataType.point3 ||
type === IField_1.FieldDataType.intPoint3 ||
type === IField_1.FieldDataType.location ||
type === IField_1.FieldDataType.locationOffset) {
return undefined;
}
let dataStr = JSON.stringify(data);
if (dataStr.length > 60) {
dataStr = dataStr.substring(0, 60) + "...";
}
return ("Data '" +
dataStr +
"' is an object, which does not match type '" +
DataFormUtilities_1.default.getFieldTypeDescription(type) +
"'.");
}
return undefined;
}
/**
* Validates that a numeric value is within the allowed range.
*/
static validateNumericRange(data, field, issues, path) {
if (field.minValue !== undefined && data < field.minValue) {
issues.push({
message: `At ${path}, value ${data} is below minimum value ${field.minValue}.`,
type: DataFormIssueType.valueBelowMinimum,
});
}
if (field.maxValue !== undefined && data > field.maxValue) {
issues.push({
message: `At ${path}, value ${data} is above maximum value ${field.maxValue}.`,
type: DataFormIssueType.valueAboveMaximum,
});
}
}
/**
* Validates that a string value is within the allowed length.
*/
static validateStringLength(data, field, issues, path) {
if (field.minLength !== undefined && data.length < field.minLength) {
issues.push({
message: `At ${path}, string length ${data.length} is below minimum length ${field.minLength}.`,
type: DataFormIssueType.stringTooShort,
});
}
if (field.maxLength !== undefined && data.length > field.maxLength) {
issues.push({
message: `At ${path}, string length ${data.length} is above maximum length ${field.maxLength}.`,
type: DataFormIssueType.stringTooLong,
});
}
}
/**
* Validates that a value matches one of the allowed choices.
*/
static validateChoices(data, field, issues, path) {
// If no choices defined or mustMatchChoices is explicitly false, skip
if (!field.choices || field.choices.length === 0) {
return;
}
// If mustMatchChoices is explicitly false, skip validation
if (field.mustMatchChoices === false) {
return;
}
// For lookup fields without mustMatchChoices explicitly set, skip by default
// since lookups may have dynamic values
if (field.mustMatchChoices === undefined &&
(field.dataType === IField_1.FieldDataType.stringLookup || field.dataType === IField_1.FieldDataType.intValueLookup)) {
return;
}
const dataValue = typeof data === "string" || typeof data === "number" ? data : undefined;
if (dataValue === undefined) {
return;
}
const validChoiceIds = field.choices.map((choice) => choice.id);
const valueStr = String(dataValue);
if (!validChoiceIds.includes(valueStr)) {
const choiceList = validChoiceIds.slice(0, 5).join(", ") + (validChoiceIds.length > 5 ? ", ..." : "");
issues.push({
message: `At ${path}, value '${valueStr}' is not one of the allowed choices: ${choiceList}.`,
type: DataFormIssueType.valueNotInChoices,
});
}
}
/**
* Validates that a value matches patterns defined in field.validity conditions.
*/
static validatePatterns(data, field, issues, path) {
if (!field.validity || field.validity.length === 0) {
return;
}
if (typeof data !== "string") {
return;
}
for (const condition of field.validity) {
if (condition.comparison === ICondition_1.ComparisonType.matchesPattern && condition.value !== undefined) {
try {
const pattern = new RegExp(String(condition.value));
if (!pattern.test(data)) {
issues.push({
message: `At ${path}, value '${data}' does not match required pattern '${condition.value}'.`,
type: DataFormIssueType.patternMismatch,
});
}
}
catch (e) {
// Invalid regex pattern - skip validation but don't crash
}
}
}
}
/**
* Validates that an array has the correct length if fixedLength is specified.
*/
static validateArrayLength(data, field, issues, path) {
if (field.fixedLength !== undefined && data.length !== field.fixedLength) {
issues.push({
message: `At ${path}, array has ${data.length} elements but expected exactly ${field.fixedLength}.`,
type: DataFormIssueType.arrayLengthMismatch,
});
}
}
/**
* Validates that point arrays (point2, point3, etc.) have the correct number of elements.
*/
static validatePointSize(data, field, issues, path) {
if (!Array.isArray(data)) {
return;
}
let expectedSize;
switch (field.dataType) {
case IField_1.FieldDataType.point2:
expectedSize = 2;
break;
case IField_1.FieldDataType.point3:
expectedSize = 3;
break;
case IField_1.FieldDataType.floatRange:
expectedSize = 2;
break;
case IField_1.FieldDataType.intRange:
expectedSize = 2;
break;
}
if (expectedSize !== undefined && data.length !== expectedSize) {
const typeName = DataFormUtilities_1.default.getFieldTypeDescription(field.dataType);
issues.push({
message: `At ${path}, ${typeName} has ${data.length} elements but expected exactly ${expectedSize}.`,
type: DataFormIssueType.pointSizeMismatch,
});
}
}
/**
* Gets a subForm for a field, using the validation context cache to avoid repeated lookups.
* This significantly improves performance when validating deeply nested structures.
*/
static async getCachedSubForm(field, context) {
// If there's an inline subForm, return it directly (no caching needed)
if (field.subForm) {
return field.subForm;
}
// If there's no subFormId, nothing to look up
if (!field.subFormId) {
return null;
}
const cacheKey = field.subFormId;
if (context.subFormCache.has(cacheKey)) {
return context.subFormCache.get(cacheKey) || null;
}
const subForm = await FieldUtilities_1.default.getSubForm(field);
context.subFormCache.set(cacheKey, subForm || null);
return subForm || null;
}
/**
* Validates that keys in a keyed collection are allowed if allowedKeys is specified.
*/
static async validateKeyedCollection(data, field, issues, path, context) {
const isKeyedCollection = field.dataType === IField_1.FieldDataType.keyedObjectCollection ||
field.dataType === IField_1.FieldDataType.keyedStringCollection ||
field.dataType === IField_1.FieldDataType.keyedNumberCollection ||
field.dataType === IField_1.FieldDataType.keyedBooleanCollection ||
field.dataType === IField_1.FieldDataType.keyedStringArrayCollection ||
field.dataType === IField_1.FieldDataType.keyedNumberArrayCollection ||
field.dataType === IField_1.FieldDataType.keyedKeyedStringArrayCollection;
if (!isKeyedCollection) {
// For regular objects, validate subForm if available
const subForm = await this.getCachedSubForm(field, context);
if (subForm) {
await this.validate(data, subForm, issues, path, {
depth: context.depth + 1,
subFormCache: context.subFormCache,
});
}
return;
}
// Validate allowedKeys if specified
if (field.allowedKeys && field.allowedKeys.length > 0) {
const dataKeys = Object.keys(data);
for (const key of dataKeys) {
if (!field.allowedKeys.includes(key)) {
issues.push({
message: `At ${path}, key '${key}' is not in the allowed keys list.`,
type: DataFormIssueType.keyNotAllowed,
});
}
}
}
// If there's a subForm, validate each value in the keyed collection
if (field.dataType === IField_1.FieldDataType.keyedObjectCollection) {
const subForm = await this.getCachedSubForm(field, context);
if (subForm) {
for (const key of Object.keys(data)) {
const value = data[key];
if (value !== undefined && value !== null && typeof value === "object") {
await this.validate(value, subForm, issues, `${path}.${key}`, {
depth: context.depth + 1,
subFormCache: context.subFormCache,
});
}
}
}
}
}
/**
* Checks if a field ID contains regex metacharacters, indicating it's a pattern
* that should be matched against data keys rather than used as a literal property name.
* Common patterns in Minecraft forms: "geometry.[a-zA-Z0-9_.'-:]+" for legacy geometry.
*/
static isPatternFieldId(fieldId) {
// Check for common regex metacharacters that indicate a pattern
return /[[\]{}()*+?\\^$|]/.test(fieldId);
}
/**
* Validates a pattern-based field ID against the data object.
* For pattern fields, iterates through all data keys and validates those matching the pattern.
* If the field is required, at least one matching key must exist.
* @param cleanedFieldId - The field ID after cleaning (removing embedded enum syntax)
*/
static async validatePatternField(data, field, issues, path, context, cleanedFieldId) {
const fieldIdToUse = cleanedFieldId || (field.id ? cleanFieldId(field.id) : undefined);
if (!fieldIdToUse) {
return;
}
let pattern;
try {
// Anchor the pattern to match the full key
pattern = new RegExp("^" + fieldIdToUse + "$");
}
catch (e) {
// Invalid regex - treat as literal field ID (fallback)
const fieldData = data[fieldIdToUse];
await this.validateField(fieldData, field, issues, path + fieldIdToUse, context);
return;
}
const dataKeys = Object.keys(data);
const matchingKeys = dataKeys.filter((key) => pattern.test(key));
if (matchingKeys.length === 0) {
if (field.isRequired) {
issues.push({
message: `At ${path}${fieldIdToUse}, data is missing required field matching pattern '${fieldIdToUse}'.`,
type: DataFormIssueType.missingRequiredField,
});
}
return;
}
// Validate each matching key against the field's subForm or validation rules
for (const matchingKey of matchingKeys) {
const fieldData = data[matchingKey];
await this.validateField(fieldData, field, issues, path + matchingKey, context);
}
}
static getValidationIssue(type) {
return {
message: Utilities_1.default.getTitleFromEnum(DataFormIssueType, type),
type: type,
};
}
}
exports.default = DataFormValidator;