feelin 7.0.1 → 7.0.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d14d729a1a7a6dfac83eae232db700fe7ee2772e6c6a73b0697d26451b9acb1f
4
- data.tar.gz: f1ce26b4fe80144b014c50ee6c23d2cf8b1b018044d0143a3838933ef3c164f4
3
+ metadata.gz: 1d42d010d26029134be39b2bcdbd097683a00b0866820f1d816f69607b5bd053
4
+ data.tar.gz: cbee6c7dc98e5c2e078ebdecdbe3abac6dee702966841ffa3e5a3df399e69853
5
5
  SHA512:
6
- metadata.gz: 3a1ddc86be9fadbb2043bb23b881b198c81962635276324ad4950c605078f128d3bc5e96f7cca4031633aca42f5084a042e7b20f696e86878117daf3e6d707e2
7
- data.tar.gz: de182a5cdeb48279e195090b9f93c651fef336b8db7dd6b176e721e32edbd9ea6fd53d31037d789227ec0bf09ed541acf0e60e7a5931801edd25e14676c6b775
6
+ metadata.gz: 247e1e7cce0bf1a66df682268273282000c3ef98ba755af7ac213f5b9c978cb30aa9f68bef46e4ab49ff0c2573cf25838ed11e0da51aca861d0b30ccd268f7cf
7
+ data.tar.gz: 5ff326f4111002f0f65fef3e84f31bf307d1fea982372c72805936ada9484e8b579a57fe853a3a88c1cf2f35b4b116271996327498853b8ef608969a3483050b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [7.0.3] - 2026-10-05
9
+
10
+ ### Changed
11
+
12
+ * **Breaking:** `FEELIN.parse` is `FEELIN.parse_expression`, after feelin's own `parseExpression`.
13
+ * **Breaking:** errors are the gem's own. A failure inside V8 is raised as `FEELIN::Error` — `FEELIN::SyntaxError` for an expression that does not parse, `FEELIN::TimeoutError` and `FEELIN::MemoryError` for a context's limits — instead of `MiniRacer::RuntimeError`, `MiniRacer::ScriptTerminatedError` and `MiniRacer::V8OutOfMemoryError`. The message names the expression, also available as `error.expression`; `error.reason` is the cause without it. An exception raised by a custom function still passes unwrapped.
14
+ * **Breaking:** a custom function receives a date, a time and a duration as their ISO 8601 strings — the form a result has — instead of a hash of the internal fields of the JavaScript object.
15
+
16
+ ## [7.0.2] - 2026-10-05
17
+
18
+ ### Added
19
+
20
+ * `FEELIN.parse` and `FEELIN.parse_unary_tests` answer the syntax tree of an expression as nested hashes (`type`, `from`, `to`, `text`, `children`), without evaluating it. A syntax error raises with the message `evaluate` gives.
21
+ * `FEELIN::Context` — a V8 context of one's own, with the same methods as the module, optional `timeout` and `max_memory` limits, and custom functions of its own. All contexts start from one snapshot of the bundle.
22
+
23
+ ### Changed
24
+
25
+ * Updated the bundled `feelin` JavaScript library to 7.0.2 (prototype access from FEEL expressions is prevented).
26
+ * **Breaking:** a result is JSON-compatible. A date, a time and a duration are now the ISO 8601 strings FEEL's `string()` gives them (`"2020-01-02"`, `"10:00:00+03:00"`, `"2020-01-02T03:04:05Z"`, `"P1DT2H"`) instead of a hash of the internal fields of the JavaScript object. A function is a hash of its `parameterNames`.
27
+ * The context is passed to V8 as an argument rather than written into the evaluated script, so V8 no longer compiles and keeps a script per distinct context. Measured speed is the same as before.
28
+
8
29
  ## [7.0.1] - 2026-06-22
9
30
 
10
31
  ### Changed
data/README.md CHANGED
@@ -21,6 +21,16 @@ FEELIN.evaluate('for a in [1, 2, 3] return a * 2') # [ 2, 4, 6 ]
21
21
  FEELIN.evaluate("Mike's daughter.name", { 'Mike\'s daughter.name' => 'Lisa' }) # Lisa
22
22
  ```
23
23
 
24
+ A result is JSON-compatible. A date, a time and a duration come back as ISO 8601 strings, in the form
25
+ FEEL's own `string()` gives them:
26
+
27
+ ```ruby
28
+ FEELIN.evaluate('date("2020-01-02") + duration("P1D")') # "2020-01-03"
29
+ FEELIN.evaluate('date and time("2020-01-02T03:04:05Z")') # "2020-01-02T03:04:05Z"
30
+ FEELIN.evaluate('time("10:00:00+03:00")') # "10:00:00+03:00"
31
+ FEELIN.evaluate('duration("P1DT2H")') # "P1DT2H"
32
+ ```
33
+
24
34
  ### Unary tests
25
35
 
26
36
  ```ruby
@@ -31,6 +41,38 @@ FEELIN.unary_test('1', 1) # true
31
41
  FEELIN.unary_test('[1..end]', 1, { 'end' => 10 }) # true
