@xeplr/expression-handler 1.0.6 → 2.0.0

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/functions.js CHANGED
@@ -1,50 +1,285 @@
1
- // Field functions — applied to an operand's resolved value before the operator
2
- // runs. Level 1: a function takes one value and returns one value.
1
+ // The ONE function list — every function a formula can call, with the words
2
+ // the UI shows for it. BI's reference panel, autocomplete and validation and
3
+ // Workflow's condition picker are all built from this; adding a function is
4
+ // adding one entry here.
3
5
  //
4
- // Dates use UTC getters (xeplr stores datetimes as UTC), and month/weekday
5
- // return English names so `month(dateField) eq 'January'` reads naturally.
6
- // null/invalid input → null (so the downstream comparison simply fails rather
7
- // than throwing).
8
-
9
- var MONTHS = [
10
- 'January', 'February', 'March', 'April', 'May', 'June',
11
- 'July', 'August', 'September', 'October', 'November', 'December'
6
+ // Moved from BI's report-engine/formulaFunctions.js; its implementations and
7
+ // their behaviour are unchanged (BI's saved reports depend on them). The 1.x
8
+ // functions merged in: weekday, between. 1.x's monthNum is now month.
9
+ //
10
+ // EVERY FUNCTION IS TOTAL — nonsense in, null out, never an exception. The
11
+ // engine only evaluates the branch an if() takes, but a function can still see
12
+ // a missing value or a column holding text where a number was expected, and a
13
+ // report must show an empty cell there, not fail.
14
+ //
15
+ // Entry shape:
16
+ // name, category, args, description, example what the UI shows
17
+ // fn the implementation
18
+ // min / max how many arguments it takes (max: Infinity = any number)
19
+ // context fn receives the run context first: fn(ctx, …args)
20
+ // volatile its answer depends on more than its arguments (the date), so
21
+ // it is never folded to a constant at compile time
22
+ // aggregate spans rows — compiled by the engine, see compile.js
23
+ // special syntax rather than a function (if, $thisrow) — listed so the
24
+ // reference shows it, compiled by the engine
25
+
26
+ var dates = require('./dates');
27
+ var asDate = dates.asDate;
28
+
29
+ var CATEGORIES = ['Math', 'Text', 'Date', 'Logic', 'Aggregate'];
30
+
31
+ function str(v) { return v === null || v === undefined ? '' : String(v); }
32
+ function num(v) { var n = Number(v); return isNaN(n) ? null : n; }
33
+ function isEmpty(v) { return v === null || v === undefined || v === ''; }
34
+ function math(f) { return function (v) { var n = num(v); return n === null ? null : f(n); }; }
35
+ function minMax(pick) {
36
+ return function () {
37
+ var vals = arguments.length === 1 && Array.isArray(arguments[0]) ? arguments[0] : Array.prototype.slice.call(arguments);
38
+ if (!vals.length) return null;
39
+ var out = null;
40
+ for (var i = 0; i < vals.length; i++) {
41
+ var n = num(vals[i]);
42
+ if (n === null) return null;
43
+ out = out === null ? n : pick(out, n);
44
+ }
45
+ return out;
46
+ };
47
+ }
48
+
49
+ var LIST = [
50
+ // ── Math ──
51
+ { name: 'round', category: 'Math', args: 'value, places', min: 1, max: 2,
52
+ description: 'Round to a number of decimal places', example: 'round(amount, 2)',
53
+ // Nothing rounds to 0, not null — Number(null) is 0, the coercion the rest
54
+ // of the engine uses (`revenue - missing` is revenue). Text that isn't a
55
+ // number is null, not NaN.
56
+ fn: function (v, places) {
57
+ var n = num(v);
58
+ if (n === null) return null;
59
+ var p = Math.pow(10, Number(places) || 0);
60
+ return Math.round(n * p) / p;
61
+ } },
62
+ { name: 'abs', category: 'Math', args: 'value', min: 1, max: 1,
63
+ description: 'Distance from zero, ignoring sign', example: 'abs(variance)', fn: math(Math.abs) },
64
+ { name: 'ceil', category: 'Math', args: 'value', min: 1, max: 1,
65
+ description: 'Round up to a whole number', example: 'ceil(days)', fn: math(Math.ceil) },
66
+ { name: 'floor', category: 'Math', args: 'value', min: 1, max: 1,
67
+ description: 'Round down to a whole number', example: 'floor(days)', fn: math(Math.floor) },
68
+ { name: 'sqrt', category: 'Math', args: 'value', min: 1, max: 1,
69
+ description: 'Square root', example: 'sqrt(area)', fn: math(Math.sqrt) },
70
+ { name: 'min', category: 'Math', args: 'a, b, …', min: 1, max: Infinity,
71
+ description: 'Smallest of the values given — not across rows', example: 'min(price, cap)',
72
+ fn: minMax(function (a, b) { return b < a ? b : a; }) },
73
+ { name: 'max', category: 'Math', args: 'a, b, …', min: 1, max: Infinity,
74
+ description: 'Largest of the values given — not across rows', example: 'max(price, floor_price)',
75
+ fn: minMax(function (a, b) { return b > a ? b : a; }) },
76
+ // expr-eval shipped these unlisted, so saved formulas may use them.
77
+ // log is the NATURAL log there (not Excel's base 10), kept for that reason.
78
+ { name: 'trunc', category: 'Math', args: 'value', min: 1, max: 1,
79
+ description: 'Drop the decimals, toward zero', example: 'trunc(hours)', fn: math(Math.trunc) },
80
+ { name: 'exp', category: 'Math', args: 'value', min: 1, max: 1,
81
+ description: 'e raised to the value', example: 'exp(rate)', fn: math(Math.exp) },
82
+ { name: 'ln', category: 'Math', args: 'value', min: 1, max: 1,
83
+ description: 'Natural logarithm', example: 'ln(growth)', fn: math(Math.log) },
84
+ { name: 'log', category: 'Math', args: 'value', min: 1, max: 1,
85
+ description: 'Natural logarithm — the same as ln, not base 10', example: 'log(growth)', fn: math(Math.log) },
86
+ { name: 'log10', category: 'Math', args: 'value', min: 1, max: 1,
87
+ description: 'Base-10 logarithm', example: 'log10(size)', fn: math(Math.log10) },
88
+ { name: 'log2', category: 'Math', args: 'value', min: 1, max: 1,
89
+ description: 'Base-2 logarithm', example: 'log2(size)', fn: math(Math.log2) },
90
+ { name: 'cbrt', category: 'Math', args: 'value', min: 1, max: 1,
91
+ description: 'Cube root', example: 'cbrt(volume)', fn: math(Math.cbrt) },
92
+ { name: 'pow', category: 'Math', args: 'value, exponent', min: 2, max: 2,
93
+ description: 'Raise to a power', example: 'pow(rate, years)',
94
+ fn: function (v, e) { var a = num(v), b = num(e); return a === null || b === null ? null : Math.pow(a, b); } },
95
+
96
+ // ── Text ──
97
+ // Text of nothing is empty, never the word "null" — that would show in a report.
98
+ { name: 'upper', category: 'Text', args: 'text', min: 1, max: 1,
99
+ description: 'Convert to upper case', example: 'upper(city)', fn: function (v) { return str(v).toUpperCase(); } },
100
+ { name: 'lower', category: 'Text', args: 'text', min: 1, max: 1,
101
+ description: 'Convert to lower case', example: 'lower(email)', fn: function (v) { return str(v).toLowerCase(); } },
102
+ { name: 'trim', category: 'Text', args: 'text', min: 1, max: 1,
103
+ description: 'Remove leading and trailing spaces', example: 'trim(name)', fn: function (v) { return str(v).trim(); } },
104
+ { name: 'concat', category: 'Text', args: 'a, b, …', min: 0, max: Infinity,
105
+ description: 'Join values into one piece of text', example: 'concat(first, " ", last)',
106
+ fn: function () { var s = ''; for (var i = 0; i < arguments.length; i++) s += str(arguments[i]); return s; } },
107
+ // 1-based, like every spreadsheet — a report author is not a programmer.
108
+ { name: 'substring', category: 'Text', args: 'text, start, count', min: 2, max: 3,
109
+ description: 'Part of a value, counting from 1', example: 'substring(code, 1, 3)',
110
+ fn: function (v, start, count) {
111
+ var s = str(v);
112
+ var from = Math.max(0, (Number(start) || 1) - 1);
113
+ return count === undefined ? s.slice(from) : s.slice(from, from + (Number(count) || 0));
114
+ } },
115
+ { name: 'left', category: 'Text', args: 'text, count', min: 2, max: 2,
116
+ description: 'First few characters', example: 'left(deptCode, 3)',
117
+ fn: function (v, count) { return str(v).slice(0, Number(count) || 0); } },
118
+ { name: 'right', category: 'Text', args: 'text, count', min: 2, max: 2,
119
+ description: 'Last few characters', example: 'right(invoice, 4)',
120
+ fn: function (v, count) { var n = Number(count) || 0; return n <= 0 ? '' : str(v).slice(-n); } },
121
+ { name: 'replace', category: 'Text', args: 'text, find, replace with', min: 3, max: 3,
122
+ description: 'Swap one piece of text for another', example: 'replace(phone, "-", "")',
123
+ fn: function (v, find, into) { return str(v).split(str(find)).join(str(into)); } },
124
+ { name: 'contains', category: 'Text', args: 'text, find', min: 2, max: 2,
125
+ description: 'True when the text contains it', example: 'contains(notes, "urgent")',
126
+ fn: function (v, sub) { return str(v).indexOf(str(sub)) !== -1; } },
127
+ { name: 'startsWith', category: 'Text', args: 'text, find', min: 2, max: 2,
128
+ description: 'True when the text begins with it', example: 'startsWith(sku, "IN")',
129
+ fn: function (v, sub) { return str(v).startsWith(str(sub)); } },
130
+ { name: 'endsWith', category: 'Text', args: 'text, find', min: 2, max: 2,
131
+ description: 'True when the text ends with it', example: 'endsWith(file, ".pdf")',
132
+ fn: function (v, sub) { return str(v).endsWith(str(sub)); } },
133
+ { name: 'length', category: 'Text', args: 'text', min: 1, max: 1,
134
+ description: 'How many characters', example: 'length(name)',
135
+ fn: function (v) { return Array.isArray(v) ? v.length : str(v).length; } },
136
+
137
+ // ── Date ── (local time; a date-only string is that calendar day — see dates.js)
138
+ { name: 'year', category: 'Date', args: 'date', min: 1, max: 1,
139
+ description: 'The year, as a number', example: 'year(order_date)',
140
+ fn: function (v) { var d = asDate(v); return d ? d.getFullYear() : null; } },
141
+ { name: 'month', category: 'Date', args: 'date', min: 1, max: 1,
142
+ description: 'The month, 1–12', example: 'month(order_date)',
143
+ fn: function (v) { var d = asDate(v); return d ? d.getMonth() + 1 : null; } },
144
+ { name: 'day', category: 'Date', args: 'date', min: 1, max: 1,
145
+ description: 'Day of the month', example: 'day(order_date)',
146
+ fn: function (v) { var d = asDate(v); return d ? d.getDate() : null; } },
147
+ { name: 'quarter', category: 'Date', args: 'date', min: 1, max: 1,
148
+ description: 'The quarter, 1–4', example: 'quarter(order_date)',
149
+ fn: function (v) { var d = asDate(v); return d ? Math.floor(d.getMonth() / 3) + 1 : null; } },
150
+ { name: 'weekday', category: 'Date', args: 'date', min: 1, max: 1,
151
+ description: 'The day of the week by name', example: 'weekday(order_date)',
152
+ fn: function (v) { var d = asDate(v); return d ? dates.WEEKDAYS[d.getDay()] : null; } },
153
+ { name: 'datediff', category: 'Date', args: 'from, to', min: 2, max: 2,
154
+ description: 'Whole days between two dates', example: 'datediff(ordered, shipped)',
155
+ // Later minus earlier.
156
+ fn: function (a, b) {
157
+ var from = asDate(a), to = asDate(b);
158
+ if (!from || !to) return null;
159
+ return Math.round((to.getTime() - from.getTime()) / dates.MS_PER_DAY);
160
+ } },
161
+ { name: 'dateadd', category: 'Date', args: 'date, days', min: 2, max: 2,
162
+ description: 'Move a date by a number of days', example: 'dateadd(order_date, 30)',
163
+ // Built from calendar parts, not by adding milliseconds, so a DST boundary
164
+ // can't turn "+1 day" into 23 or 25 hours and land on the wrong date.
165
+ fn: function (v, days) {
166
+ var d = asDate(v);
167
+ if (!d) return null;
168
+ return dates.isoLocal(new Date(d.getFullYear(), d.getMonth(), d.getDate() + (Number(days) || 0)));
169
+ } },
170
+ { name: 'monthname', category: 'Date', args: 'date or month number', min: 1, max: 1,
171
+ description: 'The month by name. Reads well, but sorts alphabetically — group by monthkey instead', example: 'monthname(order_date)',
172
+ // A number is always a month, never an epoch timestamp — monthname(13)
173
+ // must not read as 13ms after 1970 and answer "January".
174
+ fn: function (v) {
175
+ if (typeof v === 'number') return v >= 1 && v <= 12 ? dates.MONTH_NAMES[v - 1] : null;
176
+ var d = asDate(v);
177
+ return d ? dates.MONTH_NAMES[d.getMonth()] : null;
178
+ } },
179
+ // Grouping KEYS: they sort correctly as text ("2024-06" before "2024-10"),
180
+ // which is what makes them safe to pivot or group on, unlike monthname.
181
+ { name: 'monthkey', category: 'Date', args: 'date', min: 1, max: 1,
182
+ description: 'The month as 2024-06 — sorts chronologically, so group or pivot by this', example: 'monthkey(order_date)',
183
+ fn: function (v) { var d = asDate(v); return d ? d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') : null; } },
184
+ { name: 'quarterkey', category: 'Date', args: 'date', min: 1, max: 1,
185
+ description: 'The quarter as 2024-Q2 — sorts chronologically', example: 'quarterkey(order_date)',
186
+ fn: function (v) { var d = asDate(v); return d ? d.getFullYear() + '-Q' + (Math.floor(d.getMonth() / 3) + 1) : null; } },
187
+ { name: 'daykey', category: 'Date', args: 'date', min: 1, max: 1,
188
+ description: 'The day as 2024-06-17 — sorts chronologically', example: 'daykey(order_date)',
189
+ fn: function (v) { var d = asDate(v); return d ? dates.isoLocal(d) : null; } },
190
+ // Predicates, not date maths — they exist to go inside sumif / countif. Both
191
+ // ends matter: a date later this year is NOT year-to-date. False, not null,
192
+ // on nothing — the honest answer, and it composes with `not`.
193
+ { name: 'ytd', category: 'Date', args: 'date', min: 1, max: 1, context: true, volatile: true,
194
+ description: 'True when the date is in this calendar year, up to today. Pair it with sumif', example: 'ytd(order_date)',
195
+ fn: function (ctx, v) {
196
+ var d = asDate(v);
197
+ if (!d) return false;
198
+ var now = ctx.now();
199
+ return d.getFullYear() === now.year && d.getTime() <= now.end.getTime();
200
+ } },
201
+ { name: 'fytd', category: 'Date', args: 'date', min: 1, max: 1, context: true, volatile: true,
202
+ description: 'True when the date is in this financial year, up to today. Pair it with sumif', example: 'fytd(order_date)',
203
+ fn: function (ctx, v) {
204
+ var d = asDate(v);
205
+ if (!d) return false;
206
+ var now = ctx.now();
207
+ return d.getTime() >= now.fyStart.getTime() && d.getTime() <= now.end.getTime();
208
+ } },
209
+
210
+ // ── Logic ──
211
+ { name: 'if', category: 'Logic', args: 'condition, then, otherwise', min: 3, max: 3, special: true,
212
+ description: 'Choose between two values. Nests freely', example: 'if(qty > 10, "bulk", "single")' },
213
+ // Empty text counts as missing: a database NULL and an empty cell mean the
214
+ // same thing to someone reading a report. Zero does not.
215
+ { name: 'coalesce', category: 'Logic', args: 'a, b, …', min: 1, max: Infinity,
216
+ description: 'First value that is not empty', example: 'coalesce(nickname, name)',
217
+ fn: function () { for (var i = 0; i < arguments.length; i++) if (!isEmpty(arguments[i])) return arguments[i]; return null; } },
218
+ { name: 'ifnull', category: 'Logic', args: 'value, fallback', min: 2, max: 2,
219
+ description: 'Use a fallback when the value is empty', example: 'ifnull(discount, 0)',
220
+ fn: function (v, fallback) { return isEmpty(v) ? fallback : v; } },
221
+ { name: 'isnull', category: 'Logic', args: 'value', min: 1, max: 1,
222
+ description: 'True when the value is empty', example: 'isnull(closed_date)',
223
+ fn: function (v) { return isEmpty(v); } },
224
+ { name: 'between', category: 'Logic', args: 'value, low, high', min: 3, max: 3,
225
+ description: 'True when the value is from low to high, both included', example: 'between(score, 600, 750)',
226
+ // Numbers compare as numbers; anything else as text, which orders ISO
227
+ // dates ("2026-01-31") correctly. Nothing is never between.
228
+ fn: function (v, lo, hi) {
229
+ if (isEmpty(v) || isEmpty(lo) || isEmpty(hi)) return false;
230
+ var x = num(v), a = num(lo), b = num(hi);
231
+ if (x !== null && a !== null && b !== null) return x >= a && x <= b;
232
+ var s = String(v);
233
+ return s >= String(lo) && s <= String(hi);
234
+ } },
235
+
236
+ // ── Aggregate ── spans ROWS; compiled by the engine (compile.js).
237
+ // In BI the rows are the current group: sumif(status = "paid", amount).
238
+ // Anywhere else the list comes first: sumif(orders, status = "paid", amount).
239
+ { name: 'sum', category: 'Aggregate', args: 'value', min: 1, max: 2, aggregate: true, shape: { cond: false, value: true },
240
+ description: 'Total across the rows in this group', example: 'sum(amount)' },
241
+ { name: 'sumif', category: 'Aggregate', args: 'condition, value', min: 2, max: 3, aggregate: true, shape: { cond: true, value: true },
242
+ description: 'Total across the rows that match', example: 'sumif(status == "refunded", amount)' },
243
+ { name: 'countif', category: 'Aggregate', args: 'condition', min: 1, max: 2, aggregate: true, shape: { cond: true, value: false },
244
+ description: 'How many rows match', example: 'countif(qty > 10)' },
245
+ { name: 'countifunique', category: 'Aggregate', args: 'condition, value', min: 2, max: 3, aggregate: true, shape: { cond: true, value: true },
246
+ description: 'How many different values among the rows that match', example: 'countifunique(status == "paid", customer_id)' },
247
+ // Not a function — a reference, usable inside any aggregate's condition.
248
+ { name: '$thisrow.column', category: 'Aggregate', args: '', special: true,
249
+ description: 'This row\'s own value, so a total can cover every row sharing it', example: 'sumif(month == $thisrow.month, revenue)' }
12
250
  ];
13
- var WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
14
251
 
15
- function toDate(v) {
16
- if (v === null || v === undefined) return null;
17
- var d = v instanceof Date ? v : new Date(v);
18
- return isNaN(d.getTime()) ? null : d;
252
+ // Lookup by lower-cased name. A null-prototype map, so no formula can reach
253
+ // Object.prototype through a function name.
254
+ var FUNCTIONS = Object.create(null);
255
+ LIST.forEach(function (f) { if (!f.special || f.name === 'if') FUNCTIONS[f.name.toLowerCase()] = f; });
256
+ // 1.x name, kept so stored 1.x conditions still run. Not listed.
257
+ FUNCTIONS.monthnum = Object.assign({}, FUNCTIONS.month, { name: 'monthNum', hidden: true });
258
+
259
+ function lookup(name) {
260
+ return FUNCTIONS[String(name).toLowerCase()] || null;
19
261
  }
20
262
 
21
- function nil(v) { return v === null || v === undefined; }
22
-
23
- var FUNCTIONS = {
24
- // ── string ──
25
- upper: function (v) { return nil(v) ? null : String(v).toUpperCase(); },
26
- lower: function (v) { return nil(v) ? null : String(v).toLowerCase(); },
27
- trim: function (v) { return nil(v) ? null : String(v).trim(); },
28
- length: function (v) { return nil(v) ? null : String(v).length; },
29
-
30
- // ── date (UTC) ──
31
- month: function (v) { var d = toDate(v); return d ? MONTHS[d.getUTCMonth()] : null; }, // 'January'
32
- monthNum:function (v) { var d = toDate(v); return d ? d.getUTCMonth() + 1 : null; }, // 1–12
33
- year: function (v) { var d = toDate(v); return d ? d.getUTCFullYear() : null; },
34
- day: function (v) { var d = toDate(v); return d ? d.getUTCDate() : null; }, // 1–31
35
- quarter: function (v) { var d = toDate(v); return d ? Math.floor(d.getUTCMonth() / 3) + 1 : null; }, // 1–4
36
- weekday: function (v) { var d = toDate(v); return d ? WEEKDAYS[d.getUTCDay()] : null; } // 'Monday'
37
- };
38
-
39
- function registerFunction(name, fn) {
40
- if (typeof fn !== 'function') throw new Error('registerFunction: fn must be a function');
41
- FUNCTIONS[name] = fn;
263
+ // registerFunction(entry) or, as in 1.x, registerFunction(name, fn).
264
+ function registerFunction(entryOrName, fn) {
265
+ var entry = typeof entryOrName === 'string'
266
+ ? { name: entryOrName, fn: fn, category: 'Custom', args: '', description: '', example: '' }
267
+ : Object.assign({}, entryOrName);
268
+ if (!entry.name || typeof entry.fn !== 'function') throw new Error('registerFunction: needs a name and a function');
269
+ if (entry.min === undefined) entry.min = 0;
270
+ if (entry.max === undefined) entry.max = Infinity;
271
+ var key = entry.name.toLowerCase();
272
+ if (FUNCTIONS[key] && (FUNCTIONS[key].special || FUNCTIONS[key].aggregate)) {
273
+ throw new Error('registerFunction: "' + entry.name + '" is built into the language');
274
+ }
275
+ var at = LIST.findIndex(function (f) { return f.name.toLowerCase() === key; });
276
+ if (at === -1) LIST.push(entry); else LIST[at] = entry;
277
+ FUNCTIONS[key] = entry;
42
278
  }
43
279
 
44
- function applyFunction(name, value) {
45
- var fn = FUNCTIONS[name];
46
- if (!fn) throw new Error('Unknown function: "' + name + '"');
47
- return fn(value);
280
+ /** The list the UI renders: every visible entry, in reference order. */
281
+ function listFunctions() {
282
+ return LIST.filter(function (f) { return !f.hidden; });
48
283
  }
49
284
 
50
- module.exports = { FUNCTIONS, registerFunction, applyFunction };
285
+ module.exports = { FUNCTIONS: FUNCTIONS, CATEGORIES: CATEGORIES, lookup: lookup, registerFunction: registerFunction, listFunctions: listFunctions, str: str, num: num, isEmpty: isEmpty };
package/lib/nlp.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // and callers can feature-detect. Wire an LLM/grammar backend here later.
8
8
 
9
9
  function parse(_text, _options) {
10
- throw new Error('nlp wrapper: not implemented yet (stub). Use formula.parseFormula or the structured JSON API.');
10
+ throw new Error('nlp wrapper: not implemented yet (stub). Use xf.parse / xf.compile or the structured JSON API.');
11
11
  }
12
12
 
13
13
  module.exports = {
package/lib/parse.js ADDED
@@ -0,0 +1,301 @@
1
+ // Formula text → JSON tree. Our own reader: no eval, no dependencies.
2
+ //
3
+ // The grammar is BI's formula language (expr-eval's), with the agreed changes:
4
+ // `=` is equals, `[any name]` is a column, lists are `in (a, b)`. Precedence,
5
+ // lowest first — identical to expr-eval, so old formulas mean what they meant:
6
+ //
7
+ // ? : → or → and → = == != <> < <= > >= in → + - ||
8
+ // → * / % → unary - + not → ^ (right) → calls, paths
9
+ //
10
+ // The tree (all plain JSON — it is what gets stored):
11
+ // { value: 42 } literal
12
+ // { field: 'revenue' } a column
13
+ // { path: ['form', 'first name'] } a path, or a name only brackets can write
14
+ // { thisrow: ['city'] } $thisrow.city
15
+ // { call: 'upper', args: [ … ] } a function
16
+ // { op: '+', left, right } + - * / % ^ || < <= > >= in
17
+ // { op: 'eq', left, right } = == (and 'neq' for != <>) — the 1.x shape
18
+ // { neg: x } { pos: x } { not: x } unary
19
+ // { all: [ … ] } { any: [ … ] } and / or
20
+ // { if, then, else } if() and ? :
21
+ // { list: [ … ] } the right side of `in (…)`
22
+
23
+ var functions = require('./functions');
24
+
25
+ var KEYWORDS = new Set(['and', 'or', 'not', 'in', 'true', 'false', 'null']);
26
+ // A Set, not an object literal: `{ __proto__: 1 }` would set the prototype.
27
+ var BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
28
+ var ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '\\': '\\', '/': '/', '"': '"', "'": "'" };
29
+
30
+ function fail(message, at) {
31
+ var err = new Error(message + (at === undefined ? '' : ' at character ' + (at + 1)));
32
+ err.position = at;
33
+ throw err;
34
+ }
35
+
36
+ // ── tokens ────────────────────────────────────────────────────────────────
37
+ // { t: 'num'|'str'|'name'|'bracket'|'punct'|'end', v, at }
38
+ function tokenize(src) {
39
+ var out = [];
40
+ var i = 0, n = src.length;
41
+ while (i < n) {
42
+ var c = src[i];
43
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
44
+ var at = i;
45
+
46
+ if (c === '"' || c === "'") {
47
+ // A backslash escapes (as expr-eval); a doubled quote escapes (as 1.x).
48
+ var q = c, s = '';
49
+ i++;
50
+ for (;;) {
51
+ if (i >= n) fail('Text is never closed — missing ' + q, at);
52
+ var ch = src[i];
53
+ if (ch === '\\') {
54
+ var e = src[i + 1];
55
+ if (e === 'u' && /^[0-9a-fA-F]{4}$/.test(src.substr(i + 2, 4))) { s += String.fromCharCode(parseInt(src.substr(i + 2, 4), 16)); i += 6; continue; }
56
+ if (ESCAPES[e] === undefined) fail('Unknown escape \\' + (e || ''), i);
57
+ s += ESCAPES[e]; i += 2; continue;
58
+ }
59
+ if (ch === q) {
60
+ if (src[i + 1] === q) { s += q; i += 2; continue; }
61
+ i++; break;
62
+ }
63
+ s += ch; i++;
64
+ }
65
+ out.push({ t: 'str', v: s, at: at });
66
+ continue;
67
+ }
68
+
69
+ if (c === '[') {
70
+ // Everything up to the closing ] is the name; ]] is a literal ].
71
+ var name = '';
72
+ i++;
73
+ for (;;) {
74
+ if (i >= n) fail('Column name is never closed — missing ]', at);
75
+ if (src[i] === ']') {
76
+ if (src[i + 1] === ']') { name += ']'; i += 2; continue; }
77
+ i++; break;
78
+ }
79
+ name += src[i++];
80
+ }
81
+ out.push({ t: 'bracket', v: name, at: at });
82
+ continue;
83
+ }
84
+
85
+ if ((c >= '0' && c <= '9') || (c === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
86
+ var m = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/.exec(src.slice(i));
87
+ out.push({ t: 'num', v: parseFloat(m[0]), at: at });
88
+ i += m[0].length;
89
+ continue;
90
+ }
91
+
92
+ // Any letter, as expr-eval allows — `café`, `größe`.
93
+ if (/[\p{L}_$]/u.test(c)) {
94
+ var id = /^[\p{L}_$][\p{L}\p{N}_$]*/u.exec(src.slice(i))[0];
95
+ out.push({ t: 'name', v: id, at: at });
96
+ i += id.length;
97
+ continue;
98
+ }
99
+
100
+ var two = src.substr(i, 2);
101
+ if (two === '==' || two === '!=' || two === '<>' || two === '<=' || two === '>=' || two === '||') {
102
+ out.push({ t: 'punct', v: two, at: at }); i += 2; continue;
103
+ }
104
+ if ('()+-*/%^,.?:=<>'.indexOf(c) !== -1) { out.push({ t: 'punct', v: c, at: at }); i++; continue; }
105
+ if (c === '!') fail('Use "not" rather than "!"', at);
106
+ fail('Unexpected character "' + c + '"', at);
107
+ }
108
+ out.push({ t: 'end', at: n });
109
+ return out;
110
+ }
111
+
112
+ // ── grammar ───────────────────────────────────────────────────────────────
113
+ function parse(src) {
114
+ if (typeof src !== 'string') throw new Error('parse: formula must be text');
115
+ if (!src.trim()) fail('The formula is empty');
116
+ var toks = tokenize(src);
117
+ var pos = 0;
118
+
119
+ function peek() { return toks[pos]; }
120
+ function next() { return toks[pos++]; }
121
+ function isPunct(v) { var t = toks[pos]; return t.t === 'punct' && t.v === v; }
122
+ function isWord(w) { var t = toks[pos]; return t.t === 'name' && t.v.toLowerCase() === w; }
123
+ function describe(t) {
124
+ if (t.t === 'end') return 'the end of the formula';
125
+ if (t.t === 'str') return 'text "' + t.v + '"';
126
+ if (t.t === 'bracket') return '[' + t.v + ']';
127
+ return '"' + t.v + '"';
128
+ }
129
+ function expect(v) {
130
+ if (!isPunct(v)) fail('Expected "' + v + '" but found ' + describe(peek()), peek().at);
131
+ return next();
132
+ }
133
+
134
+ function conditional() {
135
+ var cond = or();
136
+ if (!isPunct('?')) return cond;
137
+ next();
138
+ var yes = conditional();
139
+ expect(':');
140
+ return { if: cond, then: yes, else: conditional() };
141
+ }
142
+
143
+ function or() {
144
+ var first = and();
145
+ if (!isWord('or')) return first;
146
+ var parts = [first];
147
+ while (isWord('or')) { next(); parts.push(and()); }
148
+ return { any: parts };
149
+ }
150
+
151
+ function and() {
152
+ var first = comparison();
153
+ if (!isWord('and')) return first;
154
+ var parts = [first];
155
+ while (isWord('and')) { next(); parts.push(comparison()); }
156
+ return { all: parts };
157
+ }
158
+
159
+ // Equality is the 1.x `eq` / `neq` whichever way it's spelled — forgiving
160
+ // (18 = "18"), and the shape Workflow's stored conditions already use.
161
+ var COMPARE = { '=': 'eq', '==': 'eq', '!=': 'neq', '<>': 'neq', '<': '<', '<=': '<=', '>': '>', '>=': '>=' };
162
+
163
+ function comparison() {
164
+ var left = addSub();
165
+ for (;;) {
166
+ var t = peek();
167
+ if (t.t === 'punct' && COMPARE[t.v]) { next(); left = { op: COMPARE[t.v], left: left, right: addSub() }; continue; }
168
+ if (isWord('in')) { next(); left = { op: 'in', left: left, right: inList() }; continue; }
169
+ return left;
170
+ }
171
+ }
172
+
173
+ // `in (a, b, c)` is a list; anything else is an expression holding one.
174
+ function inList() {
175
+ var t = peek();
176
+ if (t.t === 'bracket') fail('Lists are written in parentheses — in (' + t.v + ')', t.at);
177
+ if (!isPunct('(')) return addSub();
178
+ next();
179
+ var items = [];
180
+ if (!isPunct(')')) {
181
+ items.push(conditional());
182
+ while (isPunct(',')) { next(); items.push(conditional()); }
183
+ }
184
+ expect(')');
185
+ return { list: items };
186
+ }
187
+
188
+ function addSub() {
189
+ var left = term();
190
+ for (;;) {
191
+ var t = peek();
192
+ if (t.t === 'punct' && (t.v === '+' || t.v === '-' || t.v === '||')) { next(); left = { op: t.v, left: left, right: term() }; continue; }
193
+ return left;
194
+ }
195
+ }
196
+
197
+ function term() {
198
+ var left = factor();
199
+ for (;;) {
200
+ var t = peek();
201
+ if (t.t === 'punct' && (t.v === '*' || t.v === '/' || t.v === '%')) { next(); left = { op: t.v, left: left, right: factor() }; continue; }
202
+ return left;
203
+ }
204
+ }
205
+
206
+ function factor() {
207
+ if (isPunct('-')) { next(); return { neg: factor() }; }
208
+ if (isPunct('+')) { next(); return { pos: factor() }; }
209
+ if (isWord('not')) { next(); return { not: factor() }; }
210
+ return exponential();
211
+ }
212
+
213
+ function exponential() {
214
+ var base = atom();
215
+ if (!isPunct('^')) return base;
216
+ next();
217
+ return { op: '^', left: base, right: factor() };
218
+ }
219
+
220
+ function atom() {
221
+ var t = next();
222
+ if (t.t === 'num') return { value: t.v };
223
+ if (t.t === 'str') return { value: t.v };
224
+ if (t.t === 'punct' && t.v === '(') { var inner = conditional(); expect(')'); return inner; }
225
+ if (t.t === 'bracket') return pathFrom([segment(t.v, t.at)]);
226
+ if (t.t === 'name') {
227
+ var low = t.v.toLowerCase();
228
+ if (isPunct('(')) return call(t);
229
+ if (low === 'true') return { value: true };
230
+ if (low === 'false') return { value: false };
231
+ if (low === 'null') return { value: null };
232
+ if (KEYWORDS.has(low)) fail('"' + t.v + '" is a keyword here — write [' + t.v + '] for a column of that name', t.at);
233
+ if (low === '$thisrow') {
234
+ var rest = pathSegments([]);
235
+ if (!rest.length) fail('$thisrow needs a column — $thisrow.city', t.at);
236
+ return { thisrow: rest };
237
+ }
238
+ return pathFrom([segment(t.v, t.at)]);
239
+ }
240
+ fail('Expected a value but found ' + describe(t), t.at);
241
+ }
242
+
243
+ function segment(name, at) {
244
+ if (BLOCKED.has(name)) fail('"' + name + '" can\'t be used as a column name', at);
245
+ return name;
246
+ }
247
+
248
+ // Continues a path: .name .[any name] [any name] ["name"] [0]
249
+ function pathSegments(segs) {
250
+ for (;;) {
251
+ var t = peek();
252
+ if (t.t === 'punct' && t.v === '.') {
253
+ next();
254
+ var s = next();
255
+ if (s.t === 'name' || s.t === 'bracket') { segs.push(segment(s.v, s.at)); continue; }
256
+ fail('Expected a name after "." but found ' + describe(s), s.at);
257
+ }
258
+ if (t.t === 'bracket') {
259
+ next();
260
+ var q = /^\s*(["'])([\s\S]*)\1\s*$/.exec(t.v);
261
+ segs.push(segment(q ? q[2] : t.v, t.at));
262
+ continue;
263
+ }
264
+ return segs;
265
+ }
266
+ }
267
+
268
+ function pathFrom(segs) {
269
+ var start = pos;
270
+ segs = pathSegments(segs);
271
+ if (isPunct('(')) fail('Only a function name can be followed by "("', peek().at);
272
+ // A lone plain name keeps the 1.x { field } shape; anything else is a path,
273
+ // so a bracketed name containing a dot is never split into two.
274
+ if (segs.length === 1 && segs[0].indexOf('.') === -1 && pos === start) return { field: segs[0] };
275
+ return { path: segs };
276
+ }
277
+
278
+ function call(nameTok) {
279
+ var entry = functions.lookup(nameTok.v);
280
+ if (!entry || (entry.special && entry.name !== 'if')) fail('Unknown function "' + nameTok.v + '"', nameTok.at);
281
+ expect('(');
282
+ var args = [];
283
+ if (!isPunct(')')) {
284
+ args.push(conditional());
285
+ while (isPunct(',')) { next(); args.push(conditional()); }
286
+ }
287
+ expect(')');
288
+ if (args.length < entry.min || args.length > entry.max) {
289
+ var want = entry.min === entry.max ? String(entry.min) : entry.max === Infinity ? 'at least ' + entry.min : entry.min + ' to ' + entry.max;
290
+ fail(entry.name + '() takes ' + want + ' argument' + (want === '1' ? '' : 's') + ', not ' + args.length, nameTok.at);
291
+ }
292
+ if (entry.name === 'if') return { if: args[0], then: args[1], else: args[2] };
293
+ return { call: entry.name, args: args };
294
+ }
295
+
296
+ var tree = conditional();
297
+ if (peek().t !== 'end') fail('Unexpected ' + describe(peek()), peek().at);
298
+ return tree;
299
+ }
300
+
301
+ module.exports = { parse: parse, tokenize: tokenize, BLOCKED: BLOCKED };