feelin 7.0.1 → 7.0.2

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: 3a2aab7f50ce55a02f7c45876a113972cf952c23ab505a2a95f527c213015591
4
+ data.tar.gz: 68ffbc5239c2f1387ea5982e6af9b7c9fc619a93ab43e42c2750a981cfb6813c
5
5
  SHA512:
6
- metadata.gz: 3a1ddc86be9fadbb2043bb23b881b198c81962635276324ad4950c605078f128d3bc5e96f7cca4031633aca42f5084a042e7b20f696e86878117daf3e6d707e2
7
- data.tar.gz: de182a5cdeb48279e195090b9f93c651fef336b8db7dd6b176e721e32edbd9ea6fd53d31037d789227ec0bf09ed541acf0e60e7a5931801edd25e14676c6b775
6
+ metadata.gz: da2fea8b67b029c34710ee51f1c0e3f491eb974e2270a83eaed1ac6d88935148882e56bbde7a7709e4543ffada442181f3b02ed14d407b9c0671087d59e46f95
7
+ data.tar.gz: 3cf684bb66042782c7415b16d2cca27608ed45f2dc9eb2a83b6e25a4cfab1806cbd1c4c04148417651060164137491d654e67e21361ba79e4ca5ba47ff7d608c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ 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.2] - 2026-10-05
9
+
10
+ ### Added
11
+
12
+ * `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.
13
+ * `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.
14
+
15
+ ### Changed
16
+
17
+ * Updated the bundled `feelin` JavaScript library to 7.0.2 (prototype access from FEEL expressions is prevented).
18
+ * **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`.
19
+ * 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.
20
+
21
+ ### Not updated
22
+
23
+ * `feelin` 8.2.0 was tried and held back: evaluating an expression that names a variable takes about twice as long there (8.2.0 looks every variable name up among the built-ins on each `evaluate`), and it produces enough garbage to run a context with `max_memory: 64_000_000` out of memory after ~10 000 evaluations.
24
+
8
25
  ## [7.0.1] - 2026-06-22
9
26
 
10
27
  ### 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` 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('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("Mike's daughter.name + 1", { "Mike's daughter.name" => nil })
72
+ ```
73
+
74
+ An expression that does not parse raises `MiniRacer::RuntimeError`, with the same message `evaluate` gives.
75
+
34
76
  ### Custom functions
35
77
 
36
78
  ```ruby
@@ -38,6 +80,26 @@ FEELIN.add_function('rates', proc { [10, 20] })
38
80
  FEELIN.evaluate('every rate in rates() satisfies rate < 10') # false
39
81
  ```
40
82
 
83
+ ### A context of one's own
84
+
85
+ The methods above work on one shared V8 context, which has no limits. `FEELIN::Context` is a separate
86
+ one with the same methods — for an expression that is not trusted to end or to stay small, or for custom
87
+ functions the rest of the process should not see.
88
+
89
+ ```ruby
90
+ context = FEELIN::Context.new(timeout: 1_000, max_memory: 64_000_000) # ms, bytes; both optional
91
+
92
+ context.add_function('rate', proc { 0.2 })
93
+ context.evaluate('price * rate()', { 'price' => 100 }) # 20
94
+ context.parse('price * rate()')
95
+
96
+ context.dispose
97
+ ```
98
+
99
+ Past its timeout an evaluation raises `MiniRacer::ScriptTerminatedError`, past its memory
100
+ `MiniRacer::V8OutOfMemoryError`. Contexts are created from one snapshot of the bundle, so a new one does
101
+ not load feelin again.
102
+
41
103
  ## Development
42
104
 
43
105
  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,65 @@ 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
+ var json = {
13691
+ evaluate: (expression, contextJson) => toJson(evaluate(expression, readContext(contextJson)).value),
13692
+ unaryTest: (expression, contextJson) => toJson(unaryTest(expression, readContext(contextJson)).value),
13693
+ parseExpression: (expression, contextJson) => JSON.stringify(toAst(parseExpression(expression, readContext(contextJson)), expression)),
13694
+ parseUnaryTests: (expression, contextJson) => JSON.stringify(toAst(parseUnaryTests(expression, readContext(contextJson)), expression)),
13695
+ addFunction: (name2) => {
13696
+ functions[name2] = function(...args) {
13697
+ return globalThis[name2](...args);
13698
+ };
13699
+ hasFunctions = true;
13700
+ }
13701
+ };
13629
13702
  return __toCommonJS(entry_exports);
13630
13703
  })();
@@ -1 +1,93 @@
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
+ const functions = {};
76
+ let hasFunctions = false;
77
+
78
+ function readContext(contextJson) {
79
+ const context = (contextJson == null ? null : JSON.parse(contextJson)) || {};
80
+
81
+ return hasFunctions ? Object.assign(context, functions) : context;
82
+ }
83
+
84
+ export const json = {
85
+ evaluate: (expression, contextJson) => toJson(evaluate(expression, readContext(contextJson)).value),
86
+ unaryTest: (expression, contextJson) => toJson(unaryTest(expression, readContext(contextJson)).value),
87
+ parseExpression: (expression, contextJson) => JSON.stringify(toAst(parseExpression(expression, readContext(contextJson)), expression)),
88
+ parseUnaryTests: (expression, contextJson) => JSON.stringify(toAst(parseUnaryTests(expression, readContext(contextJson)), expression)),
89
+ addFunction: (name) => {
90
+ functions[name] = function(...args) { return globalThis[name](...args); };
91
+ hasFunctions = true;
92
+ }
93
+ };
@@ -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.2"
3
3
  end
data/lib/feelin.rb CHANGED
@@ -4,61 +4,104 @@ 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
+ # A V8 context with feelin loaded. The module-level methods below work on one
13
+ # shared context without limits; a context of one's own is for an expression
14
+ # that is not trusted to end or to stay small — `timeout` (ms) and `max_memory`
15
+ # (bytes) are MiniRacer's limits, and going over one raises
16
+ # MiniRacer::ScriptTerminatedError or MiniRacer::V8OutOfMemoryError — and for
17
+ # custom functions that the shared context should not see.
18
+ #
19
+ # Every context starts from one snapshot of the bundle, so creating one does
20
+ # not load and compile feelin again.
21
+ #
22
+ # The context goes into V8 as a JSON string and the result comes back as one
23
+ # (`feel.json.*` in js/entry.js). That keeps a call a function call with
24
+ # arguments: the data is never written into a script of its own, which V8 would
25
+ # compile and keep for every distinct context. And it is how a date, time or
26
+ # duration arrives as its ISO 8601 string, the form FEEL's `string()` gives
27
+ # it, instead of the internal fields of the object V8 holds.
28
+ class Context
29
+ class << self
30
+ def snapshot
31
+ @snapshot ||= MiniRacer::Snapshot.new(File.read(BUNDLE_PATH))
32
+ end
33
+ end
12
34
 
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)
35
+ def initialize(timeout: nil, max_memory: nil)
36
+ limits = { timeout: timeout, max_memory: max_memory }.compact
37
+ @context = MiniRacer::Context.new(snapshot: self.class.snapshot, **limits)
38
+ end
20
39
 
21
- unwrap(@@context.eval("feel.evaluate(#{JSON.generate(expression)}, #{context_json})"))
22
- end
40
+ def evaluate(expression, context = nil)
41
+ call("evaluate", expression, context)
42
+ end
23
43
 
24
- def self.unary_test(expression, value, context = {})
25
- context_json = serialize_context({ **context, '?' => value })
44
+ def unary_test(expression, value, context = {})
45
+ call("unaryTest", expression, { **context, '?' => value })
46
+ end
26
47
 
27
- unwrap(@@context.eval("feel.unaryTest(#{JSON.generate(expression)}, #{context_json})"))
28
- end
48
+ # The syntax tree of an expression, without evaluating it: nested hashes of
49
+ # `type` (the grammar's node name), `from` / `to` (the span in the expression,
50
+ # counted in UTF-16 code units), `text` (that span) and `children`. Tokens are
51
+ # nodes too. The context only supplies the names of the variables, which is
52
+ # what lets a name with spaces in it be read as one. An expression that does
53
+ # not parse raises, as it does in `evaluate`.
54
+ def parse(expression, context = nil)
55
+ call("parseExpression", expression, context)
56
+ end
29
57
 
30
- def self.add_function(name, proc)
31
- @@functions.add(name)
32
- @@context.attach(name, proc)
33
- end
58
+ def parse_unary_tests(expression, context = nil)
59
+ call("parseUnaryTests", expression, context)
60
+ end
34
61
 
35
- private
62
+ def add_function(name, proc)
63
+ @context.attach(name, proc)
64
+ @context.call("feel.json.addFunction", name)
65
+ end
36
66
 
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
67
+ def dispose
68
+ @context.dispose
69
+ end
70
+
71
+ private
72
+
73
+ def call(function, expression, context)
74
+ JSON.parse(@context.call("feel.json.#{function}", expression, context.nil? ? nil : JSON.generate(context)))
75
+ end
41
76
  end
42
77
 
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}}"
78
+ class << self
79
+ def evaluate(expression, context = nil)
80
+ shared.evaluate(expression, context)
81
+ end
82
+
83
+ def unary_test(expression, value, context = {})
84
+ shared.unary_test(expression, value, context)
85
+ end
86
+
87
+ def parse(expression, context = nil)
88
+ shared.parse(expression, context)
89
+ end
90
+
91
+ def parse_unary_tests(expression, context = nil)
92
+ shared.parse_unary_tests(expression, context)
93
+ end
94
+
95
+ def add_function(name, proc)
96
+ shared.add_function(name, proc)
97
+ end
98
+
99
+ private
100
+
101
+ def shared
102
+ @shared
103
+ end
60
104
  end
61
105
 
62
- @@context = MiniRacer::Context.new
63
- @@context.eval(File.read(BUNDLE_PATH))
106
+ @shared = Context.new
64
107
  end
@@ -36,6 +36,132 @@ RSpec.describe FEELIN do
36
36
  end
37
37
  end
38
38
 
39
+ describe "temporal results" do
40
+ it "answers a date, a time and a duration in their ISO 8601 form" do
41
+ expect(FEELIN.evaluate('date("2020-01-02")')).to eq '2020-01-02'
42
+ expect(FEELIN.evaluate('time("10:00:00+03:00")')).to eq '10:00:00+03:00'
43
+ expect(FEELIN.evaluate('date and time("2020-01-02T03:04:05Z")')).to eq '2020-01-02T03:04:05Z'
44
+ expect(FEELIN.evaluate('duration("P1DT2H")')).to eq 'P1DT2H'
45
+ end
46
+
47
+ it "does so inside a list and a context too" do
48
+ expect(FEELIN.evaluate('{ on: date(day) + duration("P1D"), all: [ @"2020-01-02" ] }', { 'day' => '2020-05-06' }))
49
+ .to eq('on' => '2020-05-07', 'all' => [ '2020-01-02' ])
50
+ end
51
+
52
+ it "answers null for what has no value" do
53
+ expect(FEELIN.evaluate('missing')).to be_nil
54
+ expect(FEELIN.evaluate('{ a: missing }')).to eq('a' => nil)
55
+ end
56
+ end
57
+
58
+ describe FEELIN::Context do
59
+ it "evaluates in a context of its own, with its own functions" do
60
+ context = FEELIN::Context.new
61
+ context.add_function('only here', proc { 7 })
62
+
63
+ expect(context.evaluate('only here() + n', { 'n' => 1 })).to eq 8
64
+ expect(FEELIN.evaluate('only here()')).to be_nil
65
+ ensure
66
+ context&.dispose
67
+ end
68
+
69
+ it "stops an expression that runs past its timeout" do
70
+ context = FEELIN::Context.new(timeout: 50)
71
+
72
+ expect { context.evaluate('count(for a in 1..3000, b in 1..3000 return a * b)') }
73
+ .to raise_error(MiniRacer::ScriptTerminatedError)
74
+ ensure
75
+ context&.dispose
76
+ end
77
+
78
+ # the context reaches V8 as an argument, not as the text of a script: a script
79
+ # per distinct context is compiled and kept, and ran a limited context out of
80
+ # memory
81
+ it "evaluates over context after context without its memory growing" do
82
+ context = FEELIN::Context.new(max_memory: 8_000_000)
83
+
84
+ results = Array.new(10_000) { |index| context.evaluate('"L" + string(old.n)', { 'old' => { 'n' => index } }) }
85
+
86
+ expect(results.values_at(0, 9_999)).to eq %w[L0 L9999]
87
+ ensure
88
+ context&.dispose
89
+ end
90
+ end
91
+
92
+ describe "syntax errors" do
93
+ it "raises on an expression that does not parse" do
94
+ expect { FEELIN.evaluate('1 +') }.to raise_error(MiniRacer::RuntimeError, /Incomplete <ArithmeticExpression>/)
95
+ end
96
+ end
97
+
98
+ describe "#parse" do
99
+ # the types of a tree, nested as the tree is, tokens left out
100
+ def shape(node)
101
+ children = node['children'].reject { |child| child['children'].empty? && child['type'] !~ /\A[A-Z]/ }
102
+ children.empty? ? node['type'] : { node['type'] => children.map { |child| shape(child) } }
103
+ end
104
+
105
+ it "answers the syntax tree without evaluating the expression" do
106
+ tree = FEELIN.parse('a.b + 1 > 2')
107
+
108
+ expect(shape(tree)).to eq(
109
+ 'Expression' => [ {
110
+ 'Comparison' => [
111
+ { 'ArithmeticExpression' => [
112
+ { 'PathExpression' => [ { 'VariableName' => [ 'Identifier' ] }, { 'PathName' => [ 'Identifier' ] } ] },
113
+ 'ArithOp',
114
+ 'NumericLiteral'
115
+ ] },
116
+ 'CompareOp',
117
+ 'NumericLiteral'
118
+ ]
119
+ } ]
120
+ )
121
+ end
122
+
123
+ it "gives every node its span and its text" do
124
+ comparison = FEELIN.parse('a.b + 1 > 2')['children'].first
125
+
126
+ expect(comparison.slice('type', 'from', 'to', 'text')).to eq('type' => 'Comparison', 'from' => 0, 'to' => 11, 'text' => 'a.b + 1 > 2')
127
+ expect(comparison['children'].map { |child| child['text'] }).to eq [ 'a.b + 1', '>', '2' ]
128
+ end
129
+
130
+ it "reads a name with spaces as one name when the context has it" do
131
+ tree = FEELIN.parse("Mike's daughter.name + 1", { "Mike's daughter.name" => 'Lisa' })
132
+ name = tree['children'].first['children'].first
133
+
134
+ expect(name.slice('type', 'text')).to eq('type' => 'VariableName', 'text' => "Mike's daughter.name")
135
+ end
136
+
137
+ it "raises on an expression that does not parse, as evaluate does" do
138
+ expect { FEELIN.parse('1 +') }.to raise_error(MiniRacer::RuntimeError, /Incomplete <ArithmeticExpression>/)
139
+ expect { FEELIN.parse('1 + ) 2') }.to raise_error(MiniRacer::RuntimeError, /Unrecognized token/)
140
+ end
141
+
142
+ it "reports a syntax error as evaluate reports it" do
143
+ [ '1 +', '{ a: 1', 'if x then', '1 + ) 2', '1 2', '[1, 2' ].each do |expression|
144
+ expected = begin
145
+ FEELIN.evaluate(expression)
146
+ rescue MiniRacer::RuntimeError => e
147
+ e.message
148
+ end
149
+
150
+ expect { FEELIN.parse(expression) }.to raise_error(MiniRacer::RuntimeError, expected), expression
151
+ end
152
+ end
153
+ end
154
+
155
+ describe "#parse_unary_tests" do
156
+ it "answers the syntax tree of unary tests" do
157
+ tree = FEELIN.parse_unary_tests('[1..end], > 5')
158
+ tests = tree['children'].first['children'].select { |child| child['type'] == 'PositiveUnaryTest' }
159
+
160
+ expect(tree['type']).to eq 'UnaryTests'
161
+ expect(tests.map { |test| test['text'] }).to eq [ '[1..end]', '> 5' ]
162
+ end
163
+ end
164
+
39
165
  describe "#unary_test" do
40
166
  context "without context" do
41
167
  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.2
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: []