Source: schema.js

// Licensed under the MIT License
// https://github.com/craigahobbs/schema-markdown-js/blob/main/LICENSE

/** @module lib/schema */

import {typeModel} from './typeModel.js';
import {validateTypeModelErrors} from './schemaUtil.js';


/**
 * Get a user type's referenced type model
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Types'}
 * @param {string} typeName - The type name
 * @param {Object} [referencedTypes=null] - Optional map of referenced user type name to user type model
 * @returns {Object} The referenced [type model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Types'}
 */
export function getReferencedTypes(types, typeName, referencedTypes = {}) {
    return getReferencedTypesHelper(types, {'user': typeName}, referencedTypes);
}


function getReferencedTypesHelper(types, type, referencedTypes) {
    // Array?
    if ('array' in type) {
        const {array} = type;
        getReferencedTypesHelper(types, array.type, referencedTypes);

    // Dict?
    } else if ('dict' in type) {
        const {dict} = type;
        getReferencedTypesHelper(types, dict.type, referencedTypes);
        if ('keyType' in dict) {
            getReferencedTypesHelper(types, dict.keyType, referencedTypes);
        }

    // User type?
    } else if ('user' in type) {
        const typeName = type.user;

        // Already encountered?
        if (!Object.hasOwn(referencedTypes, typeName)) {
            const userType = getUserType(types, typeName);
            setValue(referencedTypes, typeName, userType);

            // Struct?
            if ('struct' in userType) {
                const {struct} = userType;
                if ('bases' in struct) {
                    for (const base of struct.bases) {
                        getReferencedTypesHelper(types, {'user': base}, referencedTypes);
                    }
                }
                for (const member of getStructMembers(types, struct)) {
                    getReferencedTypesHelper(types, member.type, referencedTypes);
                }

            // Enum?
            } else if ('enum' in userType) {
                const enum_ = userType.enum;
                if ('bases' in enum_) {
                    for (const base of enum_.bases) {
                        getReferencedTypesHelper(types, {'user': base}, referencedTypes);
                    }
                }

            // Typedef?
            } else if ('typedef' in userType) {
                const {typedef} = userType;
                getReferencedTypesHelper(types, typedef.type, referencedTypes);

            // Action?
            } else if ('action' in userType) {
                const {action} = userType;
                if ('path' in action) {
                    getReferencedTypesHelper(types, {'user': action.path}, referencedTypes);
                }
                if ('query' in action) {
                    getReferencedTypesHelper(types, {'user': action.query}, referencedTypes);
                }
                if ('input' in action) {
                    getReferencedTypesHelper(types, {'user': action.input}, referencedTypes);
                }
                if ('output' in action) {
                    getReferencedTypesHelper(types, {'user': action.output}, referencedTypes);
                }
                if ('errors' in action) {
                    getReferencedTypesHelper(types, {'user': action.errors}, referencedTypes);
                }
            }
        }
    }

    return referencedTypes;
}


/**
 * Schema Markdown type model validation error
 *
 * @extends {Error}
 * @property {?string} memberFqn - The fully qualified member name
 */
export class ValidationError extends Error {
    /**
     * Schema Markdown type model validation error constructor
     *
     * @param {string} message - The validation error message
     * @param {string} [memberFqn=null] - The fully-qualified member name
     */
    constructor(message, memberFqn = null) {
        super(message);
        this.name = this.constructor.name;
        this.memberFqn = memberFqn;
    }
}


// Helper to look up a user type by name - throws ValidationError for unknown type names
function getUserType(types, typeName) {
    const userType = Object.hasOwn(types, typeName) ? types[typeName] : null;
    if (userType === null) {
        throw new ValidationError(`Unknown type "${typeName}"`);
    }
    return userType;
}


// Helper to set an own property on a plain-object copy - the "__proto__" key is defined directly
// since assignment would invoke the inherited prototype setter instead of creating an own property
function setValue(obj, key, value) {
    if (key === '__proto__') {
        Object.defineProperty(obj, key, {'value': value, 'enumerable': true, 'writable': true, 'configurable': true});
    } else {
        obj[key] = value;
    }
}


