introhive_expression_language 0.6.8

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.github/dependabot.yml +80 -0
  3. data/.github/pull_request_template.md +17 -0
  4. data/.github/workflows/ci.yml +31 -0
  5. data/.gitignore +15 -0
  6. data/.qlty/qlty.toml +10 -0
  7. data/.ruby-version +1 -0
  8. data/CLAUDE.md +169 -0
  9. data/Gemfile +4 -0
  10. data/Gemfile.lock +87 -0
  11. data/README.md +55 -0
  12. data/Rakefile +10 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +8 -0
  15. data/introhive_expression_language.gemspec +54 -0
  16. data/lib/introhive_expression_language/iel/evaluation_context.rb +35 -0
  17. data/lib/introhive_expression_language/iel/evaluation_error.rb +9 -0
  18. data/lib/introhive_expression_language/iel/evaluator.rb +166 -0
  19. data/lib/introhive_expression_language/iel/node_util.rb +65 -0
  20. data/lib/introhive_expression_language/iel/parser.rb +30 -0
  21. data/lib/introhive_expression_language/iel/sexp_parser.rb +339 -0
  22. data/lib/introhive_expression_language/iel/std_lib.rb +43 -0
  23. data/lib/introhive_expression_language/iel/std_lib_assoc.rb +84 -0
  24. data/lib/introhive_expression_language/iel/std_lib_control.rb +81 -0
  25. data/lib/introhive_expression_language/iel/std_lib_enum.rb +30 -0
  26. data/lib/introhive_expression_language/iel/std_lib_existence.rb +19 -0
  27. data/lib/introhive_expression_language/iel/std_lib_json.rb +38 -0
  28. data/lib/introhive_expression_language/iel/std_lib_kind.rb +88 -0
  29. data/lib/introhive_expression_language/iel/std_lib_let.rb +22 -0
  30. data/lib/introhive_expression_language/iel/std_lib_list.rb +85 -0
  31. data/lib/introhive_expression_language/iel/std_lib_logic.rb +52 -0
  32. data/lib/introhive_expression_language/iel/std_lib_math.rb +75 -0
  33. data/lib/introhive_expression_language/iel/std_lib_number.rb +28 -0
  34. data/lib/introhive_expression_language/iel/std_lib_regexp.rb +30 -0
  35. data/lib/introhive_expression_language/iel/std_lib_string.rb +79 -0
  36. data/lib/introhive_expression_language/iel/symbol_detail.rb +16 -0
  37. data/lib/introhive_expression_language/iel.rb +2 -0
  38. data/lib/introhive_expression_language/version.rb +3 -0
  39. data/lib/introhive_expression_language.rb +9 -0
  40. metadata +305 -0
