UNPKG

@borgar/fx

Version:

Utilities for working with Excel formulas

1,411 lines (1,270 loc) 46.2 kB
import { describe, test, expect } from 'vitest'; import { parse } from './parse.ts'; import { tokenize } from './tokenize.ts'; function isParsed (expr: string, expected: any, opts?: any) { const result = parse(tokenize(expr, { ...opts, allowTernary: true }), opts); const cleaned = JSON.parse(JSON.stringify(result)); expect(cleaned).toEqual(expected); } function isInvalidExpr (expr: string, opts?: any) { expect(() => parse(tokenize(expr, { allowTernary: true }), { allowTernary: true, ...opts })).toThrow(); } describe('parser', () => { describe('parse numbers', () => { test('basic number parsing', () => { isParsed('1', { type: 'Literal', value: 1, raw: '1' }); isParsed('-1', { type: 'Literal', value: -1, raw: '-1' }); isParsed('2.4e+3', { type: 'Literal', value: 2400, raw: '2.4e+3' }); isParsed('-1e+10', { type: 'Literal', value: -10000000000, raw: '-1e+10' }); isParsed('1e-3', { type: 'Literal', value: 0.001, raw: '1e-3' }); }); }); describe('parse booleans', () => { test('true values with different cases', () => { isParsed('TRUE', { type: 'Literal', value: true, raw: 'TRUE' }); isParsed('true', { type: 'Literal', value: true, raw: 'true' }); isParsed('trUe', { type: 'Literal', value: true, raw: 'trUe' }); isParsed('TRue', { type: 'Literal', value: true, raw: 'TRue' }); }); test('false values with different cases', () => { isParsed('FALSE', { type: 'Literal', value: false, raw: 'FALSE' }); isParsed('false', { type: 'Literal', value: false, raw: 'false' }); isParsed('False', { type: 'Literal', value: false, raw: 'False' }); isParsed('fAlSe', { type: 'Literal', value: false, raw: 'fAlSe' }); }); }); describe('parse strings', () => { test('string literal parsing', () => { isParsed('""', { type: 'Literal', value: '', raw: '""' }); isParsed('""""', { type: 'Literal', value: '"', raw: '""""' }); isParsed('" "', { type: 'Literal', value: ' ', raw: '" "' }); isParsed('"foobar"', { type: 'Literal', value: 'foobar', raw: '"foobar"' }); isParsed('"foo bar"', { type: 'Literal', value: 'foo bar', raw: '"foo bar"' }); isParsed('"foo""bar"', { type: 'Literal', value: 'foo"bar', raw: '"foo""bar"' }); }); }); describe('parse errors', () => { test('error literal parsing', () => { isParsed('#CALC!', { type: 'ErrorLiteral', value: '#CALC!', raw: '#CALC!' }); isParsed('#DIV/0!', { type: 'ErrorLiteral', value: '#DIV/0!', raw: '#DIV/0!' }); isParsed('#FIELD!', { type: 'ErrorLiteral', value: '#FIELD!', raw: '#FIELD!' }); isParsed('#GETTING_DATA', { type: 'ErrorLiteral', value: '#GETTING_DATA', raw: '#GETTING_DATA' }); isParsed('#N/A', { type: 'ErrorLiteral', value: '#N/A', raw: '#N/A' }); isParsed('#NAME?', { type: 'ErrorLiteral', value: '#NAME?', raw: '#NAME?' }); isParsed('#NULL!', { type: 'ErrorLiteral', value: '#NULL!', raw: '#NULL!' }); isParsed('#NUM!', { type: 'ErrorLiteral', value: '#NUM!', raw: '#NUM!' }); isParsed('#REF!', { type: 'ErrorLiteral', value: '#REF!', raw: '#REF!' }); isParsed('#SPILL!', { type: 'ErrorLiteral', value: '#SPILL!', raw: '#SPILL!' }); isParsed('#SYNTAX?', { type: 'ErrorLiteral', value: '#SYNTAX?', raw: '#SYNTAX?' }); isParsed('#UNKNOWN!', { type: 'ErrorLiteral', value: '#UNKNOWN!', raw: '#UNKNOWN!' }); isParsed('#VALUE!', { type: 'ErrorLiteral', value: '#VALUE!', raw: '#VALUE!' }); }); }); describe('parse ranges', () => { test('basic range references', () => { isParsed('A1', { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }); isParsed('A1:B2', { type: 'ReferenceIdentifier', value: 'A1:B2', kind: 'range' }); isParsed('A:B', { type: 'ReferenceIdentifier', value: 'A:B', kind: 'beam' }); isParsed('1:2', { type: 'ReferenceIdentifier', value: '1:2', kind: 'beam' }); isParsed('A1:2', { type: 'ReferenceIdentifier', value: 'A1:2', kind: 'range' }); isParsed('1:A2', { type: 'ReferenceIdentifier', value: '1:A2', kind: 'range' }); isParsed('A1.:.B2', { type: 'ReferenceIdentifier', value: 'A1.:.B2', kind: 'range' }); }); test('sheet qualified references', () => { isParsed('Sheet!A1', { type: 'ReferenceIdentifier', value: 'Sheet!A1', kind: 'range' }); isParsed('[Workbook]Sheet!A1', { type: 'ReferenceIdentifier', value: '[Workbook]Sheet!A1', kind: 'range' }); isParsed('\'Sheet\'!A1', { type: 'ReferenceIdentifier', value: '\'Sheet\'!A1', kind: 'range' }); isParsed('\'[Workbook]Sheet\'!A1', { type: 'ReferenceIdentifier', value: '\'[Workbook]Sheet\'!A1', kind: 'range' }); isParsed('\'Workbook\'!A1', { type: 'ReferenceIdentifier', value: '\'Workbook\'!A1', kind: 'range' }); isParsed('\'[Workbook]Sheet\'!A1', { type: 'ReferenceIdentifier', value: '\'[Workbook]Sheet\'!A1', kind: 'range' }); }); test('named references', () => { isParsed('foo', { type: 'ReferenceIdentifier', value: 'foo', kind: 'name' }); isParsed('Workbook!foo', { type: 'ReferenceIdentifier', value: 'Workbook!foo', kind: 'name' }); isParsed('[Workbook]Sheet!foo', { type: 'ReferenceIdentifier', value: '[Workbook]Sheet!foo', kind: 'name' }); }); }); describe('parse array literals', () => { test('single element arrays', () => { isParsed('{1}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' } ] ] }); isParsed('{-1}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: -1, raw: '-1' } ] ] }); isParsed('{#DIV/0!}', { type: 'ArrayExpression', elements: [ [ { type: 'ErrorLiteral', value: '#DIV/0!', raw: '#DIV/0!' } ] ] }); isParsed('{TRUE}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: true, raw: 'TRUE' } ] ] }); isParsed('{"foo"}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 'foo', raw: '"foo"' } ] ] }); }); test('multi-element arrays', () => { isParsed('{1,2}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ] ] }); isParsed('{1,2;3}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ], [ { type: 'Literal', value: 3, raw: '3' } ] ] }); isParsed('{1,2;3,4}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ], [ { type: 'Literal', value: 3, raw: '3' }, { type: 'Literal', value: 4, raw: '4' } ] ] }); isParsed('{1;2}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' } ], [ { type: 'Literal', value: 2, raw: '2' } ] ] }); isParsed('{1;2,3}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' } ], [ { type: 'Literal', value: 2, raw: '2' }, { type: 'Literal', value: 3, raw: '3' } ] ] }); }); test('arrays with ranges', () => { isParsed('{A1,A1:B2;A:A}', { type: 'ArrayExpression', elements: [ [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'A1:B2', kind: 'range' } ], [ { type: 'ReferenceIdentifier', value: 'A:A', kind: 'beam' } ] ] }, { permitArrayRanges: true }); }); test('mixed type arrays', () => { isParsed('{-0.1,"foo";#NAME?,false}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: -0.1, raw: '-0.1' }, { type: 'Literal', value: 'foo', raw: '"foo"' } ], [ { type: 'ErrorLiteral', value: '#NAME?', raw: '#NAME?' }, { type: 'Literal', value: false, raw: 'false' } ] ] }); }); test('invalid array expressions', () => { isInvalidExpr('{A1}', { permitArrayRanges: false }); isInvalidExpr('{--1}', { negativeNumbers: true }); isInvalidExpr('{--1}', { negativeNumbers: false }); isInvalidExpr('{---1}', { negativeNumbers: true }); isInvalidExpr('{---1}', { negativeNumbers: false }); isInvalidExpr('{+1}'); // Excel silently corrects this 🤔 isInvalidExpr('{(1)}'); isInvalidExpr('{SUM(1)}'); isInvalidExpr('{{}}'); isInvalidExpr('{{}'); isInvalidExpr('{}}'); isInvalidExpr('{2+2}'); isInvalidExpr('{}'); isInvalidExpr('{,}'); isInvalidExpr('{1,}'); isInvalidExpr('{,1}'); isInvalidExpr('{;}'); }); test('array expressions with function calls', () => { isParsed('={1234; UNIQUE(A:A)}', { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1234, raw: '1234' } ], [ { type: 'CallExpression', callee: { type: 'Identifier', name: 'UNIQUE' }, arguments: [ { type: 'ReferenceIdentifier', value: 'A:A', kind: 'beam' } ] } ] ] }, { permitArrayCalls: true }); isParsed('={SUM({1,2}),3}', { type: 'ArrayExpression', elements: [ [ { type: 'CallExpression', callee: { type: 'Identifier', name: 'SUM' }, arguments: [ { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ] ] } ] }, { type: 'Literal', value: 3, raw: '3' } ] ] }, { permitArrayCalls: true }); }); }); describe('parse function calls', () => { test('basic function calls', () => { isParsed('=foo()', { type: 'CallExpression', callee: { type: 'Identifier', name: 'foo' }, arguments: [] }); isParsed('=FOO()', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [] }); isParsed('=FOO(1)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ { type: 'Literal', value: 1, raw: '1' } ] }); isParsed('=FOO(1,2)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ] }); }); test('function calls with many arguments', () => { const args = Array(300).fill('1'); isParsed(`=FOO(${args.join(',')})`, { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ ...args.map(() => ({ type: 'Literal', value: 1, raw: '1' })) ] }); }); test('function calls with ranges', () => { isParsed('=FOO(A1,B2)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); isParsed('=FOO((A1,B2))', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ { type: 'BinaryExpression', operator: ',', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] } ] }); }); test('nested function calls', () => { isParsed('=FOO(BAR())', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ { type: 'CallExpression', callee: { type: 'Identifier', name: 'BAR' }, arguments: [] } ] }); }); test('function calls with null arguments', () => { isParsed('=FOO(,)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ null, null ] }); isParsed('=FOO(,,)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ null, null, null ] }); isParsed('=FOO(1,)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ { type: 'Literal', value: 1, raw: '1' }, null ] }); isParsed('=FOO(,1)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [ null, { type: 'Literal', value: 1, raw: '1' } ] }); }); test('boolean function names', () => { isParsed('=FALSE()', { type: 'CallExpression', callee: { type: 'Identifier', name: 'FALSE' }, arguments: [] }); isParsed('=TRUE()', { type: 'CallExpression', callee: { type: 'Identifier', name: 'TRUE' }, arguments: [] }); }); test('invalid function calls', () => { isInvalidExpr('=FOO((1,2))'); isInvalidExpr('=FOO('); isInvalidExpr('=FOO ()'); }); }); describe('parse unary operators', () => { describe('unary operator %', () => { test('percentage operator', () => { isParsed('A1%', { type: 'UnaryExpression', operator: '%', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' } ] }); isParsed('1%', { type: 'UnaryExpression', operator: '%', arguments: [ { type: 'Literal', value: 1, raw: '1' } ] }); isParsed('(1)%', { type: 'UnaryExpression', operator: '%', arguments: [ { type: 'Literal', value: 1, raw: '1' } ] }); }); test('invalid percentage usage', () => { isInvalidExpr('%'); }); }); describe('unary operator -', () => { test('negative numbers', () => { isParsed('-1', { type: 'Literal', value: -1, raw: '-1' }); isParsed('-1', { type: 'UnaryExpression', operator: '-', arguments: [ { type: 'Literal', value: 1, raw: '1' } ] }, { negativeNumbers: false }); isParsed('-"1"', { type: 'UnaryExpression', operator: '-', arguments: [ { type: 'Literal', value: '1', raw: '"1"' } ] }); isParsed('-A1:B2', { type: 'UnaryExpression', operator: '-', arguments: [ { type: 'ReferenceIdentifier', value: 'A1:B2', kind: 'range' } ] }); }); test('double negative', () => { isParsed('--1', { type: 'UnaryExpression', operator: '-', arguments: [ { type: 'Literal', value: -1, raw: '-1' } ] }); isParsed('--1', { type: 'UnaryExpression', operator: '-', arguments: [ { type: 'UnaryExpression', operator: '-', arguments: [ { type: 'Literal', value: 1, raw: '1' } ] } ] }, { negativeNumbers: false }); }); test('invalid negative usage', () => { isInvalidExpr('--'); isInvalidExpr('-'); }); }); describe('unary operator +', () => { test('positive operator', () => { isParsed('+1', { type: 'UnaryExpression', operator: '+', arguments: [ { type: 'Literal', value: 1, raw: '1' } ] }); isParsed('+(1)', { type: 'UnaryExpression', operator: '+', arguments: [ { type: 'Literal', value: 1, raw: '1' } ] }); isParsed('+"1"', { type: 'UnaryExpression', operator: '+', arguments: [ { type: 'Literal', value: '1', raw: '"1"' } ] }); isParsed('+A1:B2', { type: 'UnaryExpression', operator: '+', arguments: [ { type: 'ReferenceIdentifier', value: 'A1:B2', kind: 'range' } ] }); }); test('invalid positive usage', () => { isInvalidExpr('++'); isInvalidExpr('+'); }); }); describe('unary operator #', () => { test('spill operator', () => { isParsed('D9#', { type: 'UnaryExpression', operator: '#', arguments: [ { type: 'ReferenceIdentifier', value: 'D9', kind: 'range' } ] }); isParsed('A1:B2#', { // this parses but is a runtime error in Excel type: 'UnaryExpression', operator: '#', arguments: [ { type: 'ReferenceIdentifier', value: 'A1:B2', kind: 'range' } ] }); isParsed('(A1):(B2)#', { // this parses but is a runtime error in Excel type: 'UnaryExpression', operator: '#', arguments: [ { type: 'BinaryExpression', operator: ':', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] } ] }); isParsed('(A1,B2)#', { type: 'UnaryExpression', operator: '#', arguments: [ { type: 'BinaryExpression', operator: ',', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] } ] }); isParsed('(A1 B2)#', { type: 'UnaryExpression', operator: '#', arguments: [ { type: 'BinaryExpression', operator: ' ', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] } ] }); isParsed('#REF!#', { type: 'UnaryExpression', operator: '#', arguments: [ { type: 'ErrorLiteral', value: '#REF!', raw: '#REF!' } ] }); isParsed('INDIRECT("d9")#', { type: 'UnaryExpression', operator: '#', arguments: [ { type: 'CallExpression', callee: { type: 'Identifier', name: 'INDIRECT' }, arguments: [ { type: 'Literal', value: 'd9', raw: '"d9"' } ] } ] }); }); test('invalid spill operator usage', () => { isInvalidExpr('1#'); isInvalidExpr('"foo"#'); isInvalidExpr('#A1'); isInvalidExpr('##'); isInvalidExpr('#VALUE!#'); isInvalidExpr('#'); isInvalidExpr('#A1'); }); }); describe('unary operator @', () => { test('implicit intersection operator', () => { isParsed('@1', { type: 'UnaryExpression', operator: '@', arguments: [ { type: 'Literal', raw: '1', value: 1 } ] }); isParsed('@"foo"', { type: 'UnaryExpression', operator: '@', arguments: [ { type: 'Literal', raw: '"foo"', value: 'foo' } ] }); isParsed('@D9', { type: 'UnaryExpression', operator: '@', arguments: [ { type: 'ReferenceIdentifier', value: 'D9', kind: 'range' } ] }); isParsed('@A1:B2', { type: 'UnaryExpression', operator: '@', arguments: [ { type: 'ReferenceIdentifier', value: 'A1:B2', kind: 'range' } ] }); isParsed('@#REF!', { type: 'UnaryExpression', operator: '@', arguments: [ { type: 'ErrorLiteral', value: '#REF!', raw: '#REF!' } ] }); isParsed('@FOO()', { type: 'UnaryExpression', operator: '@', arguments: [ { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [] } ] }); }); test('invalid implicit intersection usage', () => { isInvalidExpr('@'); isInvalidExpr('@@'); }); }); }); describe('parse binary operators', () => { const operators = [ '+', '-', '^', '*', '/', '&', '=', '<', '>', '<=', '>=', '<>' ]; operators.forEach(op => { describe(`binary operator ${op}`, () => { test(`basic ${op} operations`, () => { isParsed(`1${op}2`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ] }); isParsed(`1${op}2${op}3`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'BinaryExpression', operator: op, arguments: [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ] }, { type: 'Literal', value: 3, raw: '3' } ] }); }); test(`${op} with strings`, () => { isParsed(`"foo"${op}"bar"`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'Literal', value: 'foo', raw: '"foo"' }, { type: 'Literal', value: 'bar', raw: '"bar"' } ] }); }); test(`${op} with arrays`, () => { isParsed(`{1,2}${op}{3,4}`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 2, raw: '2' } ] ] }, { type: 'ArrayExpression', elements: [ [ { type: 'Literal', value: 3, raw: '3' }, { type: 'Literal', value: 4, raw: '4' } ] ] } ] }); }); test(`${op} with function calls`, () => { isParsed(`FOO()${op}BAR()`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [] }, { type: 'CallExpression', callee: { type: 'Identifier', name: 'BAR' }, arguments: [] } ] }); }); test(`invalid ${op} usage`, () => { isInvalidExpr(op); isInvalidExpr(op + op); isInvalidExpr('1' + op); if (op !== '+' && op !== '-') { isInvalidExpr('=' + op + '1'); } }); }); }); }); describe('parse range operators', () => { const rangeOps = [ [ ':', 'range-join' ], [ ',', 'union' ], [ ' ', 'intersection' ] ]; rangeOps.forEach(([ op, opName ]) => { describe(`${opName} operator "${op}"`, () => { test('basic range operations', () => { isParsed(`named1${op}named2`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'named1', kind: 'name' }, { type: 'ReferenceIdentifier', value: 'named2', kind: 'name' } ] }); isParsed(`A1${op}named2`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'named2', kind: 'name' } ] }); isParsed(`named1${op}B2`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'named1', kind: 'name' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); isParsed(`(A1)${op}(B2)`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); }); test('range operator with whitespace', () => { isParsed(`A1 ${op} B2`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); }); test('invalid range operations', () => { isInvalidExpr(`A1${op}0`); isInvalidExpr(`0${op}A1`); isInvalidExpr(`0${op}0`); isInvalidExpr(`"foo"${op}"bar"`); isInvalidExpr(`TRUE${op}FALSE`); isInvalidExpr(`A1${op}#NAME?`); isInvalidExpr(`A1${op}#VALUE!`); isInvalidExpr(`#NULL!${op}A1`); }); test('range operations with REF errors', () => { isParsed(`A1${op}#REF!`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ErrorLiteral', value: '#REF!', raw: '#REF!' } ] }); isParsed(`#REF!${op}B2`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ErrorLiteral', value: '#REF!', raw: '#REF!' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); }); test('range operations with complex expressions', () => { isParsed(`(A1,B2)${op}C3`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'BinaryExpression', operator: ',', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }, { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' } ] }); isParsed(`C3${op}(A1,B2)`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' }, { type: 'BinaryExpression', operator: ',', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] } ] }); isParsed(`(A1 B2)${op}C3`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'BinaryExpression', operator: ' ', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }, { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' } ] }); isParsed(`C3${op}(A1 B2)`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' }, { type: 'BinaryExpression', operator: ' ', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] } ] }); isParsed(`(A1:(B2))${op}C3`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'BinaryExpression', operator: ':', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }, { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' } ] }); isParsed(`C3${op}(A1:(B2))`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' }, { type: 'BinaryExpression', operator: ':', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] } ] }); }); test('range operations with ref functions', () => { const refFunctions = [ [ 'ANCHORARRAY', true ], [ 'CHOOSE', true ], [ 'DROP', true ], [ 'IF', true ], [ 'IFS', true ], [ 'INDEX', true ], [ 'INDIRECT', true ], [ 'OFFSET', true ], [ 'REDUCE', true ], [ 'SINGLE', true ], [ 'SWITCH', true ], [ 'TAKE', true ], [ 'XLOOKUP', true ], [ 'CELL', false ], [ 'COUNT', false ], [ 'HSTACK', false ], [ 'N', false ], [ 'SUM', false ] ]; refFunctions.forEach(([ funcName, shouldWork ]) => { if (shouldWork) { isParsed(`${funcName}()${op}C3`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'CallExpression', callee: { type: 'Identifier', name: funcName }, arguments: [] }, { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' } ] }); isParsed(`C3${op}${funcName}()`, { type: 'BinaryExpression', operator: op, arguments: [ { type: 'ReferenceIdentifier', value: 'C3', kind: 'range' }, { type: 'CallExpression', callee: { type: 'Identifier', name: funcName }, arguments: [] } ] }); } else { isInvalidExpr(`${funcName}()${op}C3`); isInvalidExpr(`C3${op}${funcName}()`); } }); }); }); }); }); describe('advanced parsing features', () => { test('union operators are normalized', () => { isParsed('A1 B2', { type: 'BinaryExpression', operator: ' ', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); isParsed('A1 B2', { type: 'BinaryExpression', operator: ' ', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); }); test('does not tolerate unterminated tokens', () => { isInvalidExpr('="foo'); }); test('position information is correct', () => { isParsed( '=123.45', { type: 'Literal', value: 123.45, loc: [ 1, 7 ], raw: '123.45' }, { withLocation: true } ); isParsed( '="foo"', { type: 'Literal', value: 'foo', loc: [ 1, 6 ], raw: '"foo"' }, { withLocation: true } ); isParsed( '=true', { type: 'Literal', value: true, loc: [ 1, 5 ], raw: 'true' }, { withLocation: true } ); isParsed( '=Sheet1!A1:B2', { type: 'ReferenceIdentifier', value: 'Sheet1!A1:B2', kind: 'range', loc: [ 1, 13 ] }, { withLocation: true } ); isParsed( '=(#VALUE!)', { type: 'ErrorLiteral', value: '#VALUE!', loc: [ 2, 9 ], raw: '#VALUE!' }, { withLocation: true } ); // UnaryExpression isParsed( '=-A1', { type: 'UnaryExpression', loc: [ 1, 4 ], operator: '-', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range', loc: [ 2, 4 ] } ] }, { withLocation: true } ); isParsed( '=10%', { type: 'UnaryExpression', loc: [ 1, 4 ], operator: '%', arguments: [ { type: 'Literal', value: 10, loc: [ 1, 3 ], raw: '10' } ] }, { withLocation: true } ); isParsed( '=-(123)', { type: 'UnaryExpression', loc: [ 1, 6 ], operator: '-', arguments: [ { type: 'Literal', value: 123, loc: [ 3, 6 ], raw: '123' } ] }, { withLocation: true } ); isParsed( '(123+(234))', { type: 'BinaryExpression', loc: [ 1, 9 ], operator: '+', arguments: [ { type: 'Literal', value: 123, loc: [ 1, 4 ], raw: '123' }, { type: 'Literal', value: 234, loc: [ 6, 9 ], raw: '234' } ] }, { withLocation: true } ); isParsed( '=(A1 B2)', { type: 'BinaryExpression', loc: [ 2, 7 ], operator: ' ', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range', loc: [ 2, 4 ] }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range', loc: [ 5, 7 ] } ] }, { withLocation: true } ); isParsed( '=SUM(4,5)', { type: 'CallExpression', loc: [ 1, 9 ], callee: { type: 'Identifier', name: 'SUM', loc: [ 1, 4 ] }, arguments: [ { type: 'Literal', value: 4, loc: [ 5, 6 ], raw: '4' }, { type: 'Literal', value: 5, loc: [ 7, 8 ], raw: '5' } ] }, { withLocation: true } ); // ArrayExpression isParsed( '={ 1, 2; 3, 4 }', { type: 'ArrayExpression', loc: [ 1, 15 ], elements: [ [ { type: 'Literal', value: 1, loc: [ 3, 4 ], raw: '1' }, { type: 'Literal', value: 2, loc: [ 6, 7 ], raw: '2' } ], [ { type: 'Literal', value: 3, loc: [ 9, 10 ], raw: '3' }, { type: 'Literal', value: 4, loc: [ 12, 13 ], raw: '4' } ] ] }, { withLocation: true } ); }); test('whitespace handling in various contexts', () => { // whitespace in arrays isParsed('=SORT({ A:A, B:B })', { type: 'CallExpression', callee: { type: 'Identifier', name: 'SORT' }, arguments: [ { type: 'ArrayExpression', elements: [ [ { type: 'ReferenceIdentifier', value: 'A:A', kind: 'beam' }, { type: 'ReferenceIdentifier', value: 'B:B', kind: 'beam' } ] ] } ] }, { permitArrayRanges: true }); // whitespace in arguments isParsed('=A2:A5=XLOOKUP(B1,C:C, D:D)', { type: 'BinaryExpression', operator: '=', arguments: [ { type: 'ReferenceIdentifier', value: 'A2:A5', kind: 'range' }, { type: 'CallExpression', callee: { type: 'Identifier', name: 'XLOOKUP' }, arguments: [ { type: 'ReferenceIdentifier', value: 'B1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'C:C', kind: 'beam' }, { type: 'ReferenceIdentifier', value: 'D:D', kind: 'beam' } ] } ] }, { permitArrayRanges: true }); // whitespace surrounding comma isParsed('=SUM(12 , B:B)', { type: 'CallExpression', callee: { type: 'Identifier', name: 'SUM' }, arguments: [ { type: 'Literal', value: 12, raw: '12' }, { type: 'ReferenceIdentifier', value: 'B:B', kind: 'beam' } ] }, { permitArrayCalls: true }); // whitespace tailing operator isParsed('=A:A= C1', { type: 'BinaryExpression', operator: '=', arguments: [ { type: 'ReferenceIdentifier', value: 'A:A', kind: 'beam' }, { type: 'ReferenceIdentifier', value: 'C1', kind: 'range' } ] }, { permitArrayCalls: true }); }); test('parser can permit xlsx mode references', () => { isInvalidExpr('=SUM([Workbook.xlsx]!A1+[Workbook.xlsx]!Table1[#Data])'); isParsed('=SUM([Workbook.xlsx]!A1+[Workbook.xlsx]!Table1[#Data])', { type: 'CallExpression', callee: { type: 'Identifier', name: 'SUM' }, arguments: [ { type: 'BinaryExpression', operator: '+', arguments: [ { type: 'ReferenceIdentifier', value: '[Workbook.xlsx]!A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: '[Workbook.xlsx]!Table1[#Data]', kind: 'table' } ] } ] }, { xlsx: true }); }); test('parser supports LAMBDA expressions', () => { // Invalid LAMBDA expressions isInvalidExpr('LAMBDA(,)'); isInvalidExpr('LAMBDA(a,)'); isInvalidExpr('LAMBDA(a,,)'); isInvalidExpr('=LAMBDA(1,1)'); isInvalidExpr('=LAMBDA(a,1,a)'); isInvalidExpr('=LAMBDA(a,A,1)'); isInvalidExpr('=LAMBDA(a,a,1)'); isInvalidExpr('=LAMBDA(A1,B1,1)'); // Valid LAMBDA expressions isParsed('=LAMBDA()', { type: 'LambdaExpression', params: [], body: null }); isParsed('=LAMBDA(1)', { type: 'LambdaExpression', params: [], body: { type: 'Literal', value: 1, raw: '1' } }); isParsed('=LAMBDA(1+1)', { type: 'LambdaExpression', params: [], body: { type: 'BinaryExpression', operator: '+', arguments: [ { type: 'Literal', value: 1, raw: '1' }, { type: 'Literal', value: 1, raw: '1' } ] } }); isParsed('=LAMBDA(a,1)', { type: 'LambdaExpression', body: { type: 'Literal', value: 1, raw: '1' }, params: [ { type: 'Identifier', name: 'a' } ] }); isParsed('=LAMBDA(a,b,1)', { type: 'LambdaExpression', body: { type: 'Literal', value: 1, raw: '1' }, params: [ { type: 'Identifier', name: 'a' }, { type: 'Identifier', name: 'b' } ] }); isParsed('=lambda(a,b,a*b)', { type: 'LambdaExpression', body: { type: 'BinaryExpression', operator: '*', arguments: [ { type: 'ReferenceIdentifier', value: 'a', kind: 'name' }, { type: 'ReferenceIdentifier', value: 'b', kind: 'name' } ] }, params: [ { type: 'Identifier', name: 'a' }, { type: 'Identifier', name: 'b' } ] }); isParsed('=lambda( a , b , a b )', { type: 'LambdaExpression', body: { type: 'BinaryExpression', operator: ' ', arguments: [ { type: 'ReferenceIdentifier', value: 'a', kind: 'name' }, { type: 'ReferenceIdentifier', value: 'b', kind: 'name' } ] }, params: [ { type: 'Identifier', name: 'a' }, { type: 'Identifier', name: 'b' } ] }); // r and c are forbidden as names, but should work here isParsed('=LAMBDA(r,c,r*c)', { type: 'LambdaExpression', body: { type: 'BinaryExpression', operator: '*', arguments: [ { type: 'ReferenceIdentifier', value: 'r', kind: 'name' }, { type: 'ReferenceIdentifier', value: 'c', kind: 'name' } ] }, params: [ { type: 'Identifier', name: 'r' }, { type: 'Identifier', name: 'c' } ] }); }); test('parser allows calling refs, lambda, let, and call expressions', () => { // Invalid function calls isInvalidExpr('1()'); isInvalidExpr('"str"()'); isInvalidExpr('#VALUE!()'); isInvalidExpr('foo%()'); isInvalidExpr('foo ()'); // Valid callable expressions isParsed('=lambda()()', { type: 'CallExpression', callee: { type: 'LambdaExpression', params: [], body: null }, arguments: [] }); isParsed('=lambda(1)(1)', { type: 'CallExpression', callee: { type: 'LambdaExpression', params: [], body: { type: 'Literal', value: 1, raw: '1' } }, arguments: [ { type: 'Literal', value: 1, raw: '1' } ] }); isParsed('=FOO()()', { type: 'CallExpression', callee: { type: 'CallExpression', callee: { type: 'Identifier', name: 'FOO' }, arguments: [] }, arguments: [] }); isParsed('=(A1)()', { type: 'CallExpression', callee: { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, arguments: [] }); isParsed('=LET(a,1,a)()', { type: 'CallExpression', callee: { type: 'LetExpression', declarations: [ { type: 'LetDeclarator', id: { type: 'Identifier', name: 'a' }, init: { type: 'Literal', value: 1, raw: '1' } } ], body: { type: 'ReferenceIdentifier', value: 'a', kind: 'name' } }, arguments: [] }); isParsed('=#REF!()', { type: 'CallExpression', callee: { type: 'ErrorLiteral', value: '#REF!', raw: '#REF!' }, arguments: [] }); // this is allowed because in Excel: `foo#()` is really `ANCHORARRAY(foo)()` isParsed('foo#()', { type: 'CallExpression', callee: { type: 'UnaryExpression', operator: '#', arguments: [ { type: 'ReferenceIdentifier', value: 'foo', kind: 'name' } ] }, arguments: [] }); }); test('parser supports LET expressions', () => { // Invalid LET expressions isInvalidExpr('LET(,)'); isInvalidExpr('LET(1,a,1)'); isInvalidExpr('LET()'); isInvalidExpr('LET(a,b)'); isInvalidExpr('LET(a,)'); isInvalidExpr('LET(a,a,1,1)'); isInvalidExpr('LET(a,a,1,a)'); isInvalidExpr('LET(a,1,b,1,c,1,a1+b,1)'); isInvalidExpr('LET(a,1,a,1,1)'); isInvalidExpr('LET(a,1,A,1,1)'); // Valid LET expressions isParsed('=LET(a,1,)', { type: 'LetExpression', declarations: [ { type: 'LetDeclarator', id: { type: 'Identifier', name: 'a' }, init: { type: 'Literal', value: 1, raw: '1' } } ], body: null }); isParsed('=LET(a,1,a)', { type: 'LetExpression', declarations: [ { type: 'LetDeclarator', id: { type: 'Identifier', name: 'a' }, init: { type: 'Literal', value: 1, raw: '1' } } ], body: { type: 'ReferenceIdentifier', value: 'a', kind: 'name' } }); isParsed('=LET(a,1,b,1,c,1,a+b*c)', { type: 'LetExpression', declarations: [ { type: 'LetDeclarator', id: { type: 'Identifier', name: 'a' }, init: { type: 'Literal', value: 1, raw: '1' } }, { type: 'LetDeclarator', id: { type: 'Identifier', name: 'b' }, init: { type: 'Literal', value: 1, raw: '1' } }, { type: 'LetDeclarator', id: { type: 'Identifier', name: 'c' }, init: { type: 'Literal', value: 1, raw: '1' } } ], body: { type: 'BinaryExpression', operator: '+', arguments: [ { type: 'ReferenceIdentifier', value: 'a', kind: 'name' }, { type: 'BinaryExpression', operator: '*', arguments: [ { type: 'ReferenceIdentifier', value: 'b', kind: 'name' }, { type: 'ReferenceIdentifier', value: 'c', kind: 'name' } ] } ] } }); // r and c are forbidden as names, but should work here isParsed('LET(r,1,c,1,r*c)', { type: 'LetExpression', declarations: [ { type: 'LetDeclarator', id: { type: 'Identifier', name: 'r' }, init: { type: 'Literal', value: 1, raw: '1' } }, { type: 'LetDeclarator', id: { type: 'Identifier', name: 'c' }, init: { type: 'Literal', value: 1, raw: '1' } } ], body: { type: 'BinaryExpression', operator: '*', arguments: [ { type: 'ReferenceIdentifier', value: 'r', kind: 'name' }, { type: 'ReferenceIdentifier', value: 'c', kind: 'name' } ] } }); }); test('parser whitespace handling', () => { isParsed('\tA1\u00a0+\nB2\r', { type: 'BinaryExpression', operator: '+', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'ReferenceIdentifier', value: 'B2', kind: 'range' } ] }); }); test('looseRefCalls: true relaxes ref function restrictions', () => { isInvalidExpr('A1:TESTFN()'); isParsed('A1:TESTFN()', { type: 'BinaryExpression', operator: ':', arguments: [ { type: 'ReferenceIdentifier', value: 'A1', kind: 'range' }, { type: 'CallExpression', callee: { type: 'Identifier', name: 'TESTFN' }, arguments: [] } ] }, { looseRefCalls: true }); }); }); });