/**
 * Type-validate a value using a user type model. Container values are duplicated since some member types are
 * transformed during validation.
 *
 * Validates JSON-like values. In this implementation, date values are `Date` objects (time-of-day
 * cleared), datetime values are `Date` objects, and uuid strings remain strings. A `Date` input is
 * left as a `Date` (date values have time-of-day cleared). See the language documentation on
 * [built-in types]{@link https://craigahobbs.github.io/schema-markdown-js/language/#built-in-types}
 * for accepted JSON input formats and host-runtime behavior.
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Types'}
 * @param {string} typeName - The type name
 * @param {Object} value - The value object to validate
 * @param {?string} [memberFqn=null] - The fully-qualified member name
 * @returns {Object} The validated, transformed value object
 * @throws [ValidationError]{@link module:lib/schema.ValidationError}
 */
export function validateType(types, typeName, value, memberFqn = null) {
    return validateTypeHelper(types, {'user': typeName}, value, memberFqn);
}


// Regular expressions used by validateTypeHelper
const rDate = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/;
const rDatetime = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
const rUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;


// Default dict key type used by validateTypeHelper
const defaultDictKeyType = {'builtin': 'string'};


function validateTypeHelper(types, type, value, memberFqn) {
    let valueNew = value;

    // Built-in type?
    if ('builtin' in type) {
        const {builtin} = type;

        // string?
        if (builtin === 'string') {
            // Not a string?
            if (typeof value !== 'string') {
                throwMemberError(type, value, memberFqn);
            }

        // int or float?
        } else if (builtin === 'int' || builtin === 'float') {
            // Convert string?
            if (typeof value === 'string') {
                valueNew = parseFloat(value);
                if (isNaN(value) || !Number.isFinite(valueNew)) {
                    throwMemberError(type, value, memberFqn);
                }

            // Not a number?
            } else if (typeof value !== 'number') {
                throwMemberError(type, value, memberFqn);
            }

            // Non-int number?
            if (builtin === 'int' && Math.trunc(valueNew) !== valueNew) {
                throwMemberError(type, value, memberFqn);
            }

        // bool?
        } else if (builtin === 'bool') {
            // Convert string?
            if (typeof value === 'string') {
                if (value === 'true') {
                    valueNew = true;
                } else if (value === 'false') {
                    valueNew = false;
                } else {
                    throwMemberError(type, value, memberFqn);
                }

            // Not a bool?
            } else if (typeof value !== 'boolean') {
                throwMemberError(type, value, memberFqn);
            }

        // date?
        } else if (builtin === 'date') {
            // Convert string?
            if (typeof value === 'string') {
                // Date-only?
                const mDate = value.match(rDate);
                if (mDate !== null) {
                    const year = Number.parseInt(mDate.groups.year, 10);
                    const month = Number.parseInt(mDate.groups.month, 10);
                    const day = Number.parseInt(mDate.groups.day, 10);
                    valueNew = new Date(year, month - 1, day);
                    // new Date rolls an out-of-range month/day over (e.g. Feb 30 -> Mar 1); reject that
                    if (valueNew.getMonth() !== month - 1) {
                        throwMemberError(type, value, memberFqn);
                    }
                } else {
                    throwMemberError(type, value, memberFqn);
                }

            // Not a date, or a date with a time-of-day?
            } else if (!(value instanceof Date) ||
                value.getHours() + value.getMinutes() + value.getSeconds() + value.getMilliseconds() !== 0) {
                throwMemberError(type, value, memberFqn);
            }

        // datetime?
        } else if (builtin === 'datetime') {
            // Convert string?
            if (typeof value === 'string') {
                const mDatetime = value.match(rDatetime);
                if (mDatetime !== null) {
                    valueNew = new Date(value);
                    const year = Number.parseInt(mDatetime.groups.year, 10);
                    const month = Number.parseInt(mDatetime.groups.month, 10);
                    const day = Number.parseInt(mDatetime.groups.day, 10);
                    // new Date rolls an out-of-range day over (e.g. Feb 30 -> Mar 1); reject that
                    if (Number.isNaN(valueNew.getTime()) || new Date(year, month - 1, day).getMonth() !== month - 1) {
                        throwMemberError(type, value, memberFqn);
                    }
                } else {
                    throwMemberError(type, value, memberFqn);
                }

            // Not a datetime?
            } else if (!(value instanceof Date)) {
                throwMemberError(type, value, memberFqn);
            }

        // uuid?
        } else if (builtin === 'uuid') {
            if (typeof value !== 'string') {
                throwMemberError(type, value, memberFqn);
            }
            if (!rUuid.test(value)) {
                throwMemberError(type, value, memberFqn);
            }
        }

    // User type?
    } else if ('user' in type) {
        const userType = getUserType(types, type.user);

        // action?
        if ('action' in userType) {
            throwMemberError(type, value, memberFqn);
        }

        // typedef?
        if ('typedef' in userType) {
            const {typedef} = userType;
            const typedefAttr = 'attr' in typedef ? typedef.attr : null;

            // Validate the value
            const valueNullable = typedefAttr !== null && 'nullable' in typedefAttr && typedefAttr.nullable;
            if (valueNullable && (value === null || value === 'null')) {
                valueNew = null;
            } else {
                valueNew = validateTypeHelper(types, typedef.type, value, memberFqn);
                if (typedefAttr !== null) {
                    validateAttr(type, typedefAttr, valueNew, memberFqn);
                }
            }

        // enum?
        } else if ('enum' in userType) {
            const enum_ = userType.enum;

            // Not a valid enum value?
            if (!getEnumValues(types, enum_).some((enumValue) => value === enumValue.name)) {
                throwMemberError(type, value, memberFqn);
            }

        // struct?
        } else if ('struct' in userType) {
            const {struct} = userType;

            // Valid value type?
            if (value === '') {
                valueNew = {};
            } else if (value === null || typeof value !== 'object' || Array.isArray(value)) {
                throwMemberError({'user': struct.name}, value, memberFqn);
            }

            // Valid union?
            const isMap = valueNew instanceof Map;
            const isUnion = 'union' in struct ? struct.union : false;
            if (isUnion) {
                if ((isMap ? valueNew.size : Object.keys(valueNew).length) !== 1) {
                    throwMemberError({'user': struct.name}, value, memberFqn);
                }
            }

            // Validate the struct members
            const valueCopy = isMap ? new Map() : {};
            for (const member of getStructMembers(types, struct)) {
                const memberName = member.name;
                const memberFqnMember = [memberFqn, memberName];
                const memberOptional = 'optional' in member && member.optional;
                const memberAttr = 'attr' in member ? member.attr : null;

                // Missing non-optional member? Check own properties only - "in" matches inherited
                // Object.prototype properties for member names like "constructor"
                if (!(isMap ? valueNew.has(memberName) : Object.hasOwn(valueNew, memberName))) {
                    if (!memberOptional && !isUnion) {
                        throw new ValidationError(`Required member "${memberFqnStr(memberFqnMember)}" missing`);
                    }
                } else {
                    // Validate the member value
                    let memberValue = isMap ? valueNew.get(memberName) : valueNew[memberName];

                    // No member attributes? Validate the member value directly
                    if (memberAttr === null) {
                        memberValue = validateTypeHelper(types, member.type, memberValue, memberFqnMember);

                    // Nullable null?
                    } else if ('nullable' in memberAttr && memberAttr.nullable &&
                               (memberValue === null || memberValue === 'null')) {
                        memberValue = null;
                    } else {
                        memberValue = validateTypeHelper(types, member.type, memberValue, memberFqnMember);
                        validateAttr(member.type, memberAttr, memberValue, memberFqnMember);
                    }

                    // Copy the validated member
                    if (isMap) {
                        valueCopy.set(memberName, memberValue);
                    } else {
                        setValue(valueCopy, memberName, memberValue);
                    }
                }
            }

            // Any unknown members?
            const valueCopyCount = isMap ? valueCopy.size : Object.keys(valueCopy).length;
            const valueNewCount = isMap ? valueNew.size : Object.keys(valueNew).length;
            if (valueCopyCount !== valueNewCount) {
                const memberSet = new Set(getStructMembers(types, struct).map((member) => member.name));
                const valueNewKeys = isMap ? Array.from(valueNew.keys()) : Object.keys(valueNew);
                const [unknownKey] = valueNewKeys.filter((key) => !memberSet.has(key));
                const unknownFqn = memberFqnStr([memberFqn, unknownKey]);
                throw new ValidationError(`Unknown member "${unknownFqn.slice(0, 100)}"`);
            }

            // Return the validated, transformed copy
            valueNew = valueCopy;
        }

    // array?
    } else if ('array' in type) {
        // Valid value type?
        const {array} = type;
        const arrayType = array.type;
        const arrayAttr = 'attr' in array ? array.attr : null;
        if (value === '') {
            valueNew = [];
        } else if (!Array.isArray(value)) {
            throwMemberError(type, value, memberFqn);
        }

        // Validate the list contents
        const valueCopy = [];
        const arrayValueNullable = arrayAttr !== null && 'nullable' in arrayAttr && arrayAttr.nullable;
        for (let ixArrayValue = 0; ixArrayValue < valueNew.length; ixArrayValue++) {
            const memberFqnValue = [memberFqn, ixArrayValue];
            let arrayValue = valueNew[ixArrayValue];
            if (arrayValueNullable && (arrayValue === null || arrayValue === 'null')) {
                arrayValue = null;
            } else {
                arrayValue = validateTypeHelper(types, arrayType, arrayValue, memberFqnValue);
                if (arrayAttr !== null) {
                    validateAttr(arrayType, arrayAttr, arrayValue, memberFqnValue);
                }
            }
            valueCopy.push(arrayValue);
        }

        // Return the validated, transformed copy
        valueNew = valueCopy;

    // dict?
    } else if ('dict' in type) {
        // Valid value type?
        const {dict} = type;
        const dictType = dict.type;
        const dictAttr = 'attr' in dict ? dict.attr : null;
        const dictKeyType = 'keyType' in dict ? dict.keyType : defaultDictKeyType;
        const dictKeyAttr = 'keyAttr' in dict ? dict.keyAttr : null;
        if (value === '') {
            valueNew = {};
        } else if (value === null || typeof value !== 'object' || Array.isArray(value)) {
            throwMemberError(type, value, memberFqn);
        }

        // Validate the dict key/value pairs
        const isMap = valueNew instanceof Map;
        const valueCopy = isMap ? new Map() : {};
        const dictKeyNullable = dictKeyAttr !== null && 'nullable' in dictKeyAttr && dictKeyAttr.nullable;
        const dictValueNullable = dictAttr !== null && 'nullable' in dictAttr && dictAttr.nullable;
        for (let [dictKey, dictValue] of (isMap ? valueNew.entries() : Object.entries(valueNew))) {
            const memberFqnKey = [memberFqn, dictKey];

            // Validate the key
            if (dictKeyNullable && (dictKey === null || dictKey === 'null')) {
                dictKey = null;
            } else {
                dictKey = validateTypeHelper(types, dictKeyType, dictKey, memberFqn);
                if (dictKeyAttr !== null) {
                    validateAttr(dictKeyType, dictKeyAttr, dictKey, memberFqn);
                }
            }

            // Validate the value
            if (dictValueNullable && (dictValue === null || dictValue === 'null')) {
                dictValue = null;
            } else {
                dictValue = validateTypeHelper(types, dictType, dictValue, memberFqnKey);
                if (dictAttr !== null) {
                    validateAttr(dictType, dictAttr, dictValue, memberFqnKey);
                }
            }

            // Copy the key/value
            if (isMap) {
                valueCopy.set(dictKey, dictValue);
            } else {
                setValue(valueCopy, dictKey, dictValue);
            }
        }

        // Return the validated, transformed copy
        valueNew = valueCopy;
    }

    return valueNew;
}


