@borgar/fx
Version:
Utilities for working with Excel formulas
305 lines (275 loc) • 12 kB
text/typescript
import { describe, test, expect } from 'vitest';
import { translateFormulaToR1C1, translateTokensToR1C1 } from './translateToR1C1.ts';
import { tokenize } from './tokenize.ts';
import { addTokenMeta } from './addTokenMeta.ts';
import { FUNCTION, FX_PREFIX, OPERATOR, REF_RANGE, REF_BEAM, REF_STRUCT, REF_NAMED, NUMBER } from './constants.ts';
function isA2R (expr: string, anchor: string, result: string) {
expect(translateFormulaToR1C1(expr, anchor)).toBe(result);
}
describe('translate absolute cells from A1 to RC', () => {
test('absolute cells with B2 anchor', () => {
isA2R('=$A$1', 'B2', '=R1C1');
isA2R('=$A$2', 'B2', '=R2C1');
isA2R('=$A$3', 'B2', '=R3C1');
isA2R('=$B$1', 'B2', '=R1C2');
isA2R('=$B$2', 'B2', '=R2C2');
isA2R('=$B$3', 'B2', '=R3C2');
isA2R('=$C$1', 'B2', '=R1C3');
isA2R('=$C$2', 'B2', '=R2C3');
isA2R('=$C$3', 'B2', '=R3C3');
});
test('absolute cells with Z19 anchor', () => {
// absolute cells, anchor has no real effect
isA2R('=$A$1', 'Z19', '=R1C1');
isA2R('=$A$2', 'Z19', '=R2C1');
isA2R('=$A$3', 'Z19', '=R3C1');
isA2R('=$B$1', 'Z19', '=R1C2');
isA2R('=$B$2', 'Z19', '=R2C2');
isA2R('=$B$3', 'Z19', '=R3C2');
isA2R('=$C$1', 'Z19', '=R1C3');
isA2R('=$C$2', 'Z19', '=R2C3');
isA2R('=$C$3', 'Z19', '=R3C3');
});
});
describe('translate relative cells from A1 to RC', () => {
test('relative cells with B2 anchor', () => {
isA2R('=A1', 'B2', '=R[-1]C[-1]');
isA2R('=A2', 'B2', '=RC[-1]');
isA2R('=A3', 'B2', '=R[1]C[-1]');
isA2R('=B1', 'B2', '=R[-1]C');
isA2R('=B2', 'B2', '=RC');
isA2R('=B3', 'B2', '=R[1]C');
isA2R('=C1', 'B2', '=R[-1]C[1]');
isA2R('=C2', 'B2', '=RC[1]');
isA2R('=C3', 'B2', '=R[1]C[1]');
});
test('relative cells with I12 anchor', () => {
// relative cells, but with [0] notation
isA2R('=H11', 'I12', '=R[-1]C[-1]');
isA2R('=H12', 'I12', '=RC[-1]');
isA2R('=H13', 'I12', '=R[1]C[-1]');
isA2R('=I11', 'I12', '=R[-1]C');
isA2R('=I12', 'I12', '=RC');
isA2R('=I13', 'I12', '=R[1]C');
isA2R('=J11', 'I12', '=R[-1]C[1]');
isA2R('=J12', 'I12', '=RC[1]');
isA2R('=J13', 'I12', '=R[1]C[1]');
});
});
describe('translate rows from A1 to RC', () => {
test('relative row references', () => {
isA2R('=2:2', 'B1', '=R[1]');
isA2R('=2:2', 'B2', '=R');
isA2R('=2:2', 'B3', '=R[-1]');
isA2R('=13:13', 'B13', '=R');
});
test('mixed row references', () => {
isA2R('=$2:$2', 'B2', '=R2');
isA2R('=2:$2', 'B2', '=R:R2');
isA2R('=11:9', 'Z10', '=R[-1]:R[1]');
});
});
describe('translate cols from A1 to RC', () => {
test('relative column references', () => {
isA2R('=B:B', 'A2', '=C[1]');
isA2R('=B:B', 'B2', '=C');
isA2R('=B:B', 'C2', '=C[-1]');
isA2R('=Z:Z', 'Z2', '=C');
});
test('mixed column references', () => {
isA2R('=$B:$B', 'B2', '=C2');
isA2R('=B:$B', 'B2', '=C:C2');
isA2R('=N:L', 'M10', '=C[-1]:C[1]');
});
});
describe('translate partials from A1 to RC', () => {
test('partial range references', () => {
isA2R('=A1:A', 'C6', '=R[-5]C[-2]:C[-2]');
isA2R('=A1:1', 'D6', '=R[-5]C[-3]:R[-5]');
isA2R('=$A1:$A', 'C7', '=R[-6]C1:C1');
isA2R('=$A:$A1', 'D7', '=R[-6]C1:C1');
isA2R('=$A1:1', 'C7', '=R[-6]C1:R[-6]');
isA2R('=1:$A1', 'C7', '=R[-6]C1:R[-6]');
isA2R('=A$1:A', 'C6', '=R1C[-2]:C[-2]');
isA2R('=A:A$1', 'C6', '=R1C[-2]:C[-2]');
isA2R('=A$1:$1', 'D6', '=R1C[-3]:R1');
isA2R('=$1:A$1', 'D6', '=R1C[-3]:R1');
isA2R('=$A$1:$A', 'D6', '=R1C1:C1');
isA2R('=$A:$A$1', 'D6', '=R1C1:C1');
isA2R('=$A$1:$1', 'D6', '=R1C1:R1');
isA2R('=$1:$A$1', 'D6', '=R1C1:R1');
});
});
describe('translate boundary coords from A1 to RC', () => {
test('boundary coordinate references', () => {
isA2R('=XFD:XFD', 'A1', '=C[16383]');
isA2R('=A1', 'B1', '=RC[-1]');
isA2R('=B1', 'C1', '=RC[-1]');
isA2R('=1048576:1048576', 'A1', '=R[1048575]');
isA2R('=$1:$1048576', 'A1', '=R1:R1048576');
isA2R('=$A:$XFD', 'A1', '=C1:C16384');
isA2R('=$A$1:$XFD$1048576', 'A1', '=R1C1:R1048576C16384');
isA2R('=A1', 'A2', '=R[-1]C');
isA2R('=A2', 'A3', '=R[-1]C');
});
});
describe('translate mixed rel/abs coords from A1 to RC', () => {
test('mixed relative/absolute references', () => {
isA2R('=B$1', 'B2', '=R1C');
isA2R('=$D8', 'B4', '=R[4]C4');
isA2R('=8:$10', 'B4', '=R[4]:R10');
isA2R('=$J:L', 'B4', '=C10:C[10]');
isA2R('=$A$1:$B$2', 'D4', '=R1C1:R2C2');
isA2R('=C3:F6', 'D4', '=R[-1]C[-1]:R[2]C[2]');
});
});
describe('translate involved cases from A1 to RC', () => {
test('complex function expressions', () => {
isA2R('=SUM(IF(E10,$E$2,$E$3),Sheet1!$2:$2*Sheet2!B:B)', 'D10',
'=SUM(IF(RC[1],R2C5,R3C5),Sheet1!R2*Sheet2!C[-2])');
});
test('expressions with structured and named references', () => {
// make sure we don't get confused by structured, or named refs
isA2R('=A1+Table1[#Data]', 'D10', '=R[-9]C[-3]+Table1[#Data]');
isA2R('=A1+foobar', 'D10', '=R[-9]C[-3]+foobar');
});
test('XLSX internal syntax', () => {
// This [123]Sheet!A1 variant of the syntax is used internally in xlsx files
isA2R('=[2]Sheet1!A1', 'D10', '=[2]Sheet1!R[-9]C[-3]');
});
});
describe('translate cross-sheet ranges', () => {
test('sheet prefix not consumed as ternary range end', () => {
// B!F2:B!F20 from B137 — the second B! is a sheet prefix, not a
// ternary range endpoint. Without the fix, the lexer with
// allowTernary:true parses "F2:B" as a ternary range, consuming
// the B from the second sheet prefix.
isA2R('=B!F2:B!F20', 'B137', '=B!R[-135]C[4]:B!R[-117]C[4]');
isA2R('=SUM(Sheet1!A1:Sheet1!A10)', 'C5', '=SUM(Sheet1!R[-4]C[-2]:Sheet1!R[5]C[-2])');
// Single-letter sheet names that look like column letters
isA2R('=X!D3:X!D10', 'A1', '=X!R[2]C[3]:X!R[9]C[3]');
});
});
describe('translate works with merged ranges', () => {
test('preserves token metadata and locations', () => {
// This tests that:
// - Translate works with ranges that have context attached
// - If input is a tokenlist, output is also a tokenlist
// - If tokens have ranges, those ranges are adjusted to new token lengths
// - Properties added by addTokenMeta are preserved
const expr = '=SUM(IF(E10,$E$2,$E$3),Sheet1!$2:$2*Sheet2!B:B)';
const tokens = addTokenMeta(tokenize(expr, { withLocation: true }));
const expected = [
{ type: FX_PREFIX, value: '=', loc: [ 0, 1 ], index: 0, depth: 0 },
{ type: FUNCTION, value: 'SUM', loc: [ 1, 4 ], index: 1, depth: 0 },
{ type: OPERATOR, value: '(', loc: [ 4, 5 ], index: 2, depth: 1, groupId: 'fxg7' },
{ type: FUNCTION, value: 'IF', loc: [ 5, 7 ], index: 3, depth: 1 },
{ type: OPERATOR, value: '(', loc: [ 7, 8 ], index: 4, depth: 2, groupId: 'fxg4' },
{ type: REF_RANGE, value: 'RC[1]', loc: [ 8, 13 ], index: 5, depth: 2, groupId: 'fxg1' },
{ type: OPERATOR, value: ',', loc: [ 13, 14 ], index: 6, depth: 2 },
{ type: REF_RANGE, value: 'R2C5', loc: [ 14, 18 ], index: 7, depth: 2, groupId: 'fxg2' },
{ type: OPERATOR, value: ',', loc: [ 18, 19 ], index: 8, depth: 2 },
{ type: REF_RANGE, value: 'R3C5', loc: [ 19, 23 ], index: 9, depth: 2, groupId: 'fxg3' },
{ type: OPERATOR, value: ')', loc: [ 23, 24 ], index: 10, depth: 2, groupId: 'fxg4' },
{ type: OPERATOR, value: ',', loc: [ 24, 25 ], index: 11, depth: 1 },
{ type: REF_BEAM, value: 'Sheet1!R2', loc: [ 25, 34 ], index: 12, depth: 1, groupId: 'fxg5' },
{ type: OPERATOR, value: '*', loc: [ 34, 35 ], index: 13, depth: 1 },
{ type: REF_BEAM, value: 'Sheet2!C[-2]', loc: [ 35, 47 ], index: 14, depth: 1, groupId: 'fxg6' },
{ type: OPERATOR, value: ')', loc: [ 47, 48 ], index: 15, depth: 1, groupId: 'fxg7' }
];
expect(translateTokensToR1C1(tokens, 'D10')).toEqual(expected);
});
});
describe('translate works with xlsx mode', () => {
function testExpr (expr: string, anchor: string, expected: any[]) {
const opts = { mergeRefs: true, xlsx: true, r1c1: false };
const tokens = tokenize(expr, opts);
expect(translateTokensToR1C1(tokens, anchor)).toEqual(expected);
}
test('XLSX workbook references', () => {
testExpr("'[My Fancy Workbook.xlsx]'!B$1", 'B2', [
{ type: REF_RANGE, value: "'[My Fancy Workbook.xlsx]'!R1C" }
]);
testExpr('[Workbook.xlsx]!B$1', 'B2', [
{ type: REF_RANGE, value: '[Workbook.xlsx]!R1C' }
]);
testExpr('[Workbook.xlsx]Sheet1!B$1', 'B2', [
{ type: REF_RANGE, value: '[Workbook.xlsx]Sheet1!R1C' }
]);
testExpr('[Workbook.xlsx]!table[#data]', 'B2', [
{ type: REF_STRUCT, value: '[Workbook.xlsx]!table[#data]' }
]);
});
});
describe('translate works with trimmed ranges', () => {
function testExpr (expr: string, anchor: string, expected: any[]) {
const opts = { mergeRefs: true, xlsx: true, r1c1: false };
expect(translateTokensToR1C1(tokenize(expr, opts), anchor)).toEqual(expected);
}
test('trimmed range translation', () => {
testExpr('Sheet!A1.:.B2*Sheet2!AZ.:.ZZ', 'B2', [
{ type: REF_RANGE, value: 'Sheet!R[-1]C[-1].:.RC' },
{ type: OPERATOR, value: '*' },
{ type: REF_BEAM, value: 'Sheet2!C[50].:.C[700]' }
]);
});
});
describe('translate does not create invalid LET arguments', () => {
// Unlike in A1, LET(c,1,c) is not valid syntax with the R1C1 notation in Excel.
// If you create a cell with this expression in A1 mode and flip to R1C1, Excel
// will not change it when expressing it, but will not allow you to re-enter it.
//
// Excel will always save the formula such as the arguments will have a "_xlpm."
// prefix: _xlfn.LET(_xlpm.c,1,_xlpm.c)
//
// However, that is also invalid syntax in the exposed/common Excel formula syntax.
// To counter this, fx does the following:
//
// tokenize:
// Supports _xlpm.c in both modes.
// Assumes c, C, r and R are names when encountered as tokens within LET functions.
// translateTokensToR1C1:
// Tries to be unambiguous by serializing "c" ranges in within LET as C[0].
// Same goes for "r" to R[0]. Prefixed names are left as they are.
// This way round-tripping is possible.
function testExpr (expr: string, anchor: string, expected: any[]) {
const opts = { mergeRefs: true, xlsx: true, r1c1: false };
expect(translateTokensToR1C1(tokenize(expr, opts), anchor)).toEqual(expected);
}
test('preserve C + R argument names', () => {
isA2R('=LET(c,1,c)', 'B2', '=LET(c,1,c)');
isA2R('=LET(r,1,r)', 'B2', '=LET(r,1,r)');
// ensure that we disambiguate C and R ranges
isA2R('=LET(c,B:B,c+B:B)', 'B2', '=LET(c,C[0],c+C[0])');
isA2R('=LET(r,2:2,r+2:2)', 'B2', '=LET(r,R[0],r+R[0])');
// prefixed parameters work too
isA2R('=LET(_xlpm.c,1,_xlpm.c)', 'B2', '=LET(_xlpm.c,1,_xlpm.c)');
isA2R('=LET(_xlpm.r,1,_xlpm.r)', 'B2', '=LET(_xlpm.r,1,_xlpm.r)');
testExpr('=LET(c,1,c)', 'B2', [
{ type: FX_PREFIX, value: '=' },
{ type: FUNCTION, value: 'LET' },
{ type: OPERATOR, value: '(' },
{ type: REF_NAMED, value: 'c' },
{ type: OPERATOR, value: ',' },
{ type: NUMBER, value: '1' },
{ type: OPERATOR, value: ',' },
{ type: REF_NAMED, value: 'c' },
{ type: OPERATOR, value: ')' }
]);
});
test('ensure that C + R ranges are unambiguous', () => {
isA2R('=LET(c,B:B,c)', 'B2', '=LET(c,C[0],c)');
isA2R('=LET(r,2:2,r)', 'B2', '=LET(r,R[0],r)');
testExpr('=LET(c,B:B,c)', 'B2', [
{ type: FX_PREFIX, value: '=' },
{ type: FUNCTION, value: 'LET' },
{ type: OPERATOR, value: '(' },
{ type: REF_NAMED, value: 'c' },
{ type: OPERATOR, value: ',' },
{ type: REF_BEAM, value: 'C[0]' },
{ type: OPERATOR, value: ',' },
{ type: REF_NAMED, value: 'c' },
{ type: OPERATOR, value: ')' }
]);
});
});