Module: Hecks::Bluebook::Expression::Evaluator

Defined in:
lib/hecks/bluebook/expression/evaluator.rb

Defined Under Namespace

Classes: And, Compare, Include, Not, Operator, Or, Resolve

Constant Summary collapse

PROJECTION =

Six operators, reduced to two primitives (less_than, equal) combined with a small boolean algebra: compares_less_than/compares_equal choose which primitive(s) OR together, negated inverts the result.

READ, NOT RESTATED. This table is the checked-in projection of the grammar chapter's admitted set (bin/expression_projection), joined with the algebra Vocabulary::Comparison declares. The evaluator cannot boot the chapter that configures it — the Prism adapter normalises every predicate through CanonicalForm while a bluebook loads — so the projection is how the domain reaches here: regenerated when the ledger changes, held fresh by spec/operators_export_spec.rb, and held to the live machinery by spec/operator_conformance_spec.rb.

JSON.parse(
  File.read(File.join(__dir__, "projection.json")), symbolize_names: true
).freeze
OPERATORS =
PROJECTION.fetch(:operators)
.select { |row| row[:category] == "comparison" }
.map { |row| Operator.new(**row.slice(:symbol, :compares_less_than, :compares_equal, :negated)) }
.freeze
COMPARISONS =
OPERATORS.map(&:symbol).freeze
INCLUDE_HAYSTACKS =

Declared the same way in Vocabulary::IncludeHaystack (language/bluebook/vocabulary.bluebook) — spec/vocabulary_conformance_spec holds this equal to the language, so the set of haystack types .include? supports cannot drift from what the language says it does.

Hecks::Vocabulary.fetch("IncludeHaystack")

Class Method Summary collapse

Class Method Details

.apply(op, lhs, rhs) ⇒ Object