// Helper to materialize a member's fully-qualified name string. For performance, member FQNs are
// built lazily as [parent, key] pairs and only converted to strings on error.
function memberFqnStr(memberFqn) {
    if (memberFqn === null || typeof memberFqn === 'string') {
        return memberFqn;
    }
    const parts = [];
    let fqnPart = memberFqn;
    while (Array.isArray(fqnPart)) {
        parts.push(`${fqnPart[1]}`);
        [fqnPart] = fqnPart;
    }
    if (fqnPart !== null) {
        parts.push(`${fqnPart}`);
    }
    parts.reverse();
    return parts.join('.');
}


function throwMemberError(type, value, memberFqn, attr = null) {
    const memberFqnValue = memberFqnStr(memberFqn);
    const memberPart = memberFqnValue ? ` for member "${memberFqnValue}"` : '';
    const typeName = 'builtin' in type ? type.builtin : ('array' in type ? 'array' : ('dict' in type ? 'dict' : type.user));
    const attrPart = attr !== null ? ` [${attr}]` : '';
    const valueStr = `${JSON.stringify(value)}`;
    const msg = `Invalid value ${valueStr.slice(0, 100)} (type "${typeof value}")${memberPart}, expected type "${typeName}"${attrPart}`;
    throw new ValidationError(msg, memberFqnValue);
}


