grammar/hooks.js

const TextFunctions = require('../formulas/functions/text');
const MathFunctions = require('../formulas/functions/math');
const TrigFunctions = require('../formulas/functions/trigonometry');
const LogicalFunctions = require('../formulas/functions/logical');
const EngFunctions = require('../formulas/functions/engineering');
const ReferenceFunctions = require('../formulas/functions/reference');
const InformationFunctions = require('../formulas/functions/information');
const StatisticalFunctions = require('../formulas/functions/statistical');
const DateFunctions = require('../formulas/functions/date');
const WebFunctions = require('../formulas/functions/web');
const FormulaError = require('../formulas/error');
const {FormulaHelpers} = require('../formulas/helpers');
const {Parser, allTokens} = require('./parsing');
const lexer = require('./lexing');
const Utils = require('./utils');

/**
 * A Excel Formula Parser & Evaluator
 */
class FormulaParser {

    /**
     * @param {{functions: {}, functionsNeedContext: {}, onVariable: function, onCell: function, onRange: function}} [config]
     * @param isTest - is in testing environment
     */
    constructor(config, isTest = false) {
        this.logs = [];
        this.isTest = isTest;
        this.utils = new Utils(this);
        config = Object.assign({
            functions: {},
            functionsNeedContext: {},
            onVariable: () => null,
            onCell: () => 0,
            onRange: () => [[0]],
        }, config);

        this.onVariable = config.onVariable;
        this.functions = Object.assign({}, DateFunctions, StatisticalFunctions, InformationFunctions, ReferenceFunctions,
            EngFunctions, LogicalFunctions, TextFunctions, MathFunctions, TrigFunctions, WebFunctions,
            config.functions, config.functionsNeedContext);
        this.onRange = config.onRange;
        this.onCell = config.onCell;

        // functions treat null as 0, other functions treats null as ""
        this.funsNullAs0 = Object.keys(MathFunctions)
            .concat(Object.keys(TrigFunctions))
            .concat(Object.keys(LogicalFunctions))
            .concat(Object.keys(EngFunctions))
            .concat(Object.keys(ReferenceFunctions))
            .concat(Object.keys(StatisticalFunctions))
            .concat(Object.keys(DateFunctions));

        // functions need context and don't need to retrieve references
        this.funsNeedContextAndNoDataRetrieve = ['ROW', 'ROWS', 'COLUMN', 'COLUMNS', 'SUMIF', 'INDEX', 'AVERAGEIF', 'IF'];

        // functions need parser context
        this.funsNeedContext = [...Object.keys(config.functionsNeedContext), ...this.funsNeedContextAndNoDataRetrieve,
            'INDEX', 'OFFSET', 'INDIRECT', 'IF', 'CHOOSE', 'WEBSERVICE'];

        // functions preserve reference in arguments
        this.funsPreserveRef = Object.keys(InformationFunctions);

        this.parser = new Parser(this, this.utils);
    }

    /**
     * Get all lexing token names. Webpack needs this.
     * @return {Array.<string>} - All token names that should not be minimized.
     */
    static get allTokens() {
        return allTokens;
    }

    /**
     * Get value from the cell reference
     * @param ref
     * @return {*}
     */
    getCell(ref) {
        // console.log('get cell', JSON.stringify(ref));
        if (ref.sheet == null)
            ref.sheet = this.position ? this.position.sheet : undefined;
        return this.onCell(ref);
    }

    /**
     * Get values from the range reference.
     * @param ref
     * @return {*}
     */
    getRange(ref) {
        // console.log('get range', JSON.stringify(ref));
        if (ref.sheet == null)
            ref.sheet = this.position ? this.position.sheet : undefined;
        return this.onRange(ref)
    }

    /**
     * TODO:
     * Get references or values from a user defined variable.
     * @param name
     * @return {*}
     */
    getVariable(name) {
        // console.log('get variable', name);
        const res = {ref: this.onVariable(name, this.position.sheet, this.position)};
        if (res.ref == null)
            return FormulaError.NAME;
        return res;
    }

    /**
     * Retrieve values from the given reference.
     * @param valueOrRef
     * @return {*}
     */
    retrieveRef(valueOrRef) {
        if (FormulaHelpers.isRangeRef(valueOrRef)) {
            return this.getRange(valueOrRef.ref);
        }
        if (FormulaHelpers.isCellRef(valueOrRef)) {
            return this.getCell(valueOrRef.ref)
        }
        return valueOrRef;
    }

    /**
     * Call an excel function.
     * @param name - Function name.
     * @param args - Arguments that pass to the function.
     * @return {*}
     */
    _callFunction(name, args) {
        if (name.indexOf('_xlfn.') === 0)
            name = name.slice(6);
        name = name.toUpperCase();
        // if one arg is null, it means 0 or "" depends on the function it calls
        const nullValue = this.funsNullAs0.includes(name) ? 0 : '';

        if (!this.funsNeedContextAndNoDataRetrieve.includes(name)) {
            // retrieve reference
            args = args.map(arg => {
                if (arg === null)
                    return {value: nullValue, isArray: false, omitted: true};
                const res = this.utils.extractRefValue(arg);

                if (this.funsPreserveRef.includes(name)) {
                    return {value: res.val, isArray: res.isArray, ref: arg.ref};
                }
                return {
                    value: res.val,
                    isArray: res.isArray,
                    isRangeRef: !!FormulaHelpers.isRangeRef(arg),
                    isCellRef: !!FormulaHelpers.isCellRef(arg)
                };
            });
        }
        // console.log('callFunction', name, args)

        if (this.functions[name]) {
            let res;
            try {
                if (!this.funsNeedContextAndNoDataRetrieve.includes(name) && !this.funsNeedContext.includes(name))
                    res = (this.functions[name](...args));
                else
                    res = (this.functions[name](this, ...args));
            } catch (e) {
                // allow functions throw FormulaError, this make functions easier to implement!
                if (e instanceof FormulaError) {
                    return e;
                } else {
                    throw e;
                }
            }
            if (res === undefined) {
                // console.log(`Function ${name} may be not implemented.`);
                if (this.isTest) {
                    if (!this.logs.includes(name)) this.logs.push(name);
                    return {value: 0, ref: {}};
                }
                throw FormulaError.NOT_IMPLEMENTED(name);
            }
            return res;
        } else {
            // console.log(`Function ${name} is not implemented`);
            if (this.isTest) {
                if (!this.logs.includes(name)) this.logs.push(name);
                return {value: 0, ref: {}};
            }
            throw FormulaError.NOT_IMPLEMENTED(name);
        }
    }