The algebra itself, on values already resolved — split out so a sign test (SignTest#compares_via names an Operator symbol) can apply the SAME primitives compare() uses against the literal 0, rather than re-deriving positive?/negative?/zero? by hand a second time.



131
132
133
134
135
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 131

def apply(op, lhs, rhs)
  result = (op.compares_less_than && less_than(lhs, rhs)) ||
           (op.compares_equal && equal?(lhs, rhs))
  op.negated ? !result : result
end

.ast_cacheObject

Keyed by the exact string call receives. Canonical text is already normalised at DSL-build time, so the same given/invariant's text is byte-identical across every dispatch that evaluates it — parsed once here, interpreted fresh against each call's own state/attrs. Matches MetaValidator.verdicts' unsynchronized ||= {} idiom : redundant parse work under real parallelism, never corruption.



55
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 55

def ast_cache = @ast_cache ||= {}

.call(expr, state, attrs = {}) ⇒ Object



57
58
59
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 57

def call(expr, state, attrs = {})
  interpret(ast_cache[expr] ||= parse(expr), state, attrs)
end

.class_of(value) ⇒ Object



159
160
161
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 159

def class_of(value)
  value.nil? ? "nil" : value.class.name
end

.compare(op, left, right, state, attrs) ⇒ Object



120
121
122
123
124
125
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 120

def compare(op, left, right, state, attrs)
  lhs = Resolver.interpret(left, state, attrs)
  rhs = Resolver.interpret(right, state, attrs)

  apply(op, lhs, rhs)
end

.equal?(lhs, rhs) ⇒ Boolean

Returns:

  • (Boolean)


147
148
149
150
151
152
153
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 147

def equal?(lhs, rhs)
  left  = Resolver.numeric(lhs)
  right = Resolver.numeric(rhs)
  return left == right if left && right

  lhs == rhs
end

.includes?(parts, state, attrs) ⇒ Boolean

Returns:

  • (Boolean)


194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 194

def includes?(parts, state, attrs)
  haystack, needle = parts
  wanted = Resolver.interpret(needle, state, attrs)

  case (found = Resolver.interpret(haystack, state, attrs))
  when Array then found.any? { |item| equal?(item, wanted) }
  when String
    unless wanted.is_a?(String)
      raise EvaluationError, "no implicit conversion of #{class_of(wanted)} into String"
    end

    found.include?(wanted)
  else false
  end
end

.interpret(node, state, attrs) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 98

def interpret(node, state, attrs)
  case node
  when Or      then interpret(node.left, state, attrs) || interpret(node.right, state, attrs)
  when And     then interpret(node.left, state, attrs) && interpret(node.right, state, attrs)
  when Not     then !interpret(node.node, state, attrs)
  when Compare then compare(node.operator, node.left, node.right, state, attrs)
  when Include then includes?([node.haystack, node.needle], state, attrs)
  when Resolve then truthy?(Resolver.interpret(node.expr, state, attrs))
  else
    # Every node `parse` can produce has a `when` above — a
    # backstop against the day this grammar grows a new node
    # type and `interpret` doesn't grow to match it. A missing
    # arm here used to return bare `nil`, and `Or`/`And` fold
    # that straight into the boolean algebra as ordinary falsy
    # — reading exactly like "the rule legitimately does not
    # hold" rather than "the runtime cannot evaluate this rule
    # at all", the one silent no-op this language otherwise
    # refuses.
    raise EvaluationError, "no interpreter handles #{node.class} — add a case before parse can produce it"
  end
end

.less_than(lhs, rhs) ⇒ Object

Raises:



137
138
139
140
141
142
143
144
145
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 137

def less_than(lhs, rhs)
  left  = Resolver.numeric(lhs)
  right = Resolver.numeric(rhs)
  return left < right if left && right
  return lhs < rhs    if lhs.is_a?(String) && rhs.is_a?(String)

  raise EvaluationError,
        "comparison of #{class_of(lhs)} with #{Resolver.describe(rhs)} failed"
end

.match_include(expr) ⇒ Object

THE SAME MIS-SPLIT Resolver.match_call had (its own comment has the full story), found here too by the same generator: a .include? needle can itself be — or contain — ANOTHER .include? call ("".include?(arr.all? { |el| "".include?("") }.to_s), a String built via .to_s off a block predicate whose own body happens to include one) — rindex finds the INNERMOST occurrence, not the outermost this split actually needs. Fixed identically: try each occurrence left to right, keep the first whose own balanced-paren match reaches the string's last character — Resolver.matching_paren is reused directly rather than duplicated, the same depth-tracking rule either grammar layer needs here.



175
176
177
178
179
180
181
182
183
184
185
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 175

def match_include(expr)
  start = 0
  marker = ".include?("
  while (index = expr.index(marker, start))
    close = Resolver.matching_paren(expr, index + marker.length)
    return [expr[0...index], expr[(index + marker.length)...close]] if close == expr.length - 1

    start = index + 1
  end
  nil
end

.parse(expr) ⇒ Object



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
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 61

def parse(expr)
  expr = strip_parens(expr.to_s.strip)

  left, right = split_top_level(expr, "||")
  return Or.new(left: parse(left), right: parse(right)) if left

  left, right = split_top_level(expr, "&&")
  return And.new(left: parse(left), right: parse(right)) if left

  # Tried BEFORE `.include?`/comparisons, not after — `!` negates
  # the WHOLE boolean expression that follows it (`!names.include?(x)`
  # means `!(names.include?(x))`, never "call .include? on the negated
  # receiver"), so the leading marker has to be stripped and the
  # remainder re-parsed before anything downstream gets a chance to
  # mis-scan across it. It used to sit after `match_include`, whose
  # naive `rindex(".include?(")` has no concept of a leading `!` —
  # for `!names.include?(x)` it swallowed the `!` straight into the
  # haystack text ("!names"), which `Resolver.parse` cannot resolve,
  # so every spelling of negated membership raised instead of
  # evaluating. Moving the check here fixes both the bare prefix
  # (`!names.include?(x)`) and the parenthesized form
  # (`!(names.include?(x))`) — the recursive `parse` call sees the
  # clean remainder and correctly finds the `.include?` (or `&&`/`||`)
  # inside it.
  return Not.new(node: parse(Regexp.last_match(1))) if expr =~ /\A!(.+)\z/

  membership = match_include(expr)
  return Include.new(haystack: Resolver.parse(membership[0]), needle: Resolver.parse(membership[1])) if membership

  OPERATORS.each do |op|
    left, right = split_comparison(expr, op.symbol)
    return Compare.new(operator: op, left: Resolver.parse(left), right: Resolver.parse(right)) if left
  end

  Resolve.new(expr: Resolver.parse(expr))
end

.part_of_longer?(expr, index, operator) ⇒ Boolean

Returns:

  • (Boolean)


236
237
238
239
240
241
242
243
244
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 236

def part_of_longer?(expr, index, operator)
  after  = expr[index + operator.length]
  before = index.positive? ? expr[index - 1] : nil

  return true if after == "=" && !operator.end_with?("=")
  return true if ["<", ">", "!", "="].include?(before) && operator.start_with?("=")

  false
end

.split_comparison(expr, operator) ⇒ Object



229
230
231
232
233
234
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 229

def split_comparison(expr, operator)
  index = top_level_index(expr, operator) { |at| !part_of_longer?(expr, at, operator) }
  return nil unless index

  [expr[0...index].strip, expr[(index + operator.length)..].strip]
end

.split_top_level(expr, operator) ⇒ Object



222
223
224
225
226
227
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 222

def split_top_level(expr, operator)
  index = top_level_index(expr, operator)
  return nil unless index

  [expr[0...index].strip, expr[(index + operator.length)..].strip]
end

.strip_parens(expr) ⇒ Object



210
211
212
213
214
215
216
217
218
219
220
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 210

def strip_parens(expr)
  return expr unless expr.start_with?("(") && expr.end_with?(")")

  depth = 0
  expr.each_char.with_index do |char, index|
    depth += 1 if char == "("
    depth -= 1 if char == ")"
    return expr if depth.zero? && index < expr.length - 1
  end
  strip_parens(expr[1..-2].strip)
end

.top_level_index(expr, operator) ⇒ Object



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 246

def top_level_index(expr, operator)
  depth = 0
  quote = nil
  index = 0

  while index < expr.length
    char = expr[index]

    if quote
      quote = nil if char == quote
    elsif ['"', "'"].include?(char)
      quote = char
    # `{`/`}` depth -- vendored addition, not (yet) upstream
    # hecks (migration plan task 9): this method already
    # treats `(`/`)` as a grouping construct so an operator
    # INSIDE a call's parens is never mistaken for a top-level
    # split point ; `{`/`}` needed the identical treatment the
    # moment `Bluebook::Expression::Resolver` grew block-taking
    # `.all?`/`.any?`/`.none? { |s| PREDICATE }` support (see
    # resolver.rb's own `BlockPredicate` addition) -- without
    # this, an operator INSIDE the block's own predicate (e.g.
    # `s.length > 0`) reads as a top-level split of the WHOLE
    # `value.split("::").all? { |s| s.length > 0 }` expression,
    # confirmed live via `Lexicon::Lexicon.Lookup`/`Query::Query.
    # Run` (the exact `Phrase` invariant this gap was found
    # against) : the stray `>` split the expression in half
    # before `Resolver.parse` ever saw the block as one atomic
    # leaf, and the two halves then failed independently with
    # the same raw `TypeError` the block-predicate fix was
    # built to close. `{`/`}` cannot legitimately appear inside
    # a quoted literal either, so this sits beside the existing
    # paren-depth branch, not instead of it.
    #
    # `[`/`]` -- the identical lesson a THIRD time (found live via
    # the type-directed bounded-exhaustive expression generator,
    # Phase 7 of the equivalence-gap plan): `Resolver::ArrayLiteral`
    # (`[a, b]`) can appear as a general sub-expression, not only
    # as `.include?`'s own haystack, the moment an array-typed
    # attribute or a synthesized literal is embedded anywhere else
    # -- and an element containing a top-level `+`/comparison of
    # its own (`[0, 0 + 0]`) used to read as a split point for
    # THIS expression's own boolean/comparison grammar, exactly
    # the way an un-tracked `{`/`}` once did for block predicates.
    elsif char == "(" || char == "{" || char == "["
      depth += 1
    elsif char == ")" || char == "}" || char == "]"
      depth -= 1
    elsif depth.zero? && expr[index, operator.length] == operator
      return index if !block_given? || yield(index)
    end

    index += 1
  end
  nil
end

.truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


155
156
157
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 155

def truthy?(value)
  !value.nil? && value != false
end