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



608
609
610
611
612
613
614
615
616
# File 'lib/barefoot_js/evaluator.rb', line 608

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



690
691
692
# File 'lib/barefoot_js/evaluator.rb', line 690

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.



597
598
599
600
601
602
603
604
605
606
# File 'lib/barefoot_js/evaluator.rb', line 597

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



686
687
688
# File 'lib/barefoot_js/evaluator.rb', line 686

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).



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

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).



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

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



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

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



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

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.



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

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



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

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).



546
547
548
549
550
551
552
553
554
555
556
557
558
559
# File 'lib/barefoot_js/evaluator.rb', line 546

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



561
562
563
# File 'lib/barefoot_js/evaluator.rb', line 561

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.



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

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.



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

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



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

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.



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/barefoot_js/evaluator.rb', line 206

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)


319
320
321
322
323
324
325
# File 'lib/barefoot_js/evaluator.rb', line 319

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



618
619
620
621
622
623
624
625
626
# File 'lib/barefoot_js/evaluator.rb', line 618

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



694
695
696
# File 'lib/barefoot_js/evaluator.rb', line 694

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.



570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
# File 'lib/barefoot_js/evaluator.rb', line 570

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



587
588
589
# File 'lib/barefoot_js/evaluator.rb', line 587

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