    async callFunctionAsync(name, args) {
        const awaitedArgs = [];
        for (const arg of args) {
            awaitedArgs.push(await arg);
        }
        const res = await this._callFunction(name, awaitedArgs);
        return FormulaHelpers.checkFunctionResult(res)
    }

    callFunction(name, args) {
        if (this.async) {
            return this.callFunctionAsync(name, args);
        } else {
            const res = this._callFunction(name, args);
            return FormulaHelpers.checkFunctionResult(res);
        }
    }

    /**
     * Return currently supported functions.
     * @return {this}
     */
    supportedFunctions() {
        const supported = [];
        const functions = Object.keys(this.functions);
        functions.forEach(fun => {
            try {
                const res = this.functions[fun](0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
                if (res === undefined) return;
                supported.push(fun);
            } catch (e) {
                if (e instanceof Error)
                    supported.push(fun);
            }
        });
        return supported.sort();
    }

    /**
     * Check and return the appropriate formula result.
     * @param result
     * @param {boolean} [allowReturnArray] - If the formula can return an array
     * @return {*}
     */
    checkFormulaResult(result, allowReturnArray = false) {
        const type = typeof result;
        // number
        if (type === 'number') {
            if (isNaN(result)) {
                return FormulaError.VALUE;
            } else if (!isFinite(result)) {
                return FormulaError.NUM;
            }
            result += 0; // make -0 to 0
        } else if (type === 'object') {
            if (result instanceof FormulaError)
                return result;
            if (allowReturnArray) {
                if (result.ref) {
                    result = this.retrieveRef(result);
                }
                // Disallow union, and other unknown data types.
                // e.g. `=(A1:C1, A2:E9)` -> #VALUE!
                if (typeof result === 'object' && !Array.isArray(result) && result != null) {
                    return FormulaError.VALUE;
                }

            } else {
                if (result.ref && result.ref.row && !result.ref.from) {
                    // single cell reference
                    result = this.retrieveRef(result);
                } else if (result.ref && result.ref.from && result.ref.from.col === result.ref.to.col) {
                    // single Column reference
                    result = this.retrieveRef({
                        ref: {
                            row: result.ref.from.row, col: result.ref.from.col
                        }
                    });
                } else if (Array.isArray(result)) {
                    result = result[0][0]
                } else {
                    // array, range reference, union collections
                    return FormulaError.VALUE;
                }
            }
        }
        return result;
    }

    /**
     * Parse an excel formula.
     * @param {string} inputText
     * @param {{row: number, col: number}} [position] - The position of the parsed formula
     *              e.g. {row: 1, col: 1}
     * @param {boolean} [allowReturnArray] - If the formula can return an array. Useful when parsing array formulas,
     *                                      or data validation formulas.
     * @returns {*}
     */
    parse(inputText, position, allowReturnArray = false) {
        if (inputText.length === 0) throw Error('Input must not be empty.');
        this.position = position;
        this.async = false;
        const lexResult = lexer.lex(inputText);
        this.parser.input = lexResult.tokens;
        let res;
        try {
            res = this.parser.formulaWithBinaryOp();
            res = this.checkFormulaResult(res, allowReturnArray);
            if (res instanceof FormulaError) {
                return res;
            }
        } catch (e) {
            throw FormulaError.ERROR(e.message, e);
        }
        if (this.parser.errors.length > 0) {
            const error = this.parser.errors[0];
            throw Utils.formatChevrotainError(error, inputText);
        }
        return res;
    }

    /**
     * Parse an excel formula asynchronously.
     * Use when providing custom async functions.
     * @param {string} inputText
     * @param {{row: number, col: number}} [position] - The position of the parsed formula
     *              e.g. {row: 1, col: 1}
     * @param {boolean} [allowReturnArray] - If the formula can return an array. Useful when parsing array formulas,
     *                                      or data validation formulas.
     * @returns {*}
     */
    async parseAsync(inputText, position, allowReturnArray = false) {
        if (inputText.length === 0) throw Error('Input must not be empty.');
        this.position = position;
        this.async = true;
        const lexResult = lexer.lex(inputText);
        this.parser.input = lexResult.tokens;
        let res;
        try {
            res = await this.parser.formulaWithBinaryOp();
            res = this.checkFormulaResult(res, allowReturnArray);
            if (res instanceof FormulaError) {
                return res;
            }
        } catch (e) {
            throw FormulaError.ERROR(e.message, e);
        }
        if (this.parser.errors.length > 0) {
            const error = this.parser.errors[0];
            throw Utils.formatChevrotainError(error, inputText);
        }
        return res;
    }
}

module.exports = {
    FormulaParser,
    FormulaHelpers,
};