decision_agent 0.2.0 → 0.3.0
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/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
- data/lib/decision_agent/dsl/schema_validator.rb +2 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +3 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1846 -1570
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- metadata +65 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
require "parslet"
|
|
2
|
+
require_relative "../errors"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Dmn
|
|
6
|
+
module Feel
|
|
7
|
+
# Parslet-based FEEL 1.3 expression parser
|
|
8
|
+
# Implements full FEEL grammar including:
|
|
9
|
+
# - Literals (numbers, strings, booleans, null)
|
|
10
|
+
# - Lists and contexts
|
|
11
|
+
# - Arithmetic, comparison, and logical operators
|
|
12
|
+
# - Function calls
|
|
13
|
+
# - Property access and path expressions
|
|
14
|
+
# - Quantified expressions (some/every)
|
|
15
|
+
# - For expressions
|
|
16
|
+
# - If/then/else conditionals
|
|
17
|
+
# - Ranges and intervals
|
|
18
|
+
class Parser < Parslet::Parser
|
|
19
|
+
# Root rule - entry point for parsing
|
|
20
|
+
rule(:expression) { boxed_expression }
|
|
21
|
+
|
|
22
|
+
# Boxed expressions - top-level expression types
|
|
23
|
+
rule(:boxed_expression) do
|
|
24
|
+
if_expression |
|
|
25
|
+
quantified_expression |
|
|
26
|
+
for_expression |
|
|
27
|
+
disjunction
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# If-then-else conditional
|
|
31
|
+
rule(:if_expression) do
|
|
32
|
+
str("if") >> space >> expression.as(:condition) >>
|
|
33
|
+
space >> str("then") >> space >> expression.as(:then_expr) >>
|
|
34
|
+
space >> str("else") >> space >> expression.as(:else_expr)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Quantified expressions: some/every
|
|
38
|
+
rule(:quantified_expression) do
|
|
39
|
+
quantifier.as(:quantifier) >> space >>
|
|
40
|
+
identifier.as(:var) >> space >>
|
|
41
|
+
str("in") >> space >>
|
|
42
|
+
expression.as(:list) >> space >>
|
|
43
|
+
str("satisfies") >> space >>
|
|
44
|
+
expression.as(:condition)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
rule(:quantifier) { str("some") | str("every") }
|
|
48
|
+
|
|
49
|
+
# For expressions
|
|
50
|
+
rule(:for_expression) do
|
|
51
|
+
str("for") >> space >>
|
|
52
|
+
identifier.as(:var) >> space >>
|
|
53
|
+
str("in") >> space >>
|
|
54
|
+
expression.as(:list) >> space >>
|
|
55
|
+
str("return") >> space >>
|
|
56
|
+
expression.as(:return_expr)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Logical OR (disjunction)
|
|
60
|
+
rule(:disjunction) do
|
|
61
|
+
(conjunction.as(:left) >>
|
|
62
|
+
(space >> str("or") >> space >>
|
|
63
|
+
conjunction.as(:right)).repeat(1).as(:or_ops)).as(:or) |
|
|
64
|
+
conjunction
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Logical AND (conjunction)
|
|
68
|
+
rule(:conjunction) do
|
|
69
|
+
(comparison.as(:left) >>
|
|
70
|
+
(space >> str("and") >> space >>
|
|
71
|
+
comparison.as(:right)).repeat(1).as(:and_ops)).as(:and) |
|
|
72
|
+
comparison
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Comparison operators
|
|
76
|
+
rule(:comparison) do
|
|
77
|
+
(arithmetic.as(:left) >> space? >>
|
|
78
|
+
comparison_op.as(:op) >> space? >>
|
|
79
|
+
arithmetic.as(:right)).as(:comparison) |
|
|
80
|
+
between_expression |
|
|
81
|
+
in_expression |
|
|
82
|
+
instance_of_expression |
|
|
83
|
+
arithmetic
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
rule(:comparison_op) do
|
|
87
|
+
str("!=") | str("<=") | str(">=") |
|
|
88
|
+
str("=") | str("<") | str(">")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Between expression
|
|
92
|
+
rule(:between_expression) do
|
|
93
|
+
(arithmetic.as(:value) >> space >>
|
|
94
|
+
str("between") >> space >>
|
|
95
|
+
arithmetic.as(:min) >> space >>
|
|
96
|
+
str("and") >> space >>
|
|
97
|
+
arithmetic.as(:max)).as(:between)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# In expression (list membership)
|
|
101
|
+
rule(:in_expression) do
|
|
102
|
+
(arithmetic.as(:value) >> space >>
|
|
103
|
+
str("in") >> space >>
|
|
104
|
+
(positive_unary_test | simple_positive_unary_tests).as(:list)).as(:in)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Instance of type checking
|
|
108
|
+
rule(:instance_of_expression) do
|
|
109
|
+
(arithmetic.as(:value) >> space >>
|
|
110
|
+
str("instance") >> space >> str("of") >> space >>
|
|
111
|
+
type_name.as(:type)).as(:instance_of)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
rule(:type_name) do
|
|
115
|
+
str("number") | str("string") | str("boolean") |
|
|
116
|
+
str("date") | str("time") | str("duration") |
|
|
117
|
+
str("list") | str("context")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Arithmetic expressions (addition, subtraction)
|
|
121
|
+
rule(:arithmetic) do
|
|
122
|
+
(term.as(:left) >> space? >>
|
|
123
|
+
arithmetic_op.as(:op) >> space? >>
|
|
124
|
+
arithmetic.as(:right)).as(:arithmetic) |
|
|
125
|
+
term
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
rule(:arithmetic_op) { str("+") | str("-") }
|
|
129
|
+
|
|
130
|
+
# Term (multiplication, division, modulo)
|
|
131
|
+
rule(:term) do
|
|
132
|
+
(exponentiation.as(:left) >> space? >>
|
|
133
|
+
term_op.as(:op) >> space? >>
|
|
134
|
+
term.as(:right)).as(:term) |
|
|
135
|
+
exponentiation
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
rule(:term_op) { str("*") | str("/") | str("%") }
|
|
139
|
+
|
|
140
|
+
# Exponentiation
|
|
141
|
+
rule(:exponentiation) do
|
|
142
|
+
(unary.as(:left) >> space? >>
|
|
143
|
+
str("**").as(:op) >> space? >>
|
|
144
|
+
exponentiation.as(:right)).as(:exponentiation) |
|
|
145
|
+
unary
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Unary expressions (not, negation)
|
|
149
|
+
rule(:unary) do
|
|
150
|
+
(str("not").as(:op) >> space >> unary.as(:operand)).as(:unary) |
|
|
151
|
+
(str("-").as(:op) >> unary.as(:operand)).as(:unary) |
|
|
152
|
+
postfix
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Postfix expressions (property access, function calls, filtering)
|
|
156
|
+
rule(:postfix) do
|
|
157
|
+
(primary.as(:base) >>
|
|
158
|
+
(property_access | function_call | filter_expression).repeat(1).as(:postfix_ops)).as(:postfix) |
|
|
159
|
+
primary
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Property access: .property
|
|
163
|
+
rule(:property_access) do
|
|
164
|
+
(str(".") >> identifier.as(:property)).as(:property_access)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Function call: (args...)
|
|
168
|
+
rule(:function_call) do
|
|
169
|
+
(str("(") >> space? >>
|
|
170
|
+
arguments.maybe.as(:arguments) >>
|
|
171
|
+
space? >> str(")")).as(:function_call)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Filter expression: [condition]
|
|
175
|
+
rule(:filter_expression) do
|
|
176
|
+
(str("[") >> space? >>
|
|
177
|
+
expression.as(:filter) >>
|
|
178
|
+
space? >> str("]")).as(:filter)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Function arguments
|
|
182
|
+
rule(:arguments) do
|
|
183
|
+
named_arguments | positional_arguments
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
rule(:positional_arguments) do
|
|
187
|
+
expression.as(:arg) >>
|
|
188
|
+
(space? >> str(",") >> space? >> expression.as(:arg)).repeat
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
rule(:named_arguments) do
|
|
192
|
+
named_argument >>
|
|
193
|
+
(space? >> str(",") >> space? >> named_argument).repeat
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
rule(:named_argument) do
|
|
197
|
+
(identifier.as(:name) >> space? >>
|
|
198
|
+
str(":") >> space? >>
|
|
199
|
+
expression.as(:value)).as(:named_arg)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Primary expressions
|
|
203
|
+
rule(:primary) do
|
|
204
|
+
null_literal |
|
|
205
|
+
boolean_literal |
|
|
206
|
+
number_literal |
|
|
207
|
+
string_literal |
|
|
208
|
+
list_literal |
|
|
209
|
+
context_literal |
|
|
210
|
+
range_literal |
|
|
211
|
+
function_definition |
|
|
212
|
+
identifier_or_function |
|
|
213
|
+
parenthesized
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Null literal
|
|
217
|
+
rule(:null_literal) { str("null").as(:null) }
|
|
218
|
+
|
|
219
|
+
# Boolean literals
|
|
220
|
+
rule(:boolean_literal) do
|
|
221
|
+
(str("true") | str("false")).as(:boolean)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Number literal
|
|
225
|
+
rule(:number_literal) do
|
|
226
|
+
(str("-").maybe >> match["\\d"] >> match["\\d"].repeat >>
|
|
227
|
+
(str(".") >> match["\\d"].repeat(1)).maybe).as(:number)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# String literal
|
|
231
|
+
rule(:string_literal) do
|
|
232
|
+
str('"') >>
|
|
233
|
+
((str("\\") >> any) | (str('"').absent? >> any)).repeat.as(:string) >>
|
|
234
|
+
str('"')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# List literal: [1, 2, 3]
|
|
238
|
+
rule(:list_literal) do
|
|
239
|
+
(str("[") >> space? >>
|
|
240
|
+
(expression >> (space? >> str(",") >> space? >> expression).repeat).maybe.as(:list) >>
|
|
241
|
+
space? >> str("]")).as(:list_literal)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Context literal: {a: 1, b: 2}
|
|
245
|
+
rule(:context_literal) do
|
|
246
|
+
(str("{") >> space? >>
|
|
247
|
+
context_entries.maybe.as(:context) >>
|
|
248
|
+
space? >> str("}")).as(:context_literal)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
rule(:context_entries) do
|
|
252
|
+
context_entry >> (space? >> str(",") >> space? >> context_entry).repeat
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
rule(:context_entry) do
|
|
256
|
+
(context_key.as(:key) >> space? >>
|
|
257
|
+
str(":") >> space? >>
|
|
258
|
+
expression.as(:value)).as(:entry)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
rule(:context_key) do
|
|
262
|
+
identifier | string_literal
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Range literal: [1..10], (1..10), etc.
|
|
266
|
+
rule(:range_literal) do
|
|
267
|
+
((str("[") | str("(")).as(:start_bracket) >>
|
|
268
|
+
space? >>
|
|
269
|
+
expression.as(:start) >>
|
|
270
|
+
space? >> str("..") >> space? >>
|
|
271
|
+
expression.as(:end) >>
|
|
272
|
+
space? >>
|
|
273
|
+
(str("]") | str(")")).as(:end_bracket)).as(:range)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Function definition: function(x, y) x + y
|
|
277
|
+
rule(:function_definition) do
|
|
278
|
+
(str("function") >> space? >>
|
|
279
|
+
str("(") >> space? >>
|
|
280
|
+
parameters.maybe.as(:params) >>
|
|
281
|
+
space? >> str(")") >> space? >>
|
|
282
|
+
(str("external") | expression.as(:body))).as(:function_def)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
rule(:parameters) do
|
|
286
|
+
identifier.as(:param) >>
|
|
287
|
+
(space? >> str(",") >> space? >> identifier.as(:param)).repeat
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Identifier or function name
|
|
291
|
+
rule(:identifier_or_function) do
|
|
292
|
+
(identifier.as(:name) >>
|
|
293
|
+
(str("(") >> space? >>
|
|
294
|
+
arguments.maybe.as(:arguments) >>
|
|
295
|
+
space? >> str(")")).maybe).as(:identifier_or_call)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Identifier (variable/field name)
|
|
299
|
+
rule(:identifier) do
|
|
300
|
+
(match["a-zA-Z_"] >> match["a-zA-Z0-9_"].repeat).as(:identifier)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Parenthesized expression
|
|
304
|
+
rule(:parenthesized) do
|
|
305
|
+
str("(") >> space? >> expression >> space? >> str(")")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Unary tests (for DMN decision tables)
|
|
309
|
+
rule(:simple_positive_unary_tests) do
|
|
310
|
+
(positive_unary_test >>
|
|
311
|
+
(space? >> str(",") >> space? >> positive_unary_test).repeat).as(:unary_tests)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
rule(:positive_unary_test) do
|
|
315
|
+
range_literal | comparison | expression
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Whitespace
|
|
319
|
+
rule(:space) { match["\\s"].repeat(1) }
|
|
320
|
+
rule(:space?) { match["\\s"].repeat }
|
|
321
|
+
|
|
322
|
+
# Root parsing method
|
|
323
|
+
root(:expression)
|
|
324
|
+
|
|
325
|
+
# Instance method for parsing with error handling
|
|
326
|
+
def parse(input)
|
|
327
|
+
super
|
|
328
|
+
rescue Parslet::ParseFailed => e
|
|
329
|
+
error_msg = "Failed to parse FEEL expression: #{e.parse_failure_cause.ascii_tree}"
|
|
330
|
+
error = FeelParseError.new(error_msg)
|
|
331
|
+
error.instance_variable_set(:@expression, input)
|
|
332
|
+
error.instance_variable_set(:@position, 0)
|
|
333
|
+
raise error
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Parse with error handling (class method for backward compatibility)
|
|
337
|
+
def self.parse_expression(input)
|
|
338
|
+
new.parse(input)
|
|
339
|
+
rescue Parslet::ParseFailed => e
|
|
340
|
+
error_msg = "Failed to parse FEEL expression: #{e.parse_failure_cause.ascii_tree}"
|
|
341
|
+
error = FeelParseError.new(error_msg)
|
|
342
|
+
error.instance_variable_set(:@expression, input)
|
|
343
|
+
error.instance_variable_set(:@position, 0)
|
|
344
|
+
raise error
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
require_relative "../errors"
|
|
2
|
+
require_relative "types"
|
|
3
|
+
|
|
4
|
+
module DecisionAgent
|
|
5
|
+
module Dmn
|
|
6
|
+
module Feel
|
|
7
|
+
# Simple regex-based parser for common FEEL expressions
|
|
8
|
+
# Handles arithmetic, logical operators, and simple comparisons
|
|
9
|
+
# Uses operator precedence climbing for correct evaluation order
|
|
10
|
+
class SimpleParser
|
|
11
|
+
ARITHMETIC_OPS = %w[+ - * / ** %].freeze
|
|
12
|
+
LOGICAL_OPS = %w[and or].freeze
|
|
13
|
+
COMPARISON_OPS = %w[>= <= != > < =].freeze
|
|
14
|
+
|
|
15
|
+
# Operator precedence (higher number = higher precedence)
|
|
16
|
+
PRECEDENCE = {
|
|
17
|
+
"or" => 1,
|
|
18
|
+
"and" => 2,
|
|
19
|
+
"=" => 3,
|
|
20
|
+
"!=" => 3,
|
|
21
|
+
"<" => 4,
|
|
22
|
+
"<=" => 4,
|
|
23
|
+
">" => 4,
|
|
24
|
+
">=" => 4,
|
|
25
|
+
"+" => 5,
|
|
26
|
+
"-" => 5,
|
|
27
|
+
"*" => 6,
|
|
28
|
+
"/" => 6,
|
|
29
|
+
"%" => 6,
|
|
30
|
+
"**" => 7
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@tokens = []
|
|
35
|
+
@position = 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if expression can be handled by simple parser
|
|
39
|
+
def self.can_parse?(expression)
|
|
40
|
+
expr = expression.to_s.strip
|
|
41
|
+
# Can't handle: lists, contexts, functions, quantifiers, for expressions
|
|
42
|
+
return false if expr.match?(/[\[{]/) # Lists or contexts
|
|
43
|
+
return false if expr.match?(/\w+\s*\(/) # Function calls
|
|
44
|
+
return false if expr.match?(/\b(some|every|for|if)\b/) # Complex constructs
|
|
45
|
+
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Parse expression and return AST-like structure
|
|
50
|
+
def parse(expression)
|
|
51
|
+
expr = expression.to_s.strip
|
|
52
|
+
raise DecisionAgent::Dmn::FeelParseError, "Empty expression" if expr.empty?
|
|
53
|
+
|
|
54
|
+
@tokens = tokenize(expr)
|
|
55
|
+
@position = 0
|
|
56
|
+
|
|
57
|
+
parse_expression
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Tokenize the expression
|
|
63
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
64
|
+
def tokenize(expr)
|
|
65
|
+
tokens = []
|
|
66
|
+
i = 0
|
|
67
|
+
|
|
68
|
+
while i < expr.length
|
|
69
|
+
char = expr[i]
|
|
70
|
+
|
|
71
|
+
# Skip whitespace
|
|
72
|
+
if char.match?(/\s/)
|
|
73
|
+
i += 1
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check for multi-character operators
|
|
78
|
+
if i + 1 < expr.length
|
|
79
|
+
two_char = expr[i, 2]
|
|
80
|
+
if %w[>= <= != ** or].include?(two_char)
|
|
81
|
+
tokens << { type: :operator, value: two_char }
|
|
82
|
+
i += 2
|
|
83
|
+
next
|
|
84
|
+
elsif two_char == "an" && i + 2 < expr.length && expr[i, 3] == "and"
|
|
85
|
+
tokens << { type: :operator, value: "and" }
|
|
86
|
+
i += 3
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Numbers (integer or float) - check BEFORE single char operators to handle negative numbers
|
|
92
|
+
if char.match?(/\d/) ||
|
|
93
|
+
(char == "-" && i + 1 < expr.length && expr[i + 1].match?(/\d/) &&
|
|
94
|
+
(tokens.empty? || tokens.last[:type] == :operator || tokens.last[:type] == :paren))
|
|
95
|
+
num_str = ""
|
|
96
|
+
num_str << char if char == "-"
|
|
97
|
+
i += 1 if char == "-"
|
|
98
|
+
|
|
99
|
+
while i < expr.length && expr[i].match?(/[\d.]/)
|
|
100
|
+
num_str << expr[i]
|
|
101
|
+
i += 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
value = num_str.include?(".") ? num_str.to_f : num_str.to_i
|
|
105
|
+
tokens << { type: :number, value: value }
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Single character operators
|
|
110
|
+
if "+-*/%><()=".include?(char)
|
|
111
|
+
type = %w[( )].include?(char) ? :paren : :operator
|
|
112
|
+
tokens << { type: type, value: char }
|
|
113
|
+
i += 1
|
|
114
|
+
next
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Quoted strings
|
|
118
|
+
if char == '"'
|
|
119
|
+
str = ""
|
|
120
|
+
i += 1
|
|
121
|
+
while i < expr.length && expr[i] != '"'
|
|
122
|
+
str << expr[i]
|
|
123
|
+
i += 1
|
|
124
|
+
end
|
|
125
|
+
i += 1 # Skip closing quote
|
|
126
|
+
tokens << { type: :string, value: str }
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Booleans and keywords
|
|
131
|
+
if char.match?(/[a-zA-Z]/)
|
|
132
|
+
word = ""
|
|
133
|
+
while i < expr.length && expr[i].match?(/[a-zA-Z_]/)
|
|
134
|
+
word << expr[i]
|
|
135
|
+
i += 1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
tokens << case word.downcase
|
|
139
|
+
when "true"
|
|
140
|
+
{ type: :boolean, value: true }
|
|
141
|
+
when "false"
|
|
142
|
+
{ type: :boolean, value: false }
|
|
143
|
+
when "not"
|
|
144
|
+
{ type: :operator, value: "not" }
|
|
145
|
+
when "and", "or"
|
|
146
|
+
{ type: :operator, value: word.downcase }
|
|
147
|
+
else
|
|
148
|
+
# Field reference
|
|
149
|
+
{ type: :field, value: word }
|
|
150
|
+
end
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
raise DecisionAgent::Dmn::FeelParseError, "Unexpected character: #{char} at position #{i}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
tokens
|
|
158
|
+
end
|
|
159
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
160
|
+
|
|
161
|
+
# Parse expression with operator precedence
|
|
162
|
+
def parse_expression(min_precedence = 0)
|
|
163
|
+
left = parse_unary
|
|
164
|
+
|
|
165
|
+
while @position < @tokens.length
|
|
166
|
+
token = current_token
|
|
167
|
+
break unless token && token[:type] == :operator
|
|
168
|
+
|
|
169
|
+
op = token[:value]
|
|
170
|
+
precedence = PRECEDENCE[op]
|
|
171
|
+
break if precedence.nil? || precedence < min_precedence
|
|
172
|
+
|
|
173
|
+
consume_token # Consume operator
|
|
174
|
+
|
|
175
|
+
right = parse_expression(precedence + 1)
|
|
176
|
+
|
|
177
|
+
left = {
|
|
178
|
+
type: operator_type(op),
|
|
179
|
+
operator: op,
|
|
180
|
+
left: left,
|
|
181
|
+
right: right
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
left
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Parse unary expressions (not, -, +)
|
|
189
|
+
def parse_unary
|
|
190
|
+
token = current_token
|
|
191
|
+
|
|
192
|
+
if token && token[:type] == :operator
|
|
193
|
+
case token[:value]
|
|
194
|
+
when "not"
|
|
195
|
+
consume_token
|
|
196
|
+
operand = parse_unary
|
|
197
|
+
return {
|
|
198
|
+
type: :logical,
|
|
199
|
+
operator: "not",
|
|
200
|
+
operand: operand
|
|
201
|
+
}
|
|
202
|
+
when "-"
|
|
203
|
+
consume_token
|
|
204
|
+
operand = parse_unary
|
|
205
|
+
return {
|
|
206
|
+
type: :arithmetic,
|
|
207
|
+
operator: "negate",
|
|
208
|
+
operand: operand
|
|
209
|
+
}
|
|
210
|
+
when "+"
|
|
211
|
+
consume_token # Skip unary plus
|
|
212
|
+
return parse_unary
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
parse_primary
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Parse primary expressions (numbers, strings, booleans, fields, parentheses)
|
|
220
|
+
def parse_primary
|
|
221
|
+
token = current_token
|
|
222
|
+
|
|
223
|
+
raise DecisionAgent::Dmn::FeelParseError, "Unexpected end of expression" unless token
|
|
224
|
+
|
|
225
|
+
case token[:type]
|
|
226
|
+
when :number
|
|
227
|
+
consume_token
|
|
228
|
+
{ type: :literal, value: token[:value] }
|
|
229
|
+
|
|
230
|
+
when :string
|
|
231
|
+
consume_token
|
|
232
|
+
{ type: :literal, value: token[:value] }
|
|
233
|
+
|
|
234
|
+
when :boolean
|
|
235
|
+
consume_token
|
|
236
|
+
{ type: :boolean, value: token[:value] }
|
|
237
|
+
|
|
238
|
+
when :field
|
|
239
|
+
consume_token
|
|
240
|
+
{ type: :field, name: token[:value] }
|
|
241
|
+
|
|
242
|
+
when :paren
|
|
243
|
+
raise DecisionAgent::Dmn::FeelParseError, "Unexpected closing parenthesis" unless token[:value] == "("
|
|
244
|
+
|
|
245
|
+
consume_token
|
|
246
|
+
expr = parse_expression
|
|
247
|
+
closing = current_token
|
|
248
|
+
raise DecisionAgent::Dmn::FeelParseError, "Expected closing parenthesis" unless closing && closing[:value] == ")"
|
|
249
|
+
|
|
250
|
+
consume_token
|
|
251
|
+
expr
|
|
252
|
+
|
|
253
|
+
else
|
|
254
|
+
raise DecisionAgent::Dmn::FeelParseError, "Unexpected token: #{token.inspect}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def current_token
|
|
259
|
+
@tokens[@position]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def consume_token
|
|
263
|
+
@position += 1
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def operator_type(op)
|
|
267
|
+
return :arithmetic if ARITHMETIC_OPS.include?(op)
|
|
268
|
+
return :logical if LOGICAL_OPS.include?(op) || op == "not"
|
|
269
|
+
return :comparison if COMPARISON_OPS.include?(op)
|
|
270
|
+
|
|
271
|
+
:unknown
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|