@crossworks/content-core 0.230.43
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/LICENSE.md +135 -0
- package/package.json +41 -0
- package/src/block-diff.test.ts +190 -0
- package/src/block-diff.ts +163 -0
- package/src/block-ids.test.ts +358 -0
- package/src/block-ids.ts +242 -0
- package/src/block-list.test.ts +241 -0
- package/src/block-list.ts +177 -0
- package/src/contacts-format.ts +260 -0
- package/src/doc-to-markdown.test.ts +194 -0
- package/src/doc-to-markdown.ts +315 -0
- package/src/formula-dimensions.test.ts +103 -0
- package/src/formula-dimensions.ts +231 -0
- package/src/formula-eval.ts +294 -0
- package/src/formula-seed.test.ts +175 -0
- package/src/formula-seed.ts +466 -0
- package/src/formula-signature.test.ts +336 -0
- package/src/formula-signature.ts +435 -0
- package/src/formula-spec.test.ts +458 -0
- package/src/formula-spec.ts +566 -0
- package/src/journal-options.test.ts +57 -0
- package/src/journal-options.ts +77 -0
- package/src/markdown-refs.test.ts +143 -0
- package/src/markdown-refs.ts +172 -0
- package/src/markdown-to-doc.test.ts +179 -0
- package/src/markdown-to-doc.ts +567 -0
- package/src/onboarding-questions.test.ts +75 -0
- package/src/onboarding-questions.ts +90 -0
- package/src/page-diff.test.ts +82 -0
- package/src/page-diff.ts +120 -0
- package/src/page-split.test.ts +141 -0
- package/src/page-split.ts +128 -0
- package/src/page-toc.test.ts +58 -0
- package/src/page-toc.ts +89 -0
- package/src/persona-bank.test.ts +67 -0
- package/src/persona-bank.ts +234 -0
- package/src/table-formula-mathjs.ts +259 -0
- package/src/table-formula.test.ts +157 -0
- package/src/table-formula.ts +496 -0
- package/src/table-model.test.ts +429 -0
- package/src/table-model.ts +870 -0
- package/src/thinking-tiers.ts +56 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { evalFormula } from './table-formula';
|
|
3
|
+
import type { Row, TableDoc } from './table-model';
|
|
4
|
+
|
|
5
|
+
const doc: TableDoc = {
|
|
6
|
+
columns: [
|
|
7
|
+
{ id: 'c_qty', name: 'Qty', type: 'number' },
|
|
8
|
+
{ id: 'c_price', name: 'Price', type: 'currency' },
|
|
9
|
+
{ id: 'c_paid', name: 'Paid', type: 'checkbox' },
|
|
10
|
+
{ id: 'c_name', name: 'Name', type: 'text' },
|
|
11
|
+
{ id: 'c_calc', name: 'Calc', type: 'formula', formula: '{Qty}+1' },
|
|
12
|
+
],
|
|
13
|
+
rows: [],
|
|
14
|
+
aggregates: {},
|
|
15
|
+
views: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const row: Row = {
|
|
19
|
+
id: 'r1',
|
|
20
|
+
cells: { c_qty: 4, c_price: 2.5, c_paid: true, c_name: 'Bolt' },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ev = (f: string) => evalFormula(f, doc, row);
|
|
24
|
+
|
|
25
|
+
describe('evalFormula — arithmetic', () => {
|
|
26
|
+
it('multiplies and adds column refs', () => {
|
|
27
|
+
expect(ev('{Qty} * {Price}')).toBe(10);
|
|
28
|
+
expect(ev('{Qty} + 6')).toBe(10);
|
|
29
|
+
expect(ev('({Qty} + 1) * 2')).toBe(10);
|
|
30
|
+
});
|
|
31
|
+
it('respects precedence and unary minus', () => {
|
|
32
|
+
expect(ev('2 + 3 * 4')).toBe(14);
|
|
33
|
+
expect(ev('-{Qty} + 10')).toBe(6);
|
|
34
|
+
});
|
|
35
|
+
it('division by zero collapses to null', () => {
|
|
36
|
+
expect(ev('{Qty} / 0')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('evalFormula — functions', () => {
|
|
41
|
+
it('ROUND, ABS, MIN, MAX, SUM', () => {
|
|
42
|
+
expect(ev('ROUND({Price} * {Qty} * 0.333, 2)')).toBe(3.33);
|
|
43
|
+
expect(ev('ABS(0 - {Qty})')).toBe(4);
|
|
44
|
+
expect(ev('MIN({Qty}, 2, 9)')).toBe(2);
|
|
45
|
+
expect(ev('MAX({Qty}, 2, 9)')).toBe(9);
|
|
46
|
+
expect(ev('SUM({Qty}, {Price})')).toBe(6.5);
|
|
47
|
+
});
|
|
48
|
+
it('IF with comparisons', () => {
|
|
49
|
+
expect(ev('IF({Qty} > 3, 100, 0)')).toBe(100);
|
|
50
|
+
expect(ev('IF({Paid}, 0, {Price})')).toBe(0);
|
|
51
|
+
expect(ev("IF({Name} == 'Bolt', 'yes', 'no')")).toBe('yes');
|
|
52
|
+
});
|
|
53
|
+
it('CONCAT and string +', () => {
|
|
54
|
+
expect(ev("CONCAT({Name}, '-', {Qty})")).toBe('Bolt-4');
|
|
55
|
+
expect(ev("{Name} + '!'")).toBe('Bolt!');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('evalFormula — scientific', () => {
|
|
60
|
+
it('SQRT, LN, LOG10, EXP, POW', () => {
|
|
61
|
+
expect(ev('SQRT(16)')).toBe(4);
|
|
62
|
+
expect(ev('LN(1)')).toBe(0);
|
|
63
|
+
expect(ev('LOG10(1000)')).toBe(3);
|
|
64
|
+
expect(ev('ROUND(EXP(1), 4)')).toBe(2.7183);
|
|
65
|
+
expect(ev('POW(2, 10)')).toBe(1024);
|
|
66
|
+
});
|
|
67
|
+
it('out-of-domain inputs render blank rather than a bogus number', () => {
|
|
68
|
+
expect(ev('SQRT(0 - 1)')).toBeNull(); // NaN
|
|
69
|
+
expect(ev('LN(0)')).toBeNull(); // -Infinity
|
|
70
|
+
});
|
|
71
|
+
it('PI and E are constants, not column refs', () => {
|
|
72
|
+
expect(ev('ROUND(PI, 5)')).toBe(3.14159);
|
|
73
|
+
expect(ev('ROUND(PI / 4 * 0.375 ^ 2, 4)')).toBe(0.1104); // area of a 3/8" hole
|
|
74
|
+
});
|
|
75
|
+
it('^ binds tighter than * but looser than unary minus', () => {
|
|
76
|
+
expect(ev('2 * 3 ^ 2')).toBe(18); // not 36
|
|
77
|
+
expect(ev('0 - 2 ^ 2')).toBe(-4);
|
|
78
|
+
expect(ev('-2 ^ 2')).toBe(-4); // -(2^2), per maths convention. Excel says +4.
|
|
79
|
+
});
|
|
80
|
+
it('accepts scientific notation and leading-dot decimals', () => {
|
|
81
|
+
expect(ev('1e5')).toBe(100000);
|
|
82
|
+
expect(ev('1.5E-6')).toBe(0.0000015);
|
|
83
|
+
expect(ev('6.02e+23')).toBe(6.02e23);
|
|
84
|
+
expect(ev('.5 * 4')).toBe(2);
|
|
85
|
+
// A bare E after a number is still the constant, and so fails loudly
|
|
86
|
+
// rather than being silently swallowed as a malformed exponent.
|
|
87
|
+
expect(ev('2E')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
it('compares numbers the same way it adds them', () => {
|
|
90
|
+
// Regression: `compare` once used bare Number() while toNum stripped
|
|
91
|
+
// separators, so '1,000' was 1000 to arithmetic and NaN to a comparison —
|
|
92
|
+
// and a NaN comparison fell through to STRING ordering.
|
|
93
|
+
expect(ev("'1,000' > 28.7")).toBe(true);
|
|
94
|
+
expect(ev("'1,000' * 1")).toBe(1000); // `+` would concatenate, by design
|
|
95
|
+
});
|
|
96
|
+
it('^ is right-associative and accepts a negative exponent', () => {
|
|
97
|
+
expect(ev('2 ^ 3 ^ 2')).toBe(512); // 2^(3^2), not (2^3)^2 = 64
|
|
98
|
+
expect(ev('2 ^ -1')).toBe(0.5);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Acceptance: the release-rate equations from a published engineering standard
|
|
103
|
+
// (API RP 581 Part 3 §5.3.2/§5.3.3), which is why the scientific set exists.
|
|
104
|
+
// None of these were expressible before. Expected values verified independently.
|
|
105
|
+
describe('evalFormula — engineering formulas', () => {
|
|
106
|
+
const vessel: TableDoc = {
|
|
107
|
+
columns: [
|
|
108
|
+
{ id: 'c_rho', name: 'Density', type: 'number' },
|
|
109
|
+
{ id: 'c_pg', name: 'Pgauge', type: 'number' },
|
|
110
|
+
{ id: 'c_ps', name: 'Ps', type: 'number' },
|
|
111
|
+
{ id: 'c_mw', name: 'MW', type: 'number' },
|
|
112
|
+
{ id: 'c_ts', name: 'Ts', type: 'number' },
|
|
113
|
+
{ id: 'c_k', name: 'k', type: 'number' },
|
|
114
|
+
],
|
|
115
|
+
rows: [],
|
|
116
|
+
aggregates: {},
|
|
117
|
+
views: [],
|
|
118
|
+
};
|
|
119
|
+
const r: Row = {
|
|
120
|
+
id: 'v1',
|
|
121
|
+
cells: { c_rho: 50, c_pg: 100, c_ps: 100, c_mw: 30, c_ts: 560, c_k: 1.5 },
|
|
122
|
+
};
|
|
123
|
+
const evv = (f: string) => evalFormula(f, vessel, r);
|
|
124
|
+
|
|
125
|
+
it('liquid release rate (Eq 3.3) → lb/sec', () => {
|
|
126
|
+
const liquid = '0.61 * 1 * {Density} * (0.11 / 12) * SQRT(2 * 32.2 * {Pgauge} / {Density})';
|
|
127
|
+
expect(evv(`ROUND(${liquid}, 3)`)).toBe(3.173);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('vapor release rate, sonic (Eq 3.6) → lb/sec', () => {
|
|
131
|
+
const sonic =
|
|
132
|
+
'(0.61 / 1) * 0.11 * {Ps} * SQRT( ({k} * {MW} * 32.2) / (1545 * {Ts})' +
|
|
133
|
+
' * (2 / ({k} + 1)) ^ (({k} + 1) / ({k} - 1)) )';
|
|
134
|
+
expect(evv(`ROUND(${sonic}, 4)`)).toBe(0.1572);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('transition pressure (Eq 3.7) selects sonic vs subsonic', () => {
|
|
138
|
+
const ptrans = '14.7 * (({k} + 1) / 2) ^ ({k} / ({k} - 1))';
|
|
139
|
+
expect(evv(`ROUND(${ptrans}, 2)`)).toBe(28.71);
|
|
140
|
+
// Ps = 100 psia is above the transition pressure, so the release is sonic.
|
|
141
|
+
expect(evv(`IF({Ps} > ${ptrans}, 'sonic', 'subsonic')`)).toBe('sonic');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('evalFormula — safety', () => {
|
|
146
|
+
it('returns null for broken / hostile input rather than throwing', () => {
|
|
147
|
+
expect(ev('{Qty} *')).toBeNull();
|
|
148
|
+
expect(ev('process.exit(1)')).toBeNull();
|
|
149
|
+
expect(ev('{Unknown} + 1')).toBe(1); // unknown ref → 0
|
|
150
|
+
});
|
|
151
|
+
it('refuses to recurse into another formula column', () => {
|
|
152
|
+
expect(ev('{Calc} + 1')).toBe(1); // {Calc} resolves to null/0 (no formula chaining)
|
|
153
|
+
});
|
|
154
|
+
it('blank formula → null', () => {
|
|
155
|
+
expect(ev('')).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, safe formula evaluator for formula columns. Same-row scalar
|
|
3
|
+
* expressions only: arithmetic over other columns, referenced by name in
|
|
4
|
+
* braces — `{Qty} * {Price}`, `ROUND({Total} * 0.15, 2)`, `IF({Paid}, 0, {Due})`.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT JavaScript `eval`: a hand-written tokenizer + recursive
|
|
7
|
+
* descent parser over a tiny grammar (numbers, strings, `{refs}`, the operators
|
|
8
|
+
* + - * / % ^, comparisons, and a fixed function set). No identifiers reach a
|
|
9
|
+
* global scope, so a hostile formula can at worst return NaN.
|
|
10
|
+
*
|
|
11
|
+
* The scientific set (`^`, SQRT, POW, LN, LOG10, EXP, and the bare constants PI
|
|
12
|
+
* and E) exists for engineering formulas, which are rarely expressible with the
|
|
13
|
+
* four spreadsheet operations alone — a square root or an exponent term is the
|
|
14
|
+
* norm rather than the exception once a formula comes out of a standard.
|
|
15
|
+
*
|
|
16
|
+
* Cross-row math (sum/avg of a whole column) is NOT a formula — that's the
|
|
17
|
+
* aggregates footer (table-model.ts `computeAggregate`). Formulas see only the
|
|
18
|
+
* current row.
|
|
19
|
+
*
|
|
20
|
+
* Imported by table-model.ts `resolveCell`; kept dependency-free and pure so it
|
|
21
|
+
* runs unchanged in tool handlers, the API, and the browser.
|
|
22
|
+
*/
|
|
23
|
+
import type { CellValue, Row, TableDoc } from './table-model';
|
|
24
|
+
|
|
25
|
+
type FnName =
|
|
26
|
+
| 'IF'
|
|
27
|
+
| 'ROUND'
|
|
28
|
+
| 'ABS'
|
|
29
|
+
| 'MIN'
|
|
30
|
+
| 'MAX'
|
|
31
|
+
| 'SUM'
|
|
32
|
+
| 'FLOOR'
|
|
33
|
+
| 'CEIL'
|
|
34
|
+
| 'CONCAT'
|
|
35
|
+
| 'SQRT'
|
|
36
|
+
| 'POW'
|
|
37
|
+
| 'LN'
|
|
38
|
+
| 'LOG10'
|
|
39
|
+
| 'EXP';
|
|
40
|
+
const FUNCTIONS = new Set<FnName>([
|
|
41
|
+
'IF',
|
|
42
|
+
'ROUND',
|
|
43
|
+
'ABS',
|
|
44
|
+
'MIN',
|
|
45
|
+
'MAX',
|
|
46
|
+
'SUM',
|
|
47
|
+
'FLOOR',
|
|
48
|
+
'CEIL',
|
|
49
|
+
'CONCAT',
|
|
50
|
+
'SQRT',
|
|
51
|
+
'POW',
|
|
52
|
+
'LN',
|
|
53
|
+
'LOG10',
|
|
54
|
+
'EXP',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
type Token =
|
|
58
|
+
| { t: 'num'; v: number }
|
|
59
|
+
| { t: 'str'; v: string }
|
|
60
|
+
| { t: 'ref'; v: string }
|
|
61
|
+
| { t: 'ident'; v: string }
|
|
62
|
+
| { t: 'op'; v: string }
|
|
63
|
+
| { t: 'lparen' }
|
|
64
|
+
| { t: 'rparen' }
|
|
65
|
+
| { t: 'comma' };
|
|
66
|
+
|
|
67
|
+
export type EvalValue = number | string | boolean | null;
|
|
68
|
+
|
|
69
|
+
const MAX_FORMULA_LEN = 2000;
|
|
70
|
+
|
|
71
|
+
const isDigit = (ch: string | undefined): boolean => ch !== undefined && ch >= '0' && ch <= '9';
|
|
72
|
+
|
|
73
|
+
function tokenize(src: string): Token[] {
|
|
74
|
+
const out: Token[] = [];
|
|
75
|
+
let i = 0;
|
|
76
|
+
const n = src.length;
|
|
77
|
+
while (i < n) {
|
|
78
|
+
const c = src[i]!;
|
|
79
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (c === '{') {
|
|
84
|
+
const end = src.indexOf('}', i);
|
|
85
|
+
if (end < 0) throw new Error('unterminated {column reference}');
|
|
86
|
+
out.push({ t: 'ref', v: src.slice(i + 1, end).trim() });
|
|
87
|
+
i = end + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (c === "'" || c === '"') {
|
|
91
|
+
const end = src.indexOf(c, i + 1);
|
|
92
|
+
if (end < 0) throw new Error('unterminated string literal');
|
|
93
|
+
out.push({ t: 'str', v: src.slice(i + 1, end) });
|
|
94
|
+
i = end + 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Numbers, including a leading-dot decimal (`.5`) and scientific notation
|
|
98
|
+
// (`1e5`, `1.5E-6`, `6.02e+23`) — which is simply how constants out of an
|
|
99
|
+
// engineering standard are written, and which previously tokenized as a
|
|
100
|
+
// number followed by the `E` constant and died as "trailing tokens",
|
|
101
|
+
// rendering a BLANK cell rather than an error.
|
|
102
|
+
if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
|
|
103
|
+
let j = i + 1;
|
|
104
|
+
while (j < n && /[0-9._]/.test(src[j]!)) j++;
|
|
105
|
+
// Only consume `e`/`E` as an exponent when real digits follow, so a bare
|
|
106
|
+
// `E` after a number stays the constant and fails loudly instead.
|
|
107
|
+
if (src[j] === 'e' || src[j] === 'E') {
|
|
108
|
+
let k = j + 1;
|
|
109
|
+
if (src[k] === '+' || src[k] === '-') k++;
|
|
110
|
+
if (isDigit(src[k])) {
|
|
111
|
+
while (k < n && isDigit(src[k])) k++;
|
|
112
|
+
j = k;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
out.push({ t: 'num', v: Number(src.slice(i, j).replace(/_/g, '')) });
|
|
116
|
+
i = j;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (/[A-Za-z]/.test(c)) {
|
|
120
|
+
let j = i + 1;
|
|
121
|
+
while (j < n && /[A-Za-z0-9_]/.test(src[j]!)) j++;
|
|
122
|
+
out.push({ t: 'ident', v: src.slice(i, j) });
|
|
123
|
+
i = j;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// Two-char comparison operators.
|
|
127
|
+
const two = src.slice(i, i + 2);
|
|
128
|
+
if (two === '>=' || two === '<=' || two === '==' || two === '!=' || two === '<>') {
|
|
129
|
+
out.push({ t: 'op', v: two === '<>' ? '!=' : two });
|
|
130
|
+
i += 2;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if ('+-*/%<>^'.includes(c)) {
|
|
134
|
+
out.push({ t: 'op', v: c });
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c === '=') {
|
|
139
|
+
out.push({ t: 'op', v: '==' });
|
|
140
|
+
i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (c === '(') {
|
|
144
|
+
out.push({ t: 'lparen' });
|
|
145
|
+
i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (c === ')') {
|
|
149
|
+
out.push({ t: 'rparen' });
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (c === ',') {
|
|
154
|
+
out.push({ t: 'comma' });
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`unexpected character '${c}'`);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* How a `{braced}` reference resolves to a value. Table formulas bind refs to
|
|
165
|
+
* columns of the current row; the formula-spec evaluator binds them to named
|
|
166
|
+
* variables. Same grammar, same parser, two binding strategies — there is
|
|
167
|
+
* deliberately only one expression language in the codebase.
|
|
168
|
+
*/
|
|
169
|
+
export type RefResolver = (name: string) => EvalValue;
|
|
170
|
+
|
|
171
|
+
class Parser {
|
|
172
|
+
private pos = 0;
|
|
173
|
+
constructor(
|
|
174
|
+
private toks: Token[],
|
|
175
|
+
private resolve: RefResolver,
|
|
176
|
+
) {}
|
|
177
|
+
|
|
178
|
+
private peek(): Token | undefined {
|
|
179
|
+
return this.toks[this.pos];
|
|
180
|
+
}
|
|
181
|
+
private next(): Token | undefined {
|
|
182
|
+
return this.toks[this.pos++];
|
|
183
|
+
}
|
|
184
|
+
private expect(t: Token['t']): void {
|
|
185
|
+
const tok = this.next();
|
|
186
|
+
if (!tok || tok.t !== t) throw new Error(`expected ${t}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
parse(): EvalValue {
|
|
190
|
+
const v = this.parseComparison();
|
|
191
|
+
if (this.pos < this.toks.length) throw new Error('trailing tokens');
|
|
192
|
+
return v;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// comparison: addsub (op addsub)?
|
|
196
|
+
private parseComparison(): EvalValue {
|
|
197
|
+
let left = this.parseAddSub();
|
|
198
|
+
const tok = this.peek();
|
|
199
|
+
if (tok?.t === 'op' && ['>', '<', '>=', '<=', '==', '!='].includes(tok.v)) {
|
|
200
|
+
this.next();
|
|
201
|
+
const right = this.parseAddSub();
|
|
202
|
+
left = compare(tok.v, left, right);
|
|
203
|
+
}
|
|
204
|
+
return left;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private parseAddSub(): EvalValue {
|
|
208
|
+
let left = this.parseMulDiv();
|
|
209
|
+
for (;;) {
|
|
210
|
+
const tok = this.peek();
|
|
211
|
+
if (tok?.t === 'op' && (tok.v === '+' || tok.v === '-')) {
|
|
212
|
+
this.next();
|
|
213
|
+
const right = this.parseMulDiv();
|
|
214
|
+
if (tok.v === '+') {
|
|
215
|
+
// '+' concatenates when either side is a non-numeric string.
|
|
216
|
+
if (typeof left === 'string' || typeof right === 'string') {
|
|
217
|
+
left = `${toStr(left)}${toStr(right)}`;
|
|
218
|
+
} else {
|
|
219
|
+
left = toNum(left) + toNum(right);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
left = toNum(left) - toNum(right);
|
|
223
|
+
}
|
|
224
|
+
} else break;
|
|
225
|
+
}
|
|
226
|
+
return left;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private parseMulDiv(): EvalValue {
|
|
230
|
+
let left = this.parseUnary();
|
|
231
|
+
for (;;) {
|
|
232
|
+
const tok = this.peek();
|
|
233
|
+
if (tok?.t === 'op' && (tok.v === '*' || tok.v === '/' || tok.v === '%')) {
|
|
234
|
+
this.next();
|
|
235
|
+
const right = toNum(this.parseUnary());
|
|
236
|
+
const l = toNum(left);
|
|
237
|
+
left =
|
|
238
|
+
tok.v === '*' ? l * right : tok.v === '/' ? (right === 0 ? NaN : l / right) : l % right;
|
|
239
|
+
} else break;
|
|
240
|
+
}
|
|
241
|
+
return left;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private parseUnary(): EvalValue {
|
|
245
|
+
const tok = this.peek();
|
|
246
|
+
if (tok?.t === 'op' && tok.v === '-') {
|
|
247
|
+
this.next();
|
|
248
|
+
return -toNum(this.parseUnary());
|
|
249
|
+
}
|
|
250
|
+
if (tok?.t === 'op' && tok.v === '+') {
|
|
251
|
+
this.next();
|
|
252
|
+
return this.parseUnary();
|
|
253
|
+
}
|
|
254
|
+
return this.parsePow();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Exponentiation binds tighter than * and /, AND tighter than unary minus —
|
|
258
|
+
// which is what makes `-2^2` parse as -(2^2) = -4, following normal
|
|
259
|
+
// mathematical convention. (Excel is the well-known counter-example: there
|
|
260
|
+
// `=-2^2` is +4. We deliberately do not copy Excel here.) Right-associative,
|
|
261
|
+
// so `2^3^2` is 2^9; the exponent re-enters parseUnary so `2^-1` is legal.
|
|
262
|
+
private parsePow(): EvalValue {
|
|
263
|
+
const base = this.parsePrimary();
|
|
264
|
+
const tok = this.peek();
|
|
265
|
+
if (tok?.t === 'op' && tok.v === '^') {
|
|
266
|
+
this.next();
|
|
267
|
+
return toNum(base) ** toNum(this.parseUnary());
|
|
268
|
+
}
|
|
269
|
+
return base;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private parsePrimary(): EvalValue {
|
|
273
|
+
const tok = this.next();
|
|
274
|
+
if (!tok) throw new Error('unexpected end of formula');
|
|
275
|
+
switch (tok.t) {
|
|
276
|
+
case 'num':
|
|
277
|
+
return tok.v;
|
|
278
|
+
case 'str':
|
|
279
|
+
return tok.v;
|
|
280
|
+
case 'ref':
|
|
281
|
+
return this.resolve(tok.v);
|
|
282
|
+
case 'lparen': {
|
|
283
|
+
const v = this.parseComparison();
|
|
284
|
+
this.expect('rparen');
|
|
285
|
+
return v;
|
|
286
|
+
}
|
|
287
|
+
case 'ident': {
|
|
288
|
+
const upper = tok.v.toUpperCase();
|
|
289
|
+
if (upper === 'TRUE') return true;
|
|
290
|
+
if (upper === 'FALSE') return false;
|
|
291
|
+
// Bare mathematical constants. Safe as identifiers because column
|
|
292
|
+
// references are always braced — `PI` can never shadow a column.
|
|
293
|
+
if (upper === 'PI') return Math.PI;
|
|
294
|
+
if (upper === 'E') return Math.E;
|
|
295
|
+
if (this.peek()?.t === 'lparen' && FUNCTIONS.has(upper as FnName)) {
|
|
296
|
+
return this.parseCall(upper as FnName);
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`unknown identifier '${tok.v}'`);
|
|
299
|
+
}
|
|
300
|
+
default:
|
|
301
|
+
throw new Error('unexpected token');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private parseCall(name: FnName): EvalValue {
|
|
306
|
+
this.expect('lparen');
|
|
307
|
+
const args: EvalValue[] = [];
|
|
308
|
+
if (this.peek()?.t !== 'rparen') {
|
|
309
|
+
args.push(this.parseComparison());
|
|
310
|
+
while (this.peek()?.t === 'comma') {
|
|
311
|
+
this.next();
|
|
312
|
+
args.push(this.parseComparison());
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
this.expect('rparen');
|
|
316
|
+
return applyFn(name, args);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The `{braced}` symbols an expression references, in source order, deduped.
|
|
322
|
+
*
|
|
323
|
+
* STATIC — nothing is evaluated, so this sees every branch of an `IF()` rather
|
|
324
|
+
* than the one a particular set of inputs would take. That is the property
|
|
325
|
+
* `signatureOf` needs: "what must I supply" has to be answered before any
|
|
326
|
+
* value exists to supply. Reusing the tokenizer rather than a `/\{([^}]*)\}/`
|
|
327
|
+
* scan matters for one case — a brace inside a string literal (`'{x}'`) is
|
|
328
|
+
* text, not a reference, and only the tokenizer knows that.
|
|
329
|
+
*
|
|
330
|
+
* Throws on an unparseable expression, like `evalExpression`.
|
|
331
|
+
*/
|
|
332
|
+
export function refsIn(src: string): string[] {
|
|
333
|
+
const text = (src ?? '').trim();
|
|
334
|
+
if (!text) return [];
|
|
335
|
+
if (text.length > MAX_FORMULA_LEN) throw new Error('expression too long');
|
|
336
|
+
const out: string[] = [];
|
|
337
|
+
const seen = new Set<string>();
|
|
338
|
+
for (const tok of tokenize(text)) {
|
|
339
|
+
if (tok.t === 'ref' && tok.v && !seen.has(tok.v)) {
|
|
340
|
+
seen.add(tok.v);
|
|
341
|
+
out.push(tok.v);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Binds `{refs}` to columns of one row. */
|
|
348
|
+
function columnResolver(doc: TableDoc, row: Row): RefResolver {
|
|
349
|
+
return (name) => {
|
|
350
|
+
const col = doc.columns.find((c) => c.name.trim().toLowerCase() === name.toLowerCase());
|
|
351
|
+
if (!col) return null;
|
|
352
|
+
// Guard against formula → formula recursion: a formula may not reference
|
|
353
|
+
// another formula column (avoids cycles without a full dependency graph).
|
|
354
|
+
if (col.type === 'formula') return null;
|
|
355
|
+
return cellToEval(row.cells[col.id] ?? null);
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function cellToEval(v: CellValue): EvalValue {
|
|
360
|
+
if (Array.isArray(v)) return v.join(', ');
|
|
361
|
+
return v;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Parse a numeric string the ONE way the whole evaluator agrees on.
|
|
366
|
+
*
|
|
367
|
+
* This existing exactly once matters. `toNum` used to strip thousands
|
|
368
|
+
* separators while `compare` called bare `Number()`, so `'1,000'` was 1000 to
|
|
369
|
+
* arithmetic and NaN to a comparison — and a NaN comparison silently falls
|
|
370
|
+
* through to STRING ordering, where `'1,000' < '28.7'`. A piecewise branch
|
|
371
|
+
* guarded by `{Ps} > {Ptrans}` therefore selected the wrong equation and
|
|
372
|
+
* returned a plausible number from it. Any divergence here is a wrong-answer
|
|
373
|
+
* bug, not a formatting quirk.
|
|
374
|
+
*/
|
|
375
|
+
function parseNumericString(s: string): number {
|
|
376
|
+
return Number(s.replace(/[, ]/g, ''));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function toNum(v: EvalValue): number {
|
|
380
|
+
if (typeof v === 'number') return v;
|
|
381
|
+
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
382
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
383
|
+
const n = parseNumericString(v);
|
|
384
|
+
return Number.isFinite(n) ? n : NaN;
|
|
385
|
+
}
|
|
386
|
+
return 0; // null / blank behaves as 0 in arithmetic
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function toStr(v: EvalValue): string {
|
|
390
|
+
if (v === null || v === undefined) return '';
|
|
391
|
+
return String(v);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Exported so the formula-spec evaluator branches on exactly these rules. */
|
|
395
|
+
export function truthy(v: EvalValue): boolean {
|
|
396
|
+
if (typeof v === 'boolean') return v;
|
|
397
|
+
if (typeof v === 'number') return v !== 0;
|
|
398
|
+
if (typeof v === 'string') return v.trim() !== '' && v.trim().toLowerCase() !== 'false';
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function compare(op: string, a: EvalValue, b: EvalValue): boolean {
|
|
403
|
+
const coerce = (v: EvalValue): number =>
|
|
404
|
+
typeof v === 'number' ? v : typeof v === 'string' ? parseNumericString(v) : Number(v);
|
|
405
|
+
const na = coerce(a);
|
|
406
|
+
const nb = coerce(b);
|
|
407
|
+
const numeric = Number.isFinite(na) && Number.isFinite(nb);
|
|
408
|
+
switch (op) {
|
|
409
|
+
case '==':
|
|
410
|
+
return numeric ? na === nb : toStr(a) === toStr(b);
|
|
411
|
+
case '!=':
|
|
412
|
+
return numeric ? na !== nb : toStr(a) !== toStr(b);
|
|
413
|
+
case '>':
|
|
414
|
+
return numeric ? na > nb : toStr(a) > toStr(b);
|
|
415
|
+
case '<':
|
|
416
|
+
return numeric ? na < nb : toStr(a) < toStr(b);
|
|
417
|
+
case '>=':
|
|
418
|
+
return numeric ? na >= nb : toStr(a) >= toStr(b);
|
|
419
|
+
case '<=':
|
|
420
|
+
return numeric ? na <= nb : toStr(a) <= toStr(b);
|
|
421
|
+
default:
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function applyFn(name: FnName, args: EvalValue[]): EvalValue {
|
|
427
|
+
switch (name) {
|
|
428
|
+
case 'IF':
|
|
429
|
+
return truthy(args[0] ?? null) ? (args[1] ?? null) : (args[2] ?? null);
|
|
430
|
+
case 'ROUND': {
|
|
431
|
+
const n = toNum(args[0] ?? null);
|
|
432
|
+
const d = args.length > 1 ? toNum(args[1]!) : 0;
|
|
433
|
+
const f = 10 ** d;
|
|
434
|
+
return Math.round(n * f) / f;
|
|
435
|
+
}
|
|
436
|
+
case 'FLOOR':
|
|
437
|
+
return Math.floor(toNum(args[0] ?? null));
|
|
438
|
+
case 'CEIL':
|
|
439
|
+
return Math.ceil(toNum(args[0] ?? null));
|
|
440
|
+
case 'ABS':
|
|
441
|
+
return Math.abs(toNum(args[0] ?? null));
|
|
442
|
+
case 'MIN':
|
|
443
|
+
return Math.min(...args.map(toNum));
|
|
444
|
+
case 'MAX':
|
|
445
|
+
return Math.max(...args.map(toNum));
|
|
446
|
+
case 'SUM':
|
|
447
|
+
return args.map(toNum).reduce((a, b) => a + b, 0);
|
|
448
|
+
case 'CONCAT':
|
|
449
|
+
return args.map(toStr).join('');
|
|
450
|
+
// Scientific set. Out-of-domain inputs (SQRT of a negative, LN of zero)
|
|
451
|
+
// yield NaN or -Infinity, which evalFormula collapses to null — a formula
|
|
452
|
+
// outside its valid range renders blank rather than showing a bogus number.
|
|
453
|
+
case 'SQRT':
|
|
454
|
+
return Math.sqrt(toNum(args[0] ?? null));
|
|
455
|
+
case 'POW':
|
|
456
|
+
return toNum(args[0] ?? null) ** toNum(args[1] ?? null);
|
|
457
|
+
case 'LN':
|
|
458
|
+
return Math.log(toNum(args[0] ?? null));
|
|
459
|
+
case 'LOG10':
|
|
460
|
+
return Math.log10(toNum(args[0] ?? null));
|
|
461
|
+
case 'EXP':
|
|
462
|
+
return Math.exp(toNum(args[0] ?? null));
|
|
463
|
+
default:
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Evaluate an expression against an arbitrary reference resolver, THROWING on
|
|
470
|
+
* a malformed expression. Callers that want a value or a diagnosis — the
|
|
471
|
+
* formula-spec evaluator, where a silently blank release rate would be worse
|
|
472
|
+
* than a loud failure — use this. Callers that want a cell to render want
|
|
473
|
+
* `evalFormula` below.
|
|
474
|
+
*/
|
|
475
|
+
export function evalExpression(src: string, resolve: RefResolver): EvalValue {
|
|
476
|
+
const text = (src ?? '').trim();
|
|
477
|
+
if (!text) throw new Error('empty expression');
|
|
478
|
+
if (text.length > MAX_FORMULA_LEN) throw new Error('expression too long');
|
|
479
|
+
return new Parser(tokenize(text), resolve).parse();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Evaluate a formula expression in the context of one row. Returns a number,
|
|
484
|
+
* string, or null. Any parse/eval error yields null (a broken formula renders
|
|
485
|
+
* blank, never throws into the caller). NaN results collapse to null too.
|
|
486
|
+
*/
|
|
487
|
+
export function evalFormula(formula: string, doc: TableDoc, row: Row): CellValue {
|
|
488
|
+
try {
|
|
489
|
+
const result = evalExpression(formula, columnResolver(doc, row));
|
|
490
|
+
if (typeof result === 'number') return Number.isFinite(result) ? result : null;
|
|
491
|
+
if (typeof result === 'boolean') return result;
|
|
492
|
+
return result ?? null;
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|