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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +98 -0
- data/lib/feelin/js/dist/feelin.js +125 -40
- data/lib/feelin/js/entry.js +111 -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 +127 -44
- data/spec/feelin/feelin_spec.rb +168 -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: 1d42d010d26029134be39b2bcdbd097683a00b0866820f1d816f69607b5bd053
|
|
4
|
+
data.tar.gz: cbee6c7dc98e5c2e078ebdecdbe3abac6dee702966841ffa3e5a3df399e69853
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,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
|
})();
|
data/lib/feelin/js/entry.js
CHANGED
|
@@ -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.
|
|
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,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
|
|
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
|
+
# 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
+
# the expression does not parse
|
|
27
|
+
class SyntaxError < Error; end
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
end
|
|
29
|
+
# the evaluation ran past the context's `timeout`
|
|
30
|
+
class TimeoutError < Error; end
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
95
|
+
private
|
|
36
96
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
@@context.eval(File.read(BUNDLE_PATH))
|
|
146
|
+
@shared = Context.new
|
|
64
147
|
end
|
data/spec/feelin/feelin_spec.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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: []
|