function validateAttr(type, attr, value, memberFqn) {
    if ('eq' in attr && !(value === attr.eq)) {
        throwMemberError(type, value, memberFqn, `== ${attr.eq}`);
    }
    if ('lt' in attr && !(value < attr.lt)) {
        throwMemberError(type, value, memberFqn, `< ${attr.lt}`);
    }
    if ('lte' in attr && !(value <= attr.lte)) {
        throwMemberError(type, value, memberFqn, `<= ${attr.lte}`);
    }
    if ('gt' in attr && !(value > attr.gt)) {
        throwMemberError(type, value, memberFqn, `> ${attr.gt}`);
    }
    if ('gte' in attr && !(value >= attr.gte)) {
        throwMemberError(type, value, memberFqn, `>= ${attr.gte}`);
    }
    if ('lenEq' in attr || 'lenLT' in attr || 'lenLTE' in attr || 'lenGT' in attr || 'lenGTE' in attr) {
        const length = Array.isArray(value) || typeof value === 'string' ? value.length
            : (value instanceof Map ? value.size : (typeof value === 'object' ? Object.keys(value).length : null));
        if ('lenEq' in attr && !(length === attr.lenEq)) {
            throwMemberError(type, value, memberFqn, `len == ${attr.lenEq}`);
        }
        if ('lenLT' in attr && !(length < attr.lenLT)) {
            throwMemberError(type, value, memberFqn, `len < ${attr.lenLT}`);
        }
        if ('lenLTE' in attr && !(length <= attr.lenLTE)) {
            throwMemberError(type, value, memberFqn, `len <= ${attr.lenLTE}`);
        }
        if ('lenGT' in attr && !(length > attr.lenGT)) {
            throwMemberError(type, value, memberFqn, `len > ${attr.lenGT}`);
        }
        if ('lenGTE' in attr && !(length >= attr.lenGTE)) {
            throwMemberError(type, value, memberFqn, `len >= ${attr.lenGTE}`);
        }
    }
}


