@bpmnkit/feel 0.0.20 → 0.1.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/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
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
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
- return { type: "date", year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
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})|Z)?(@(.+))?$/.exec(s);
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 (m[3] && s.includes("Z")) {
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
- return null;
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
- return { type: "years-months-duration", months: sign * (years * 12 + months) };
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+)?S)?)?$/.exec(s);
90
- if (dtMatch && s.length > 1) {
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")}:${String(t.second).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
- s += `${sign}${String(Math.floor(abs / 3600)).padStart(2, "0")}:${String(Math.floor((abs % 3600) / 60)).padStart(2, "0")}`;
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
- if (typeof v === "number")
155
- return v;
156
- if (typeof v === "string") {
157
- const n = Number(v);
158
- return Number.isNaN(n) ? null : n;
159
- }
160
- return null;
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 "null";
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
- return str === null ? null : str.length;
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 from end
268
- const idx = st > 0 ? st - 1 : Math.max(0, s.length + st);
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 s.slice(idx, idx + len);
468
+ return chars.slice(idx, idx + len).join("");
274
469
  }
275
- return s.slice(idx);
470
+ return chars.slice(idx).join("");
276
471
  });
277
472
  reg("substring before", (str, match) => {
278
473
  const s = toStr(str);
@@ -319,18 +514,85 @@ reg("ends with", (str, match) => {
319
514
  return null;
320
515
  return s.endsWith(m);
321
516
  });
517
+ // Patterns in FEEL come from static expression text, so the same few regexes
518
+ // are compiled over and over inside loops and decision tables; keep them.
519
+ const REGEX_CACHE_LIMIT = 256;
520
+ const regexCache = new Map();
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
+ }
574
+ function cachedRegExp(pattern, flags) {
575
+ const key = `${flags}/${pattern}`;
576
+ const hit = regexCache.get(key);
577
+ if (hit !== undefined)
578
+ return hit;
579
+ const re = toJsRegExp(pattern, flags);
580
+ if (regexCache.size >= REGEX_CACHE_LIMIT)
581
+ regexCache.clear();
582
+ regexCache.set(key, re);
583
+ return re;
584
+ }
322
585
  reg("matches", (str, pattern, flags) => {
323
586
  const s = toStr(str);
324
587
  const p = toStr(pattern);
325
588
  if (s === null || p === null)
326
589
  return null;
327
590
  const f = flags !== undefined && flags !== null ? (toStr(flags) ?? "") : "";
328
- try {
329
- return new RegExp(p, f).test(s);
330
- }
331
- catch {
591
+ const re = cachedRegExp(p, f);
592
+ if (re === null)
332
593
  return null;
333
- }
594
+ re.lastIndex = 0;
595
+ return re.test(s);
334
596
  });
335
597
  reg("replace", (str, pattern, replacement, flags) => {
336
598
  const s = toStr(str);
@@ -339,121 +601,128 @@ reg("replace", (str, pattern, replacement, flags) => {
339
601
  if (s === null || p === null || r === null)
340
602
  return null;
341
603
  const f = flags !== undefined && flags !== null ? (toStr(flags) ?? "g") : "g";
342
- try {
343
- return s.replace(new RegExp(p, f.includes("g") ? f : `${f}g`), r);
344
- }
345
- catch {
604
+ const re = cachedRegExp(p, f.includes("g") ? f : `${f}g`);
605
+ if (re === null)
346
606
  return null;
347
- }
607
+ re.lastIndex = 0;
608
+ // $0 is XPath's whole match, which JavaScript spells $&.
609
+ return s.replace(re, r.replace(/\$&/g, "$$$$&").replace(/\$0/g, "$$&"));
348
610
  });
349
611
  reg("split", (str, delimiter) => {
350
612
  const s = toStr(str);
351
613
  const d = toStr(delimiter);
352
614
  if (s === null || d === null)
353
615
  return null;
354
- try {
355
- return s.split(new RegExp(d));
356
- }
357
- catch {
358
- return s.split(d);
359
- }
616
+ const re = cachedRegExp(d, "");
617
+ return re === null ? s.split(d) : s.split(re);
360
618
  });
361
- reg("string join", (...args) => {
362
- const flat = flattenToList(args);
363
- // string join(list) or string join(list, delimiter) or string join(list, delimiter, prefix, suffix)
364
- let list;
365
- let delimiter = "";
366
- const first = flat[0];
367
- if (flat.length >= 1 && first !== undefined && isFeelList(first)) {
368
- list = first;
369
- delimiter = flat.length >= 2 ? (toStr(at(flat, 1)) ?? "") : "";
370
- }
371
- else {
372
- list = flat;
373
- }
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];
374
626
  const parts = [];
375
627
  for (const v of list) {
376
- const s = toStr(v);
377
- if (s !== null)
378
- parts.push(s);
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);
379
635
  }
380
- return parts.join(delimiter);
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;
381
644
  });
382
645
  // -------------------------------------------------------------------------
383
646
  // Number functions
384
647
  // -------------------------------------------------------------------------
385
- reg("number", (v) => {
648
+ reg("number", (v, groupingSeparator, decimalSeparator) => {
649
+ const hasSeparators = groupingSeparator !== undefined || decimalSeparator !== undefined;
386
650
  if (typeof v === "number")
387
- return v;
388
- if (typeof v === "string") {
389
- const n = Number(v);
390
- return Number.isNaN(n) ? null : n;
391
- }
392
- return null;
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;
393
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
+ }
394
678
  reg("decimal", (n, scale) => {
395
679
  const num = toNum(n);
396
- const sc = toNum(scale);
397
- if (num === null || sc === null)
680
+ const sc = toScale(scale);
681
+ if (num === null || sc === null || scale === undefined)
398
682
  return null;
399
- const factor = 10 ** sc;
400
- return Math.round(num * factor) / factor;
683
+ return roundAt(num, sc, roundHalfEven);
401
684
  });
402
685
  reg("floor", (n, scale) => {
403
686
  const num = toNum(n);
404
- if (num === null)
687
+ const sc = toScale(scale);
688
+ if (num === null || sc === null)
405
689
  return null;
406
- if (scale !== undefined && scale !== null) {
407
- const sc = toNum(scale) ?? 0;
408
- const factor = 10 ** sc;
409
- return Math.floor(num * factor) / factor;
410
- }
411
- return Math.floor(num);
690
+ return roundAt(num, sc, Math.floor);
412
691
  });
413
692
  reg("ceiling", (n, scale) => {
414
693
  const num = toNum(n);
415
- if (num === null)
694
+ const sc = toScale(scale);
695
+ if (num === null || sc === null)
416
696
  return null;
417
- if (scale !== undefined && scale !== null) {
418
- const sc = toNum(scale) ?? 0;
419
- const factor = 10 ** sc;
420
- return Math.ceil(num * factor) / factor;
421
- }
422
- return Math.ceil(num);
697
+ return roundAt(num, sc, Math.ceil);
423
698
  });
424
699
  reg("round half up", (n, scale) => {
425
700
  const num = toNum(n);
426
- const sc = toNum(scale) ?? 0;
427
- if (num === null)
701
+ const sc = toScale(scale);
702
+ if (num === null || sc === null)
428
703
  return null;
429
- const factor = 10 ** sc;
430
- return Math.round(num * factor) / factor;
704
+ return roundAt(num, sc, roundHalfUp);
431
705
  });
432
706
  reg("round half down", (n, scale) => {
433
707
  const num = toNum(n);
434
- const sc = toNum(scale) ?? 0;
435
- if (num === null)
708
+ const sc = toScale(scale);
709
+ if (num === null || sc === null)
436
710
  return null;
437
- const factor = 10 ** sc;
438
- const scaled = num * factor;
439
- return (scaled > 0 ? Math.ceil(scaled - 0.5) : Math.floor(scaled + 0.5)) / factor;
711
+ return roundAt(num, sc, roundHalfDown);
440
712
  });
441
713
  reg("round up", (n, scale) => {
442
714
  const num = toNum(n);
443
- const sc = toNum(scale) ?? 0;
444
- if (num === null)
715
+ const sc = toScale(scale);
716
+ if (num === null || sc === null)
445
717
  return null;
446
- const factor = 10 ** sc;
447
- const scaled = num * factor;
448
- return (scaled > 0 ? Math.ceil(scaled) : Math.floor(scaled)) / factor;
718
+ return roundAt(num, sc, (x) => (x >= 0 ? Math.ceil(x) : Math.floor(x)));
449
719
  });
450
720
  reg("round down", (n, scale) => {
451
721
  const num = toNum(n);
452
- const sc = toNum(scale) ?? 0;
453
- if (num === null)
722
+ const sc = toScale(scale);
723
+ if (num === null || sc === null)
454
724
  return null;
455
- const factor = 10 ** sc;
456
- return Math.trunc(num * factor) / factor;
725
+ return roundAt(num, sc, Math.trunc);
457
726
  });
458
727
  reg("abs", (n) => {
459
728
  if (typeof n === "number")
@@ -496,6 +765,9 @@ reg("random number", () => Math.random());
496
765
  // List functions
497
766
  // -------------------------------------------------------------------------
498
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;
499
771
  const list = flattenToList(args);
500
772
  const first = list[0];
501
773
  if (list.length === 1 && first !== undefined && isFeelList(first))
@@ -549,7 +821,11 @@ reg("sum", (...args) => {
549
821
  return s;
550
822
  });
551
823
  reg("product", (...args) => {
824
+ if (args.length === 0)
825
+ return null;
552
826
  const list = unwrapList(flattenToList(args));
827
+ if (list.length === 0)
828
+ return null;
553
829
  let p = 1;
554
830
  for (const v of list) {
555
831
  const n = toNum(v);
@@ -603,24 +879,28 @@ reg("stddev", (...args) => {
603
879
  return Math.sqrt(variance);
604
880
  });
605
881
  reg("mode", (...args) => {
882
+ if (args.length === 0)
883
+ return null;
606
884
  const list = unwrapList(flattenToList(args));
607
885
  const counts = new Map();
608
886
  for (const v of list) {
609
- counts.set(v, (counts.get(v) ?? 0) + 1);
610
- }
611
- let maxCount = 0;
612
- for (const cnt of counts.values()) {
613
- if (cnt > maxCount)
614
- maxCount = cnt;
615
- }
616
- const modes = [];
617
- for (const [v, cnt] of counts) {
618
- if (cnt === maxCount)
619
- modes.push(v);
887
+ const n = toNum(v);
888
+ if (n === null)
889
+ return null;
890
+ counts.set(n, (counts.get(n) ?? 0) + 1);
620
891
  }
621
- return modes;
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);
622
900
  });
623
901
  reg("all", (...args) => {
902
+ if (args.length === 0)
903
+ return null;
624
904
  const list = unwrapList(flattenToList(args));
625
905
  let hasNull = false;
626
906
  for (const v of list) {
@@ -628,10 +908,14 @@ reg("all", (...args) => {
628
908
  return false;
629
909
  if (v === null)
630
910
  hasNull = true;
911
+ else if (typeof v !== "boolean")
912
+ return null;
631
913
  }
632
914
  return hasNull ? null : true;
633
915
  });
634
916
  reg("any", (...args) => {
917
+ if (args.length === 0)
918
+ return null;
635
919
  const list = unwrapList(flattenToList(args));
636
920
  let hasNull = false;
637
921
  for (const v of list) {
@@ -639,6 +923,8 @@ reg("any", (...args) => {
639
923
  return true;
640
924
  if (v === null)
641
925
  hasNull = true;
926
+ else if (typeof v !== "boolean")
927
+ return null;
642
928
  }
643
929
  return hasNull ? null : false;
644
930
  });
@@ -713,45 +999,40 @@ reg("index of", (list, match) => {
713
999
  }
714
1000
  return result;
715
1001
  });
1002
+ // Set membership is SameValueZero, exactly what Array#includes used here, so
1003
+ // de-duplication keeps its semantics while dropping from O(n²) to O(n).
716
1004
  reg("union", (...args) => {
717
- const result = [];
1005
+ const seen = new Set();
718
1006
  for (const v of args) {
719
1007
  if (isFeelList(v)) {
720
- for (const item of v) {
721
- if (!result.includes(item))
722
- result.push(item);
723
- }
1008
+ for (const item of v)
1009
+ seen.add(item);
724
1010
  }
725
- else if (!result.includes(v)) {
726
- result.push(v);
1011
+ else {
1012
+ seen.add(v);
727
1013
  }
728
1014
  }
729
- return result;
1015
+ return [...seen];
730
1016
  });
731
1017
  reg("distinct values", (list) => {
732
1018
  if (!isFeelList(list))
733
1019
  return null;
734
- const result = [];
735
- for (const v of list) {
736
- if (!result.includes(v))
737
- result.push(v);
738
- }
739
- return result;
1020
+ return [...new Set(list)];
740
1021
  });
741
1022
  reg("flatten", (list) => {
742
1023
  if (!isFeelList(list))
743
1024
  return null;
1025
+ const result = [];
744
1026
  const flat = (arr) => {
745
- const result = [];
746
1027
  for (const v of arr) {
747
1028
  if (isFeelList(v))
748
- result.push(...flat(v));
1029
+ flat(v);
749
1030
  else
750
1031
  result.push(v);
751
1032
  }
752
- return result;
753
1033
  };
754
- return flat(list);
1034
+ flat(list);
1035
+ return result;
755
1036
  });
756
1037
  reg("sort", (list, fn) => {
757
1038
  if (!isFeelList(list))
@@ -787,11 +1068,19 @@ reg("get or else", (v, defaultVal) => {
787
1068
  reg("get value", (ctx, key) => {
788
1069
  if (!isFeelContext(ctx))
789
1070
  return null;
790
- const k = toStr(key);
791
- if (k === null)
792
- return null;
793
- const val = ctx[k];
794
- return val !== undefined ? val : null;
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;
795
1084
  });
796
1085
  reg("get entries", (ctx) => {
797
1086
  if (!isFeelContext(ctx))
@@ -801,18 +1090,42 @@ reg("get entries", (ctx) => {
801
1090
  reg("context put", (ctx, key, value) => {
802
1091
  if (!isFeelContext(ctx))
803
1092
  return null;
804
- const k = toStr(key);
805
- if (k === null)
1093
+ if (value === undefined)
806
1094
  return null;
807
- const result = {};
808
- for (const [ck, cv] of Object.entries(ctx))
809
- result[ck] = cv;
810
- result[k] = value ?? null;
811
- return result;
1095
+ const path = isFeelList(key) ? key : [key];
1096
+ if (path.length === 0)
1097
+ return null;
1098
+ return putPath(ctx, path, value);
812
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
+ }
813
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;
814
1127
  const result = {};
815
- for (const v of args) {
1128
+ for (const v of contexts) {
816
1129
  if (!isFeelContext(v))
817
1130
  return null;
818
1131
  for (const [k, cv] of Object.entries(v))
@@ -820,17 +1133,19 @@ reg("context merge", (...args) => {
820
1133
  }
821
1134
  return result;
822
1135
  });
823
- reg("context", (list) => {
824
- if (!isFeelList(list))
825
- return null;
1136
+ reg("context", (entries) => {
1137
+ const list = isFeelList(entries) ? entries : [entries];
826
1138
  const result = {};
827
1139
  for (const item of list) {
828
1140
  if (!isFeelContext(item))
829
1141
  return null;
830
1142
  const k = item.key;
831
- const v = item.value;
832
- if (typeof k === "string")
833
- result[k] = v !== undefined ? v : null;
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;
834
1149
  }
835
1150
  return result;
836
1151
  });
@@ -844,6 +1159,8 @@ reg("date", (...args) => {
844
1159
  return parseDate(v);
845
1160
  if (isFeelDateTime(v))
846
1161
  return v.date;
1162
+ if (isFeelDate(v))
1163
+ return v;
847
1164
  return null;
848
1165
  }
849
1166
  if (args.length === 3) {
@@ -852,7 +1169,7 @@ reg("date", (...args) => {
852
1169
  const d = toNum(at(args, 2));
853
1170
  if (y === null || m === null || d === null)
854
1171
  return null;
855
- return { type: "date", year: y, month: m, day: d };
1172
+ return makeDate(y, m, d);
856
1173
  }
857
1174
  return null;
858
1175
  });
@@ -863,6 +1180,10 @@ reg("time", (...args) => {
863
1180
  return parseTime(v);
864
1181
  if (isFeelDateTime(v))
865
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 };
866
1187
  return null;
867
1188
  }
868
1189
  if (args.length >= 3) {
@@ -871,6 +1192,8 @@ reg("time", (...args) => {
871
1192
  const s = toNum(at(args, 2));
872
1193
  if (h === null || m === null || s === null)
873
1194
  return null;
1195
+ if (!isValidTime(h, m, s))
1196
+ return null;
874
1197
  const t = { type: "time", hour: h, minute: m, second: s };
875
1198
  const off = at(args, 3);
876
1199
  if (off !== null && isFeelDayTimeDuration(off))
@@ -915,9 +1238,29 @@ reg("years and months duration", (from, to) => {
915
1238
  d2 = to.date;
916
1239
  if (!d1 || !d2)
917
1240
  return null;
918
- const months = (d2.year - d1.year) * 12 + (d2.month - d1.month);
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;
919
1249
  return { type: "years-months-duration", months };
920
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
+ }
921
1264
  // -------------------------------------------------------------------------
922
1265
  // Temporal utility functions
923
1266
  // -------------------------------------------------------------------------
@@ -974,17 +1317,26 @@ reg("week of year", (d) => {
974
1317
  date = d.date;
975
1318
  if (!date)
976
1319
  return null;
977
- // ISO week number
978
- const epochDays = dateToEpochDays(date);
979
- // 1970-01-01 was Thursday (dow=4), ISO week 1
980
- const jan4 = dateToEpochDays({ type: "date", year: date.year, month: 1, day: 4 });
981
- const jan4dow = ((jan4 % 7) + 7 + 4) % 7; // Monday=0
982
- const weekStart = jan4 - ((jan4dow + 6) % 7);
983
- 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);
984
1324
  if (week < 1)
985
- return 52; // last week of previous year (simplified)
1325
+ return isoWeeksInYear(date.year - 1);
1326
+ if (week > isoWeeksInYear(date.year))
1327
+ return 1;
986
1328
  return week;
987
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
+ }
988
1340
  reg("month of year", (d) => {
989
1341
  const MONTH_NAMES = [
990
1342
  "January",
@@ -1131,41 +1483,101 @@ reg("includes", (a, b) => {
1131
1483
  return cmpPts(as_, bs, "start") <= 0 && cmpPts(be, ae, "end") <= 0;
1132
1484
  });
1133
1485
  reg("starts", (a, b) => {
1134
- if (!isFeelRange(a) || !isFeelRange(b))
1486
+ // starts(point, range): the range begins at that point, inclusively.
1487
+ if (!isFeelRange(b))
1135
1488
  return null;
1136
- const as_ = startOf(a);
1137
- const bs = startOf(b);
1138
- const ae = endOf(a);
1139
- const be = endOf(b);
1140
- 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;
1141
1493
  });
1142
1494
  reg("started by", (a, b) => {
1143
- if (!isFeelRange(a) || !isFeelRange(b))
1495
+ if (!isFeelRange(a))
1144
1496
  return null;
1145
- const as_ = startOf(a);
1146
- const bs = startOf(b);
1147
- const ae = endOf(a);
1148
- const be = endOf(b);
1149
- 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;
1150
1501
  });
1151
1502
  reg("finishes", (a, b) => {
1152
- if (!isFeelRange(a) || !isFeelRange(b))
1503
+ // finishes(point, range): the range ends at that point, inclusively.
1504
+ if (!isFeelRange(b))
1153
1505
  return null;
1154
- const ae = endOf(a);
1155
- const be = endOf(b);
1156
- const as_ = startOf(a);
1157
- const bs = startOf(b);
1158
- 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;
1159
1510
  });
1160
1511
  reg("finished by", (a, b) => {
1161
- if (!isFeelRange(a) || !isFeelRange(b))
1512
+ if (!isFeelRange(a))
1162
1513
  return null;
1163
- const ae = endOf(a);
1164
- const be = endOf(b);
1165
- const as_ = startOf(a);
1166
- const bs = startOf(b);
1167
- return cmpPts(ae, be, "end") === 0 && cmpPts(as_, bs, "start") <= 0;
1168
- });
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
+ }
1169
1581
  reg("coincides", (a, b) => {
1170
1582
  if (isFeelRange(a) && isFeelRange(b)) {
1171
1583
  return cmpPts(startOf(a), startOf(b), "start") === 0 && cmpPts(endOf(a), endOf(b), "end") === 0;
@@ -1176,14 +1588,236 @@ reg("coincides", (a, b) => {
1176
1588
  return null;
1177
1589
  });
1178
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
+ // -------------------------------------------------------------------------
1179
1798
  // Exports
1180
1799
  // -------------------------------------------------------------------------
1800
+ // One FeelFunction wrapper per built-in, created on first use and shared: name
1801
+ // resolution runs for every identifier the evaluator meets, so allocating a
1802
+ // wrapper and closure per lookup showed up on every loop iteration.
1803
+ const builtinWrappers = new Map();
1181
1804
  /** Look up a built-in function by name. Returns undefined if not found. */
1182
1805
  export function getBuiltin(name) {
1806
+ const cached = builtinWrappers.get(name);
1807
+ if (cached)
1808
+ return cached;
1183
1809
  const fn = builtinMap.get(name);
1184
1810
  if (!fn)
1185
1811
  return undefined;
1186
- return { type: "function", call: (args) => fn(...args) };
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
+ };
1819
+ builtinWrappers.set(name, wrapper);
1820
+ return wrapper;
1187
1821
  }
1188
1822
  /** All built-in names. */
1189
1823
  export function builtinNames() {