UNPKG

@entestat/formula

Version:

fast excel formula parser

410 lines (370 loc) 12.5 kB
const FormulaError = require('../formulas/error'); const {Address} = require('../formulas/helpers'); const {Prefix, Postfix, Infix, Operators} = require('../formulas/operators'); const Collection = require('./type/collection'); const MAX_ROW = 1048576, MAX_COLUMN = 16384; const {NotAllInputParsedException} = require('chevrotain'); class Utils { constructor(context) { this.context = context; } columnNameToNumber(columnName) { return Address.columnNameToNumber(columnName); } /** * Parse the cell address only. * @param {string} cellAddress * @return {{ref: {col: number, address: string, row: number}}} */ parseCellAddress(cellAddress) { const res = cellAddress.match(/([$]?)([A-Za-z]{1,3})([$]?)([1-9][0-9]*)/); // console.log('parseCellAddress', cellAddress); return { ref: { address: res[0], col: this.columnNameToNumber(res[2]), row: +res[4] }, }; } parseRow(row) { const rowNum = +row; if (!Number.isInteger(rowNum)) throw Error('Row number must be integer.'); return { ref: { col: undefined, row: +row }, }; } parseCol(col) { return { ref: { col: this.columnNameToNumber(col), row: undefined, }, }; } parseColRange(col1, col2) { // const res = colRange.match(/([$]?)([A-Za-z]{1,3}):([$]?)([A-Za-z]{1,4})/); col1 = this.columnNameToNumber(col1); col2 = this.columnNameToNumber(col2); return { ref: { from: { col: Math.min(col1, col2), row: null }, to: { col: Math.max(col1, col2), row: null } } } } parseRowRange(row1, row2) { // const res = rowRange.match(/([$]?)([1-9][0-9]*):([$]?)([1-9][0-9]*)/); return { ref: { from: { col: null, row: Math.min(row1, row2), }, to: { col: null, row: Math.max(row1, row2), } } } } _applyPrefix(prefixes, val, isArray) { if (this.isFormulaError(val)) return val; return Prefix.unaryOp(prefixes, val, isArray); } async applyPrefixAsync(prefixes, value) { const {val, isArray} = this.extractRefValue(await value); return this._applyPrefix(prefixes, val, isArray); } /** * Apply + or - unary prefix. * @param {Array.<string>} prefixes * @param {*} value * @return {*} */ applyPrefix(prefixes, value) { // console.log('applyPrefix', prefixes, value); if (this.context.async) { return this.applyPrefixAsync(prefixes, value); } else { const {val, isArray} = this.extractRefValue(value); return this._applyPrefix(prefixes, val, isArray); } } _applyPostfix(val, isArray, postfix) { if (this.isFormulaError(val)) return val; return Postfix.percentOp(val, postfix, isArray); } async applyPostfixAsync(value, postfix) { const {val, isArray} = this.extractRefValue(await value); return this._applyPostfix(val, isArray, postfix); } applyPostfix(value, postfix) { // console.log('applyPostfix', value, postfix); if (this.context.async) { return this.applyPostfixAsync(value, postfix); } else { const {val, isArray} = this.extractRefValue(value); return this._applyPostfix(val, isArray, postfix) } } _applyInfix(res1, infix, res2) { const val1 = res1.val, isArray1 = res1.isArray; const val2 = res2.val, isArray2 = res2.isArray; if (this.isFormulaError(val1)) return val1; if (this.isFormulaError(val2)) return val2; if (Operators.compareOp.includes(infix)) return Infix.compareOp(val1, infix, val2, isArray1, isArray2); else if (Operators.concatOp.includes(infix)) return Infix.concatOp(val1, infix, val2, isArray1, isArray2); else if (Operators.mathOp.includes(infix)) return Infix.mathOp(val1, infix, val2, isArray1, isArray2); else throw new Error(`Unrecognized infix: ${infix}`); } async applyInfixAsync(value1, infix, value2) { const res1 = this.extractRefValue(await value1); const res2 = this.extractRefValue(await value2); return this._applyInfix(res1, infix, res2) } applyInfix(value1, infix, value2) { if (this.context.async) { return this.applyInfixAsync(value1, infix, value2) } else { const res1 = this.extractRefValue(value1); const res2 = this.extractRefValue(value2); return this._applyInfix(res1, infix, res2) } } applyIntersect(refs) { // console.log('applyIntersect', refs); if (this.isFormulaError(refs[0])) return refs[0]; if (!refs[0].ref) throw Error(`Expecting a reference, but got ${refs[0]}.`); // a intersection will keep track of references, value won't be retrieved here. let maxRow, maxCol, minRow, minCol, sheet, res; // index start from 1 // first time setup const ref = refs.shift().ref; sheet = ref.sheet; if (!ref.from) { // check whole row/col reference if (ref.row === undefined || ref.col === undefined) { throw Error('Cannot intersect the whole row or column.') } // cell ref maxRow = minRow = ref.row; maxCol = minCol = ref.col; } else { // range ref // update maxRow = Math.max(ref.from.row, ref.to.row); minRow = Math.min(ref.from.row, ref.to.row); maxCol = Math.max(ref.from.col, ref.to.col); minCol = Math.min(ref.from.col, ref.to.col); } let err; refs.forEach(ref => { if (this.isFormulaError(ref)) return ref; ref = ref.ref; if (!ref) throw Error(`Expecting a reference, but got ${ref}.`); if (!ref.from) { if (ref.row === undefined || ref.col === undefined) { throw Error('Cannot intersect the whole row or column.') } // cell ref if (ref.row > maxRow || ref.row < minRow || ref.col > maxCol || ref.col < minCol || sheet !== ref.sheet) { err = FormulaError.NULL; } maxRow = minRow = ref.row; maxCol = minCol = ref.col; } else { // range ref const refMaxRow = Math.max(ref.from.row, ref.to.row); const refMinRow = Math.min(ref.from.row, ref.to.row); const refMaxCol = Math.max(ref.from.col, ref.to.col); const refMinCol = Math.min(ref.from.col, ref.to.col); if (refMinRow > maxRow || refMaxRow < minRow || refMinCol > maxCol || refMaxCol < minCol || sheet !== ref.sheet) { err = FormulaError.NULL; } // update maxRow = Math.min(maxRow, refMaxRow); minRow = Math.max(minRow, refMinRow); maxCol = Math.min(maxCol, refMaxCol); minCol = Math.max(minCol, refMinCol); } }); if (err) return err; // check if the ref can be reduced to cell reference if (maxRow === minRow && maxCol === minCol) { res = { ref: { sheet, row: maxRow, col: maxCol } } } else { res = { ref: { sheet, from: {row: minRow, col: minCol}, to: {row: maxRow, col: maxCol} } }; } if (!res.ref.sheet) delete res.ref.sheet; return res; } applyUnion(refs) { const collection = new Collection(); for (let i = 0; i < refs.length; i++) { if (this.isFormulaError(refs[i])) return refs[i]; collection.add(this.extractRefValue(refs[i]).val, refs[i]); } // console.log('applyUnion', unions); return collection; } /** * Apply multiple references, e.g. A1:B3:C8:A:1:..... * @param refs // * @return {{ref: {from: {col: number, row: number}, to: {col: number, row: number}}}} */ applyRange(refs) { let res, maxRow = -1, maxCol = -1, minRow = MAX_ROW + 1, minCol = MAX_COLUMN + 1; refs.forEach(ref => { if (this.isFormulaError(ref)) return ref; // row ref is saved as number, parse the number to row ref here if (typeof ref === 'number') { ref = this.parseRow(ref); } ref = ref.ref; // check whole row/col reference if (ref.row === undefined) { minRow = 1; maxRow = MAX_ROW } if (ref.col === undefined) { minCol = 1; maxCol = MAX_COLUMN; } if (ref.row > maxRow) maxRow = ref.row; if (ref.row < minRow) minRow = ref.row; if (ref.col > maxCol) maxCol = ref.col; if (ref.col < minCol) minCol = ref.col; }); if (maxRow === minRow && maxCol === minCol) { res = { ref: { row: maxRow, col: maxCol } } } else { res = { ref: { from: {row: minRow, col: minCol}, to: {row: maxRow, col: maxCol} } }; } return res; } /** * Throw away the refs, and retrieve the value. * @return {{val: *, isArray: boolean}} */ extractRefValue(obj) { let res = obj, isArray = false; if (Array.isArray(res)) isArray = true; if (obj.ref) { // can be number or array return {val: this.context.retrieveRef(obj), isArray}; } return {val: res, isArray}; } /** * * @param array * @return {Array} */ toArray(array) { // TODO: check if array is valid // console.log('toArray', array); return array; } /** * @param {string} number * @return {number} */ toNumber(number) { return Number(number); } /** * @param {string} string * @return {string} */ toString(string) { return string.substring(1, string.length - 1) .replace(/""/g, '"'); } /** * @param {string} bool * @return {boolean} */ toBoolean(bool) { return bool === 'TRUE'; } /** * Parse an error. * @param {string} error * @return {string} */ toError(error) { return new FormulaError(error.toUpperCase()); } isFormulaError(obj) { return obj instanceof FormulaError; } static formatChevrotainError(error, inputText) { let line, column, msg = ''; // e.g. SUM(1)) if (error instanceof NotAllInputParsedException) { line = error.token.startLine; column = error.token.startColumn; } else { line = error.previousToken.startLine; column = error.previousToken.startColumn + 1; } msg += '\n' + inputText.split('\n')[line - 1] + '\n'; msg += Array(column - 1).fill(' ').join('') + '^\n'; msg += `Error at position ${line}:${column}\n` + error.message; error.errorLocation = {line, column}; return FormulaError.ERROR(msg, error); } } module.exports = Utils;