Module: BarefootJS::Evaluator

Defined in:
lib/barefoot_js/evaluator.rb

Overview

Lightweight evaluator for the pure ParsedExpr subset, scoped to higher-order callback bodies (reduce / sort / map / filter / find (...) => expr) -- issue #2018. Templates cannot carry a lambda in expression position, which is why the adapters historically special-cased these callbacks into fixed shapes (bf.sort's comparator catalogue, bf.reduce's +/* fold). Instead, the callback BODY rides as a pure ParsedExpr subtree (the structured IR the compiler already produces) and is evaluated here against an environment ({acc, item, ...captured free vars}).

Ruby port of BarefootJS::Evaluator (Perl), sharing the same contract as the Go evaluator (bf.go). The accepted subset and its semantics are documented in spec/compiler.md ("ParsedExpr Evaluator Semantics") and pinned isomorphically by the cross-language golden vectors (packages/adapter-tests/vectors/eval-vectors.json). The literal JS reference implementation is eval-reference.ts -- this port follows it node-for-node, including its refusal behaviour (EvalUnsupported), which is a closer contract match than the Perl port's silent-nil shortcuts (Perl blurs strings/numbers and can't cheaply enforce every refusal; Ruby's real type distinctions make strict refusal free).

Value domain: JSON-shaped Ruby data with SYMBOL hash keys throughout (object literals, environments, member/index results). AST nodes (ParsedExpr, decoded from JSON) also use symbol keys -- node[:kind], node[:left], etc. String KEYS from the AST that name environment bindings or object fields (identifier names, member.property, object-literal property keys) are plain Ruby Strings coming out of the parser; they are converted to Symbols at the point they touch a SYMBOL-keyed Hash (env or object value).

Defined Under Namespace

Classes: EvalUnsupported

Constant Summary collapse

HEX_STRING_RE =

Number <-> String helpers

/\A0[xX][0-9a-fA-F]+\z/
NUMERIC_STRING_RE =
/\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/

Class Method Summary collapse

Class Method Details

.eval_json(json, env) ⇒ Object