32
42
  ```
33
43
 
44
+ ### Syntax tree
45
+
46
+ `parse_expression` and `parse_unary_tests` answer the syntax tree of an expression without evaluating it — for
47
+ translating FEEL into something else (SQL, for one) or inspecting what an expression refers to.
48
+
49
+ ```ruby
50
+ FEELIN.parse_expression('price > 10')
51
+ # { "type" => "Expression", "from" => 0, "to" => 10, "text" => "price > 10", "children" => [
52
+ # { "type" => "Comparison", "from" => 0, "to" => 10, "text" => "price > 10", "children" => [
53
+ # { "type" => "VariableName", "from" => 0, "to" => 5, "text" => "price", "children" => [
54
+ # { "type" => "Identifier", "from" => 0, "to" => 5, "text" => "price", "children" => [] } ] },
55
+ # { "type" => "CompareOp", "from" => 6, "to" => 7, "text" => ">", "children" => [] },
56
+ # { "type" => "NumericLiteral", "from" => 8, "to" => 10, "text" => "10", "children" => [] } ] } ] }
57
+
58
+ FEELIN.parse_unary_tests('[1..end], > 5')
59
+ ```
60
+
61
+ The tree is the one feelin's parser ([lezer-feel](https://github.com/nikku/lezer-feel)) builds, node for
62
+ node: `type` is the grammar's name of the node, and keywords and punctuation (`if`, `(`, `,`) are nodes as
63
+ well. `from` / `to` are the span in the expression, counted in UTF-16 code units as JavaScript counts —
64
+ for text outside the Basic Multilingual Plane that differs from Ruby's character index, so take `text`
65
+ rather than slicing the expression.
66
+
67
+ The optional second argument is a context, of which only the names matter: it is what lets a name with
68
+ spaces in it be read as one name.
69
+
70
+ ```ruby
71
+ FEELIN.parse_expression("Mike's daughter.name + 1", { "Mike's daughter.name" => nil })
72
+ ```
73
+
74
+ An expression that does not parse raises `FEELIN::SyntaxError`, with the same message `evaluate` gives.
75
+
34
76
  ### Custom functions
35
77
 
36
78
  ```ruby
@@ -38,6 +80,62 @@ FEELIN.add_function('rates', proc { [10, 20] })
38
80
  FEELIN.evaluate('every rate in rates() satisfies rate < 10') # false
39
81
  ```
40
82
 
83
+ Arguments reach the function in the form a result has: a date, a time and a duration as their ISO 8601
84
+ strings, wherever they are among the arguments. What the function returns is plain data to FEEL — an ISO
85
+ string it returns is a string there, and `date(...)` makes a date of it.
86
+
87
+ ```ruby
88
+ FEELIN.add_function('last day of month', proc do |date|
89
+ day = Date.iso8601(date)
90
+ Date.new(day.year, day.month, -1).iso8601
91
+ end)
92
+ FEELIN.evaluate('date(last day of month(date("2024-02-10"))) + duration("P1D")') # "2024-03-01"
93
+ ```
94
+
95
+ ### A context of one's own
96
+
97
+ The methods above work on one shared V8 context, which has no limits. `FEELIN::Context` is a separate
98
+ one with the same methods — for an expression that is not trusted to end or to stay small, or for custom
99
+ functions the rest of the process should not see.
100
+
101
+ ```ruby
102
+ context = FEELIN::Context.new(timeout: 1_000, max_memory: 64_000_000) # ms, bytes; both optional
103
+
104
+ context.add_function('rate', proc { 0.2 })
105
+ context.evaluate('price * rate()', { 'price' => 100 }) # 20
106
+ context.parse_expression('price * rate()')
107
+
108
+ context.dispose
109
+ ```
110
+
111
+ Past its timeout an evaluation raises `FEELIN::TimeoutError`, past its memory `FEELIN::MemoryError`.
112
+ Contexts are created from one snapshot of the bundle, so a new one does not load feelin again.
113
+
114
+ ### Errors
115
+
116
+ Whatever goes wrong inside V8 is raised as a `FEELIN::Error`, never as an error of MiniRacer. Its message
117
+ names the expression, which is also there as `error.expression`; `error.reason` is what went wrong without
118
+ it.
119
+
120
+ | error | when |
121
+ |---|---|
122
+ | `FEELIN::SyntaxError` | the expression does not parse |
123
+ | `FEELIN::TimeoutError` | the evaluation ran past the context's `timeout` |
124
+ | `FEELIN::MemoryError` | the evaluation ran past the context's `max_memory` |
125
+ | `FEELIN::Error` | anything else, and the parent of the three above |
126
+
127
+ ```ruby
128
+ begin
129
+ FEELIN.evaluate('1 +')
130
+ rescue FEELIN::SyntaxError => e
131
+ e.message # "1 +" is not a FEEL expression: Incomplete <ArithmeticExpression>
132
+ e.expression # 1 +
133
+ e.reason # Incomplete <ArithmeticExpression>
134
+ end
135
+ ```
136
+
137
+ An exception raised by a custom function is not wrapped: it reaches the caller as it was raised.
138
+
41
139
  ## Development
42
140
 
43
141
  The gem embeds the [feelin](https://github.com/nikku/feelin) JavaScript library, pre-bundled into a single file that is loaded into the V8 context at runtime. The JavaScript sources live in `lib/feelin/js`.
@@ -24,6 +24,7 @@ var feel = (() => {
24
24
  date: () => date,
25
25
  duration: () => duration,
26
26
  evaluate: () => evaluate,
27
+ json: () => json,
27
28
  parseExpression: () => parseExpression,
28
29
  parseUnaryTests: () => parseUnaryTests,
29
30
  unaryTest: () => unaryTest
@@ -11477,6 +11478,56 @@ var feel = (() => {
11477
11478
  });
11478
11479
 
11479
11480
  // node_modules/feelin/dist/index.js
11481
+ function parseParameterNames(fn2) {
11482
+ if (Array.isArray(fn2.$args)) {
11483
+ return fn2.$args;
11484
+ }
11485
+ const code = fn2.toString();
11486
+ const match2 = /^(?:[^(]*\s*)?\(([^)]+)?\)/.exec(code);
11487
+ if (!match2) {
11488
+ throw new Error("failed to parse params: " + code);
11489
+ }
11490
+ const [_, params] = match2;
11491
+ if (!params) {
11492
+ return [];
11493
+ }
11494
+ return params.split(",").map((p) => p.trim());
11495
+ }
11496
+ function notImplemented(thing) {
11497
+ return new Error(`not implemented: ${thing}`);
11498
+ }
11499
+ function isNotImplemented(err) {
11500
+ return /^not implemented/.test(err.message);
11501
+ }
11502
+ function has2(context, name2) {
11503
+ if (Object.prototype.hasOwnProperty.call(context, name2)) {
11504
+ return true;
11505
+ }
11506
+ for (let proto = Object.getPrototypeOf(context); proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
11507
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name2);
11508
+ if (descriptor) {
11509
+ return typeof descriptor.get === "function";
11510
+ }
11511
+ }
11512
+ return false;
11513
+ }
11514
+ function getFromContext(name2, context) {
11515
+ if (["nil", "boolean", "number", "string"].includes(getType(context))) {
11516
+ return void 0;
11517
+ }
11518
+ if (has2(context, name2)) {
11519
+ return context[name2];
11520
+ }
11521
+ const normalizedName = normalizeContextKey(name2);
11522
+ if (has2(context, normalizedName)) {
11523
+ return context[normalizedName];
11524
+ }
11525
+ const entry = Object.entries(context).find(([key]) => normalizedName === normalizeContextKey(key));
11526
+ if (entry) {
11527
+ return entry[1];
11528
+ }
11529
+ return void 0;
11530
+ }
11480
11531
  function isNil2(e) {
11481
11532
  return e === null || e === void 0;
11482
11533
  }
@@ -11620,7 +11671,7 @@ var feel = (() => {
11620
11671
  if (aEntries.length !== bEntries.length) {
11621
11672
  return false;
11622
11673
  }
11623
- return aEntries.every(([key, value]) => key in b && equals(value, b[key]));
11674
+ return aEntries.every(([key, value]) => has2(b, key) && equals(value, b[key]));
11624
11675
  }
11625
11676
  if (aType === "range") {
11626
11677
  return [
@@ -11672,44 +11723,6 @@ var feel = (() => {
11672
11723
  return this.fn.call(null, ...params);
11673
11724
  }
11674
11725
  };
11675
- function parseParameterNames(fn2) {
11676
- if (Array.isArray(fn2.$args)) {
11677
- return fn2.$args;
11678
- }
11679
- const code = fn2.toString();
11680
- const match2 = /^(?:[^(]*\s*)?\(([^)]+)?\)/.exec(code);
11681
- if (!match2) {
11682
- throw new Error("failed to parse params: " + code);
11683
- }
11684
- const [_, params] = match2;
11685
- if (!params) {
11686
- return [];
11687
- }
11688
- return params.split(",").map((p) => p.trim());
11689
- }
11690
- function notImplemented(thing) {
11691
- return new Error(`not implemented: ${thing}`);
11692
- }
11693
- function isNotImplemented(err) {
11694
- return /^not implemented/.test(err.message);
11695
- }
11696
- function getFromContext(name2, context) {
11697
- if (["nil", "boolean", "number", "string"].includes(getType(context))) {
11698
- return void 0;
11699
- }
11700
- if (name2 in context) {
11701
- return context[name2];
11702
- }
11703
- const normalizedName = normalizeContextKey(name2);
11704
- if (normalizedName in context) {
11705
- return context[normalizedName];
11706
- }
11707
- const entry = Object.entries(context).find(([key]) => normalizedName === normalizeContextKey(key));
11708
- if (entry) {
11709
- return entry[1];
11710
- }
11711
- return void 0;
11712
- }
11713
11726
  function duration(opts) {
11714
11727
  if (typeof opts === "number") {
11715
11728
  return Duration.fromMillis(opts);
@@ -12236,7 +12249,7 @@ var feel = (() => {
12236
12249
  if (key === null) {
12237
12250
  return FALSE;
12238
12251
  }
12239
- if (key in context2) {
12252
+ if (has2(context2, key)) {
12240
12253
  return FALSE;
12241
12254
  }
12242
12255
  return Object.assign(Object.assign({}, context2), { [entry.key]: entry.value });
@@ -13626,5 +13639,77 @@ var feel = (() => {
13626
13639
  }
13627
13640
  return String(value);
13628
13641
  }
13642
+
13643
+ // entry.js
13644
+ function syntaxError(input, node) {
13645
+ const parent = node.parent;
13646
+ let message, position = { from: node.from, to: node.to };
13647
+ if (node.from !== node.to) {
13648
+ message = `Unrecognized token in <${parent.name}>`;
13649
+ } else {
13650
+ let next = null;
13651
+ for (let current = node; current && !next; current = current.parent) {
13652
+ next = current.nextSibling;
13653
+ }
13654
+ if (next) {
13655
+ message = `Unrecognized token <${next.name}> in <${parent.name}>`;
13656
+ position = { from: next.from, to: next.to };
13657
+ } else {
13658
+ message = `Incomplete <${(parent.enterUnfinishedNodesBefore(node.to) || parent).name}>`;
13659
+ }
13660
+ }
13661
+ return new SyntaxError2(message, { input: input.slice(position.from, position.to), position });
13662
+ }
13663
+ function toAst(tree, input) {
13664
+ const build = (node) => {
13665
+ if (node.type.isError) {
13666
+ throw syntaxError(input, node);
13667
+ }
13668
+ const children = [];
13669
+ for (let child = node.firstChild; child; child = child.nextSibling) {
13670
+ children.push(build(child));
13671
+ }
13672
+ return { type: node.name, from: node.from, to: node.to, text: input.slice(node.from, node.to), children };
13673
+ };
13674
+ return build(tree.topNode);
13675
+ }
13676
+ function replacer(key, value) {
13677
+ const raw = this[key];
13678
+ if (raw && (raw.isLuxonDateTime || raw.isLuxonDuration)) {
13679
+ return evaluate("string(v)", { v: raw }).value;
13680
+ }
13681
+ return value;
13682
+ }
13683
+ var toJson = (value) => JSON.stringify(value === void 0 ? null : value, replacer);
13684
+ var functions = {};
13685
+ var hasFunctions = false;
13686
+ function readContext(contextJson) {
13687
+ const context = (contextJson == null ? null : JSON.parse(contextJson)) || {};
13688
+ return hasFunctions ? Object.assign(context, functions) : context;
13689
+ }
13690
+ function guarded(fn2) {
13691
+ return (...args) => {
13692
+ try {
13693
+ return fn2(...args);
13694
+ } catch (error) {
13695
+ if (error instanceof SyntaxError2) {
13696
+ error.name = "FeelSyntaxError";
13697
+ }
13698
+ throw error;
13699
+ }
13700
+ };
13701
+ }
13702
+ var json = {
13703
+ evaluate: guarded((expression, contextJson) => toJson(evaluate(expression, readContext(contextJson)).value)),
13704
+ unaryTest: guarded((expression, contextJson) => toJson(unaryTest(expression, readContext(contextJson)).value)),
13705
+ parseExpression: guarded((expression, contextJson) => JSON.stringify(toAst(parseExpression(expression, readContext(contextJson)), expression))),
13706
+ parseUnaryTests: guarded((expression, contextJson) => JSON.stringify(toAst(parseUnaryTests(expression, readContext(contextJson)), expression))),
13707
+ addFunction: (name2) => {
13708
+ functions[name2] = function(...args) {
13709
+ return globalThis[name2](...JSON.parse(toJson(args)));
13710
+ };
13711
+ hasFunctions = true;
13712
+ }
13713
+ };
13629
13714
  return __toCommonJS(entry_exports);
13630
13715
  })();
@@ -1 +1,112 @@
1
+ import { evaluate, unaryTest, parseExpression, parseUnaryTests, SyntaxError } from 'feelin';
2
+
1
3
  export * from 'feelin';
4
+
5
+ // The JSON boundary of the Ruby wrapper: everything below takes its context as a JSON string and answers
6
+ // with one, so no value is marshalled between Ruby and V8 element by element, and no script is compiled
7
+ // per call — a call is a function call, whatever the data.
8
+
9
+ // feelin keeps its own syntax error reporting private, and parseExpression / parseUnaryTests answer with a
10
+ // tree that has error nodes in it instead of throwing. Same messages and positions as evaluate() gives.
11
+ function syntaxError(input, node) {
12
+ const parent = node.parent;
13
+
14
+ let message, position = { from: node.from, to: node.to };
15
+
16
+ if (node.from !== node.to) {
17
+ message = `Unrecognized token in <${parent.name}>`;
18
+ } else {
19
+ let next = null;
20
+
21
+ for (let current = node; current && !next; current = current.parent) {
22
+ next = current.nextSibling;
23
+ }
24
+
25
+ if (next) {
26
+ message = `Unrecognized token <${next.name}> in <${parent.name}>`;
27
+ position = { from: next.from, to: next.to };
28
+ } else {
29
+ message = `Incomplete <${(parent.enterUnfinishedNodesBefore(node.to) || parent).name}>`;
30
+ }
31
+ }
32
+
33
+ return new SyntaxError(message, { input: input.slice(position.from, position.to), position });
34
+ }
35
+
36
+ // The lezer tree as plain data, every node of it, tokens included: `type` is the grammar's name for the
37
+ // node, `from` / `to` its span in the input (in UTF-16 code units, as JavaScript counts), `text` that span.
38
+ function toAst(tree, input) {
39
+ const build = (node) => {
40
+ if (node.type.isError) {
41
+ throw syntaxError(input, node);
42
+ }
43
+
44
+ const children = [];
45
+
46
+ for (let child = node.firstChild; child; child = child.nextSibling) {
47
+ children.push(build(child));
48
+ }
49
+
50
+ return { type: node.name, from: node.from, to: node.to, text: input.slice(node.from, node.to), children };
51
+ };
52
+
53
+ return build(tree.topNode);
54
+ }
55
+
56
+ // A FEEL date, time or duration is a luxon object, and what luxon gives it in JSON is not FEEL's form (a
57
+ // date becomes a date-time at midnight, a time gets a date of 1900-01-01 and the zone of the machine). So
58
+ // it is replaced by FEEL's own string of it — what `string(value)` answers, ISO 8601. JSON.stringify hands
59
+ // a replacer the value AFTER its toJSON, hence the look at the holder.
60
+ function replacer(key, value) {
61
+ const raw = this[key];
62
+
63
+ if (raw && (raw.isLuxonDateTime || raw.isLuxonDuration)) {
64
+ return evaluate('string(v)', { v: raw }).value;
65
+ }
66
+
67
+ return value;
68
+ }
69
+
70
+ const toJson = (value) => JSON.stringify(value === undefined ? null : value, replacer);
71
+
72
+ // Custom functions, by name. Each forwards to the global the Ruby side attached under that name; the
73
+ // `(...args)` forwarder is deliberate: feelin reads a function's source to learn its parameters, so a bare
74
+ // reference to the (native) attached global would be seen as taking no arguments.
75
+ //
76
+ // The arguments go to Ruby in the form a result does — through the same JSON, so a date, time or duration
77
+ // among them, however deep, is its ISO 8601 string there too and not the fields of a luxon object.
78
+ const functions = {};
79
+ let hasFunctions = false;
80
+
81
+ function readContext(contextJson) {
82
+ const context = (contextJson == null ? null : JSON.parse(contextJson)) || {};
83
+
84
+ return hasFunctions ? Object.assign(context, functions) : context;
85
+ }
86
+
87
+ // A syntax error leaves under a name of its own: across the V8 boundary an error is only its name and its
88
+ // message, and feelin's SyntaxError keeps the name `Error`.
89
+ function guarded(fn) {
90
+ return (...args) => {
91
+ try {
92
+ return fn(...args);
93
+ } catch (error) {
94
+ if (error instanceof SyntaxError) {
95
+ error.name = 'FeelSyntaxError';
96
+ }
97
+
98
+ throw error;
99
+ }
100
+ };
101
+ }
102
+
103
+ export const json = {
104
+ evaluate: guarded((expression, contextJson) => toJson(evaluate(expression, readContext(contextJson)).value)),
105
+ unaryTest: guarded((expression, contextJson) => toJson(unaryTest(expression, readContext(contextJson)).value)),
106
+ parseExpression: guarded((expression, contextJson) => JSON.stringify(toAst(parseExpression(expression, readContext(contextJson)), expression))),
107
+ parseUnaryTests: guarded((expression, contextJson) => JSON.stringify(toAst(parseUnaryTests(expression, readContext(contextJson)), expression))),
108
+ addFunction: (name) => {
109
+ functions[name] = function(...args) { return globalThis[name](...JSON.parse(toJson(args))); };
110
+ hasFunctions = true;
111
+ }
112
+ };
@@ -5,7 +5,7 @@
5
5
  "packages": {
6
6
  "": {
7
7
  "dependencies": {
8
- "feelin": "^7.0.1"
8
+ "feelin": "^7.0.2"
9
9
  },
10
10
  "devDependencies": {
11
11
  "esbuild": "^0.28.1"
@@ -520,9 +520,9 @@
520
520
  }
521
521
  },
522
522
  "node_modules/feelin": {
523
- "version": "7.0.1",
524
- "resolved": "https://registry.npmjs.org/feelin/-/feelin-7.0.1.tgz",
525
- "integrity": "sha512-P7jSNHxMfDMW7zojApEUClEOugv/J7UZEVvSG4Cuyzwxh1QiB1XXgBREkEEamdy3igHHzm+20lv0Mt3pws3teA==",
523
+ "version": "7.0.2",
524
+ "resolved": "https://registry.npmjs.org/feelin/-/feelin-7.0.2.tgz",
525
+ "integrity": "sha512-+3L+9P+bdDowtzlLSvTQPiv47dDXWVW0gaF4WBOFb3jNTTGremw+KOXpteO4r/a8Uw26uNvVTtPhWMQMbM6irg==",
526
526
  "license": "MIT",
527
527
  "dependencies": {
528
528
  "@lezer/common": "^1.5.2",
@@ -558,9 +558,9 @@
558
558
  }
559
559
  },
560
560
  "node_modules/min-dash": {
561
- "version": "5.0.0",
562
- "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-5.0.0.tgz",
563
- "integrity": "sha512-EGuoBnVL7/Fnv2sqakpX5WGmZehZ3YMmLayT7sM8E9DRU74kkeyMg4Rik1lsOkR2GbFNeBca4/L+UfU6gF0Edw==",
561
+ "version": "5.1.0",
562
+ "resolved": "https://registry.npmjs.org/min-dash/-/min-dash-5.1.0.tgz",
563
+ "integrity": "sha512-HAvN6XzmCQj+js43G9sJRRw8kj/7DvoxN7qhoIN9Xo5ZN1NMljtpyKs/NunXmw0QSWwlgZzNv6tLeEa4xaZ0tg==",
564
564
  "license": "MIT"
565
565
  }
566
566
  }
@@ -4,7 +4,7 @@
4
4
  "build": "esbuild entry.js --bundle --format=iife --global-name=feel --platform=browser --outfile=dist/feelin.js"
5
5
  },
6
6
  "dependencies": {
7
- "feelin": "^7.0.1"
7
+ "feelin": "^7.0.2"
8
8
  },
9
9
  "devDependencies": {
10
10
  "esbuild": "^0.28.1"
@@ -1,3 +1,3 @@
1
1
  module FEELIN
2
- VERSION = "7.0.1"
2
+ VERSION = "7.0.3"
3
3
  end
data/lib/feelin.rb CHANGED
@@ -4,61 +4,144 @@ require "json"
4
4
  module FEELIN
5
5
  # Pre-built single-file IIFE bundle (feelin + its dependencies), produced by
6
6
  # `npm run build` in lib/feelin/js. It exposes the feelin API on the global
7
- # `feel`. feelin is distributed as an ES module, so it is bundled ahead of time
8
- # rather than assembled at load time.
7
+ # `feel`, and under `feel.json` the functions this wrapper calls. feelin is
8
+ # distributed as an ES module, so it is bundled ahead of time rather than
9
+ # assembled at load time.
9
10
  BUNDLE_PATH = File.expand_path("feelin/js/dist/feelin.js", __dir__)
10
11
 
11
- @@functions = Set.new
12
+ # What goes wrong inside V8 is raised as one of these, never as a MiniRacer
13
+ # error: the message names the expression, which is also kept in `expression`,
14
+ # and `reason` is what went wrong without it. An exception raised by a custom
15
+ # function is not one of them and passes as it is.
16
+ class Error < StandardError
17
+ attr_reader :expression, :reason
12
18
 
13
- # NOTE: the context is serialized to JSON and interpolated into the eval, rather
14
- # than passed as a native Ruby object via MiniRacer#call. That was measured to be
15
- # ~15-20% FASTER: JSON.generate (C) plus V8's highly-optimized parsing beats
16
- # mini_racer's element-by-element Ruby->V8 marshalling, and it lets custom
17
- # functions be injected straight into the context (no per-call merge).
18
- def self.evaluate(expression, context = nil)
19
- context_json = serialize_context(context)
20
-
21
- unwrap(@@context.eval("feel.evaluate(#{JSON.generate(expression)}, #{context_json})"))
19
+ def initialize(message, expression = nil, reason = message)
20
+ super(message)
21
+ @expression = expression
22
+ @reason = reason
23
+ end
22
24
  end
23
25
 
24
- def self.unary_test(expression, value, context = {})
25
- context_json = serialize_context({ **context, '?' => value })
26
+ # the expression does not parse
27
+ class SyntaxError < Error; end
26
28
 
27
- unwrap(@@context.eval("feel.unaryTest(#{JSON.generate(expression)}, #{context_json})"))
28
- end
29
+ # the evaluation ran past the context's `timeout`
30
+ class TimeoutError < Error; end
29
31
 
30
- def self.add_function(name, proc)
31
- @@functions.add(name)
32
- @@context.attach(name, proc)
33
- end
32
+ # the evaluation ran past the context's `max_memory`
33
+ class MemoryError < Error; end
34
+
35
+ # A V8 context with feelin loaded. The module-level methods below work on one
36
+ # shared context without limits; a context of one's own is for an expression
37
+ # that is not trusted to end or to stay small — `timeout` (ms) and `max_memory`
38
+ # (bytes) are its limits, and going over one raises TimeoutError or
39
+ # MemoryError — and for custom functions that the shared context should not
40
+ # see.
41
+ #
42
+ # Every context starts from one snapshot of the bundle, so creating one does
43
+ # not load and compile feelin again.
44
+ #
45
+ # The context goes into V8 as a JSON string and the result comes back as one
46
+ # (`feel.json.*` in js/entry.js). That keeps a call a function call with
47
+ # arguments: the data is never written into a script of its own, which V8 would
48
+ # compile and keep for every distinct context. And it is how a date, time or
49
+ # duration arrives as its ISO 8601 string, the form FEEL's `string()` gives
50
+ # it, instead of the internal fields of the object V8 holds.
51
+ class Context
52
+ class << self
53
+ def snapshot
54
+ @snapshot ||= MiniRacer::Snapshot.new(File.read(BUNDLE_PATH))
55
+ end
56
+ end
57
+
58
+ def initialize(timeout: nil, max_memory: nil)
59
+ @timeout = timeout
60
+ limits = { timeout: timeout, max_memory: max_memory }.compact
61
+ @context = MiniRacer::Context.new(snapshot: self.class.snapshot, **limits)
62
+ end
63
+
64
+ def evaluate(expression, context = nil)
65
+ call("evaluate", expression, context)
66
+ end
67
+
68
+ def unary_test(expression, value, context = {})
69
+ call("unaryTest", expression, { **context, '?' => value })
70
+ end
71
+
72
+ # The syntax tree of an expression, without evaluating it: nested hashes of
73
+ # `type` (the grammar's node name), `from` / `to` (the span in the expression,
74
+ # counted in UTF-16 code units), `text` (that span) and `children`. Tokens are
75
+ # nodes too. The context only supplies the names of the variables, which is
76
+ # what lets a name with spaces in it be read as one. An expression that does
77
+ # not parse raises SyntaxError, as it does in `evaluate`.
78
+ def parse_expression(expression, context = nil)
79
+ call("parseExpression", expression, context)
80
+ end
81
+
82
+ def parse_unary_tests(expression, context = nil)
83
+ call("parseUnaryTests", expression, context)
84
+ end
85
+
86
+ def add_function(name, proc)
87
+ @context.attach(name, proc)
88
+ @context.call("feel.json.addFunction", name)
89
+ end
90
+
91
+ def dispose
92
+ @context.dispose
93
+ end
34
94
 
35
- private
95
+ private
36
96
 
37
- # feelin (>= 7) returns an EvaluationResult `{ value, warnings }`; callers expect
38
- # the bare value.
39
- def self.unwrap(result)
40
- result.is_a?(Hash) && result.key?("warnings") ? result["value"] : result
97
+ SYNTAX_ERROR = /\AFeelSyntaxError: /
98
+
99
+ def call(function, expression, context)
100
+ JSON.parse(@context.call("feel.json.#{function}", expression, context.nil? ? nil : JSON.generate(context)))
101
+ rescue MiniRacer::ScriptTerminatedError
102
+ raise failure(TimeoutError, expression, "took longer than #{@timeout} ms", "")
103
+ rescue MiniRacer::V8OutOfMemoryError
104
+ raise failure(MemoryError, expression, "ran out of memory", "")
105
+ rescue MiniRacer::Error => e
106
+ reason = e.message.lines.first.to_s.strip
107
+
108
+ raise failure(SyntaxError, expression, reason.sub(SYNTAX_ERROR, ''), " is not a FEEL expression:") if reason.match?(SYNTAX_ERROR)
109
+
110
+ raise failure(Error, expression, reason.sub(/\AError: /, ''), ":")
111
+ end
112
+
113
+ def failure(error, expression, reason, link)
114
+ error.new("#{expression.inspect}#{link} #{reason}", expression, reason)
115
+ end
41
116
  end
42
117
 
43
- # Builds the JS evaluation context. When custom functions are registered they
44
- # must be injected into every context as forwarders to their attached globals.
45
- # Each function is emitted as a QUOTED key forwarding to globalThis[name] so
46
- # that names containing spaces (e.g. "string join") are valid JS; an empty
47
- # context is coerced to nil to avoid producing an invalid "{,...}" literal. The
48
- # `(...args)` forwarder is deliberate: feelin reads a function's source to learn
49
- # its parameters, so a bare reference to the (native) attached global would be
50
- # seen as taking no arguments.
51
- def self.serialize_context(context)
52
- return JSON.generate(context) if @@functions.empty?
53
-
54
- context = nil if context.respond_to?(:empty?) && context.empty?
55
- functions_json = @@functions.map do |name|
56
- %("#{name}":function(...args){return globalThis["#{name}"](...args)})
57
- end.join(",")
58
-
59
- context.nil? ? "{#{functions_json}}" : "#{JSON.generate(context)[0...-1]},#{functions_json}}"
118
+ class << self
119
+ def evaluate(expression, context = nil)
120
+ shared.evaluate(expression, context)
121
+ end
122
+
123
+ def unary_test(expression, value, context = {})
124
+ shared.unary_test(expression, value, context)
125
+ end
126
+
127
+ def parse_expression(expression, context = nil)
128
+ shared.parse_expression(expression, context)
129
+ end
130
+
131
+ def parse_unary_tests(expression, context = nil)
132
+ shared.parse_unary_tests(expression, context)
133
+ end
134
+
135
+ def add_function(name, proc)
136
+ shared.add_function(name, proc)
137
+ end
138
+
139
+ private
140
+
141
+ def shared
142
+ @shared
143
+ end
60
144
  end
61
145
 
62
- @@context = MiniRacer::Context.new
63
- @@context.eval(File.read(BUNDLE_PATH))
146
+ @shared = Context.new
64
147
  end
@@ -36,6 +36,174 @@ RSpec.describe FEELIN do
36
36
  end
37
37
  end
38
38
 
39
+ describe "custom function arguments" do
40
+ it "passes a date, a time and a duration as their ISO 8601 strings" do
41
+ seen = nil
42
+ FEELIN.add_function('seen', proc { |*args| seen = args; nil })
43
+
44
+ FEELIN.evaluate('seen(date("2020-01-02"), time("10:00:00+03:00"), date and time("2020-01-02T03:04:05Z"), duration("P1DT2H"))')
45
+
46
+ expect(seen).to eq [ '2020-01-02', '10:00:00+03:00', '2020-01-02T03:04:05Z', 'P1DT2H' ]
47
+ end
48
+
49
+ it "does so inside a list and a context, and leaves the rest as it is" do
50
+ seen = nil
51
+ FEELIN.add_function('seen', proc { |*args| seen = args; nil })
52
+
53
+ FEELIN.evaluate('seen([ @"2020-01-02" ], { on: @"2020-01-03", n: 1.5 }, "text", null, true)')
54
+
55
+ expect(seen).to eq [ [ '2020-01-02' ], { 'on' => '2020-01-03', 'n' => 1.5 }, 'text', nil, true ]
56
+ end
57
+ end
58
+
59
+ describe "temporal results" do
60
+ it "answers a date, a time and a duration in their ISO 8601 form" do
61
+ expect(FEELIN.evaluate('date("2020-01-02")')).to eq '2020-01-02'
62
+ expect(FEELIN.evaluate('time("10:00:00+03:00")')).to eq '10:00:00+03:00'
63
+ expect(FEELIN.evaluate('date and time("2020-01-02T03:04:05Z")')).to eq '2020-01-02T03:04:05Z'
64
+ expect(FEELIN.evaluate('duration("P1DT2H")')).to eq 'P1DT2H'
65
+ end
66
+
67
+ it "does so inside a list and a context too" do
68
+ expect(FEELIN.evaluate('{ on: date(day) + duration("P1D"), all: [ @"2020-01-02" ] }', { 'day' => '2020-05-06' }))
69
+ .to eq('on' => '2020-05-07', 'all' => [ '2020-01-02' ])
70
+ end
71
+
72
+ it "answers null for what has no value" do
73
+ expect(FEELIN.evaluate('missing')).to be_nil
74
+ expect(FEELIN.evaluate('{ a: missing }')).to eq('a' => nil)
75
+ end
76
+ end
77
+
78
+ describe FEELIN::Context do
79
+ it "evaluates in a context of its own, with its own functions" do
80
+ context = FEELIN::Context.new
81
+ context.add_function('only here', proc { 7 })
82
+
83
+ expect(context.evaluate('only here() + n', { 'n' => 1 })).to eq 8
84
+ expect(FEELIN.evaluate('only here()')).to be_nil
85
+ ensure
86
+ context&.dispose
87
+ end
88
+
89
+ it "stops an expression that runs past its timeout" do
90
+ context = FEELIN::Context.new(timeout: 50)
91
+
92
+ expect { context.evaluate('count(for a in 1..3000, b in 1..3000 return a * b)') }
93
+ .to raise_error(FEELIN::TimeoutError, /took longer than 50 ms/)
94
+ ensure
95
+ context&.dispose
96
+ end
97
+
98
+ # the context reaches V8 as an argument, not as the text of a script: a script
99
+ # per distinct context is compiled and kept, and ran a limited context out of
100
+ # memory
101
+ it "evaluates over context after context without its memory growing" do
102
+ context = FEELIN::Context.new(max_memory: 8_000_000)
103
+
104
+ results = Array.new(10_000) { |index| context.evaluate('"L" + string(old.n)', { 'old' => { 'n' => index } }) }
105
+
106
+ expect(results.values_at(0, 9_999)).to eq %w[L0 L9999]
107
+ ensure
108
+ context&.dispose
109
+ end
110
+ end
111
+
112
+ describe "errors" do
113
+ it "raises a syntax error that names the expression" do
114
+ expect { FEELIN.evaluate('1 +') }.to raise_error(FEELIN::SyntaxError, '"1 +" is not a FEEL expression: Incomplete <ArithmeticExpression>') do |error|
115
+ expect(error.expression).to eq '1 +'
116
+ expect(error.reason).to eq 'Incomplete <ArithmeticExpression>'
117
+ end
118
+ expect { FEELIN.unary_test('[1..', 1) }.to raise_error(FEELIN::SyntaxError)
119
+ end
120
+
121
+ it "raises nothing of MiniRacer's" do
122
+ expect { FEELIN.evaluate('1 +') }.to raise_error(FEELIN::Error) { |error| expect(error).not_to be_a(MiniRacer::Error) }
123
+ end
124
+
125
+ it "raises a memory error past the context's limit" do
126
+ context = FEELIN::Context.new(max_memory: 4_000_000)
127
+
128
+ expect { context.evaluate('for a in 1..300000 return { n: a, s: string(a) }') }.to raise_error(FEELIN::MemoryError, /ran out of memory/)
129
+ ensure
130
+ context&.dispose
131
+ end
132
+
133
+ it "lets an exception of a custom function through as it is" do
134
+ FEELIN.add_function('fails', proc { raise ArgumentError, 'no' })
135
+
136
+ expect { FEELIN.evaluate('fails()') }.to raise_error(ArgumentError, 'no')
137
+ end
138
+ end
139
+
140
+ describe "#parse_expression" do
141
+ # the types of a tree, nested as the tree is, tokens left out
142
+ def shape(node)
143
+ children = node['children'].reject { |child| child['children'].empty? && child['type'] !~ /\A[A-Z]/ }
144
+ children.empty? ? node['type'] : { node['type'] => children.map { |child| shape(child) } }
145
+ end
146
+
147
+ it "answers the syntax tree without evaluating the expression" do
148
+ tree = FEELIN.parse_expression('a.b + 1 > 2')
149
+
150
+ expect(shape(tree)).to eq(
151
+ 'Expression' => [ {
152
+ 'Comparison' => [
153
+ { 'ArithmeticExpression' => [
154
+ { 'PathExpression' => [ { 'VariableName' => [ 'Identifier' ] }, { 'PathName' => [ 'Identifier' ] } ] },
155
+ 'ArithOp',
156
+ 'NumericLiteral'
157
+ ] },
158
+ 'CompareOp',
159
+ 'NumericLiteral'
160
+ ]
161
+ } ]
162
+ )
163
+ end
164
+
165
+ it "gives every node its span and its text" do
166
+ comparison = FEELIN.parse_expression('a.b + 1 > 2')['children'].first
167
+
168
+ expect(comparison.slice('type', 'from', 'to', 'text')).to eq('type' => 'Comparison', 'from' => 0, 'to' => 11, 'text' => 'a.b + 1 > 2')
169
+ expect(comparison['children'].map { |child| child['text'] }).to eq [ 'a.b + 1', '>', '2' ]
170
+ end
171
+
172
+ it "reads a name with spaces as one name when the context has it" do
173
+ tree = FEELIN.parse_expression("Mike's daughter.name + 1", { "Mike's daughter.name" => 'Lisa' })
174
+ name = tree['children'].first['children'].first
175
+
176
+ expect(name.slice('type', 'text')).to eq('type' => 'VariableName', 'text' => "Mike's daughter.name")
177
+ end
178
+
179
+ it "raises on an expression that does not parse" do
180
+ expect { FEELIN.parse_expression('1 +') }.to raise_error(FEELIN::SyntaxError, /Incomplete <ArithmeticExpression>/)
181
+ expect { FEELIN.parse_expression('1 + ) 2') }.to raise_error(FEELIN::SyntaxError, /Unrecognized token/)
182
+ end
183
+
184
+ it "reports a syntax error as evaluate reports it" do
185
+ [ '1 +', '{ a: 1', 'if x then', '1 + ) 2', '1 2', '[1, 2' ].each do |expression|
186
+ expected = begin
187
+ FEELIN.evaluate(expression)
188
+ rescue FEELIN::SyntaxError => e
189
+ e.message
190
+ end
191
+
192
+ expect { FEELIN.parse_expression(expression) }.to raise_error(FEELIN::SyntaxError, expected), expression
193
+ end
194
+ end
195
+ end
196
+
197
+ describe "#parse_unary_tests" do
198
+ it "answers the syntax tree of unary tests" do
199
+ tree = FEELIN.parse_unary_tests('[1..end], > 5')
200
+ tests = tree['children'].first['children'].select { |child| child['type'] == 'PositiveUnaryTest' }
201
+
202
+ expect(tree['type']).to eq 'UnaryTests'
203
+ expect(tests.map { |test| test['text'] }).to eq [ '[1..end]', '> 5' ]
204
+ end
205
+ end
206
+
39
207
  describe "#unary_test" do
40
208
  context "without context" do
41
209
  it "should not fail" do
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feelin
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.1
4
+ version: 7.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Arkhipov
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: mini_racer
@@ -82,7 +81,6 @@ metadata:
82
81
  homepage_uri: https://github.com/ekzo-dev/ruby-feelin
83
82
  source_code_uri: https://github.com/ekzo-dev/ruby-feelin
84
83
  changelog_uri: https://github.com/ekzo-dev/ruby-feelin/CHANGELOG.md
85
- post_install_message:
86
84
  rdoc_options: []
87
85
  require_paths:
88
86
  - lib
@@ -97,8 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
97
95
  - !ruby/object:Gem::Version
98
96
  version: '0'
99
97
  requirements: []
100
- rubygems_version: 3.5.16
101
- signing_key:
98
+ rubygems_version: 3.6.9
102
99
  specification_version: 4
103
100
  summary: feelin Ruby wrapper
104
101
  test_files: []