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



158
159
160
# File 'lib/barefoot_js/evaluator.rb', line 158

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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# 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'
    # Each entry carries its own `kind` ('prop' | 'spread', #2696 Step
    # 2) -- the same encoding the Go/Perl/Python evaluators and the raw
    # ParsedExpr shape the eval-vectors.json golden corpus carry. A
    # 'spread' evaluates its `expr` and shallow-merges the result's own
    # keys (a non-Hash result -- including a null/undefined JS spread
    # source -- is a no-op). Every hash this evaluator builds uses
    # SYMBOL keys (`read_property` below always looks up `key.to_sym`),
    # so a spread's keys are symbolized on merge for the same reason a
    # `prop` key already is, regardless of whether the spread source's
    # own keys started out as symbols or strings. Later entries win on
    # a shared key, in source order, matching JS object-spread exactly.
    out = {}
    (node[:properties] || []).each do |prop|
      if (prop[:kind] || '') == 'spread'
        spread = evaluate(prop[:expr], env)
        spread.each { |k, v| out[k.to_sym] = v } if spread.is_a?(Hash)
        next
      end
      out[prop[:key].to_sym] = evaluate(prop[:value], env)
    end
    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



639
640
641
642
643
644
645
646
647
# File 'lib/barefoot_js/evaluator.rb', line 639

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



721
722
723
# File 'lib/barefoot_js/evaluator.rb', line 721

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.



628
629
630
631
632
633
634
635
636
637
# File 'lib/barefoot_js/evaluator.rb', line 628

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



717
718
719
# File 'lib/barefoot_js/evaluator.rb', line 717

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



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

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



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

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



733
734
735
# File 'lib/barefoot_js/evaluator.rb', line 733

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



729
730
731
# File 'lib/barefoot_js/evaluator.rb', line 729

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.



691
692
693
694
695
696
697
698
699
700
701
702
# File 'lib/barefoot_js/evaluator.rb', line 691

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



737
738
739
# File 'lib/barefoot_js/evaluator.rb', line 737

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



577
578
579
580
581
582
583
584
585
586
587
588
589
590
# File 'lib/barefoot_js/evaluator.rb', line 577

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



592
593
594
# File 'lib/barefoot_js/evaluator.rb', line 592

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.



364
365
366
367
368
369
370
371
# File 'lib/barefoot_js/evaluator.rb', line 364

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.



707
708
709
710
711
712
713
714
715
# File 'lib/barefoot_js/evaluator.rb', line 707

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



741
742
743
# File 'lib/barefoot_js/evaluator.rb', line 741

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.



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/barefoot_js/evaluator.rb', line 237

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)


350
351
352
353
354
355
356
# File 'lib/barefoot_js/evaluator.rb', line 350

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



649
650
651
652
653
654
655
656
657
# File 'lib/barefoot_js/evaluator.rb', line 649

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



725
726
727
# File 'lib/barefoot_js/evaluator.rb', line 725

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.



601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
# File 'lib/barefoot_js/evaluator.rb', line 601

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



618
619
620
# File 'lib/barefoot_js/evaluator.rb', line 618

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:



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

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:



178
179
180
181
182
183
184
# File 'lib/barefoot_js/evaluator.rb', line 178

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)


186
187
188
189
190
191
192
193
194
195
196
# File 'lib/barefoot_js/evaluator.rb', line 186

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