eval_json(json, env): decode a ParsedExpr JSON string and evaluate it. env is a plain Ruby Hash with symbol keys (caller's responsibility, matching the SYMBOL-keys-throughout value convention).



140
141
142
# File 'lib/barefoot_js/evaluator.rb', line 140

def eval_json(json, env)
  evaluate(JSON.parse(json, symbolize_names: true), env)
end

.evaluate(node, env) ⇒ Object

evaluate(node, env) -> a Ruby value (Integer/Float, String, true/false, nil, Array, Hash-with-symbol-keys) per the ParsedExpr AST node kind.



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/barefoot_js/evaluator.rb', line 43

def evaluate(node, env)
  return nil unless node.is_a?(Hash)
  kind = node[:kind]

  case kind
  when 'literal'
    node[:value]
  when 'identifier'
    name = node[:name]
    key = name.to_sym
    raise EvalUnsupported, "unbound identifier '#{name}'" unless env.key?(key)
    env[key]
  when 'binary'
    binary(node[:op], evaluate(node[:left], env), evaluate(node[:right], env))
  when 'unary'
    unary(node[:op], evaluate(node[:argument], env))
  when 'logical'
    op = node[:op]
    left = evaluate(node[:left], env)
    case op
    when '&&' then truthy?(left) ? evaluate(node[:right], env) : left
    when '||' then truthy?(left) ? left : evaluate(node[:right], env)
    else left.nil? ? evaluate(node[:right], env) : left # '??'
    end
  when 'conditional'
    truthy?(evaluate(node[:test], env)) ? evaluate(node[:consequent], env) : evaluate(node[:alternate], env)
  when 'member'
    read_property(evaluate(node[:object], env), node[:property])
  when 'index-access'
    read_index(evaluate(node[:object], env), evaluate(node[:index], env))
  when 'call'
    # A nested `.map(cb)` / `.filter(cb)` callback call (#2094):
    # syntactically a `call` whose callee is `<recv>.map`/`<recv>.filter`
    # and whose first argument is an `arrow` node -- the same shape
    # `asCallbackMethodCall` recognizes at compile time, and the shape
    # the `eval-vectors.json` golden corpus itself carries. Checked
    # BEFORE the builtin-name check below, since `<recv>.map` would
    # otherwise resolve to a non-builtin member callee and raise.
    method, object_node, arrow_node = array_callback_call(node)
    if method
      array_callback(method, object_node, arrow_node, env)
    else
      name = builtin_name(node[:callee])
      raise EvalUnsupported, 'only built-in calls (Math.*, String/Number/Boolean) are in the subset' if name.nil?
      args = (node[:args] || []).map { |a| evaluate(a, env) }
      call_builtin(name, args)
    end
  when 'template-literal'
    out = +''
    (node[:parts] || []).each do |p|
      out << if p[:type] == 'string'
               (p[:value] || '')
             else
               to_string(evaluate(p[:expr], env))
             end
    end
    out
  when 'array-literal'
    (node[:elements] || []).map { |e| evaluate(e, env) }
  when 'object-literal'
    out = {}
    (node[:properties] || []).each { |prop| out[prop[:key].to_sym] = evaluate(prop[:value], env) }
    out
  when 'array-method'
    args = node[:args] || []
    if node[:method] == 'includes' && args.length == 1
      # `.includes(x)` (#2075) -- the one `array-method` in the
      # evaluator subset, shared between `Array.prototype.includes`
      # (SameValueZero membership) and `String.prototype.includes`
      # (substring search), matching the receiver-type dispatch the SSR
      # template lowering does at runtime (`bf.includes`). Mirrors the
      # JS reference's `includes()` (eval-reference.ts).
      includes_value(evaluate(node[:object], env), evaluate(args[0], env))
    elsif node[:method] == 'join' && args.length <= 1
      # `.join(sep?)` (#2094) -- a nested `.flatMap(p => p.tags.map(...))
      # .join(...)` projection composes a `.join` on top of a nested
      # `.map`, so it must be executable in the same evaluator subset as
      # `.includes`. Default separator "," (JS); a `nil` element joins
      # as "" (not the string "null"). Mirrors Go's `evalJoin`.
      sep = args.empty? ? ',' : to_string(evaluate(args[0], env))
      array_join(evaluate(node[:object], env), sep)
    else
      # Every other array/string method (`slice`, `flat`, ...) is
      # outside the subset; a callback body containing one is refused
      # upstream (BF101) and should never reach here, but the evaluator
      # refuses explicitly rather than falling through silently,
      # matching the JS reference.
      raise EvalUnsupported, "array-method '#{node[:method]}' is not in the evaluator subset"
    end
  else
    raise EvalUnsupported, "node kind '#{kind}' is not in the evaluator subset"
  end
end

.every(items, pred, param, base_env = nil) ⇒ Object



621
622
623
624
625
626
627
628
629
# File 'lib/barefoot_js/evaluator.rb', line 621

def every(items, pred, param, base_env = nil)
  arr = items.is_a?(Array) ? items : []
  env = base_env ? base_env.dup : {}
  key = param.to_sym
  arr.all? do |item|
    env[key] = item
    truthy?(evaluate(pred, env))
  end
end

.every_json(items, pred_json, param, base_env = nil) ⇒ Object



703
704
705
# File 'lib/barefoot_js/evaluator.rb', line 703

def every_json(items, pred_json, param, base_env = nil)
  every(items, JSON.parse(pred_json, symbolize_names: true), param, base_env)
end

.filter(items, pred, param, base_env = nil) ⇒ Object


Higher-order predicates -- the generalization of filter / find / find_index / every / some onto the evaluator. pred is a pure ParsedExpr evaluated against {param => item} plus base_env.



610
611
612
613
614
615
616
617
618
619
# File 'lib/barefoot_js/evaluator.rb', line 610

def filter(items, pred, param, base_env = nil)
  return [] unless items.is_a?(Array)

  env = base_env ? base_env.dup : {}
  key = param.to_sym
  items.select do |item|
    env[key] = item
    truthy?(evaluate(pred, env))
  end
end

.filter_json(items, pred_json, param, base_env = nil) ⇒ Object



699
700
701
# File 'lib/barefoot_js/evaluator.rb', line 699

def filter_json(items, pred_json, param, base_env = nil)
  filter(items, JSON.parse(pred_json, symbolize_names: true), param, base_env)
end

.find(items, pred, param, forward = true, base_env = nil) ⇒ Object

find -- first matching element, or nil. forward false searches from the end (findLast).



643
644
645
646
647
648
649
650
651
652
653
# File 'lib/barefoot_js/evaluator.rb', line 643

def find(items, pred, param, forward = true, base_env = nil)
  arr = items.is_a?(Array) ? items : []
  arr = arr.reverse unless forward
  env = base_env ? base_env.dup : {}
  key = param.to_sym
  arr.each do |item|
    env[key] = item
    return item if truthy?(evaluate(pred, env))
  end
  nil
end

.find_index(items, pred, param, forward = true, base_env = nil) ⇒ Object

find_index -- index of the first matching element, or -1. forward false -> findLastIndex (the index is into the original array either way).



658
659
660
661
662
663
664
665
666
667
668
# File 'lib/barefoot_js/evaluator.rb', line 658

def find_index(items, pred, param, forward = true, base_env = nil)
  arr = items.is_a?(Array) ? items : []
  env = base_env ? base_env.dup : {}
  key = param.to_sym
  idxs = forward ? (0...arr.length) : (0...arr.length).to_a.reverse
  idxs.each do |i|
    env[key] = arr[i]
    return i if truthy?(evaluate(pred, env))
  end
  -1
end

.find_index_json(items, pred_json, param, forward = true, base_env = nil) ⇒ Object



715
716
717
# File 'lib/barefoot_js/evaluator.rb', line 715

def find_index_json(items, pred_json, param, forward = true, base_env = nil)
  find_index(items, JSON.parse(pred_json, symbolize_names: true), param, forward, base_env)
end

.find_json(items, pred_json, param, forward = true, base_env = nil) ⇒ Object



711
712
713
# File 'lib/barefoot_js/evaluator.rb', line 711

def find_json(items, pred_json, param, forward = true, base_env = nil)
  find(items, JSON.parse(pred_json, symbolize_names: true), param, forward, base_env)
end

.flat_map(items, proj, param, base_env = nil) ⇒ Object

flat_map -- project each element through proj and flatten one level. A projection yielding an array contributes its elements; any other value contributes itself.



673
674
675
676
677
678
679
680
681
682
683
684
# File 'lib/barefoot_js/evaluator.rb', line 673

def flat_map(items, proj, param, base_env = nil)
  arr = items.is_a?(Array) ? items : []
  env = base_env ? base_env.dup : {}
  key = param.to_sym
  out = []
  arr.each do |item|
    env[key] = item
    v = evaluate(proj, env)
    v.is_a?(Array) ? out.concat(v) : out.push(v)
  end
  out
end

.flat_map_json(items, proj_json, param, base_env = nil) ⇒ Object



719
720
721
# File 'lib/barefoot_js/evaluator.rb', line 719

def flat_map_json(items, proj_json, param, base_env = nil)
  flat_map(items, JSON.parse(proj_json, symbolize_names: true), param, base_env)
end

.fold(items, body, acc_name, item_name, init, direction = 'left', base_env = nil) ⇒ Object

fold(items, body, acc_name, item_name, init, direction, base_env)

Fold an array into a value via the evaluator. body is a pure ParsedExpr node evaluated against {acc_name => acc, item_name => item} plus the captured free vars in base_env, per element. direction is "left" (reduce) or "right" (reduceRight).



559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/barefoot_js/evaluator.rb', line 559

def fold(items, body, acc_name, item_name, init, direction = 'left', base_env = nil)
  arr = items.is_a?(Array) ? items : []
  arr = arr.reverse if direction == 'right'
  env = base_env ? base_env.dup : {}
  acc = init
  acc_key = acc_name.to_sym
  item_key = item_name.to_sym
  arr.each do |item|
    env[acc_key] = acc
    env[item_key] = item
    acc = evaluate(body, env)
  end
  acc
end

.fold_json(items, body_json, acc_name, item_name, init, direction = 'left', base_env = nil) ⇒ Object



574
575
576
# File 'lib/barefoot_js/evaluator.rb', line 574

def fold_json(items, body_json, acc_name, item_name, init, direction = 'left', base_env = nil)
  fold(items, JSON.parse(body_json, symbolize_names: true), acc_name, item_name, init, direction, base_env)
end

.includes_value(obj, needle) ⇒ Object

includes_value(obj, needle): the receiver-dispatch behind the array-method includes node above, factored out so BarefootJS::Context#includes (the runtime helper compiled templates call directly, outside any evaluator subtree) can share it too -- mirrors BarefootJS.pm::includes delegating to BarefootJS::Evaluator::_same_value_zero.



346
347
348
349
350
351
352
353
# File 'lib/barefoot_js/evaluator.rb', line 346

def includes_value(obj, needle)
  return obj.any? { |el| same_value_zero?(el, needle) } if obj.is_a?(Array)
  return obj.include?(to_string(needle)) if obj.is_a?(String)

  # Any other receiver is not a JS `.includes` target -- degrade to
  # false rather than raising, mirroring the reference.
  false
end

.map_items(items, proj, param, base_env = nil) ⇒ Object

map_items -- project each element through proj, keeping each result as one element (no flatten): value-producing .map(cb). Named map_items (not map) to stay clear of Ruby's own Enumerable#map.



689
690
691
692
693
694
695
696
697
# File 'lib/barefoot_js/evaluator.rb', line 689

def map_items(items, proj, param, base_env = nil)
  arr = items.is_a?(Array) ? items : []
  env = base_env ? base_env.dup : {}
  key = param.to_sym
  arr.map do |item|
    env[key] = item
    evaluate(proj, env)
  end
end

.map_json(items, proj_json, param, base_env = nil) ⇒ Object



723
724
725
# File 'lib/barefoot_js/evaluator.rb', line 723

def map_json(items, proj_json, param, base_env = nil)
  map_items(items, JSON.parse(proj_json, symbolize_names: true), param, base_env)
end

.number_to_string(n) ⇒ Object

JS Number#toString. Integral finite values (however they arrived -- Integer or an integral Float) render without a decimal point ("1.0" -> "1"); non-finite values use the JS spellings ("NaN" / "Infinity" / "-Infinity"), which Ruby's own Float#to_s does not use. Non-integral floats fall back to Ruby's shortest-round-trip Float#to_s (the same class of algorithm V8 uses), reformatted to JS's exponent style. This is not the full ECMA-262 Number::toString grammar (no attempt to match JS's exact exponential-notation thresholds), but it is exact for every value the golden vectors exercise.



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/barefoot_js/evaluator.rb', line 219

def number_to_string(n)
  f = n.to_f
  return 'NaN' if f.nan?
  return f.negative? ? '-Infinity' : 'Infinity' if f.infinite?
  return '0' if f.zero?
  return n.to_i.to_s if f == f.to_i && f.abs < 1e21

  s = f.to_s
  if s.include?('e')
    mantissa, exp = s.split('e')
    mantissa = mantissa.sub(/\.0\z/, '')
    sign = exp.start_with?('-') ? '-' : '+'
    digits = exp.sub(/\A[+-]/, '').sub(/\A0+(?=\d)/, '')
    "#{mantissa}e#{sign}#{digits}"
  else
    s
  end
end

.same_value_zero?(l, r) ⇒ Boolean

same_value_zero?(l, r): Array.prototype.includes membership test -- === except NaN equals itself (and +0/-0 are not distinguished, which the JSON-decoded values here can't represent anyway). Reuses strict_eq's type/value rules for the primitive cases and only special-cases the two-NaN case that strict_eq (deliberately, for ===) reports as unequal. Unlike strict_eq, never raises for a non-primitive operand -- the JS reference's sameValueZero uses native === directly (reference equality for objects/arrays, never a throw), not the subset's throwing strictEquals; two freshly JSON-decoded structures are never the same object, so this degrades to false rather than raising. Public (unlike strict_eq) because BarefootJS::Context#includes (barefoot_js.rb) calls it directly, matching the Perl port's cross-module _same_value_zero use.

Returns:

  • (Boolean)


332
333
334
335
336
337
338
# File 'lib/barefoot_js/evaluator.rb', line 332

def same_value_zero?(l, r)
  return true if l.is_a?(Numeric) && r.is_a?(Numeric) && l.to_f.nan? && r.to_f.nan?

  strict_eq(l, r)
rescue EvalUnsupported
  false
end

.some(items, pred, param, base_env = nil) ⇒ Object



631
632
633
634
635
636
637
638
639
# File 'lib/barefoot_js/evaluator.rb', line 631

def some(items, pred, param, base_env = nil)
  arr = items.is_a?(Array) ? items : []
  env = base_env ? base_env.dup : {}
  key = param.to_sym
  arr.any? do |item|
    env[key] = item
    truthy?(evaluate(pred, env))
  end
end

.some_json(items, pred_json, param, base_env = nil) ⇒ Object



707
708
709
# File 'lib/barefoot_js/evaluator.rb', line 707

def some_json(items, pred_json, param, base_env = nil)
  some(items, JSON.parse(pred_json, symbolize_names: true), param, base_env)
end

.sort_by(items, cmp, param_a, param_b, base_env = nil) ⇒ Object

sort_by(items, cmp, param_a, param_b, base_env)

Return a new array ordered by a ParsedExpr comparator cmp evaluated against {param_a => a, param_b => b} plus base_env. Stable (ties break on original index) and non-mutating.



583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
# File 'lib/barefoot_js/evaluator.rb', line 583

def sort_by(items, cmp, param_a, param_b, base_env = nil)
  return [] unless items.is_a?(Array)

  env = base_env ? base_env.dup : {}
  a_key = param_a.to_sym
  b_key = param_b.to_sym
  decorated = items.each_with_index.map { |item, i| [i, item] }
  sorted = decorated.sort do |x, y|
    env[a_key] = x[1]
    env[b_key] = y[1]
    c = to_number(evaluate(cmp, env)).to_f
    sign = c.nan? ? 0 : (c <=> 0)
    sign.zero? ? (x[0] <=> y[0]) : sign
  end
  sorted.map { |pair| pair[1] }
end

.sort_by_json(items, cmp_json, param_a, param_b, base_env = nil) ⇒ Object



600
601
602
# File 'lib/barefoot_js/evaluator.rb', line 600

def sort_by_json(items, cmp_json, param_a, param_b, base_env = nil)
  sort_by(items, JSON.parse(cmp_json, symbolize_names: true), param_a, param_b, base_env)
end

.to_number(v) ⇒ Object


JS coercion primitives (ToNumber / ToString / ToBoolean).

Raises:



148
149
150
151
152
153
154
155
156
157
158
# File 'lib/barefoot_js/evaluator.rb', line 148

def to_number(v)
  return 0 if v.nil?
  return v ? 1 : 0 if v.is_a?(TrueClass) || v.is_a?(FalseClass)
  return v if v.is_a?(Numeric)
  if v.is_a?(String)
    t = v.strip
    return 0 if t.empty?
    return parse_numeric_string(t)
  end
  raise EvalUnsupported, "cannot coerce #{v.class} to number"
end

.to_string(v) ⇒ Object

Raises:



160
161
162
163
164
165
166
# File 'lib/barefoot_js/evaluator.rb', line 160

def to_string(v)
  return v if v.is_a?(String)
  return number_to_string(v) if v.is_a?(Numeric)
  return v ? 'true' : 'false' if v.is_a?(TrueClass) || v.is_a?(FalseClass)
  return 'null' if v.nil?
  raise EvalUnsupported, "cannot coerce #{v.class} to string"
end

.truthy?(v) ⇒ Boolean

Returns:

  • (Boolean)


168
169
170
171
172
173
174
175
176
177
178
# File 'lib/barefoot_js/evaluator.rb', line 168

def truthy?(v)
  return false if v.nil? || v.is_a?(FalseClass)
  return true if v.is_a?(TrueClass)
  if v.is_a?(Numeric)
    f = v.to_f
    return false if f.nan? || f.zero?
    return true
  end
  return v != '' if v.is_a?(String)
  true # arrays / objects are always truthy in JS
end