@borgar/fx 5.0.1 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/constants.ts CHANGED
@@ -14,6 +14,9 @@ export const REF_BEAM = 'range_beam';
14
14
  export const REF_TERNARY = 'range_ternary';
15
15
  export const REF_NAMED = 'range_named';
16
16
  export const REF_STRUCT = 'structured';
17
+ // TODO: in future, we should type the difference between A1:B1 (REF_RANGE) and
18
+ // A1 (REF_CELL) but this will require a major version bump.
19
+ export const REF_CELL = 'cell'; // internal only
17
20
  export const FX_PREFIX = 'fx_prefix';
18
21
  export const UNKNOWN = 'unknown';
19
22
 
@@ -66,6 +66,20 @@ describe('fixRanges basics', () => {
66
66
  });
67
67
  });
68
68
 
69
+ describe('fixRanges prefixes', () => {
70
+ test('Quotes prefixes as needed', () => {
71
+ isFixed('=Sch1!B2', "='Sch1'!B2");
72
+ isFixed('=[Foo]Ab12x!B2', '=[Foo]Ab12x!B2');
73
+ isFixed('=[Foo]Ab12!B2', "='[Foo]Ab12'!B2");
74
+ isFixed('=ABC123!B2', "='ABC123'!B2");
75
+ isFixed('=abc123!B2', "='abc123'!B2");
76
+ isFixed('=C!B2', "='C'!B2");
77
+ isFixed('=R!B2', "='R'!B2");
78
+ isFixed('=RC!B2', "='RC'!B2");
79
+ isFixed('=CR!B2', '=CR!B2');
80
+ });
81
+ });
82
+
69
83
  describe('fixRanges A1', () => {
70
84
  const opt = { allowTernary: true };
71
85
 
@@ -99,7 +99,7 @@ export function lexRangeA1 (
99
99
  }
100
100
  }
101
101
  // LT : this is A1
102
- if (top && canEndRange(str, preOp)) {
102
+ if (top && canEndRange(str, preOp) && str.charCodeAt(preOp) !== 33) { // 33 = "!"
103
103
  return { type: REF_RANGE, value: str.slice(pos, preOp) };
104
104
  }
105
105
  }
@@ -11,6 +11,7 @@ const UC_C = 67;
11
11
  const LC_C = 99;
12
12
  const PLUS = 43;
13
13
  const MINUS = 45;
14
+ const EXCL = 33;
14
15
 
15
16
  // C
16
17
  // C\[[+-]?\d+\]
@@ -97,7 +98,7 @@ export function lexRangeR1C1 (
97
98
  p += r1;
98
99
  const c1 = lexR1C1Part(str, p);
99
100
  p += c1;
100
- if (c1 || r1) {
101
+ if ((c1 || r1) && str.charCodeAt(p) !== EXCL) {
101
102
  const op = advRangeOp(str, p);
102
103
  const preOp = p;
103
104
  if (op) {
@@ -1,27 +1,29 @@
1
- import { CONTEXT, CONTEXT_QUOTE, REF_RANGE, REF_NAMED, REF_BEAM, REF_TERNARY, OPERATOR, REF_STRUCT } from './constants.ts';
1
+ import { CONTEXT, CONTEXT_QUOTE, REF_RANGE, REF_NAMED, REF_BEAM, REF_TERNARY, OPERATOR, REF_STRUCT, REF_CELL } from './constants.ts';
2
2
  import type { Token } from './types.ts';
3
3
 
4
4
  const END = '$';
5
5
 
6
6
  const validRunsMerge = [
7
- [ REF_RANGE, ':', REF_RANGE ],
8
- [ REF_RANGE, '.:', REF_RANGE ],
9
- [ REF_RANGE, ':.', REF_RANGE ],
10
- [ REF_RANGE, '.:.', REF_RANGE ],
7
+ [ REF_CELL, ':', REF_CELL ],
8
+ [ REF_CELL, '.:', REF_CELL ],
9
+ [ REF_CELL, ':.', REF_CELL ],
10
+ [ REF_CELL, '.:.', REF_CELL ],
11
11
  [ REF_RANGE ],
12
12
  [ REF_BEAM ],
13
13
  [ REF_TERNARY ],
14
- [ CONTEXT, '!', REF_RANGE, ':', REF_RANGE ],
15
- [ CONTEXT, '!', REF_RANGE, '.:', REF_RANGE ],
16
- [ CONTEXT, '!', REF_RANGE, ':.', REF_RANGE ],
17
- [ CONTEXT, '!', REF_RANGE, '.:.', REF_RANGE ],
14
+ [ CONTEXT, '!', REF_CELL, ':', REF_CELL ],
15
+ [ CONTEXT, '!', REF_CELL, '.:', REF_CELL ],
16
+ [ CONTEXT, '!', REF_CELL, ':.', REF_CELL ],
17
+ [ CONTEXT, '!', REF_CELL, '.:.', REF_CELL ],
18
+ [ CONTEXT, '!', REF_CELL ],
18
19
  [ CONTEXT, '!', REF_RANGE ],
19
20
  [ CONTEXT, '!', REF_BEAM ],
20
21
  [ CONTEXT, '!', REF_TERNARY ],
21
- [ CONTEXT_QUOTE, '!', REF_RANGE, ':', REF_RANGE ],
22
- [ CONTEXT_QUOTE, '!', REF_RANGE, '.:', REF_RANGE ],
23
- [ CONTEXT_QUOTE, '!', REF_RANGE, ':.', REF_RANGE ],
24
- [ CONTEXT_QUOTE, '!', REF_RANGE, '.:.', REF_RANGE ],
22
+ [ CONTEXT_QUOTE, '!', REF_CELL, ':', REF_CELL ],
23
+ [ CONTEXT_QUOTE, '!', REF_CELL, '.:', REF_CELL ],
24
+ [ CONTEXT_QUOTE, '!', REF_CELL, ':.', REF_CELL ],
25
+ [ CONTEXT_QUOTE, '!', REF_CELL, '.:.', REF_CELL ],
26
+ [ CONTEXT_QUOTE, '!', REF_CELL ],
25
27
  [ CONTEXT_QUOTE, '!', REF_RANGE ],
26
28
  [ CONTEXT_QUOTE, '!', REF_BEAM ],
27
29
  [ CONTEXT_QUOTE, '!', REF_TERNARY ],
@@ -62,7 +64,14 @@ const matcher = (tokens: Token[], currNode, anchorIndex, index = 0) => {
62
64
  while (i <= max) {
63
65
  const token = tokens[anchorIndex - i];
64
66
  if (token) {
65
- const key = (token.type === OPERATOR) ? token.value : token.type;
67
+ const value = token.value;
68
+ let key = (token.type === OPERATOR) ? value : token.type;
69
+ // we need to prevent merging ["A1:B2" ":" "C3"] as a range is only
70
+ // allowed to contain a single ":" operator even if "A1:B2:C3" is
71
+ // valid Excel syntax
72
+ if (key === REF_RANGE && !value.includes(':')) {
73
+ key = REF_CELL;
74
+ }
66
75
  if (key in node) {
67
76
  node = node[key];
68
77
  i += 1;
@@ -335,3 +335,10 @@ describe('A1 trimmed ranges vs named ranges', () => {
335
335
  isA1Equal('foo.:B2', { range: { top: 1, left: 1, right: 4460, trim: 'head' } }, { allowTernary: true });
336
336
  });
337
337
  });
338
+
339
+ describe('Sheet name that looks like an A1 ref', () => {
340
+ test('parse correctly', () => {
341
+ isA1Equal("'Sch1'!B2", { context: [ 'Sch1' ], range: { top: 1, left: 1, bottom: 1, right: 1 } });
342
+ isA1Equal('Sch1!B2', { context: [ 'Sch1' ], range: { top: 1, left: 1, bottom: 1, right: 1 } });
343
+ });
344
+ });
@@ -30,6 +30,12 @@ describe('stringifyA1Ref', () => {
30
30
  // @ts-expect-error -- testing invalid input
31
31
  expect(stringifyA1Ref({ workbookName: 'MyFile.xlsx', sheetName: 'Sheet1', name: 'foo' })).toBe('foo');
32
32
  });
33
+
34
+ test('should quote prefixes that look like A1 ranges', () => {
35
+ expect(stringifyA1Ref({ context: [ 'Ab12' ], range: rangeA1 })).toBe("'Ab12'!A1");
36
+ expect(stringifyA1Ref({ context: [ 'Sch1' ], range: rangeA1 })).toBe("'Sch1'!A1");
37
+ expect(stringifyA1Ref({ context: [ 'Foo12345' ], range: rangeA1 })).toBe("'Foo12345'!A1");
38
+ });
33
39
  });
34
40
 
35
41
  describe('stringifyA1Ref in XLSX mode', () => {
@@ -61,4 +67,16 @@ describe('stringifyA1Ref in XLSX mode', () => {
61
67
  // @ts-expect-error -- testing invalid input
62
68
  expect(stringifyA1RefXlsx({ context: [ 'MyFile.xlsx', 'Sheet1' ], name: 'foo' })).toBe('foo');
63
69
  });
70
+
71
+ test('should quote prefixes that look like ranges', () => {
72
+ expect(stringifyA1RefXlsx({ sheetName: 'C', range: rangeA1 })).toBe("'C'!A1");
73
+ expect(stringifyA1RefXlsx({ sheetName: 'R', range: rangeA1 })).toBe("'R'!A1");
74
+ expect(stringifyA1RefXlsx({ sheetName: 'RC', range: rangeA1 })).toBe("'RC'!A1");
75
+ expect(stringifyA1RefXlsx({ sheetName: 'Ab12', range: rangeA1 })).toBe("'Ab12'!A1");
76
+ expect(stringifyA1RefXlsx({ sheetName: 'Sch1', range: rangeA1 })).toBe("'Sch1'!A1");
77
+ expect(stringifyA1RefXlsx({ sheetName: 'Foo12345', range: rangeA1 })).toBe("'Foo12345'!A1");
78
+ expect(stringifyA1RefXlsx({ workbookName: 'Ab12', range: rangeA1 })).toBe("'[Ab12]'!A1");
79
+ expect(stringifyA1RefXlsx({ workbookName: 'Sch1', range: rangeA1 })).toBe("'[Sch1]'!A1");
80
+ expect(stringifyA1RefXlsx({ workbookName: 'Foo12345', range: rangeA1 })).toBe("'[Foo12345]'!A1");
81
+ });
64
82
  });
@@ -10,6 +10,27 @@ import type {
10
10
  } from './types.ts';
11
11
 
12
12
  const reBannedChars = /[^0-9A-Za-z._¡¤§¨ª\u00ad¯-\uffff]/;
13
+ // A1-XFD1048575 | R | C | RC
14
+ const reIsRangelike = /^(R|C|RC|[A-Z]{1,3}\d{1,7})$/i;
15
+
16
+ export function needQuotes (scope: string, yesItDoes = 0): number {
17
+ if (yesItDoes) {
18
+ return 1;
19
+ }
20
+ if (scope) {
21
+ if (reBannedChars.test(scope)) {
22
+ return 1;
23
+ }
24
+ if (reIsRangelike.test(scope)) {
25
+ return 1;
26
+ }
27
+ }
28
+ return 0;
29
+ }
30
+
31
+ export function quotePrefix (prefix) {
32
+ return "'" + prefix.replace(/'/g, "''") + "'";
33
+ }
13
34
 
14
35
  export function stringifyPrefix (
15
36
  ref: ReferenceA1 | ReferenceName | ReferenceStruct | ReferenceR1C1
@@ -23,12 +44,12 @@ export function stringifyPrefix (
23
44
  if (scope) {
24
45
  const part = (nth % 2) ? '[' + scope + ']' : scope;
25
46
  pre = part + pre;
26
- quote += +reBannedChars.test(scope);
47
+ quote += needQuotes(scope, quote);
27
48
  nth++;
28
49
  }
29
50
  }
30
51
  if (quote) {
31
- pre = "'" + pre.replace(/'/g, "''") + "'";
52
+ pre = quotePrefix(pre);
32
53
  }
33
54
  return pre ? pre + '!' : pre;
34
55
  }
@@ -41,14 +62,14 @@ export function stringifyPrefixXlsx (
41
62
  const { workbookName, sheetName } = ref;
42
63
  if (workbookName) {
43
64
  pre += '[' + workbookName + ']';
44
- quote += +reBannedChars.test(workbookName);
65
+ quote += needQuotes(workbookName);
45
66
  }
46
67
  if (sheetName) {
47
68
  pre += sheetName;
48
- quote += +reBannedChars.test(sheetName);
69
+ quote += needQuotes(sheetName);
49
70
  }
50
71
  if (quote) {
51
- pre = "'" + pre.replace(/'/g, "''") + "'";
72
+ pre = quotePrefix(pre);
52
73
  }
53
74
  return pre ? pre + '!' : pre;
54
75
  }
@@ -1062,6 +1062,13 @@ describe('lexer', () => {
1062
1062
  { type: FX_PREFIX, value: '=' },
1063
1063
  { type: REF_RANGE, value: 'A1:C1' }
1064
1064
  ]);
1065
+
1066
+ isTokens('=A1:C1:D1', [
1067
+ { type: FX_PREFIX, value: '=' },
1068
+ { type: REF_RANGE, value: 'A1:C1' },
1069
+ { type: OPERATOR, value: ':' },
1070
+ { type: REF_RANGE, value: 'D1' }
1071
+ ]);
1065
1072
  });
1066
1073
 
1067
1074
  test('spill range syntax', () => {
@@ -2172,4 +2179,24 @@ describe('lexer', () => {
2172
2179
  { type: OPERATOR, value: ')' }
2173
2180
  ]);
2174
2181
  });
2182
+
2183
+ describe('Sheet name that looks like an A1 ref', () => {
2184
+ test('Sheet name that looks like an A1 ref', () => {
2185
+ expect(tokenize("'Sch1'!B2")).toEqual([
2186
+ { type: REF_RANGE, value: "'Sch1'!B2" }
2187
+ ]);
2188
+ expect(tokenize('Sch1!B2')).toEqual([
2189
+ { type: REF_RANGE, value: 'Sch1!B2' }
2190
+ ]);
2191
+ });
2192
+
2193
+ test('Sheet name that is a R or C ref', () => {
2194
+ expect(tokenize("'C'!R[-9]C[-3]", { r1c1: true })).toEqual([
2195
+ { type: REF_RANGE, value: "'C'!R[-9]C[-3]" }
2196
+ ]);
2197
+ expect(tokenize('C!R[-9]C[-3]', { r1c1: true })).toEqual([
2198
+ { type: REF_RANGE, value: 'C!R[-9]C[-3]' }
2199
+ ]);
2200
+ });
2201
+ });
2175
2202
  });
@@ -100,8 +100,7 @@ export function translateTokensToR1C1 (
100
100
  if (tokenType === REF_RANGE || tokenType === REF_BEAM || tokenType === REF_TERNARY) {
101
101
  token = cloneToken(token);
102
102
  const tokenValue = token.value;
103
- // We can get away with using the xlsx ref-parser here because it is more permissive
104
- // and we will end up with the same prefix after serialization anyway:
103
+ // We can get away with using the xlsx ref-parser here because it is more permissive:
105
104
  const ref = quickParseA1(tokenValue);
106
105
  if (ref) {
107
106
  const d = ref.range;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgar/fx",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "Utilities for working with Excel formulas",
5
5
  "type": "module",
6
6
  "exports": {
@@ -68,11 +68,11 @@
68
68
  "benchmark": "~2.1.4",
69
69
  "eslint": "~9.39.0",
70
70
  "typescript": "~5.9.3",
71
- "typescript-eslint": "~8.46.2",
72
- "vitest": "~4.0.6",
73
- "globals": "~16.5.0",
74
- "typedoc": "~0.28.14",
75
- "typedoc-plugin-markdown": "~4.9.0",
76
- "tsup": "~8.5.0"
71
+ "typescript-eslint": "~8.55.0",
72
+ "vitest": "~4.0.18",
73
+ "globals": "~17.3.0",
74
+ "typedoc": "~0.28.17",
75
+ "typedoc-plugin-markdown": "~4.10.0",
76
+ "tsup": "~8.5.1"
77
77
  }
78
78
  }