/**
 * Get the struct's members (inherited members first)
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Types'}
 * @param {Object} struct - The [struct model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Struct'}
 * @returns {Array<Object>} The array of
 *     [struct member models]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='StructMember'}
 */
export function getStructMembers(types, struct) {
    // No base structs?
    if (!('bases' in struct)) {
        return 'members' in struct ? struct.members : [];
    }

    // Get base struct members
    const members = [];
    for (const base of struct.bases) {
        let baseUserType = getUserType(types, base);
        while ('typedef' in baseUserType) {
            baseUserType = getUserType(types, baseUserType.typedef.type.user);
        }
        members.push(...getStructMembers(types, baseUserType.struct));
    }

    // Add struct members
    if ('members' in struct) {
        members.push(...struct.members);
    }

    return members;
}


/**
 * Get the enum's values (inherited values first)
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Types'}
 * @param {Object} enum_ - The [enum model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Enum'}
 * @returns {Array<Object>} The array of
 *     [enum value models]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='EnumValue'}
 */
export function getEnumValues(types, enum_) {
    // No base enums?
    if (!('bases' in enum_)) {
        return 'values' in enum_ ? enum_.values : [];
    }

    // Get base enum values
    const values = [];
    for (const base of enum_.bases) {
        let baseUserType = getUserType(types, base);
        while ('typedef' in baseUserType) {
            baseUserType = getUserType(types, baseUserType.typedef.type.user);
        }
        values.push(...getEnumValues(types, baseUserType.enum));
    }

    // Add enum values
    if ('values' in enum_) {
        values.push(...enum_.values);
    }

    return values;
}


/**
 * Validate a type model's types object
 *
 * @param {Object} types - The [type model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Types'}
 * @returns {Object} The validated [type model]{@link https://craigahobbs.github.io/bare-script/model/#var.vURL=''&var.vName='Types'}
 * @throws [ValidationError]{@link module:lib/schema.ValidationError}
 */
export function validateTypeModel(types) {
    // Validate with the type model
    const validatedTypes = validateType(typeModel, 'Types', types);

    // Do additional type model validation
    const errors = validateTypeModelErrors(validatedTypes);
    if (errors.length) {
        // Sort by (typeName, memberName, message); null memberName as ''
        errors.sort((errorA, errorB) => {
            let result = 0;
            for (let ixPart = 0; ixPart < 3; ixPart++) {
                const partA = errorA[ixPart] ?? '';
                const partB = errorB[ixPart] ?? '';
                if (partA < partB) {
                    result = -1;
                    break;
                }
                if (partA > partB) {
                    result = 1;
                    break;
                }
            }
            return result;
        });
        throw new ValidationError(errors.map(([,, message]) => message).join('\n'));
    }

    return validatedTypes;
}