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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +62 -0
- data/lib/feelin/js/dist/feelin.js +113 -40
- data/lib/feelin/js/entry.js +92 -0
- data/lib/feelin/js/package-lock.json +7 -7
- data/lib/feelin/js/package.json +1 -1
- data/lib/feelin/version.rb +1 -1
- data/lib/feelin.rb +87 -44
- data/spec/feelin/feelin_spec.rb +126 -0
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a2aab7f50ce55a02f7c45876a113972cf952c23ab505a2a95f527c213015591
|
|
4
|
+
data.tar.gz: 68ffbc5239c2f1387ea5982e6af9b7c9fc619a93ab43e42c2750a981cfb6813c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
})();
|
data/lib/feelin/js/entry.js
CHANGED
|
@@ -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.
|
|
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.
|
|
524
|
-
"resolved": "https://registry.npmjs.org/feelin/-/feelin-7.0.
|
|
525
|
-
"integrity": "sha512
|
|
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.
|
|
562
|
-
"resolved": "https://registry.npmjs.org/min-dash/-/min-dash-5.
|
|
563
|
-
"integrity": "sha512-
|
|
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
|
}
|
data/lib/feelin/js/package.json
CHANGED
data/lib/feelin/version.rb
CHANGED
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
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
40
|
+
def evaluate(expression, context = nil)
|
|
41
|
+
call("evaluate", expression, context)
|
|
42
|
+
end
|
|
23
43
|
|
|
24
|
-
|
|
25
|
-
|
|
44
|
+
def unary_test(expression, value, context = {})
|
|
45
|
+
call("unaryTest", expression, { **context, '?' => value })
|
|
46
|
+
end
|
|
26
47
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
end
|
|
58
|
+
def parse_unary_tests(expression, context = nil)
|
|
59
|
+
call("parseUnaryTests", expression, context)
|
|
60
|
+
end
|
|
34
61
|
|
|
35
|
-
|
|
62
|
+
def add_function(name, proc)
|
|
63
|
+
@context.attach(name, proc)
|
|
64
|
+
@context.call("feel.json.addFunction", name)
|
|
65
|
+
end
|
|
36
66
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
@@context.eval(File.read(BUNDLE_PATH))
|
|
106
|
+
@shared = Context.new
|
|
64
107
|
end
|
data/spec/feelin/feelin_spec.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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.
|
|
101
|
-
signing_key:
|
|
98
|
+
rubygems_version: 3.6.9
|
|
102
99
|
specification_version: 4
|
|
103
100
|
summary: feelin Ruby wrapper
|
|
104
101
|
test_files: []
|