@@ -0,0 +1,166 @@
1
+ require_relative 'evaluation_context'
2
+ require_relative 'evaluation_error'
3
+ require_relative 'sexp_parser'
4
+ require_relative 'std_lib'
5
+ require_relative 'symbol_detail'
6
+ require_relative 'parser'
7
+ require_relative 'node_util'
8
+
9
+ module Platform
10
+ module IEL
11
+ class Evaluator
12
+ def self.eval_expr(expr, ctx)
13
+ ast = Parser.parse(expr)
14
+ eval_node(ast, ctx)
15
+ end
16
+
17
+ def self.eval_block_expr(expr, ctx)
18
+ ast = Parser.parse(expr)
19
+ eval_block(ast, ctx)
20
+ end
21
+
22
+ def self.eval_node(node, ctx)
23
+ case node.kind
24
+ when :list
25
+ eval_list_node(node, ctx)
26
+ when :symbol
27
+ deref_symbol(node, ctx)
28
+ else
29
+ node
30
+ end
31
+ end
32
+
33
+ def self.eval_list_node(node, ctx)
34
+ # First special form - function application
35
+ if function_application?(node, ctx)
36
+ apply_fn(node, ctx)
37
+ else
38
+ SexpParser::Node.list(
39
+ node.value.map {|child_node| eval_node(child_node, ctx)},
40
+ node.source)
41
+ end
42
+ end
43
+
44
+ def self.function_application?(node, ctx)
45
+ node.kind == :list && node.value.first && node.value.first.kind == :symbol && ctx[node.value.first.value] && ctx[node.value.first.value].kind == :native_function
46
+ end
47
+
48
+ def self.deref_symbol(symbol_node, ctx)
49
+ target = ctx[symbol_node.value]
50
+ raise EvaluationError.new("symbol '#{symbol_node.value}' at #{symbol_node.source} not found", target) if target.nil?
51
+ target
52
+ end
53
+
54
+ def self.apply_fn(node, ctx)
55
+ # Decode the function name.
56
+ name_node = node.value.first
57
+ raise EvaluationError.new('symbol expected', name_node) if name_node.nil?
58
+ raise EvaluationError.new('function application requires symbol', name_node) if name_node.kind != :symbol
59
+ symbol_name = name_node.value
60
+
61
+ # Get the detail of this symbol from the provided context.
62
+ detail_node = ctx[symbol_name]
63
+ raise EvaluationError.new("function '#{symbol_name}' is not found", detail_node) if detail_node.nil?
64
+ fn_proc = detail_node.value[:proc]
65
+ arg_list = detail_node.value[:arg_list]
66
+
67
+ # Create a new context overriding the provided context.
68
+ this_ctx = EvaluationContext.new(ctx)
69
+
70
+ # Decode the parameters and bind each to the new context.
71
+ if arg_list.size == 1 && arg_list[0].start_with?('va_')
72
+ # Varargs - build a list and bind as single arg
73
+ this_ctx[arg_list[0]] = SexpParser::Node.list(node.value.drop(1).map do |arg_node|
74
+ if arg_list[0].end_with?('__')
75
+ arg_node
76
+ else
77
+ eval_node(arg_node, ctx)
78
+ end
79
+ end, node.source)
80
+ else
81
+ # Args are bound by placement and the number must match.
82
+ raise EvaluationError.new("function '#{symbol_name}' requires #{arg_list.size} arguments but #{node.value.size - 1} are provided.", node) if arg_list.size != node.value.size - 1
83
+ node.value.drop(1).each_with_index do |arg_node, index|
84
+ arg_name = arg_list[index]
85
+ this_ctx[arg_name] = if arg_name.end_with?('__')
86
+ arg_node
87
+ else
88
+ eval_node(arg_node, ctx)
89
+ end
90
+ end
91
+ end
92
+
93
+ # Invoke the function.
94
+ rv = fn_proc.call(this_ctx)
95
+ raise EvaluationError.new("function '#{symbol_name}' does not return a node", rv) unless rv.is_a?(SexpParser::Node)
96
+ rv
97
+ end
98
+
99
+ # Evaluate each item inside this "block" of code (e.g. a list) and return the last evaluated result.
100
+ # e.g.
101
+ # [
102
+ # [puts "hello"]
103
+ # [puts "world"]
104
+ # 123
105
+ # ]
106
+ # hello
107
+ # world
108
+ # => 123
109
+ def self.eval_block(node, ctx)
110
+ rv = nil
111
+ node.value.each do |child_node|
112
+ rv = Evaluator.eval_node(child_node, ctx)
113
+ end
114
+ rv
115
+ end
116
+
117
+ # Wraps a native function so that it can be invoked in an expression.
118
+ # The native function MUST accept the evaluation context as its first param.
119
+ # The remaining params are mapped as iel mandatory args.
120
+ def self.wrap_native_function(&fn)
121
+ arg_list = fn.parameters.drop(1).map {|_kind, name| name.to_s}
122
+
123
+ wrapped_fn = lambda do |ctx|
124
+ args = [ctx] + arg_list.map {|name| ctx[name]}
125
+ fn.call(*args)
126
+ end
127
+
128
+ SexpParser::Node.new(
129
+ :native_function,
130
+ 0,
131
+ {
132
+ proc: wrapped_fn,
133
+ arg_list: arg_list
134
+ }
135
+ )
136
+ end
137
+
138
+ # Returns the numeric value of this node. It does not evaluate the node first, just returns its numeric value if
139
+ # possible.
140
+ def self.numeric_value_of(node)
141
+ if node.kind == :numeric_literal
142
+ node.value
143
+ elsif node.kind == :string_literal
144
+ node.value.to_f
145
+ else
146
+ 0
147
+ end
148
+ end
149
+
150
+ # Returns the string value of this node. It does not evaluate the node first, just returns its string value if
151
+ # possible.
152
+ def self.string_value_of(node)
153
+ if node.kind == :numeric_literal
154
+ node.value.to_s
155
+ elsif node.kind == :string_literal
156
+ node.value
157
+ elsif node.kind == :list
158
+ node.value.map {|item| string_value_of(item)}.join
159
+ else
160
+ ''
161
+ end
162
+ end
163
+ end
164
+
165
+ end
166
+ end
@@ -0,0 +1,65 @@
1
+ module Platform
2
+ module IEL
3
+ # Converts data from and to standard node graph formats.
4
+ class NodeUtil
5
+ # Recursively convert the given native value to a node using standard representations.
6
+ # The following types are converted:
7
+ # Hash => nested list e.g. [[a 1] [b 2]]
8
+ # String, Time (etc) => string_literal
9
+ # Fixnum, Float => numeric_literal, nan
10
+ # Array => list
11
+ # nil => nil
12
+ # Boolean => boolean
13
+ #
14
+ # e.g.
15
+ # [{:z => 123, 5 => 6}, [9, 8, 7.1, "hi", false, [nil]]]
16
+ # is transformed to:
17
+ # [[["z" 123] [5 6]] [9 8 7.1 "hi" false [nil]]]
18
+ def self.from_native(value)
19
+ if value.is_a? Hash
20
+ Platform::IEL::SexpParser::Node.list([SexpParser::Node.symbol('assoc')] + value.map { |k, v| SexpParser::Node.list([from_native(k), from_native(v)]) })
21
+ elsif value.is_a? Array
22
+ Platform::IEL::SexpParser::Node.list(value.map(&method(:from_native)))
23
+ elsif value.is_a? Numeric
24
+ Platform::IEL::SexpParser::Node.numeric_literal(value)
25
+ elsif value.is_a? NilClass
26
+ Platform::IEL::SexpParser::Node.nil
27
+ elsif value.is_a? TrueClass
28
+ Platform::IEL::SexpParser::Node.boolean(true)
29
+ elsif value.is_a? FalseClass
30
+ Platform::IEL::SexpParser::Node.boolean(false)
31
+ elsif value.is_a? Symbol
32
+ Platform::IEL::SexpParser::Node.symbol(value.to_s)
33
+ else
34
+ Platform::IEL::SexpParser::Node.string_literal(value.to_s)
35
+ end
36
+ end
37
+
38
+ # Recursively
39
+ def self.to_native(node)
40
+ case node.kind
41
+ when :string_literal, :numeric_literal, :boolean
42
+ node.value
43
+ when :symbol
44
+ node.value.to_sym
45
+ when :nil
46
+ nil
47
+ when :nan
48
+ Float::NAN
49
+ when :list
50
+ if node.value.size > 0 && node.value[0].kind == :symbol && node.value[0].value == 'assoc'
51
+ Hash[node.value.drop(1).map do |kv_pair|
52
+ if kv_pair.kind == :list && kv_pair.value.size >= 2
53
+ [to_native(kv_pair.value[0]), to_native(kv_pair.value[1])]
54
+ end
55
+ end.compact]
56
+ else
57
+ node.value.map { |n| to_native(n) }
58
+ end
59
+ else
60
+ raise ArgumentError, "Unable to convert kind #{node.kind} to native value"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,30 @@
1
+ module Platform
2
+ module IEL
3
+ class Parser
4
+ def self.parse(expr)
5
+ ast = SexpParser.parse(expr)
6
+ apply_transforms(ast)
7
+ ast
8
+ end
9
+
10
+ def self.apply_transforms(node)
11
+ if node.kind == :list
12
+ # recursively apply first
13
+ raise EvaluationError.new("Node can't end with trailing quote", node) if node.value[-1].to_s == "'" #trailing quote
14
+ node.value.each { |child| apply_transforms(child) }
15
+ # look for the quote pattern in the nodes
16
+ loop do
17
+ match, index = node.value.each_with_index.find { |child, index| child.kind == :symbol && child.value == "'" }
18
+ break if match.nil?
19
+ if index + 1 < node.value.size # There's a node following the quote
20
+ # Replace the two nodes with a single one
21
+ after_match = node.value.delete_at(index + 1)
22
+ node.value[index] = SexpParser::Node.list([SexpParser::Node.symbol('quote', match.source), after_match], match.source)
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,339 @@
1
+ module Platform
2
+ module IEL
3
+ class SexpParser
4
+ class ParseError < StandardError
5
+ def initialize(message, source = nil)
6
+ @source = source
7
+ super("#{message}#{source.nil? ? '' : " at #{source}"}")
8
+ end
9
+ end
10
+
11
+ class UnexpectedTokenError < ParseError
12
+ def initialize(token)
13
+ super("#{token.kind} unexpected", token.source)
14
+ end
15
+ end
16
+
17
+ # Parse the given expression and return its AST.
18
+ # Raises SexpParser::ParseError if a syntax error is encountered.
19
+ # @expr string in correct format.
20
+ def self.parse(expr)
21
+ parse_context = ParseContext.new(expr)
22
+ node_stack = [Node.new(:list, 0, [], parse_context)]
23
+ Lexer.tokenize(expr.chars, 0, parse_context) do |token|
24
+ case token.kind
25
+ when :string_literal, :numeric_literal, :symbol, :boolean, :nan, :nil
26
+ node_stack.last.value << token
27
+ when :list_end
28
+ raise UnexpectedTokenError, token if node_stack.size == 1
29
+ value = node_stack.pop
30
+ raise UnexpectedTokenError, token if value.nil?
31
+ node_stack.last.value << value
32
+ when :list_start
33
+ node_stack.push(Node.new(:list, token.source, [], parse_context))
34
+ when :comment
35
+ # Do nothing
36
+ else
37
+ raise UnexpectedTokenError, token
38
+ end
39
+ end
40
+ if node_stack.size != 1
41
+ raise ParseError, 'Unexpected end of input'
42
+ end
43
+
44
+ # Remove containing list if there's only a single item results.
45
+ if node_stack.first.value.size == 1
46
+ node_stack = node_stack.first.value
47
+ end
48
+
49
+ node_stack.first
50
+ end
51
+
52
+ class ParseContext
53
+ attr_reader :code
54
+
55
+ def initialize(code)
56
+ @code = code
57
+ end
58
+
59
+ def code_line(offset)
60
+ idx = 0
61
+ line = 1
62
+ pos = 0
63
+ last_line_start = 0
64
+ line_location = 1
65
+ @code.chars.each do |c|
66
+ if c == "\n"
67
+ line += 1
68
+ pos = 0
69
+ last_line_start = idx + 1
70
+ else
71
+ pos += 1
72
+ end
73
+ if idx == offset
74
+ line_location = pos
75
+ break
76
+ end
77
+ idx += 1
78
+ end
79
+
80
+ code_line = ''
81
+ @code[last_line_start..-1].chars.each do |c|
82
+ if c == "\n"
83
+ break
84
+ else
85
+ code_line += c
86
+ end
87
+ end
88
+
89
+ " at Line #{line}, Pos #{line_location}\n#{code_line}\n#{'-' * (line_location-1)}^"
90
+ end
91
+ end
92
+
93
+ class Node
94
+ include Comparable
95
+ attr_reader :kind, :value, :source, :context
96
+
97
+ def initialize(kind, source, value=nil, context=nil)
98
+ @kind = kind
99
+ @source = source
100
+ @value = value
101
+ @context = context
102
+ end
103
+
104
+ def source_desc
105
+ if context
106
+ context.code_line(@source)
107
+ else
108
+ " Offset #{@source}"
109
+ end
110
+ end
111
+
112
+ def <=>(other)
113
+ [kind, value] <=> [other.kind, other.value]
114
+ end
115
+
116
+ def inspect
117
+ if value.is_a? Array
118
+ "[#{value.map { |item| item.inspect}.join(', ')}]"
119
+ elsif kind == :symbol
120
+ ":#{value}"
121
+ elsif kind == :nil
122
+ 'nil'
123
+ elsif kind == :nan
124
+ 'nan'
125
+ else
126
+ value.inspect
127
+ end
128
+ end
129
+
130
+ def self.symbol(symbol_name, source = 0, context = nil)
131
+ new(:symbol, source, symbol_name)
132
+ end
133
+
134
+ def self.string_literal(str, source = 0, context = nil)
135
+ new(:string_literal, source, str)
136
+ end
137
+
138
+ def self.numeric_literal(n, source = 0, context = nil)
139
+ new(:numeric_literal, source, n)
140
+ end
141
+
142
+ def self.nan(source = 0, context = nil)
143
+ new(:nan, source)
144
+ end
145
+
146
+ def self.list(contents, source = 0, context = nil)
147
+ new(:list, source, contents)
148
+ end
149
+
150
+ def self.nil(source = 0, context = nil)
151
+ new(:nil, source)
152
+ end
153
+
154
+ def self.boolean(boolean_value, source = 0, context = nil)
155
+ new(:boolean, source, boolean_value)
156
+ end
157
+
158
+ def to_s
159
+ value
160
+ end
161
+ end
162
+
163
+ class Lexer
164
+ SYMBOL_LEADING_CHAR_SE = %q{[\w@+*\/=!<>-]}
165
+ SYMBOL_LEADING_CHAR = Regexp.new(SYMBOL_LEADING_CHAR_SE)
166
+ SYMBOL_TRAILING_CHAR_SE = '[\w:@=.()#+*\/!<>?-]'
167
+ SYMBOL_TRAILING_CHAR = Regexp.new(SYMBOL_TRAILING_CHAR_SE)
168
+ SYMBOL = Regexp.new("#{SYMBOL_LEADING_CHAR_SE}#{SYMBOL_TRAILING_CHAR_SE}*")
169
+
170
+ def self.tokenize(chars, pos, context, &block)
171
+ self.new.tokenize(chars, pos, context, &block)
172
+ end
173
+
174
+ def tokenize(chars, pos, context, &block)
175
+ @chars = chars
176
+ @pos = pos
177
+ @context = context
178
+ @block = block
179
+ while next_token
180
+ end
181
+ end
182
+
183
+ def next_token
184
+ case cpeek
185
+ when nil
186
+ return false
187
+ when /\s/
188
+ cnext
189
+ when '['
190
+ @block.call(Node.new(:list_start, @pos, nil, @context))
191
+ cnext
192
+ when ']'
193
+ @block.call(Node.new(:list_end, @pos, nil, @context))
194
+ cnext
195
+ when '"'
196
+ string_literal
197
+ when "'"
198
+ quote_symbol
199
+ when '-'
200
+ numeric_literal_or_symbol
201
+ when /[-\d]/
202
+ numeric_literal
203
+ when SYMBOL_LEADING_CHAR
204
+ symbol_or_comment
205
+ else
206
+ raise ParseError, "'#{cpeek}' unexpected at #{@pos}"
207
+ end
208
+ true
209
+ end
210
+
211
+ def numeric_literal_or_symbol
212
+ cnext # consume the minus
213
+ if cpeek =~ /[\d]/
214
+ numeric_literal('-')
215
+ else
216
+ symbol('-')
217
+ end
218
+ end
219
+
220
+ def string_literal
221
+ pos = @pos
222
+ expect('"')
223
+ s = ''
224
+ while true
225
+ case cpeek
226
+ when nil
227
+ raise ParseError, "Unexpected end of string literal at #{@pos}"
228
+ when '"'
229
+ cnext
230
+ if cpeek == '"'
231
+ s += cnext
232
+ else
233
+ @block.call(Node.new(:string_literal, pos, s, @context))
234
+ break
235
+ end
236
+ else
237
+ s += cnext
238
+ end
239
+ end
240
+ end
241
+
242
+ def numeric_literal(s = '')
243
+ pos = @pos - s.length
244
+ dot_count = 0
245
+ while true
246
+ case cpeek
247
+ when /[\d]/
248
+ s += cnext
249
+ when /[.]/
250
+ dot_count += 1
251
+ raise ParseError, "Unexpected '.' in numeric literal at #{@pos}" if dot_count > 1
252
+ s += cnext
253
+ else
254
+ @block.call(Node.new(:numeric_literal, pos, s.include?('.') ? s.to_f : s.to_i, @context))
255
+ break
256
+ end
257
+ end
258
+ end
259
+
260
+ def symbol_or_comment(s = '')
261
+ s += cnext
262
+ if cpeek == '*'
263
+ s += cnext
264
+ comment(s)
265
+ else
266
+ symbol(s)
267
+ end
268
+ end
269
+
270
+ def comment(s = '')
271
+ pos = @pos
272
+ maybe_ending = false
273
+ while true
274
+ case cpeek
275
+ when '*'
276
+ maybe_ending = true
277
+ s += cnext
278
+ when '/'
279
+ s += cnext
280
+ if maybe_ending
281
+ @block.call(Node.new(:comment, pos, s, @context))
282
+ break
283
+ end
284
+ else
285
+ maybe_ending = false
286
+ next_c = cnext
287
+ raise ParseError, "Unexpected EOF in comment at #{@pos}" if next_c.nil?
288
+ s += next_c
289
+ end
290
+ end
291
+ end
292
+
293
+ def symbol(s = '')
294
+ pos = @pos - s.length
295
+ while true
296
+ case cpeek
297
+ when SYMBOL_TRAILING_CHAR
298
+ s += cnext
299
+ else
300
+ case s
301
+ when 'true'
302
+ @block.call(Node.new(:boolean, pos, true, @context))
303
+ when 'false'
304
+ @block.call(Node.new(:boolean, pos, false, @context))
305
+ when 'nan'
306
+ @block.call(Node.new(:nan, pos, nil, @context))
307
+ when 'nil'
308
+ @block.call(Node.new(:nil, pos, nil, @context))
309
+ else
310
+ @block.call(Node.new(:symbol, pos, s, @context))
311
+ end
312
+ break
313
+ end
314
+ end
315
+ end
316
+
317
+ def quote_symbol
318
+ cnext
319
+ @block.call(Node.new(:symbol, @pos, "'", @context))
320
+ end
321
+
322
+ def expect(c)
323
+ raise ParseError, "'#{c}' expected at #{@pos}" if cpeek != c
324
+ cnext
325
+ end
326
+
327
+ def cpeek
328
+ @chars[@pos]
329
+ end
330
+
331
+ def cnext
332
+ c = @chars[@pos]
333
+ @pos += 1
334
+ c
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,43 @@
1
+ require_relative 'std_lib_let'
2
+ require_relative 'std_lib_control'
3
+ require_relative 'std_lib_enum'
4
+ require_relative 'std_lib_math'
5
+ require_relative 'std_lib_string'
6
+ require_relative 'std_lib_assoc'
7
+ require_relative 'std_lib_list'
8
+ require_relative 'std_lib_kind'
9
+ require_relative 'std_lib_json'
10
+ require_relative 'std_lib_number'
11
+ require_relative 'std_lib_logic'
12
+ require_relative 'std_lib_existence'
13
+ require_relative 'std_lib_regexp'
14
+
15
+ module Platform
16
+ module IEL
17
+ class StdLib
18
+
19
+ # Declare the standard lib functions in this eval context.
20
+ def self.declare(defining_context)
21
+ StdLibLet.declare(defining_context)
22
+ StdLibControl.declare(defining_context)
23
+ StdLibEnum.declare(defining_context)
24
+ StdLibMath.declare(defining_context)
25
+ StdLibString.declare(defining_context)
26
+ StdLibAssoc.declare(defining_context)
27
+ StdLibKind.declare(defining_context)
28
+ StdLibJson.declare(defining_context)
29
+ StdLibNumber.declare(defining_context)
30
+ StdLibLogic.declare(defining_context)
31
+ StdLibExistence.declare(defining_context)
32
+ StdLibRegexp.declare(defining_context)
33
+ end
34
+
35
+ # Helper to create a new context with stdlib already defined.
36
+ def self.new_context
37
+ ec = EvaluationContext.new
38
+ declare(ec)
39
+ ec
40
+ end
41
+ end
42
+ end
43
+ end