@bpmnkit/feel 0.0.21 → 1.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/README.md +18 -14
- package/dist/ast.d.ts +4 -0
- package/dist/builtins.d.ts +7 -0
- package/dist/builtins.js +778 -164
- package/dist/evaluator.d.ts +4 -1
- package/dist/evaluator.js +251 -139
- package/dist/formatter.js +16 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -0
- package/dist/lexer.d.ts +7 -0
- package/dist/lexer.js +92 -5
- package/dist/parser.d.ts +11 -2
- package/dist/parser.js +315 -98
- package/dist/types.js +35 -2
- package/package.json +8 -2
package/dist/builtins.js
CHANGED
|
@@ -41,14 +41,43 @@ function epochDaysToDate(days) {
|
|
|
41
41
|
}
|
|
42
42
|
return { type: "date", year, month, day: remaining + 1 };
|
|
43
43
|
}
|
|
44
|
+
// The widest year XSD's date types allow, which bounds DMN's too.
|
|
45
|
+
const MAX_YEAR = 999999999;
|
|
46
|
+
/** Builds a date, or null when the day does not exist in that month. */
|
|
47
|
+
function makeDate(year, month, day) {
|
|
48
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day))
|
|
49
|
+
return null;
|
|
50
|
+
if (Math.abs(year) > MAX_YEAR)
|
|
51
|
+
return null;
|
|
52
|
+
if (month < 1 || month > 12)
|
|
53
|
+
return null;
|
|
54
|
+
if (day < 1 || day > daysInMonth(year, month))
|
|
55
|
+
return null;
|
|
56
|
+
return { type: "date", year, month, day };
|
|
57
|
+
}
|
|
44
58
|
function parseDate(s) {
|
|
45
|
-
|
|
59
|
+
// Exactly four year digits: "01211" carries a leading zero and
|
|
60
|
+
// "9999999999" is past any calendar, and neither is a date.
|
|
61
|
+
const m = /^(-?)(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
46
62
|
if (!m)
|
|
47
63
|
return null;
|
|
48
|
-
|
|
64
|
+
const year = Number(m[2]) * (m[1] === "-" ? -1 : 1);
|
|
65
|
+
return makeDate(year, Number(m[3]), Number(m[4]));
|
|
66
|
+
}
|
|
67
|
+
// The largest UTC offset XSD allows.
|
|
68
|
+
const MAX_OFFSET_SECONDS = 18 * 3600;
|
|
69
|
+
/** True for a zone name the platform's time zone database knows. */
|
|
70
|
+
function isKnownTimezone(name) {
|
|
71
|
+
try {
|
|
72
|
+
new Intl.DateTimeFormat("en-US", { timeZone: name });
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
49
78
|
}
|
|
50
79
|
function parseTime(s) {
|
|
51
|
-
const m = /^(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:([+-])(\d{2}):(\d{2})
|
|
80
|
+
const m = /^(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:([+-])(\d{2}):(\d{2})(?::(\d{2}))?|Z)?(?:@(.+))?$/.exec(s);
|
|
52
81
|
if (!m)
|
|
53
82
|
return null;
|
|
54
83
|
const hour = Number(m[1]);
|
|
@@ -58,19 +87,39 @@ function parseTime(s) {
|
|
|
58
87
|
let timezone;
|
|
59
88
|
if (m[4]) {
|
|
60
89
|
const sign = m[4] === "+" ? 1 : -1;
|
|
61
|
-
offsetSeconds = sign * (Number(m[5]) * 3600 + Number(m[6]) * 60);
|
|
90
|
+
offsetSeconds = sign * (Number(m[5]) * 3600 + Number(m[6]) * 60 + (m[7] ? Number(m[7]) : 0));
|
|
91
|
+
if (Math.abs(offsetSeconds) > MAX_OFFSET_SECONDS)
|
|
92
|
+
return null;
|
|
62
93
|
}
|
|
63
|
-
else if (
|
|
94
|
+
else if (s.includes("Z")) {
|
|
64
95
|
offsetSeconds = 0;
|
|
65
96
|
}
|
|
66
|
-
if (m[8])
|
|
97
|
+
if (m[8]) {
|
|
98
|
+
// A zone and an offset name the same thing twice, and may disagree.
|
|
99
|
+
if (offsetSeconds !== undefined)
|
|
100
|
+
return null;
|
|
101
|
+
if (!isKnownTimezone(m[8]))
|
|
102
|
+
return null;
|
|
67
103
|
timezone = m[8];
|
|
104
|
+
}
|
|
105
|
+
if (!isValidTime(hour, minute, second))
|
|
106
|
+
return null;
|
|
68
107
|
return { type: "time", hour, minute, second, offsetSeconds, timezone };
|
|
69
108
|
}
|
|
109
|
+
/** 24:00:00 is the end-of-day form the ISO calendar allows; 24:00:01 is not. */
|
|
110
|
+
function isValidTime(hour, minute, second) {
|
|
111
|
+
if (hour < 0 || hour > 24 || minute < 0 || minute > 59 || second < 0 || second >= 60)
|
|
112
|
+
return false;
|
|
113
|
+
return hour !== 24 || (minute === 0 && second === 0);
|
|
114
|
+
}
|
|
70
115
|
function parseDateTime(s) {
|
|
71
116
|
const idx = s.indexOf("T");
|
|
72
|
-
if (idx < 0)
|
|
73
|
-
|
|
117
|
+
if (idx < 0) {
|
|
118
|
+
const dateOnly = parseDate(s);
|
|
119
|
+
return dateOnly
|
|
120
|
+
? { type: "date-time", date: dateOnly, time: { type: "time", hour: 0, minute: 0, second: 0 } }
|
|
121
|
+
: null;
|
|
122
|
+
}
|
|
74
123
|
const d = parseDate(s.slice(0, idx));
|
|
75
124
|
const t = parseTime(s.slice(idx + 1));
|
|
76
125
|
if (!d || !t)
|
|
@@ -80,14 +129,15 @@ function parseDateTime(s) {
|
|
|
80
129
|
function parseDuration(s) {
|
|
81
130
|
// P[n]Y[n]M or P[n]DT[n]H[n]M[n]S
|
|
82
131
|
const ymMatch = /^-?P(\d+Y)?(\d+M)?$/.exec(s);
|
|
83
|
-
if (ymMatch) {
|
|
132
|
+
if (ymMatch && /\d/.test(s)) {
|
|
84
133
|
const sign = s.startsWith("-") ? -1 : 1;
|
|
85
134
|
const years = ymMatch[1] ? Number(ymMatch[1].slice(0, -1)) : 0;
|
|
86
135
|
const months = ymMatch[2] ? Number(ymMatch[2].slice(0, -1)) : 0;
|
|
87
|
-
|
|
136
|
+
// "|| 0" keeps a negative zero out: -P0M is the same duration as P0M.
|
|
137
|
+
return { type: "years-months-duration", months: sign * (years * 12 + months) || 0 };
|
|
88
138
|
}
|
|
89
|
-
const dtMatch = /^-?P(\d+D)?(?:T(\d+H)?(\d+M)?(\d+(?:\.\d
|
|
90
|
-
if (dtMatch && s
|
|
139
|
+
const dtMatch = /^-?P(\d+D)?(?:T(\d+H)?(\d+M)?(\d+(?:\.\d*)?S)?)?$/.exec(s);
|
|
140
|
+
if (dtMatch && /\d/.test(s)) {
|
|
91
141
|
const sign = s.startsWith("-") ? -1 : 1;
|
|
92
142
|
const days = dtMatch[1] ? Number(dtMatch[1].slice(0, -1)) : 0;
|
|
93
143
|
const hours = dtMatch[2] ? Number(dtMatch[2].slice(0, -1)) : 0;
|
|
@@ -95,7 +145,7 @@ function parseDuration(s) {
|
|
|
95
145
|
const seconds = dtMatch[4] ? Number(dtMatch[4].slice(0, -1)) : 0;
|
|
96
146
|
return {
|
|
97
147
|
type: "days-time-duration",
|
|
98
|
-
seconds: sign * (days * 86400 + hours * 3600 + minutes * 60 + seconds),
|
|
148
|
+
seconds: sign * (days * 86400 + hours * 3600 + minutes * 60 + seconds) || 0,
|
|
99
149
|
};
|
|
100
150
|
}
|
|
101
151
|
return null;
|
|
@@ -120,8 +170,14 @@ function parseTemporal(raw) {
|
|
|
120
170
|
function formatDate(d) {
|
|
121
171
|
return `${String(d.year).padStart(4, "0")}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`;
|
|
122
172
|
}
|
|
173
|
+
/** Two digits before the decimal point, and the fraction as written. */
|
|
174
|
+
function formatSeconds(second) {
|
|
175
|
+
const whole = Math.floor(second);
|
|
176
|
+
const fraction = `${second}`.split(".")[1];
|
|
177
|
+
return String(whole).padStart(2, "0") + (fraction ? `.${fraction}` : "");
|
|
178
|
+
}
|
|
123
179
|
function formatTime(t) {
|
|
124
|
-
let s = `${String(t.hour).padStart(2, "0")}:${String(t.minute).padStart(2, "0")}:${
|
|
180
|
+
let s = `${String(t.hour).padStart(2, "0")}:${String(t.minute).padStart(2, "0")}:${formatSeconds(t.second)}`;
|
|
125
181
|
if (t.offsetSeconds !== undefined) {
|
|
126
182
|
if (t.offsetSeconds === 0) {
|
|
127
183
|
s += "Z";
|
|
@@ -129,7 +185,11 @@ function formatTime(t) {
|
|
|
129
185
|
else {
|
|
130
186
|
const sign = t.offsetSeconds >= 0 ? "+" : "-";
|
|
131
187
|
const abs = Math.abs(t.offsetSeconds);
|
|
132
|
-
|
|
188
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
189
|
+
s += `${sign}${pad(Math.floor(abs / 3600))}:${pad(Math.floor((abs % 3600) / 60))}`;
|
|
190
|
+
// Offsets are written to the second only when they have one.
|
|
191
|
+
if (abs % 60 !== 0)
|
|
192
|
+
s += `:${pad(abs % 60)}`;
|
|
133
193
|
}
|
|
134
194
|
}
|
|
135
195
|
if (t.timezone)
|
|
@@ -150,14 +210,57 @@ function dayOfYear(d) {
|
|
|
150
210
|
// -------------------------------------------------------------------------
|
|
151
211
|
// Helpers
|
|
152
212
|
// -------------------------------------------------------------------------
|
|
213
|
+
/**
|
|
214
|
+
* A number argument. FEEL does not coerce, so "1.5" is not a number here;
|
|
215
|
+
* number() is the way to convert one.
|
|
216
|
+
*/
|
|
153
217
|
function toNum(v) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
218
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A rounding scale: an optional integer. Absent means 0, but an explicitly
|
|
222
|
+
* null or non-numeric scale is an error. Beyond float64's reach the value is
|
|
223
|
+
* already exact at that scale, so it is returned unrounded.
|
|
224
|
+
*/
|
|
225
|
+
const MIN_SCALE = -6111;
|
|
226
|
+
const MAX_SCALE = 6176;
|
|
227
|
+
const SCALE_LIMIT = 300;
|
|
228
|
+
function toScale(v) {
|
|
229
|
+
if (v === undefined)
|
|
230
|
+
return 0;
|
|
231
|
+
const n = toNum(v);
|
|
232
|
+
if (n === null)
|
|
233
|
+
return null;
|
|
234
|
+
const scale = Math.trunc(n);
|
|
235
|
+
return scale < MIN_SCALE || scale > MAX_SCALE ? null : scale;
|
|
236
|
+
}
|
|
237
|
+
/** Rounds `n` at `scale` with the given rounding of a value exactly halfway. */
|
|
238
|
+
function roundAt(n, scale, round) {
|
|
239
|
+
if (Math.abs(scale) > SCALE_LIMIT)
|
|
240
|
+
return n;
|
|
241
|
+
const factor = 10 ** scale;
|
|
242
|
+
// Re-reading the scaled value through its decimal form keeps a product like
|
|
243
|
+
// 1.005 * 100 from landing just under the halfway point it should sit on.
|
|
244
|
+
const scaled = Number(`${n}e${scale}`);
|
|
245
|
+
return Number(`${round(scaled)}e${-scale}`) || round(scaled) / factor;
|
|
246
|
+
}
|
|
247
|
+
/** Round half to even, the rounding DMN's decimal() uses. */
|
|
248
|
+
function roundHalfEven(x) {
|
|
249
|
+
const floor = Math.floor(x);
|
|
250
|
+
const diff = x - floor;
|
|
251
|
+
if (diff > 0.5)
|
|
252
|
+
return floor + 1;
|
|
253
|
+
if (diff < 0.5)
|
|
254
|
+
return floor;
|
|
255
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
256
|
+
}
|
|
257
|
+
/** Round half away from zero. */
|
|
258
|
+
function roundHalfUp(x) {
|
|
259
|
+
return x >= 0 ? Math.floor(x + 0.5) : Math.ceil(x - 0.5);
|
|
260
|
+
}
|
|
261
|
+
/** Round half toward zero. */
|
|
262
|
+
function roundHalfDown(x) {
|
|
263
|
+
return x >= 0 ? Math.ceil(x - 0.5) : Math.floor(x + 0.5);
|
|
161
264
|
}
|
|
162
265
|
function toStr(v) {
|
|
163
266
|
if (typeof v === "string")
|
|
@@ -188,6 +291,55 @@ function inRange(v, r) {
|
|
|
188
291
|
const endOk = r.endIncluded ? cmpEnd <= 0 : cmpEnd < 0;
|
|
189
292
|
return startOk && endOk;
|
|
190
293
|
}
|
|
294
|
+
const offsetCache = new Map();
|
|
295
|
+
/**
|
|
296
|
+
* The UTC offset a zone is on at the given wall-clock day. Resolved through
|
|
297
|
+
* the platform's time zone database, so it follows daylight saving: Melbourne
|
|
298
|
+
* is +11:00 in April and +10:00 in October.
|
|
299
|
+
*/
|
|
300
|
+
function zoneOffsetSeconds(timezone, epochDays) {
|
|
301
|
+
const key = `${timezone}/${epochDays}`;
|
|
302
|
+
const cached = offsetCache.get(key);
|
|
303
|
+
if (cached !== undefined)
|
|
304
|
+
return cached;
|
|
305
|
+
let offset = 0;
|
|
306
|
+
try {
|
|
307
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
308
|
+
timeZone: timezone,
|
|
309
|
+
timeZoneName: "longOffset",
|
|
310
|
+
}).formatToParts(new Date(epochDays * 86400_000));
|
|
311
|
+
const name = parts.find((part) => part.type === "timeZoneName")?.value ?? "";
|
|
312
|
+
const m = /GMT([+-])(\d{2}):(\d{2})/.exec(name);
|
|
313
|
+
if (m)
|
|
314
|
+
offset = (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 3600 + Number(m[3]) * 60);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
offset = 0;
|
|
318
|
+
}
|
|
319
|
+
if (offsetCache.size > 512)
|
|
320
|
+
offsetCache.clear();
|
|
321
|
+
offsetCache.set(key, offset);
|
|
322
|
+
return offset;
|
|
323
|
+
}
|
|
324
|
+
/** The offset a time is on, or undefined when it is a local time. */
|
|
325
|
+
function offsetOf(t, epochDays) {
|
|
326
|
+
if (t.offsetSeconds !== undefined)
|
|
327
|
+
return t.offsetSeconds;
|
|
328
|
+
if (t.timezone !== undefined)
|
|
329
|
+
return zoneOffsetSeconds(t.timezone, epochDays);
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Seconds since midnight, shifted to UTC where the time says which UTC it
|
|
334
|
+
* means. A local time carries no offset, so it is left where it is.
|
|
335
|
+
*/
|
|
336
|
+
function timeToSeconds(t, epochDays = 0) {
|
|
337
|
+
return t.hour * 3600 + t.minute * 60 + t.second - (offsetOf(t, epochDays) ?? 0);
|
|
338
|
+
}
|
|
339
|
+
/** True when both times are local, or both say which UTC they mean. */
|
|
340
|
+
function sameTimeKind(a, b, epochA, epochB) {
|
|
341
|
+
return (offsetOf(a, epochA) === undefined) === (offsetOf(b, epochB) === undefined);
|
|
342
|
+
}
|
|
191
343
|
function compareValues(a, b) {
|
|
192
344
|
if (typeof a === "number" && typeof b === "number")
|
|
193
345
|
return a - b;
|
|
@@ -195,12 +347,30 @@ function compareValues(a, b) {
|
|
|
195
347
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
196
348
|
if (isFeelDate(a) && isFeelDate(b))
|
|
197
349
|
return dateToEpochDays(a) - dateToEpochDays(b);
|
|
350
|
+
if (isFeelTime(a) && isFeelTime(b)) {
|
|
351
|
+
// A local time and one at a known offset name different things and
|
|
352
|
+
// cannot be ordered against each other.
|
|
353
|
+
if (!sameTimeKind(a, b, 0, 0))
|
|
354
|
+
return null;
|
|
355
|
+
return timeToSeconds(a) - timeToSeconds(b);
|
|
356
|
+
}
|
|
357
|
+
if (isFeelDateTime(a) && isFeelDateTime(b)) {
|
|
358
|
+
const epochA = dateToEpochDays(a.date);
|
|
359
|
+
const epochB = dateToEpochDays(b.date);
|
|
360
|
+
if (!sameTimeKind(a.time, b.time, epochA, epochB))
|
|
361
|
+
return null;
|
|
362
|
+
return dateTimeToSeconds(a) - dateTimeToSeconds(b);
|
|
363
|
+
}
|
|
198
364
|
if (isFeelDayTimeDuration(a) && isFeelDayTimeDuration(b))
|
|
199
365
|
return a.seconds - b.seconds;
|
|
200
366
|
if (isFeelYearsMonthsDuration(a) && isFeelYearsMonthsDuration(b))
|
|
201
367
|
return a.months - b.months;
|
|
202
368
|
return null;
|
|
203
369
|
}
|
|
370
|
+
function dateTimeToSeconds(dt) {
|
|
371
|
+
const epochDays = dateToEpochDays(dt.date);
|
|
372
|
+
return epochDays * 86400 + timeToSeconds(dt.time, epochDays);
|
|
373
|
+
}
|
|
204
374
|
const builtinMap = new Map();
|
|
205
375
|
function reg(name, fn) {
|
|
206
376
|
builtinMap.set(name, fn);
|
|
@@ -209,8 +379,30 @@ function reg(name, fn) {
|
|
|
209
379
|
// String functions
|
|
210
380
|
// -------------------------------------------------------------------------
|
|
211
381
|
reg("string", (v) => {
|
|
382
|
+
if (v === undefined)
|
|
383
|
+
return null;
|
|
384
|
+
return stringify(v, false);
|
|
385
|
+
});
|
|
386
|
+
/**
|
|
387
|
+
* Renders a value the way FEEL's string() does. Strings nested inside a list
|
|
388
|
+
* or context are quoted; a string rendered on its own is not.
|
|
389
|
+
*/
|
|
390
|
+
function stringify(v, nested) {
|
|
212
391
|
if (v === null)
|
|
213
|
-
return
|
|
392
|
+
return null;
|
|
393
|
+
if (typeof v === "string")
|
|
394
|
+
return nested ? JSON.stringify(v) : v;
|
|
395
|
+
if (isFeelList(v)) {
|
|
396
|
+
const parts = v.map((item) => stringify(item, true) ?? "null");
|
|
397
|
+
return `[${parts.join(", ")}]`;
|
|
398
|
+
}
|
|
399
|
+
if (isFeelContext(v)) {
|
|
400
|
+
const parts = Object.entries(v).map(([k, value]) => `${k}: ${stringify(value, true) ?? "null"}`);
|
|
401
|
+
return `{${parts.join(", ")}}`;
|
|
402
|
+
}
|
|
403
|
+
return scalarToString(v);
|
|
404
|
+
}
|
|
405
|
+
function scalarToString(v) {
|
|
214
406
|
if (typeof v === "string")
|
|
215
407
|
return v;
|
|
216
408
|
if (typeof v === "number")
|
|
@@ -252,10 +444,11 @@ reg("string", (v) => {
|
|
|
252
444
|
return r;
|
|
253
445
|
}
|
|
254
446
|
return null;
|
|
255
|
-
}
|
|
447
|
+
}
|
|
256
448
|
reg("string length", (s) => {
|
|
257
449
|
const str = toStr(s);
|
|
258
|
-
|
|
450
|
+
// FEEL counts characters, so an astral character counts once, not twice.
|
|
451
|
+
return str === null ? null : [...str].length;
|
|
259
452
|
});
|
|
260
453
|
reg("substring", (str, start, length) => {
|
|
261
454
|
const s = toStr(str);
|
|
@@ -264,15 +457,17 @@ reg("substring", (str, start, length) => {
|
|
|
264
457
|
const st = toNum(start);
|
|
265
458
|
if (st === null)
|
|
266
459
|
return null;
|
|
267
|
-
// FEEL substring is 1-based, negative counts
|
|
268
|
-
|
|
460
|
+
// FEEL substring is 1-based over characters, and a negative start counts
|
|
461
|
+
// back from the end.
|
|
462
|
+
const chars = [...s];
|
|
463
|
+
const idx = st > 0 ? st - 1 : Math.max(0, chars.length + st);
|
|
269
464
|
if (length !== undefined && length !== null) {
|
|
270
465
|
const len = toNum(length);
|
|
271
466
|
if (len === null)
|
|
272
467
|
return null;
|
|
273
|
-
return
|
|
468
|
+
return chars.slice(idx, idx + len).join("");
|
|
274
469
|
}
|
|
275
|
-
return
|
|
470
|
+
return chars.slice(idx).join("");
|
|
276
471
|
});
|
|
277
472
|
reg("substring before", (str, match) => {
|
|
278
473
|
const s = toStr(str);
|
|
@@ -324,18 +519,64 @@ reg("ends with", (str, match) => {
|
|
|
324
519
|
const REGEX_CACHE_LIMIT = 256;
|
|
325
520
|
const regexCache = new Map();
|
|
326
521
|
/** Compiled regex for `pattern`/`flags`, or null when the pattern is invalid. */
|
|
522
|
+
/**
|
|
523
|
+
* Translates the XPath flags FEEL uses into a JavaScript regex. "i", "s" and
|
|
524
|
+
* "m" map straight across; "x" (ignore whitespace in the pattern) and "q"
|
|
525
|
+
* (treat the pattern as a literal) have no JavaScript equivalent and are
|
|
526
|
+
* applied to the pattern instead. Any other flag makes the call fail.
|
|
527
|
+
*/
|
|
528
|
+
function toJsRegExp(pattern, flags) {
|
|
529
|
+
let jsFlags = "";
|
|
530
|
+
for (const flag of flags) {
|
|
531
|
+
if (flag === "i" || flag === "s" || flag === "m" || flag === "g") {
|
|
532
|
+
if (!jsFlags.includes(flag))
|
|
533
|
+
jsFlags += flag;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (flag !== "x" && flag !== "q")
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
let source = pattern;
|
|
540
|
+
if (flags.includes("q")) {
|
|
541
|
+
source = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
542
|
+
}
|
|
543
|
+
else if (flags.includes("x")) {
|
|
544
|
+
source = stripPatternWhitespace(pattern);
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
return new RegExp(source, jsFlags);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** Removes the whitespace an "x"-flagged pattern ignores, keeping character classes intact. */
|
|
554
|
+
function stripPatternWhitespace(pattern) {
|
|
555
|
+
let out = "";
|
|
556
|
+
let inClass = false;
|
|
557
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
558
|
+
const c = pattern[i];
|
|
559
|
+
if (c === "\\" && i + 1 < pattern.length) {
|
|
560
|
+
out += c + pattern[i + 1];
|
|
561
|
+
i++;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (c === "[")
|
|
565
|
+
inClass = true;
|
|
566
|
+
else if (c === "]")
|
|
567
|
+
inClass = false;
|
|
568
|
+
if (!inClass && /\s/.test(c))
|
|
569
|
+
continue;
|
|
570
|
+
out += c;
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
327
574
|
function cachedRegExp(pattern, flags) {
|
|
328
575
|
const key = `${flags}/${pattern}`;
|
|
329
576
|
const hit = regexCache.get(key);
|
|
330
577
|
if (hit !== undefined)
|
|
331
578
|
return hit;
|
|
332
|
-
|
|
333
|
-
try {
|
|
334
|
-
re = new RegExp(pattern, flags);
|
|
335
|
-
}
|
|
336
|
-
catch {
|
|
337
|
-
re = null;
|
|
338
|
-
}
|
|
579
|
+
const re = toJsRegExp(pattern, flags);
|
|
339
580
|
if (regexCache.size >= REGEX_CACHE_LIMIT)
|
|
340
581
|
regexCache.clear();
|
|
341
582
|
regexCache.set(key, re);
|
|
@@ -364,7 +605,8 @@ reg("replace", (str, pattern, replacement, flags) => {
|
|
|
364
605
|
if (re === null)
|
|
365
606
|
return null;
|
|
366
607
|
re.lastIndex = 0;
|
|
367
|
-
|
|
608
|
+
// $0 is XPath's whole match, which JavaScript spells $&.
|
|
609
|
+
return s.replace(re, r.replace(/\$&/g, "$$$$&").replace(/\$0/g, "$$&"));
|
|
368
610
|
});
|
|
369
611
|
reg("split", (str, delimiter) => {
|
|
370
612
|
const s = toStr(str);
|
|
@@ -374,102 +616,113 @@ reg("split", (str, delimiter) => {
|
|
|
374
616
|
const re = cachedRegExp(d, "");
|
|
375
617
|
return re === null ? s.split(d) : s.split(re);
|
|
376
618
|
});
|
|
377
|
-
reg("string join", (
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
list = first;
|
|
385
|
-
delimiter = flat.length >= 2 ? (toStr(at(flat, 1)) ?? "") : "";
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
list = flat;
|
|
389
|
-
}
|
|
619
|
+
reg("string join", (value, delimiter, prefix, suffix) => {
|
|
620
|
+
// A value that is not a list joins as a list of one, so the delimiter
|
|
621
|
+
// never appears. A null, or anything in the list that is not a string,
|
|
622
|
+
// has no joined form.
|
|
623
|
+
if (value === null || value === undefined)
|
|
624
|
+
return null;
|
|
625
|
+
const list = isFeelList(value) ? value : [value];
|
|
390
626
|
const parts = [];
|
|
391
627
|
for (const v of list) {
|
|
392
|
-
|
|
393
|
-
if (
|
|
394
|
-
|
|
628
|
+
// Nulls are skipped; any other non-string is an error.
|
|
629
|
+
if (v === null)
|
|
630
|
+
continue;
|
|
631
|
+
const text = toStr(v);
|
|
632
|
+
if (text === null)
|
|
633
|
+
return null;
|
|
634
|
+
parts.push(text);
|
|
395
635
|
}
|
|
396
|
-
|
|
636
|
+
const between = delimiter === undefined || delimiter === null ? "" : toStr(delimiter);
|
|
637
|
+
if (between === null)
|
|
638
|
+
return null;
|
|
639
|
+
const head = prefix === undefined || prefix === null ? "" : toStr(prefix);
|
|
640
|
+
const tail = suffix === undefined || suffix === null ? "" : toStr(suffix);
|
|
641
|
+
if (head === null || tail === null)
|
|
642
|
+
return null;
|
|
643
|
+
return head + parts.join(between) + tail;
|
|
397
644
|
});
|
|
398
645
|
// -------------------------------------------------------------------------
|
|
399
646
|
// Number functions
|
|
400
647
|
// -------------------------------------------------------------------------
|
|
401
|
-
reg("number", (v) => {
|
|
648
|
+
reg("number", (v, groupingSeparator, decimalSeparator) => {
|
|
649
|
+
const hasSeparators = groupingSeparator !== undefined || decimalSeparator !== undefined;
|
|
402
650
|
if (typeof v === "number")
|
|
403
|
-
return v;
|
|
404
|
-
if (typeof v
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
651
|
+
return hasSeparators ? null : v;
|
|
652
|
+
if (typeof v !== "string")
|
|
653
|
+
return null;
|
|
654
|
+
const grouping = separatorArg(groupingSeparator, [" ", ",", "."]);
|
|
655
|
+
const decimal = separatorArg(decimalSeparator, [",", "."]);
|
|
656
|
+
if (grouping === undefined || decimal === undefined)
|
|
657
|
+
return null;
|
|
658
|
+
if (grouping !== null && grouping === decimal)
|
|
659
|
+
return null;
|
|
660
|
+
let text = v;
|
|
661
|
+
if (grouping !== null)
|
|
662
|
+
text = text.split(grouping).join("");
|
|
663
|
+
if (decimal !== null)
|
|
664
|
+
text = text.split(decimal).join(".");
|
|
665
|
+
if (text.trim() === "")
|
|
666
|
+
return null;
|
|
667
|
+
const n = Number(text);
|
|
668
|
+
return Number.isNaN(n) ? null : n;
|
|
409
669
|
});
|
|
670
|
+
/** Reads a number() separator argument: null when absent, undefined when invalid. */
|
|
671
|
+
function separatorArg(v, allowed) {
|
|
672
|
+
if (v === undefined || v === null)
|
|
673
|
+
return null;
|
|
674
|
+
if (typeof v !== "string" || !allowed.includes(v))
|
|
675
|
+
return undefined;
|
|
676
|
+
return v;
|
|
677
|
+
}
|
|
410
678
|
reg("decimal", (n, scale) => {
|
|
411
679
|
const num = toNum(n);
|
|
412
|
-
const sc =
|
|
413
|
-
if (num === null || sc === null)
|
|
680
|
+
const sc = toScale(scale);
|
|
681
|
+
if (num === null || sc === null || scale === undefined)
|
|
414
682
|
return null;
|
|
415
|
-
|
|
416
|
-
return Math.round(num * factor) / factor;
|
|
683
|
+
return roundAt(num, sc, roundHalfEven);
|
|
417
684
|
});
|
|
418
685
|
reg("floor", (n, scale) => {
|
|
419
686
|
const num = toNum(n);
|
|
420
|
-
|
|
687
|
+
const sc = toScale(scale);
|
|
688
|
+
if (num === null || sc === null)
|
|
421
689
|
return null;
|
|
422
|
-
|
|
423
|
-
const sc = toNum(scale) ?? 0;
|
|
424
|
-
const factor = 10 ** sc;
|
|
425
|
-
return Math.floor(num * factor) / factor;
|
|
426
|
-
}
|
|
427
|
-
return Math.floor(num);
|
|
690
|
+
return roundAt(num, sc, Math.floor);
|
|
428
691
|
});
|
|
429
692
|
reg("ceiling", (n, scale) => {
|
|
430
693
|
const num = toNum(n);
|
|
431
|
-
|
|
694
|
+
const sc = toScale(scale);
|
|
695
|
+
if (num === null || sc === null)
|
|
432
696
|
return null;
|
|
433
|
-
|
|
434
|
-
const sc = toNum(scale) ?? 0;
|
|
435
|
-
const factor = 10 ** sc;
|
|
436
|
-
return Math.ceil(num * factor) / factor;
|
|
437
|
-
}
|
|
438
|
-
return Math.ceil(num);
|
|
697
|
+
return roundAt(num, sc, Math.ceil);
|
|
439
698
|
});
|
|
440
699
|
reg("round half up", (n, scale) => {
|
|
441
700
|
const num = toNum(n);
|
|
442
|
-
const sc =
|
|
443
|
-
if (num === null)
|
|
701
|
+
const sc = toScale(scale);
|
|
702
|
+
if (num === null || sc === null)
|
|
444
703
|
return null;
|
|
445
|
-
|
|
446
|
-
return Math.round(num * factor) / factor;
|
|
704
|
+
return roundAt(num, sc, roundHalfUp);
|
|
447
705
|
});
|
|
448
706
|
reg("round half down", (n, scale) => {
|
|
449
707
|
const num = toNum(n);
|
|
450
|
-
const sc =
|
|
451
|
-
if (num === null)
|
|
708
|
+
const sc = toScale(scale);
|
|
709
|
+
if (num === null || sc === null)
|
|
452
710
|
return null;
|
|
453
|
-
|
|
454
|
-
const scaled = num * factor;
|
|
455
|
-
return (scaled > 0 ? Math.ceil(scaled - 0.5) : Math.floor(scaled + 0.5)) / factor;
|
|
711
|
+
return roundAt(num, sc, roundHalfDown);
|
|
456
712
|
});
|
|
457
713
|
reg("round up", (n, scale) => {
|
|
458
714
|
const num = toNum(n);
|
|
459
|
-
const sc =
|
|
460
|
-
if (num === null)
|
|
715
|
+
const sc = toScale(scale);
|
|
716
|
+
if (num === null || sc === null)
|
|
461
717
|
return null;
|
|
462
|
-
|
|
463
|
-
const scaled = num * factor;
|
|
464
|
-
return (scaled > 0 ? Math.ceil(scaled) : Math.floor(scaled)) / factor;
|
|
718
|
+
return roundAt(num, sc, (x) => (x >= 0 ? Math.ceil(x) : Math.floor(x)));
|
|
465
719
|
});
|
|
466
720
|
reg("round down", (n, scale) => {
|
|
467
721
|
const num = toNum(n);
|
|
468
|
-
const sc =
|
|
469
|
-
if (num === null)
|
|
722
|
+
const sc = toScale(scale);
|
|
723
|
+
if (num === null || sc === null)
|
|
470
724
|
return null;
|
|
471
|
-
|
|
472
|
-
return Math.trunc(num * factor) / factor;
|
|
725
|
+
return roundAt(num, sc, Math.trunc);
|
|
473
726
|
});
|
|
474
727
|
reg("abs", (n) => {
|
|
475
728
|
if (typeof n === "number")
|
|
@@ -512,6 +765,9 @@ reg("random number", () => Math.random());
|
|
|
512
765
|
// List functions
|
|
513
766
|
// -------------------------------------------------------------------------
|
|
514
767
|
reg("count", (...args) => {
|
|
768
|
+
// count() takes a list; a null argument is a type error, not a one-item list.
|
|
769
|
+
if (args.length === 1 && args[0] === null)
|
|
770
|
+
return null;
|
|
515
771
|
const list = flattenToList(args);
|
|
516
772
|
const first = list[0];
|
|
517
773
|
if (list.length === 1 && first !== undefined && isFeelList(first))
|
|
@@ -565,7 +821,11 @@ reg("sum", (...args) => {
|
|
|
565
821
|
return s;
|
|
566
822
|
});
|
|
567
823
|
reg("product", (...args) => {
|
|
824
|
+
if (args.length === 0)
|
|
825
|
+
return null;
|
|
568
826
|
const list = unwrapList(flattenToList(args));
|
|
827
|
+
if (list.length === 0)
|
|
828
|
+
return null;
|
|
569
829
|
let p = 1;
|
|
570
830
|
for (const v of list) {
|
|
571
831
|
const n = toNum(v);
|
|
@@ -619,24 +879,28 @@ reg("stddev", (...args) => {
|
|
|
619
879
|
return Math.sqrt(variance);
|
|
620
880
|
});
|
|
621
881
|
reg("mode", (...args) => {
|
|
882
|
+
if (args.length === 0)
|
|
883
|
+
return null;
|
|
622
884
|
const list = unwrapList(flattenToList(args));
|
|
623
885
|
const counts = new Map();
|
|
624
886
|
for (const v of list) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
if (cnt > maxCount)
|
|
630
|
-
maxCount = cnt;
|
|
631
|
-
}
|
|
632
|
-
const modes = [];
|
|
633
|
-
for (const [v, cnt] of counts) {
|
|
634
|
-
if (cnt === maxCount)
|
|
635
|
-
modes.push(v);
|
|
887
|
+
const n = toNum(v);
|
|
888
|
+
if (n === null)
|
|
889
|
+
return null;
|
|
890
|
+
counts.set(n, (counts.get(n) ?? 0) + 1);
|
|
636
891
|
}
|
|
637
|
-
|
|
892
|
+
if (counts.size === 0)
|
|
893
|
+
return [];
|
|
894
|
+
const maxCount = Math.max(...counts.values());
|
|
895
|
+
// The most frequent values, in ascending order.
|
|
896
|
+
return [...counts]
|
|
897
|
+
.filter(([, count]) => count === maxCount)
|
|
898
|
+
.map(([value]) => value)
|
|
899
|
+
.sort((a, b) => a - b);
|
|
638
900
|
});
|
|
639
901
|
reg("all", (...args) => {
|
|
902
|
+
if (args.length === 0)
|
|
903
|
+
return null;
|
|
640
904
|
const list = unwrapList(flattenToList(args));
|
|
641
905
|
let hasNull = false;
|
|
642
906
|
for (const v of list) {
|
|
@@ -644,10 +908,14 @@ reg("all", (...args) => {
|
|
|
644
908
|
return false;
|
|
645
909
|
if (v === null)
|
|
646
910
|
hasNull = true;
|
|
911
|
+
else if (typeof v !== "boolean")
|
|
912
|
+
return null;
|
|
647
913
|
}
|
|
648
914
|
return hasNull ? null : true;
|
|
649
915
|
});
|
|
650
916
|
reg("any", (...args) => {
|
|
917
|
+
if (args.length === 0)
|
|
918
|
+
return null;
|
|
651
919
|
const list = unwrapList(flattenToList(args));
|
|
652
920
|
let hasNull = false;
|
|
653
921
|
for (const v of list) {
|
|
@@ -655,6 +923,8 @@ reg("any", (...args) => {
|
|
|
655
923
|
return true;
|
|
656
924
|
if (v === null)
|
|
657
925
|
hasNull = true;
|
|
926
|
+
else if (typeof v !== "boolean")
|
|
927
|
+
return null;
|
|
658
928
|
}
|
|
659
929
|
return hasNull ? null : false;
|
|
660
930
|
});
|
|
@@ -798,11 +1068,19 @@ reg("get or else", (v, defaultVal) => {
|
|
|
798
1068
|
reg("get value", (ctx, key) => {
|
|
799
1069
|
if (!isFeelContext(ctx))
|
|
800
1070
|
return null;
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const
|
|
805
|
-
|
|
1071
|
+
// A list of keys walks into nested contexts: get value(c, ["y", "a"]).
|
|
1072
|
+
const path = isFeelList(key) ? key : [key];
|
|
1073
|
+
let current = ctx;
|
|
1074
|
+
for (const step of path) {
|
|
1075
|
+
const name = toStr(step);
|
|
1076
|
+
if (name === null || !isFeelContext(current))
|
|
1077
|
+
return null;
|
|
1078
|
+
const next = current[name];
|
|
1079
|
+
if (next === undefined)
|
|
1080
|
+
return null;
|
|
1081
|
+
current = next;
|
|
1082
|
+
}
|
|
1083
|
+
return current;
|
|
806
1084
|
});
|
|
807
1085
|
reg("get entries", (ctx) => {
|
|
808
1086
|
if (!isFeelContext(ctx))
|
|
@@ -812,18 +1090,42 @@ reg("get entries", (ctx) => {
|
|
|
812
1090
|
reg("context put", (ctx, key, value) => {
|
|
813
1091
|
if (!isFeelContext(ctx))
|
|
814
1092
|
return null;
|
|
815
|
-
|
|
816
|
-
if (k === null)
|
|
1093
|
+
if (value === undefined)
|
|
817
1094
|
return null;
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
return result;
|
|
1095
|
+
const path = isFeelList(key) ? key : [key];
|
|
1096
|
+
if (path.length === 0)
|
|
1097
|
+
return null;
|
|
1098
|
+
return putPath(ctx, path, value);
|
|
823
1099
|
});
|
|
1100
|
+
/** Copies a context with `path` set to `value`, creating contexts on the way. */
|
|
1101
|
+
function putPath(ctx, path, value) {
|
|
1102
|
+
const name = toStr(path[0] ?? null);
|
|
1103
|
+
if (name === null)
|
|
1104
|
+
return null;
|
|
1105
|
+
const result = { ...ctx };
|
|
1106
|
+
if (path.length === 1) {
|
|
1107
|
+
result[name] = value;
|
|
1108
|
+
return result;
|
|
1109
|
+
}
|
|
1110
|
+
const nested = result[name];
|
|
1111
|
+
// A step onto something that is not a context has nowhere to go.
|
|
1112
|
+
if (nested !== undefined && !isFeelContext(nested))
|
|
1113
|
+
return null;
|
|
1114
|
+
const inner = putPath(nested ?? {}, path.slice(1), value);
|
|
1115
|
+
if (inner === null)
|
|
1116
|
+
return null;
|
|
1117
|
+
result[name] = inner;
|
|
1118
|
+
return result;
|
|
1119
|
+
}
|
|
824
1120
|
reg("context merge", (...args) => {
|
|
1121
|
+
// The signature is one list of contexts; a bare argument list is accepted
|
|
1122
|
+
// too, the way the other list built-ins are.
|
|
1123
|
+
const first = args[0] ?? null;
|
|
1124
|
+
const contexts = args.length === 1 && isFeelList(first) ? first : args;
|
|
1125
|
+
if (contexts.length === 0)
|
|
1126
|
+
return null;
|
|
825
1127
|
const result = {};
|
|
826
|
-
for (const v of
|
|
1128
|
+
for (const v of contexts) {
|
|
827
1129
|
if (!isFeelContext(v))
|
|
828
1130
|
return null;
|
|
829
1131
|
for (const [k, cv] of Object.entries(v))
|
|
@@ -831,17 +1133,19 @@ reg("context merge", (...args) => {
|
|
|
831
1133
|
}
|
|
832
1134
|
return result;
|
|
833
1135
|
});
|
|
834
|
-
reg("context", (
|
|
835
|
-
|
|
836
|
-
return null;
|
|
1136
|
+
reg("context", (entries) => {
|
|
1137
|
+
const list = isFeelList(entries) ? entries : [entries];
|
|
837
1138
|
const result = {};
|
|
838
1139
|
for (const item of list) {
|
|
839
1140
|
if (!isFeelContext(item))
|
|
840
1141
|
return null;
|
|
841
1142
|
const k = item.key;
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1143
|
+
if (typeof k !== "string")
|
|
1144
|
+
return null;
|
|
1145
|
+
// An entry naming a key already set is a conflict, not an overwrite.
|
|
1146
|
+
if (k in result)
|
|
1147
|
+
return null;
|
|
1148
|
+
result[k] = item.value !== undefined ? item.value : null;
|
|
845
1149
|
}
|
|
846
1150
|
return result;
|
|
847
1151
|
});
|
|
@@ -855,6 +1159,8 @@ reg("date", (...args) => {
|
|
|
855
1159
|
return parseDate(v);
|
|
856
1160
|
if (isFeelDateTime(v))
|
|
857
1161
|
return v.date;
|
|
1162
|
+
if (isFeelDate(v))
|
|
1163
|
+
return v;
|
|
858
1164
|
return null;
|
|
859
1165
|
}
|
|
860
1166
|
if (args.length === 3) {
|
|
@@ -863,7 +1169,7 @@ reg("date", (...args) => {
|
|
|
863
1169
|
const d = toNum(at(args, 2));
|
|
864
1170
|
if (y === null || m === null || d === null)
|
|
865
1171
|
return null;
|
|
866
|
-
return
|
|
1172
|
+
return makeDate(y, m, d);
|
|
867
1173
|
}
|
|
868
1174
|
return null;
|
|
869
1175
|
});
|
|
@@ -874,6 +1180,10 @@ reg("time", (...args) => {
|
|
|
874
1180
|
return parseTime(v);
|
|
875
1181
|
if (isFeelDateTime(v))
|
|
876
1182
|
return v.time;
|
|
1183
|
+
if (isFeelTime(v))
|
|
1184
|
+
return v;
|
|
1185
|
+
if (isFeelDate(v))
|
|
1186
|
+
return { type: "time", hour: 0, minute: 0, second: 0 };
|
|
877
1187
|
return null;
|
|
878
1188
|
}
|
|
879
1189
|
if (args.length >= 3) {
|
|
@@ -882,6 +1192,8 @@ reg("time", (...args) => {
|
|
|
882
1192
|
const s = toNum(at(args, 2));
|
|
883
1193
|
if (h === null || m === null || s === null)
|
|
884
1194
|
return null;
|
|
1195
|
+
if (!isValidTime(h, m, s))
|
|
1196
|
+
return null;
|
|
885
1197
|
const t = { type: "time", hour: h, minute: m, second: s };
|
|
886
1198
|
const off = at(args, 3);
|
|
887
1199
|
if (off !== null && isFeelDayTimeDuration(off))
|
|
@@ -926,9 +1238,29 @@ reg("years and months duration", (from, to) => {
|
|
|
926
1238
|
d2 = to.date;
|
|
927
1239
|
if (!d1 || !d2)
|
|
928
1240
|
return null;
|
|
929
|
-
|
|
1241
|
+
let months = (d2.year - d1.year) * 12 + (d2.month - d1.month);
|
|
1242
|
+
// Only whole months count, so a end that has not yet reached the start's
|
|
1243
|
+
// day-and-time within the month gives back the month it was counted.
|
|
1244
|
+
const remainder = compareWithinMonth(from, to);
|
|
1245
|
+
if (months > 0 && remainder < 0)
|
|
1246
|
+
months -= 1;
|
|
1247
|
+
else if (months < 0 && remainder > 0)
|
|
1248
|
+
months += 1;
|
|
930
1249
|
return { type: "years-months-duration", months };
|
|
931
1250
|
});
|
|
1251
|
+
/** Orders two temporals by day of month and time of day, ignoring year and month. */
|
|
1252
|
+
function compareWithinMonth(a, b) {
|
|
1253
|
+
const partsOf = (v) => {
|
|
1254
|
+
if (isFeelDate(v))
|
|
1255
|
+
return [v.day, 0];
|
|
1256
|
+
if (isFeelDateTime(v))
|
|
1257
|
+
return [v.date.day, timeToSeconds(v.time)];
|
|
1258
|
+
return [0, 0];
|
|
1259
|
+
};
|
|
1260
|
+
const [dayA, secA] = partsOf(a);
|
|
1261
|
+
const [dayB, secB] = partsOf(b);
|
|
1262
|
+
return dayB - dayA || secB - secA;
|
|
1263
|
+
}
|
|
932
1264
|
// -------------------------------------------------------------------------
|
|
933
1265
|
// Temporal utility functions
|
|
934
1266
|
// -------------------------------------------------------------------------
|
|
@@ -985,17 +1317,26 @@ reg("week of year", (d) => {
|
|
|
985
1317
|
date = d.date;
|
|
986
1318
|
if (!date)
|
|
987
1319
|
return null;
|
|
988
|
-
// ISO week
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
const
|
|
992
|
-
const jan4dow = ((jan4 % 7) + 7 + 4) % 7; // Monday=0
|
|
993
|
-
const weekStart = jan4 - ((jan4dow + 6) % 7);
|
|
994
|
-
const week = Math.floor((epochDays - weekStart) / 7) + 1;
|
|
1320
|
+
// ISO 8601: week 1 is the one holding the first Thursday, so the turn of
|
|
1321
|
+
// the year can fall in the neighbouring year's last or first week.
|
|
1322
|
+
const weekday = isoWeekday(date);
|
|
1323
|
+
const week = Math.floor((dayOfYear(date) - weekday + 10) / 7);
|
|
995
1324
|
if (week < 1)
|
|
996
|
-
return
|
|
1325
|
+
return isoWeeksInYear(date.year - 1);
|
|
1326
|
+
if (week > isoWeeksInYear(date.year))
|
|
1327
|
+
return 1;
|
|
997
1328
|
return week;
|
|
998
1329
|
});
|
|
1330
|
+
/** Monday is 1, Sunday is 7. Epoch day 0, 1970-01-01, was a Thursday. */
|
|
1331
|
+
function isoWeekday(d) {
|
|
1332
|
+
return ((((dateToEpochDays(d) + 3) % 7) + 7) % 7) + 1;
|
|
1333
|
+
}
|
|
1334
|
+
/** 52 or 53, whichever ISO 8601 gives the year. */
|
|
1335
|
+
function isoWeeksInYear(year) {
|
|
1336
|
+
const jan1 = isoWeekday({ type: "date", year, month: 1, day: 1 });
|
|
1337
|
+
const long = jan1 === 4 || (isLeapYear(year) && jan1 === 3);
|
|
1338
|
+
return long ? 53 : 52;
|
|
1339
|
+
}
|
|
999
1340
|
reg("month of year", (d) => {
|
|
1000
1341
|
const MONTH_NAMES = [
|
|
1001
1342
|
"January",
|
|
@@ -1142,41 +1483,101 @@ reg("includes", (a, b) => {
|
|
|
1142
1483
|
return cmpPts(as_, bs, "start") <= 0 && cmpPts(be, ae, "end") <= 0;
|
|
1143
1484
|
});
|
|
1144
1485
|
reg("starts", (a, b) => {
|
|
1145
|
-
|
|
1486
|
+
// starts(point, range): the range begins at that point, inclusively.
|
|
1487
|
+
if (!isFeelRange(b))
|
|
1146
1488
|
return null;
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
return cmpPts(as_, bs, "start") === 0 && cmpPts(ae, be, "end") <= 0;
|
|
1489
|
+
if (!isFeelRange(a)) {
|
|
1490
|
+
return b.startIncluded && compareValues(a, b.start) === 0;
|
|
1491
|
+
}
|
|
1492
|
+
return cmpPts(startOf(a), startOf(b), "start") === 0 && cmpPts(endOf(a), endOf(b), "end") <= 0;
|
|
1152
1493
|
});
|
|
1153
1494
|
reg("started by", (a, b) => {
|
|
1154
|
-
if (!isFeelRange(a)
|
|
1495
|
+
if (!isFeelRange(a))
|
|
1155
1496
|
return null;
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
return cmpPts(as_, bs, "start") === 0 && cmpPts(be, ae, "end") <= 0;
|
|
1497
|
+
if (!isFeelRange(b)) {
|
|
1498
|
+
return a.startIncluded && compareValues(a.start, b) === 0;
|
|
1499
|
+
}
|
|
1500
|
+
return cmpPts(startOf(a), startOf(b), "start") === 0 && cmpPts(endOf(b), endOf(a), "end") <= 0;
|
|
1161
1501
|
});
|
|
1162
1502
|
reg("finishes", (a, b) => {
|
|
1163
|
-
|
|
1503
|
+
// finishes(point, range): the range ends at that point, inclusively.
|
|
1504
|
+
if (!isFeelRange(b))
|
|
1164
1505
|
return null;
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
return cmpPts(ae, be, "end") === 0 && cmpPts(bs, as_, "start") <= 0;
|
|
1506
|
+
if (!isFeelRange(a)) {
|
|
1507
|
+
return b.endIncluded && compareValues(a, b.end) === 0;
|
|
1508
|
+
}
|
|
1509
|
+
return cmpPts(endOf(a), endOf(b), "end") === 0 && cmpPts(startOf(b), startOf(a), "start") <= 0;
|
|
1170
1510
|
});
|
|
1171
1511
|
reg("finished by", (a, b) => {
|
|
1172
|
-
if (!isFeelRange(a)
|
|
1512
|
+
if (!isFeelRange(a))
|
|
1173
1513
|
return null;
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1514
|
+
if (!isFeelRange(b)) {
|
|
1515
|
+
return a.endIncluded && compareValues(a.end, b) === 0;
|
|
1516
|
+
}
|
|
1517
|
+
return cmpPts(endOf(a), endOf(b), "end") === 0 && cmpPts(startOf(a), startOf(b), "start") <= 0;
|
|
1518
|
+
});
|
|
1519
|
+
/**
|
|
1520
|
+
* DMN's is(): whether two values are the same value, not merely equal ones.
|
|
1521
|
+
* Temporal values differ when they are written differently even where they
|
|
1522
|
+
* name the same instant, so a local time is not the same value as one at
|
|
1523
|
+
* UTC, and a zone is not the same value as the offset it currently has.
|
|
1524
|
+
*/
|
|
1525
|
+
reg("is", (a, b, ...rest) => {
|
|
1526
|
+
if (rest.length > 0)
|
|
1527
|
+
return null;
|
|
1528
|
+
if (b === undefined)
|
|
1529
|
+
return false;
|
|
1530
|
+
if (a === null || b === null)
|
|
1531
|
+
return a === null && b === null;
|
|
1532
|
+
if (isFeelTime(a) || isFeelDateTime(a) || isFeelTime(b) || isFeelDateTime(b)) {
|
|
1533
|
+
return sameTemporal(a, b);
|
|
1534
|
+
}
|
|
1535
|
+
if (valueType(a) !== valueType(b))
|
|
1536
|
+
return false;
|
|
1537
|
+
return compareValues(a, b) === 0 || deepEquals(a, b);
|
|
1538
|
+
});
|
|
1539
|
+
/** Compares the written form of a time or date-time, field by field. */
|
|
1540
|
+
function sameTemporal(a, b) {
|
|
1541
|
+
if (isFeelTime(a) && isFeelTime(b)) {
|
|
1542
|
+
return (a.hour === b.hour &&
|
|
1543
|
+
a.minute === b.minute &&
|
|
1544
|
+
a.second === b.second &&
|
|
1545
|
+
a.offsetSeconds === b.offsetSeconds &&
|
|
1546
|
+
a.timezone === b.timezone);
|
|
1547
|
+
}
|
|
1548
|
+
if (isFeelDateTime(a) && isFeelDateTime(b)) {
|
|
1549
|
+
return (a.date.year === b.date.year &&
|
|
1550
|
+
a.date.month === b.date.month &&
|
|
1551
|
+
a.date.day === b.date.day &&
|
|
1552
|
+
sameTemporal(a.time, b.time));
|
|
1553
|
+
}
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
/** The FEEL type of a value, for deciding whether two values are the same kind. */
|
|
1557
|
+
function valueType(v) {
|
|
1558
|
+
if (v === null)
|
|
1559
|
+
return "null";
|
|
1560
|
+
if (Array.isArray(v))
|
|
1561
|
+
return "list";
|
|
1562
|
+
if (typeof v !== "object")
|
|
1563
|
+
return typeof v;
|
|
1564
|
+
const tagged = v.type;
|
|
1565
|
+
return typeof tagged === "string" ? tagged : "context";
|
|
1566
|
+
}
|
|
1567
|
+
function deepEquals(a, b) {
|
|
1568
|
+
if (a === b)
|
|
1569
|
+
return true;
|
|
1570
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
1571
|
+
return a.length === b.length && a.every((x, i) => deepEquals(x, b[i] ?? null));
|
|
1572
|
+
}
|
|
1573
|
+
if (isFeelContext(a) && isFeelContext(b)) {
|
|
1574
|
+
const keys = Object.keys(a);
|
|
1575
|
+
if (keys.length !== Object.keys(b).length)
|
|
1576
|
+
return false;
|
|
1577
|
+
return keys.every((k) => deepEquals(a[k] ?? null, b[k] ?? null));
|
|
1578
|
+
}
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1180
1581
|
reg("coincides", (a, b) => {
|
|
1181
1582
|
if (isFeelRange(a) && isFeelRange(b)) {
|
|
1182
1583
|
return cmpPts(startOf(a), startOf(b), "start") === 0 && cmpPts(endOf(a), endOf(b), "end") === 0;
|
|
@@ -1187,6 +1588,213 @@ reg("coincides", (a, b) => {
|
|
|
1187
1588
|
return null;
|
|
1188
1589
|
});
|
|
1189
1590
|
// -------------------------------------------------------------------------
|
|
1591
|
+
// Parameter names
|
|
1592
|
+
// -------------------------------------------------------------------------
|
|
1593
|
+
// Parameter names of every built-in, in declaration order, so that a named
|
|
1594
|
+
// invocation such as `substring(start position: 2, string: "hello")` binds by
|
|
1595
|
+
// name rather than by the order the arguments happen to appear in. Built-ins
|
|
1596
|
+
// with several signatures list one entry per signature.
|
|
1597
|
+
const PARAM_SIGNATURES = {
|
|
1598
|
+
// Conversion
|
|
1599
|
+
string: [["from"]],
|
|
1600
|
+
number: [
|
|
1601
|
+
["from"],
|
|
1602
|
+
["from", "grouping separator"],
|
|
1603
|
+
["from", "grouping separator", "decimal separator"],
|
|
1604
|
+
],
|
|
1605
|
+
context: [["entries"]],
|
|
1606
|
+
date: [["from"], ["year", "month", "day"]],
|
|
1607
|
+
time: [["from"], ["hour", "minute", "second"], ["hour", "minute", "second", "offset"]],
|
|
1608
|
+
"date and time": [["from"], ["date", "time"], ["date", "timezone"]],
|
|
1609
|
+
duration: [["from"]],
|
|
1610
|
+
"years and months duration": [["from", "to"]],
|
|
1611
|
+
// Boolean
|
|
1612
|
+
not: [["negand"]],
|
|
1613
|
+
is: [["value1"], ["value2"], ["value1", "value2"]],
|
|
1614
|
+
"is defined": [["value"]],
|
|
1615
|
+
"get or else": [["value", "default"]],
|
|
1616
|
+
// String
|
|
1617
|
+
substring: [
|
|
1618
|
+
["string", "start position"],
|
|
1619
|
+
["string", "start position", "length"],
|
|
1620
|
+
],
|
|
1621
|
+
"string length": [["string"]],
|
|
1622
|
+
"upper case": [["string"]],
|
|
1623
|
+
"lower case": [["string"]],
|
|
1624
|
+
"substring before": [["string", "match"]],
|
|
1625
|
+
"substring after": [["string", "match"]],
|
|
1626
|
+
contains: [["string", "match"]],
|
|
1627
|
+
"starts with": [["string", "match"]],
|
|
1628
|
+
"ends with": [["string", "match"]],
|
|
1629
|
+
matches: [
|
|
1630
|
+
["input", "pattern"],
|
|
1631
|
+
["input", "pattern", "flags"],
|
|
1632
|
+
],
|
|
1633
|
+
replace: [
|
|
1634
|
+
["input", "pattern", "replacement"],
|
|
1635
|
+
["input", "pattern", "replacement", "flags"],
|
|
1636
|
+
],
|
|
1637
|
+
split: [["string", "delimiter"]],
|
|
1638
|
+
"string join": [["list"], ["list", "delimiter"], ["list", "delimiter", "prefix", "suffix"]],
|
|
1639
|
+
// List
|
|
1640
|
+
"list contains": [["list", "element"]],
|
|
1641
|
+
count: [["list"]],
|
|
1642
|
+
min: [["list"]],
|
|
1643
|
+
max: [["list"]],
|
|
1644
|
+
sum: [["list"]],
|
|
1645
|
+
product: [["list"]],
|
|
1646
|
+
mean: [["list"]],
|
|
1647
|
+
median: [["list"]],
|
|
1648
|
+
stddev: [["list"]],
|
|
1649
|
+
mode: [["list"]],
|
|
1650
|
+
all: [["list"]],
|
|
1651
|
+
any: [["list"]],
|
|
1652
|
+
sublist: [
|
|
1653
|
+
["list", "start position"],
|
|
1654
|
+
["list", "start position", "length"],
|
|
1655
|
+
],
|
|
1656
|
+
append: [["list", "items"]],
|
|
1657
|
+
concatenate: [["lists"]],
|
|
1658
|
+
"insert before": [["list", "position", "newItem"]],
|
|
1659
|
+
remove: [["list", "position"]],
|
|
1660
|
+
reverse: [["list"]],
|
|
1661
|
+
"index of": [["list", "match"]],
|
|
1662
|
+
union: [["list"]],
|
|
1663
|
+
"distinct values": [["list"]],
|
|
1664
|
+
flatten: [["list"]],
|
|
1665
|
+
sort: [["list", "precedes"]],
|
|
1666
|
+
// Numeric
|
|
1667
|
+
decimal: [["n", "scale"]],
|
|
1668
|
+
floor: [["n"], ["n", "scale"]],
|
|
1669
|
+
ceiling: [["n"], ["n", "scale"]],
|
|
1670
|
+
"round up": [["n", "scale"]],
|
|
1671
|
+
"round down": [["n", "scale"]],
|
|
1672
|
+
"round half up": [["n", "scale"]],
|
|
1673
|
+
"round half down": [["n", "scale"]],
|
|
1674
|
+
abs: [["number"], ["n"]],
|
|
1675
|
+
modulo: [["dividend", "divisor"]],
|
|
1676
|
+
sqrt: [["number"]],
|
|
1677
|
+
log: [["number"]],
|
|
1678
|
+
exp: [["number"]],
|
|
1679
|
+
odd: [["number"]],
|
|
1680
|
+
even: [["number"]],
|
|
1681
|
+
"random number": [[]],
|
|
1682
|
+
// Context
|
|
1683
|
+
"get value": [
|
|
1684
|
+
["context", "key"],
|
|
1685
|
+
["context", "keys"],
|
|
1686
|
+
["m", "key"],
|
|
1687
|
+
["m", "keys"],
|
|
1688
|
+
],
|
|
1689
|
+
"get entries": [["context"], ["m"]],
|
|
1690
|
+
"context put": [
|
|
1691
|
+
["context", "key", "value"],
|
|
1692
|
+
["context", "keys", "value"],
|
|
1693
|
+
],
|
|
1694
|
+
"context merge": [["contexts"]],
|
|
1695
|
+
// Temporal
|
|
1696
|
+
now: [[]],
|
|
1697
|
+
today: [[]],
|
|
1698
|
+
"day of week": [["date"]],
|
|
1699
|
+
"day of year": [["date"]],
|
|
1700
|
+
"week of year": [["date"]],
|
|
1701
|
+
"month of year": [["date"]],
|
|
1702
|
+
"last day of month": [["date"]],
|
|
1703
|
+
// Range
|
|
1704
|
+
before: [
|
|
1705
|
+
["point1", "point2"],
|
|
1706
|
+
["range", "point"],
|
|
1707
|
+
["point", "range"],
|
|
1708
|
+
["range1", "range2"],
|
|
1709
|
+
],
|
|
1710
|
+
after: [
|
|
1711
|
+
["point1", "point2"],
|
|
1712
|
+
["range", "point"],
|
|
1713
|
+
["point", "range"],
|
|
1714
|
+
["range1", "range2"],
|
|
1715
|
+
],
|
|
1716
|
+
meets: [["range1", "range2"]],
|
|
1717
|
+
"met by": [["range1", "range2"]],
|
|
1718
|
+
overlaps: [["range1", "range2"]],
|
|
1719
|
+
"overlaps before": [["range1", "range2"]],
|
|
1720
|
+
"overlaps after": [["range1", "range2"]],
|
|
1721
|
+
finishes: [
|
|
1722
|
+
["point", "range"],
|
|
1723
|
+
["range1", "range2"],
|
|
1724
|
+
],
|
|
1725
|
+
"finished by": [
|
|
1726
|
+
["range", "point"],
|
|
1727
|
+
["range1", "range2"],
|
|
1728
|
+
],
|
|
1729
|
+
includes: [
|
|
1730
|
+
["range", "point"],
|
|
1731
|
+
["range1", "range2"],
|
|
1732
|
+
],
|
|
1733
|
+
during: [
|
|
1734
|
+
["point", "range"],
|
|
1735
|
+
["range1", "range2"],
|
|
1736
|
+
],
|
|
1737
|
+
starts: [
|
|
1738
|
+
["point", "range"],
|
|
1739
|
+
["range1", "range2"],
|
|
1740
|
+
],
|
|
1741
|
+
"started by": [
|
|
1742
|
+
["range", "point"],
|
|
1743
|
+
["range1", "range2"],
|
|
1744
|
+
],
|
|
1745
|
+
coincides: [
|
|
1746
|
+
["point1", "point2"],
|
|
1747
|
+
["range1", "range2"],
|
|
1748
|
+
],
|
|
1749
|
+
};
|
|
1750
|
+
// Built-ins DMN also defines over a bare argument list, so that max(1,2,3)
|
|
1751
|
+
// means max([1,2,3]). Their arity is not checked; everything else's is.
|
|
1752
|
+
const VARIADIC = new Set([
|
|
1753
|
+
"min",
|
|
1754
|
+
"max",
|
|
1755
|
+
"sum",
|
|
1756
|
+
"product",
|
|
1757
|
+
"mean",
|
|
1758
|
+
"median",
|
|
1759
|
+
"stddev",
|
|
1760
|
+
"mode",
|
|
1761
|
+
"all",
|
|
1762
|
+
"any",
|
|
1763
|
+
"count",
|
|
1764
|
+
"append",
|
|
1765
|
+
"concatenate",
|
|
1766
|
+
"union",
|
|
1767
|
+
"context merge",
|
|
1768
|
+
]);
|
|
1769
|
+
/** The argument counts a built-in accepts, or undefined when it takes any. */
|
|
1770
|
+
function aritiesOf(name) {
|
|
1771
|
+
if (VARIADIC.has(name))
|
|
1772
|
+
return undefined;
|
|
1773
|
+
const signatures = PARAM_SIGNATURES[name];
|
|
1774
|
+
if (!signatures)
|
|
1775
|
+
return undefined;
|
|
1776
|
+
return new Set(signatures.map((params) => params.length));
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Orders the arguments of a named invocation to match a built-in's signature.
|
|
1780
|
+
* Returns, for each parameter position, the index of the argument supplying
|
|
1781
|
+
* it, or null when no signature of the built-in accepts exactly these names —
|
|
1782
|
+
* which FEEL treats as an invocation error rather than a positional call.
|
|
1783
|
+
*/
|
|
1784
|
+
export function orderNamedArgs(name, argNames) {
|
|
1785
|
+
const signatures = PARAM_SIGNATURES[name];
|
|
1786
|
+
if (!signatures)
|
|
1787
|
+
return null;
|
|
1788
|
+
for (const params of signatures) {
|
|
1789
|
+
if (params.length !== argNames.length)
|
|
1790
|
+
continue;
|
|
1791
|
+
const order = params.map((param) => argNames.indexOf(param));
|
|
1792
|
+
if (order.every((idx) => idx >= 0))
|
|
1793
|
+
return order;
|
|
1794
|
+
}
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
// -------------------------------------------------------------------------
|
|
1190
1798
|
// Exports
|
|
1191
1799
|
// -------------------------------------------------------------------------
|
|
1192
1800
|
// One FeelFunction wrapper per built-in, created on first use and shared: name
|
|
@@ -1201,7 +1809,13 @@ export function getBuiltin(name) {
|
|
|
1201
1809
|
const fn = builtinMap.get(name);
|
|
1202
1810
|
if (!fn)
|
|
1203
1811
|
return undefined;
|
|
1204
|
-
const
|
|
1812
|
+
const arities = aritiesOf(name);
|
|
1813
|
+
const wrapper = {
|
|
1814
|
+
type: "function",
|
|
1815
|
+
// Calling a built-in with a number of arguments no signature accepts is
|
|
1816
|
+
// an error, and FEEL reports an error as null.
|
|
1817
|
+
call: (args) => (arities && !arities.has(args.length) ? null : fn(...args)),
|
|
1818
|
+
};
|
|
1205
1819
|
builtinWrappers.set(name, wrapper);
|
|
1206
1820
|
return wrapper;
|
|
1207
1821
|
}
|