diff --git a/grammar/parsing.ts b/grammar/parsing.ts index b6fe7b5f..e7a64d4b 100644 --- a/grammar/parsing.ts +++ b/grammar/parsing.ts @@ -51,9 +51,21 @@ interface ParserContext { getVariable(name: string): any; } +type OperatorAssociativity = 'left' | 'right'; +interface BinaryOperatorGroup { + opSet: ReadonlySet; + associativity: OperatorAssociativity; +} + class Parsing extends EmbeddedActionsParser { private utils: Utils; - private binaryOperatorsPrecedence: string[][]; + private static readonly BINARY_OPERATOR_GROUPS: ReadonlyArray = [ + { opSet: new Set(['^']), associativity: 'right' }, + { opSet: new Set(['*', '/']), associativity: 'left' }, + { opSet: new Set(['+', '-']), associativity: 'left' }, + { opSet: new Set(['&']), associativity: 'left' }, + { opSet: new Set(['<', '>', '=', '<>', '<=', '>=']), associativity: 'left' }, + ]; private c1?: { ALT: () => string }[]; // Cache for alternatives /** @@ -68,13 +80,6 @@ class Parsing extends EmbeddedActionsParser { // traceInitPerf: true, }); this.utils = utils; - this.binaryOperatorsPrecedence = [ - ['^'], - ['*', '/'], - ['+', '-'], - ['&'], - ['<', '>', '=', '<>', '<=', '>='], - ]; const $ = this; // Adopted from https://github.com/spreadsheetlab/XLParser/blob/master/src/XLParser/ExcelFormulaGrammar.cs @@ -105,17 +110,7 @@ class Parsing extends EmbeddedActionsParser { values.push($.SUBRULE2(($ as any).formulaWithPercentOp)); }); $.ACTION(() => { - // evaluate - for (const ops of this.binaryOperatorsPrecedence) { - for (let index = 0, length = infixes.length; index < length; index++) { - const infix = infixes[index]; - if (!ops.includes(infix)) continue; - infixes.splice(index, 1); - values.splice(index, 2, this.utils.applyInfix(values[index], infix, values[index + 1])); - index--; - length--; - } - } + this.reduceBinaryOperators(values, infixes); }); return values[0]; @@ -128,7 +123,7 @@ class Parsing extends EmbeddedActionsParser { ($ as any).RULE('formulaWithPercentOp', () => { let value = $.SUBRULE(($ as any).formulaWithUnaryOp); - $.OPTION(() => { + $.MANY(() => { const postfix = $.CONSUME(PercentOp).image; value = $.ACTION(() => this.utils.applyPostfix(value, postfix)); }); @@ -399,6 +394,34 @@ class Parsing extends EmbeddedActionsParser { this.performSelfAnalysis(); } + + private reduceBinaryOperators(values: any[], infixes: string[]): void { + for (const { opSet, associativity } of Parsing.BINARY_OPERATOR_GROUPS) { + if (associativity === 'right') { + // Right-associative groups, currently exponentiation, must reduce from the end. + for (let index = infixes.length - 1; index >= 0; index--) { + if (!opSet.has(infixes[index])) continue; + this.applyInfixAt(values, infixes, index); + } + continue; + } + + for (let index = 0, length = infixes.length; index < length; index++) { + if (!opSet.has(infixes[index])) continue; + this.applyInfixAt(values, infixes, index); + // Re-check the same position because splicing shifts later operators left. + index--; + length--; + } + } + } + + private applyInfixAt(values: any[], infixes: string[], index: number): void { + const infix = infixes[index]; + infixes.splice(index, 1); + values.splice(index, 2, this.utils.applyInfix(values[index], infix, values[index + 1])); + } + } export { diff --git a/package.json b/package.json index bfda89f4..8b3f0592 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "jstat": "^1.9.6" }, "devDependencies": { + "@types/node": "24.1.0", "@vitest/coverage-v8": "^2.1.1", "coveralls-next": "^5.0.0", "docdash": "^2.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 985d6317..7c3187a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: specifier: ^1.9.6 version: 1.9.6 devDependencies: + '@types/node': + specifier: 24.1.0 + version: 24.1.0 '@vitest/coverage-v8': specifier: ^2.1.1 version: 2.1.9(vitest@2.1.9(@types/node@24.1.0)) diff --git a/test/operators/testcase.ts b/test/operators/testcase.ts index 2d0b6552..905e9abd 100644 --- a/test/operators/testcase.ts +++ b/test/operators/testcase.ts @@ -63,10 +63,34 @@ const testCases: TestCase = { 'A13+1': 1 }, 'Operator Precedence': { - // '1+2*2': 5, + '1+2*2': 5, '1+4/2+1': 4, '1+4/2+2*3': 9, '(1+4/2+2*3)/3^2': 1, + '2^3^2': 512, + '2^3^2^1': 512, + '2*3^2^2': 162, + '2^3*2': 16, + '(2^3)^2': 64, + '-2^2': 4, + '-(2^2)': -4, + '8/4/2': 1, + '10-3-2': 5, + '"a"&"b"&"c"': 'abc', + '"x"&1+2': 'x3', + '1+2&"x"': '3x', + '1+2=3': true, + '"a"&"b"="ab"': true, + // Chained comparisons are reduced left-to-right, not treated as mathematical ranges. + '1<2<3': false, + '3>2>1': true, + '200%^2': 4, + '200%%^2': 0.0004, + '100%%': 0.01, + '100% %': 0.01, + '5%%%': 0.000005, + '1%%%%': 0.00000001, + '(200+300)%%': 0.05, '1-234/78+9/-78+45%': -1.6653846153846200 }, 'Intersection Test': {