@bpmnkit/feel 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![ai-assisted](https://img.shields.io/badge/AI--assisted-claude-8b5cf6?style=flat-square)](https://github.com/bpmnkit/monorepo)
10
10
  [![experimental](https://img.shields.io/badge/status-experimental-f59e0b?style=flat-square)](https://github.com/bpmnkit/monorepo)
11
11
 
12
- [Website](https://bpmnkit.com) · [Documentation](https://docs.bpmnkit.com) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/feel/CHANGELOG.md)
12
+ [Website](https://bpmnkit.com) · [Documentation](https://bpmnkit.com/docs) · [GitHub](https://github.com/bpmnkit/monorepo) · [Changelog](https://github.com/bpmnkit/monorepo/blob/main/packages/feel/CHANGELOG.md)
13
13
  </div>
14
14
 
15
15
  ---
@@ -117,6 +117,7 @@ interface ParseResult {
117
117
  | [`@bpmnkit/patterns`](https://www.npmjs.com/package/@bpmnkit/patterns) | Domain process patterns for BPMNKit AIKit |
118
118
  | [`@bpmnkit/reebe-wasm`](https://www.npmjs.com/package/@bpmnkit/reebe-wasm) | WebAssembly BPMN engine for browser simulation |
119
119
  | [`@bpmnkit/worker-client`](https://www.npmjs.com/package/@bpmnkit/worker-client) | Thin Zeebe REST client for standalone workers |
120
+ | [`@bpmnkit/user-tasks`](https://www.npmjs.com/package/@bpmnkit/user-tasks) | Embeddable user task widget for Camunda 8 |
120
121
  | [`@bpmnkit/cli-sdk`](https://www.npmjs.com/package/@bpmnkit/cli-sdk) | Plugin authoring SDK for the casen CLI |
121
122
  | [`@bpmnkit/create-casen-plugin`](https://www.npmjs.com/package/@bpmnkit/create-casen-plugin) | Scaffold a new casen CLI plugin in seconds |
122
123
  | [`@bpmnkit/casen-report`](https://www.npmjs.com/package/@bpmnkit/casen-report) | HTML reports from Camunda 8 incident and SLA data |
package/dist/builtins.js CHANGED
@@ -319,18 +319,39 @@ reg("ends with", (str, match) => {
319
319
  return null;
320
320
  return s.endsWith(m);
321
321
  });
322
+ // Patterns in FEEL come from static expression text, so the same few regexes
323
+ // are compiled over and over inside loops and decision tables; keep them.
324
+ const REGEX_CACHE_LIMIT = 256;
325
+ const regexCache = new Map();
326
+ /** Compiled regex for `pattern`/`flags`, or null when the pattern is invalid. */
327
+ function cachedRegExp(pattern, flags) {
328
+ const key = `${flags}/${pattern}`;
329
+ const hit = regexCache.get(key);
330
+ if (hit !== undefined)
331
+ return hit;
332
+ let re;
333
+ try {
334
+ re = new RegExp(pattern, flags);
335
+ }
336
+ catch {
337
+ re = null;
338
+ }
339
+ if (regexCache.size >= REGEX_CACHE_LIMIT)
340
+ regexCache.clear();
341
+ regexCache.set(key, re);
342
+ return re;
343
+ }
322
344
  reg("matches", (str, pattern, flags) => {
323
345
  const s = toStr(str);
324
346
  const p = toStr(pattern);
325
347
  if (s === null || p === null)
326
348
  return null;
327
349
  const f = flags !== undefined && flags !== null ? (toStr(flags) ?? "") : "";
328
- try {
329
- return new RegExp(p, f).test(s);
330
- }
331
- catch {
350
+ const re = cachedRegExp(p, f);
351
+ if (re === null)
332
352
  return null;
333
- }
353
+ re.lastIndex = 0;
354
+ return re.test(s);
334
355
  });
335
356
  reg("replace", (str, pattern, replacement, flags) => {
336
357
  const s = toStr(str);
@@ -339,24 +360,19 @@ reg("replace", (str, pattern, replacement, flags) => {
339
360
  if (s === null || p === null || r === null)
340
361
  return null;
341
362
  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 {
363
+ const re = cachedRegExp(p, f.includes("g") ? f : `${f}g`);
364
+ if (re === null)
346
365
  return null;
347
- }
366
+ re.lastIndex = 0;
367
+ return s.replace(re, r);
348
368
  });
349
369
  reg("split", (str, delimiter) => {
350
370
  const s = toStr(str);
351
371
  const d = toStr(delimiter);
352
372
  if (s === null || d === null)
353
373
  return null;
354
- try {
355
- return s.split(new RegExp(d));
356
- }
357
- catch {
358
- return s.split(d);
359
- }
374
+ const re = cachedRegExp(d, "");
375
+ return re === null ? s.split(d) : s.split(re);
360
376
  });
361
377
  reg("string join", (...args) => {
362
378
  const flat = flattenToList(args);
@@ -713,45 +729,40 @@ reg("index of", (list, match) => {
713
729
  }
714
730
  return result;
715
731
  });
732
+ // Set membership is SameValueZero, exactly what Array#includes used here, so
733
+ // de-duplication keeps its semantics while dropping from O(n²) to O(n).
716
734
  reg("union", (...args) => {
717
- const result = [];
735
+ const seen = new Set();
718
736
  for (const v of args) {
719
737
  if (isFeelList(v)) {
720
- for (const item of v) {
721
- if (!result.includes(item))
722
- result.push(item);
723
- }
738
+ for (const item of v)
739
+ seen.add(item);
724
740
  }
725
- else if (!result.includes(v)) {
726
- result.push(v);
741
+ else {
742
+ seen.add(v);
727
743
  }
728
744
  }
729
- return result;
745
+ return [...seen];
730
746
  });
731
747
  reg("distinct values", (list) => {
732
748
  if (!isFeelList(list))
733
749
  return null;
734
- const result = [];
735
- for (const v of list) {
736
- if (!result.includes(v))
737
- result.push(v);
738
- }
739
- return result;
750
+ return [...new Set(list)];
740
751
  });
741
752
  reg("flatten", (list) => {
742
753
  if (!isFeelList(list))
743
754
  return null;
755
+ const result = [];
744
756
  const flat = (arr) => {
745
- const result = [];
746
757
  for (const v of arr) {
747
758
  if (isFeelList(v))
748
- result.push(...flat(v));
759
+ flat(v);
749
760
  else
750
761
  result.push(v);
751
762
  }
752
- return result;
753
763
  };
754
- return flat(list);
764
+ flat(list);
765
+ return result;
755
766
  });
756
767
  reg("sort", (list, fn) => {
757
768
  if (!isFeelList(list))
@@ -1178,12 +1189,21 @@ reg("coincides", (a, b) => {
1178
1189
  // -------------------------------------------------------------------------
1179
1190
  // Exports
1180
1191
  // -------------------------------------------------------------------------
1192
+ // One FeelFunction wrapper per built-in, created on first use and shared: name
1193
+ // resolution runs for every identifier the evaluator meets, so allocating a
1194
+ // wrapper and closure per lookup showed up on every loop iteration.
1195
+ const builtinWrappers = new Map();
1181
1196
  /** Look up a built-in function by name. Returns undefined if not found. */
1182
1197
  export function getBuiltin(name) {
1198
+ const cached = builtinWrappers.get(name);
1199
+ if (cached)
1200
+ return cached;
1183
1201
  const fn = builtinMap.get(name);
1184
1202
  if (!fn)
1185
1203
  return undefined;
1186
- return { type: "function", call: (args) => fn(...args) };
1204
+ const wrapper = { type: "function", call: (args) => fn(...args) };
1205
+ builtinWrappers.set(name, wrapper);
1206
+ return wrapper;
1187
1207
  }
1188
1208
  /** All built-in names. */
1189
1209
  export function builtinNames() {
@@ -120,6 +120,16 @@ export function annotate(input) {
120
120
  i = end;
121
121
  continue;
122
122
  }
123
+ // The greedy match above already decided whether this word is a
124
+ // built-in; re-running the lookahead in classifyToken would repeat it.
125
+ result.push({
126
+ kind: BUILTINS.has(name) ? "builtin" : "variable",
127
+ value: tok.value,
128
+ start: tok.start,
129
+ end: tok.end,
130
+ });
131
+ i++;
132
+ continue;
123
133
  }
124
134
  const { kind } = classifyToken(tok, tokens, i);
125
135
  result.push({ kind, value: tok.value, start: tok.start, end: tok.end });
@@ -127,22 +137,25 @@ export function annotate(input) {
127
137
  }
128
138
  return result;
129
139
  }
140
+ const HTML_ESCAPE_RE = /[&<>]/;
141
+ const HTML_ESCAPE_ALL_RE = /[&<>]/g;
142
+ const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
130
143
  function escapeHtml(s) {
131
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
144
+ // Most tokens contain nothing to escape; one test beats three replace passes.
145
+ if (!HTML_ESCAPE_RE.test(s))
146
+ return s;
147
+ return s.replace(HTML_ESCAPE_ALL_RE, (ch) => HTML_ESCAPES[ch] ?? ch);
132
148
  }
133
149
  /** Render annotated tokens to HTML with span wrappers. */
134
150
  export function highlightToHtml(input) {
135
151
  if (!input.trim())
136
152
  return escapeHtml(input) || '<span class="feel-empty">-</span>';
137
- const tokens = annotate(input);
138
- return tokens
139
- .map((t) => {
153
+ let html = "";
154
+ for (const t of annotate(input)) {
140
155
  const escaped = escapeHtml(t.value);
141
- if (t.kind === "plain")
142
- return escaped;
143
- return `<span class="feel-${t.kind}">${escaped}</span>`;
144
- })
145
- .join("");
156
+ html += t.kind === "plain" ? escaped : `<span class="feel-${t.kind}">${escaped}</span>`;
157
+ }
158
+ return html;
146
159
  }
147
160
  /** Backward-compatible alias. */
148
161
  export const highlightFeel = highlightToHtml;
package/dist/lexer.js CHANGED
@@ -20,140 +20,171 @@ const KEYWORDS = new Set([
20
20
  "instance",
21
21
  "of",
22
22
  ]);
23
+ // Character codes; comparing codes avoids allocating a one-character string
24
+ // for every position the scanner visits.
25
+ const SLASH = 0x2f;
26
+ const STAR = 0x2a;
27
+ const AT = 0x40;
28
+ const DQUOTE = 0x22;
29
+ const BACKSLASH = 0x5c;
30
+ const BACKTICK = 0x60;
31
+ const SPACE = 0x20;
32
+ const TAB = 0x09;
33
+ const LF = 0x0a;
34
+ const CR = 0x0d;
35
+ const DOT = 0x2e;
36
+ const GT = 0x3e;
37
+ const LT = 0x3c;
38
+ const BANG = 0x21;
39
+ const MINUS = 0x2d;
40
+ const EQ = 0x3d;
41
+ const UNDERSCORE = 0x5f;
42
+ function isDigit(c) {
43
+ return c >= 0x30 && c <= 0x39;
44
+ }
45
+ function isLetter(c) {
46
+ return (c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a);
47
+ }
48
+ function isWhitespace(c) {
49
+ return c === SPACE || c === TAB || c === LF || c === CR;
50
+ }
51
+ const SINGLE_OPS = new Set("+-*/=<>?".split("").map((c) => c.charCodeAt(0)));
52
+ const PUNCT = new Set("()[]{},:".split("").map((c) => c.charCodeAt(0)));
23
53
  export function tokenize(input) {
24
54
  const tokens = [];
25
55
  let i = 0;
26
56
  const len = input.length;
27
- const ch = (offset = 0) => input.charAt(i + offset);
28
- const slice = (start, end) => input.slice(start, end);
29
57
  while (i < len) {
30
58
  const start = i;
59
+ const c = input.charCodeAt(i);
60
+ // NaN past the end never equals any code, so lookahead needs no bounds check.
61
+ const next = input.charCodeAt(i + 1);
31
62
  // Line comment
32
- if (ch() === "/" && ch(1) === "/") {
63
+ if (c === SLASH && next === SLASH) {
33
64
  i += 2;
34
- while (i < len && ch() !== "\n")
65
+ while (i < len && input.charCodeAt(i) !== LF)
35
66
  i++;
36
- tokens.push({ kind: "comment", value: slice(start, i), start, end: i });
67
+ tokens.push({ kind: "comment", value: input.slice(start, i), start, end: i });
37
68
  continue;
38
69
  }
39
70
  // Block comment
40
- if (ch() === "/" && ch(1) === "*") {
71
+ if (c === SLASH && next === STAR) {
41
72
  i += 2;
42
- while (i < len && !(ch() === "*" && ch(1) === "/"))
73
+ while (i < len && !(input.charCodeAt(i) === STAR && input.charCodeAt(i + 1) === SLASH))
43
74
  i++;
44
75
  i += 2;
45
- tokens.push({ kind: "comment", value: slice(start, i), start, end: i });
76
+ tokens.push({ kind: "comment", value: input.slice(start, i), start, end: i });
46
77
  continue;
47
78
  }
48
79
  // Temporal literal @"..."
49
- if (ch() === "@" && ch(1) === '"') {
80
+ if (c === AT && next === DQUOTE) {
50
81
  i += 2;
51
- while (i < len && ch() !== '"') {
52
- if (ch() === "\\")
82
+ while (i < len && input.charCodeAt(i) !== DQUOTE) {
83
+ if (input.charCodeAt(i) === BACKSLASH)
53
84
  i++;
54
85
  i++;
55
86
  }
56
87
  i++; // closing "
57
- tokens.push({ kind: "temporal", value: slice(start, i), start, end: i });
88
+ tokens.push({ kind: "temporal", value: input.slice(start, i), start, end: i });
58
89
  continue;
59
90
  }
60
91
  // String literal
61
- if (ch() === '"') {
92
+ if (c === DQUOTE) {
62
93
  i++;
63
- while (i < len && ch() !== '"') {
64
- if (ch() === "\\")
94
+ while (i < len && input.charCodeAt(i) !== DQUOTE) {
95
+ if (input.charCodeAt(i) === BACKSLASH)
65
96
  i++;
66
97
  i++;
67
98
  }
68
99
  i++; // closing "
69
- tokens.push({ kind: "string", value: slice(start, i), start, end: i });
100
+ tokens.push({ kind: "string", value: input.slice(start, i), start, end: i });
70
101
  continue;
71
102
  }
72
103
  // Backtick name
73
- if (ch() === "`") {
104
+ if (c === BACKTICK) {
74
105
  i++;
75
- while (i < len && ch() !== "`")
106
+ while (i < len && input.charCodeAt(i) !== BACKTICK)
76
107
  i++;
77
108
  i++; // closing `
78
109
  tokens.push({
79
110
  kind: "backtick",
80
- value: slice(start + 1, i - 1),
111
+ value: input.slice(start + 1, i - 1),
81
112
  start,
82
113
  end: i,
83
114
  });
84
115
  continue;
85
116
  }
86
117
  // Whitespace
87
- if (ch() === " " || ch() === "\t" || ch() === "\n" || ch() === "\r") {
88
- while (i < len && (ch() === " " || ch() === "\t" || ch() === "\n" || ch() === "\r"))
118
+ if (isWhitespace(c)) {
119
+ while (i < len && isWhitespace(input.charCodeAt(i)))
89
120
  i++;
90
- tokens.push({ kind: "whitespace", value: slice(start, i), start, end: i });
121
+ tokens.push({ kind: "whitespace", value: input.slice(start, i), start, end: i });
91
122
  continue;
92
123
  }
93
124
  // Two-char operators (check before single-char)
94
- const two = slice(i, i + 2);
95
- if (two === "**" ||
96
- two === ">=" ||
97
- two === "<=" ||
98
- two === "!=" ||
99
- two === "->" ||
100
- two === "..") {
101
- tokens.push({ kind: "op", value: two, start, end: i + 2 });
125
+ if ((c === STAR && next === STAR) ||
126
+ (c === GT && next === EQ) ||
127
+ (c === LT && next === EQ) ||
128
+ (c === BANG && next === EQ) ||
129
+ (c === MINUS && next === GT) ||
130
+ (c === DOT && next === DOT)) {
131
+ tokens.push({ kind: "op", value: input.slice(i, i + 2), start, end: i + 2 });
102
132
  i += 2;
103
133
  continue;
104
134
  }
105
135
  // "==" is not standard FEEL but users familiar with JS/Java write it; treat as "=".
106
- if (two === "==") {
136
+ if (c === EQ && next === EQ) {
107
137
  tokens.push({ kind: "op", value: "=", start, end: i + 2 });
108
138
  i += 2;
109
139
  continue;
110
140
  }
111
141
  // Single-char operators
112
- if ("+-*/=<>?".includes(ch())) {
113
- tokens.push({ kind: "op", value: ch(), start, end: i + 1 });
142
+ if (SINGLE_OPS.has(c)) {
143
+ tokens.push({ kind: "op", value: input[i], start, end: i + 1 });
114
144
  i++;
115
145
  continue;
116
146
  }
117
147
  // Punctuation
118
- if ("()[]{},:".includes(ch())) {
119
- tokens.push({ kind: "punct", value: ch(), start, end: i + 1 });
148
+ if (PUNCT.has(c)) {
149
+ tokens.push({ kind: "punct", value: input[i], start, end: i + 1 });
120
150
  i++;
121
151
  continue;
122
152
  }
123
153
  // Dot (not ..)
124
- if (ch() === ".") {
154
+ if (c === DOT) {
125
155
  tokens.push({ kind: "punct", value: ".", start, end: i + 1 });
126
156
  i++;
127
157
  continue;
128
158
  }
129
159
  // Number (only consume one decimal point, and only if followed by a digit)
130
- if (ch() >= "0" && ch() <= "9") {
131
- while (i < len && ch() >= "0" && ch() <= "9")
160
+ if (isDigit(c)) {
161
+ while (i < len && isDigit(input.charCodeAt(i)))
132
162
  i++;
133
163
  // Consume decimal fraction only if next char is '.' followed by a digit (not '..')
134
- if (i < len && ch() === "." && i + 1 < len && ch(1) >= "0" && ch(1) <= "9") {
164
+ if (i + 1 < len && input.charCodeAt(i) === DOT && isDigit(input.charCodeAt(i + 1))) {
135
165
  i++; // consume the '.'
136
- while (i < len && ch() >= "0" && ch() <= "9")
166
+ while (i < len && isDigit(input.charCodeAt(i)))
137
167
  i++;
138
168
  }
139
- tokens.push({ kind: "number", value: slice(start, i), start, end: i });
169
+ tokens.push({ kind: "number", value: input.slice(start, i), start, end: i });
140
170
  continue;
141
171
  }
142
172
  // Identifier / keyword
143
- if ((ch() >= "a" && ch() <= "z") || (ch() >= "A" && ch() <= "Z") || ch() === "_") {
144
- while (i < len &&
145
- ((ch() >= "a" && ch() <= "z") ||
146
- (ch() >= "A" && ch() <= "Z") ||
147
- (ch() >= "0" && ch() <= "9") ||
148
- ch() === "_"))
173
+ if (isLetter(c) || c === UNDERSCORE) {
174
+ i++;
175
+ while (i < len) {
176
+ const w = input.charCodeAt(i);
177
+ if (!isLetter(w) && !isDigit(w) && w !== UNDERSCORE)
178
+ break;
149
179
  i++;
150
- const word = slice(start, i);
180
+ }
181
+ const word = input.slice(start, i);
151
182
  const kind = KEYWORDS.has(word) ? "keyword" : "name";
152
183
  tokens.push({ kind, value: word, start, end: i });
153
184
  continue;
154
185
  }
155
186
  // Unknown character
156
- tokens.push({ kind: "unknown", value: ch(), start, end: i + 1 });
187
+ tokens.push({ kind: "unknown", value: input[i], start, end: i + 1 });
157
188
  i++;
158
189
  }
159
190
  return tokens;
package/dist/parser.js CHANGED
@@ -854,18 +854,40 @@ class Parser {
854
854
  }
855
855
  }
856
856
  }
857
+ // Expression text is static model content that engines and decision tables
858
+ // evaluate over and over, so parse results are memoized. Results are treated
859
+ // as immutable by every consumer; the caches are bounded and simply reset when
860
+ // full.
861
+ const PARSE_CACHE_LIMIT = 2048;
862
+ const expressionCache = new Map();
863
+ const unaryTestsCache = new Map();
864
+ function remember(cache, input, result) {
865
+ if (cache.size >= PARSE_CACHE_LIMIT)
866
+ cache.clear();
867
+ cache.set(input, result);
868
+ return result;
869
+ }
857
870
  export function parseExpression(input) {
871
+ const cached = expressionCache.get(input);
872
+ if (cached !== undefined)
873
+ return cached;
858
874
  const p = new Parser(input);
859
875
  const ast = p.parseExpression(0);
860
876
  p.checkDone();
861
- return { ast, errors: p.errors };
877
+ return remember(expressionCache, input, { ast, errors: p.errors });
862
878
  }
863
879
  export function parseUnaryTests(input) {
880
+ const cached = unaryTestsCache.get(input);
881
+ if (cached !== undefined)
882
+ return cached;
864
883
  if (input.trim() === "-") {
865
- return { ast: { kind: "any-input", start: 0, end: input.length }, errors: [] };
884
+ return remember(unaryTestsCache, input, {
885
+ ast: { kind: "any-input", start: 0, end: input.length },
886
+ errors: [],
887
+ });
866
888
  }
867
889
  const p = new Parser(input);
868
890
  const ast = p.parseUnaryTests();
869
- return { ast, errors: p.errors };
891
+ return remember(unaryTestsCache, input, { ast, errors: p.errors });
870
892
  }
871
893
  //# sourceMappingURL=parser.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bpmnkit/feel",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {