validator.js

/**
 * @module validator
 */

'use strict';

const { XForm } = require( './xform' );

/**
 * @constant
 * @static
 * @type {string}
 */
const { version } = require( '../package' );

/**
 * @typedef ValidateResult
 * @property {Array<string>} warnings - List of warnings.
 * @property {Array<string>} errors - List of errors.
 * @property {string} version - Package version.
 */

/**
 * @typedef ValidationOptions
 * @property {boolean} debug - Run validator in debug mode.
 * @property {boolean} openclinica - Run validator in OpenClinica mode.
 */

/**
 * The validate function. Relies heavily on the {@link XForm} class.
 *
 * @static
 * @param {string} xformStr - XForm content.
 * @param {ValidationOptions} [options] - Validation options.
 * @return {ValidateResult} validation results.
 */
const validate = async( xformStr, options = {} ) => {
    const start = Date.now();
    let warnings = [];
    let errors = [];
    let result = {};
    let xform;

    try {
        xform = new XForm( xformStr, options );
    } catch ( e ) {
        errors.push( e );
    }

    if ( !xform ){
        const duration = Date.now() - start;

        return Promise.resolve( { warnings, errors, version, duration } );
    }

    result = xform.checkStructure();
    warnings = warnings.concat( result.warnings );
    errors = errors.concat( result.errors );

    result = xform.checkBinds();
    warnings = warnings.concat( result.warnings );
    errors = errors.concat( result.errors );

    result = xform.checkAppearances();
    warnings = warnings.concat( result.warnings );
    errors = errors.concat( result.errors );

    if ( options.openclinica ) {
        result = xform.checkOpenClinicaRules(  );
        warnings = warnings.concat( result.warnings );
        errors = errors.concat( result.errors );
    }

    try{
        await xform.parseModel();
    } catch ( e ) {
        let ers = Array.isArray( e ) ? e : [ e ];
        errors = errors.concat( ers );
    }

    // Check logic

    for( const el of xform.binds.concat( xform.setvalues ) ){
        const type = el.nodeName.toLowerCase();
        const props = type === 'bind' ? { path: 'nodeset', logic: [ 'calculate', 'constraint', 'relevant', 'required', 'readonly' ]  } : { path: 'ref', logic: [ 'value' ] };
        const path = el.getAttribute( props.path );

        if ( !path ) {
            errors.push( `Found ${type} without a ${props.path} attribute.` );

            continue;
        }

        const nodeName = xform._nodeName( path );
        // Note: using enketoEvaluate here, would be much slower
        const nodeExists = await xform.nodeExists( path );

        if ( !nodeExists ) {
            errors.push( `Found ${type} for "${nodeName}" that does not exist in the model.` );

            continue;
        }

        for ( const logicName of props.logic ){
            const logicExpr = el.getAttribute( logicName );
            const calculation = logicName === 'calculate';

            if ( logicExpr ) {
                let friendlyLogicName = logicName[ 0 ].toUpperCase() + logicName.substring( 1 );
                if ( calculation ){
                    friendlyLogicName = 'Calculation';
                } else if ( type === 'setvalue' ){
                    const event = el.getAttribute( 'event' );
                    if ( !event ){
                        errors.push( 'Found ${type} without event attribute.' );
                        continue;
                    }
                    friendlyLogicName = event.split( ' ' ).includes( 'xforms-value-changed' ) ? 'Triggered calculation' : 'Dynamic default';
                } else {
                    // e.g. the results for accidentally writing "ues" instead of "yes", putting an appearance in a logic column, etc
                    // and accidentally writing 'true' or 'false' or 'yes' or 'no' in the constraint or relevant column in XLSForm
                    if ( likelyNonSyntaxError( logicExpr )
                        || [ 'relevant', 'constraint' ].includes( logicName ) && likelyTrueFalseError( logicExpr ) ) {
                        warnings.push( `${friendlyLogicName} formula "${logicExpr}" for "${nodeName}" is likely meant for something else.` );
                    }
                }

                try {
                    await xform.enketoEvaluate( logicExpr, ( calculation ? 'string' : 'boolean' ), path );
                }
                catch( e ){
                    errors.push( `${friendlyLogicName} formula for "${nodeName}": ${e}` );
                }
                // TODO: check for cyclic dependencies within single expression and between calculations, e.g. triangular calculation dependencies
            }
        }
    }
    const duration = Date.now() - start;

    await xform.exit();

    return { warnings, errors, version, duration };
};

const likelyNonSyntaxError = ( logicExpr ) => {
    return /^[A-z0-9_]+$/.test( logicExpr.trim() );
};

const likelyTrueFalseError = ( logicExpr ) => {
    return /^(true|false)\(\)$/.test( logicExpr.trim() );
};

module.exports = { validate, version };