@bpmnkit/feel 0.0.21 → 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);
@@ -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
- let re;
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
- return s.replace(re, r);
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", (...args) => {
378
- const flat = flattenToList(args);
379
- // string join(list) or string join(list, delimiter) or string join(list, delimiter, prefix, suffix)
380
- let list;
381
- let delimiter = "";
382
- const first = flat[0];
383
- if (flat.length >= 1 && first !== undefined && isFeelList(first)) {
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
- const s = toStr(v);
393
- if (s !== null)
394
- 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);
395
635
  }
396
- 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;
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 === "string") {
405
- const n = Number(v);
406
- return Number.isNaN(n) ? null : n;
407
- }
408
- 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;
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 = toNum(scale);
413
- if (num === null || sc === null)
680
+ const sc = toScale(scale);
681
+ if (num === null || sc === null || scale === undefined)
414
682
  return null;
415
- const factor = 10 ** sc;
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
- if (num === null)
687
+ const sc = toScale(scale);
688
+ if (num === null || sc === null)
421
689
  return null;
422
- if (scale !== undefined && scale !== null) {
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
- if (num === null)
694
+ const sc = toScale(scale);
695
+ if (num === null || sc === null)
432
696
  return null;
433
- if (scale !== undefined && scale !== null) {
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 = toNum(scale) ?? 0;
443
- if (num === null)
701
+ const sc = toScale(scale);
702
+ if (num === null || sc === null)
444
703
  return null;
445
- const factor = 10 ** sc;
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 = toNum(scale) ?? 0;
451
- if (num === null)
708
+ const sc = toScale(scale);
709
+ if (num === null || sc === null)
452
710
  return null;
453
- const factor = 10 ** sc;
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 = toNum(scale) ?? 0;
460
- if (num === null)
715
+ const sc = toScale(scale);
716
+ if (num === null || sc === null)
461
717
  return null;
462
- const factor = 10 ** sc;
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 = toNum(scale) ?? 0;
469
- if (num === null)
722
+ const sc = toScale(scale);
723
+ if (num === null || sc === null)
470
724
  return null;
471
- const factor = 10 ** sc;
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
- counts.set(v, (counts.get(v) ?? 0) + 1);
626
- }
627
- let maxCount = 0;
628
- for (const cnt of counts.values()) {
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
- 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);
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
- const k = toStr(key);
802
- if (k === null)
803
- return null;
804
- const val = ctx[k];
805
- 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;
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
- const k = toStr(key);
816
- if (k === null)
1093
+ if (value === undefined)
817
1094
  return null;
818
- const result = {};
819
- for (const [ck, cv] of Object.entries(ctx))
820
- result[ck] = cv;
821
- result[k] = value ?? null;
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 args) {
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", (list) => {
835
- if (!isFeelList(list))
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
- const v = item.value;
843
- if (typeof k === "string")
844
- 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;
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 { type: "date", year: y, month: m, day: d };
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
- 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;
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 number
989
- const epochDays = dateToEpochDays(date);
990
- // 1970-01-01 was Thursday (dow=4), ISO week 1
991
- const jan4 = dateToEpochDays({ type: "date", year: date.year, month: 1, day: 4 });
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 52; // last week of previous year (simplified)
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
- if (!isFeelRange(a) || !isFeelRange(b))
1486
+ // starts(point, range): the range begins at that point, inclusively.
1487
+ if (!isFeelRange(b))
1146
1488
  return null;
1147
- const as_ = startOf(a);
1148
- const bs = startOf(b);
1149
- const ae = endOf(a);
1150
- const be = endOf(b);
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) || !isFeelRange(b))
1495
+ if (!isFeelRange(a))
1155
1496
  return null;
1156
- const as_ = startOf(a);
1157
- const bs = startOf(b);
1158
- const ae = endOf(a);
1159
- const be = endOf(b);
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
- if (!isFeelRange(a) || !isFeelRange(b))
1503
+ // finishes(point, range): the range ends at that point, inclusively.
1504
+ if (!isFeelRange(b))
1164
1505
  return null;
1165
- const ae = endOf(a);
1166
- const be = endOf(b);
1167
- const as_ = startOf(a);
1168
- const bs = startOf(b);
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) || !isFeelRange(b))
1512
+ if (!isFeelRange(a))
1173
1513
  return null;
1174
- const ae = endOf(a);
1175
- const be = endOf(b);
1176
- const as_ = startOf(a);
1177
- const bs = startOf(b);
1178
- return cmpPts(ae, be, "end") === 0 && cmpPts(as_, bs, "start") <= 0;
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 wrapper = { 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
+ };
1205
1819
  builtinWrappers.set(name, wrapper);
1206
1820
  return wrapper;
1207
1821
  }