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.



116
117
118
119
120
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 116

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



144
145
146
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 144

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

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



105
106
107
108
109
110
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 105

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)


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

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)


162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 162

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



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 83

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:



122
123
124
125
126
127
128
129
130
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 122

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



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

def match_include(expr)
  index = expr.rindex(".include?(")
  return nil unless index && expr.end_with?(")")

  [expr[0...index], expr[(index + ".include?(".length)...-1]]
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
# 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

  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

  return Not.new(node: parse(Regexp.last_match(1))) if expr =~ /\A!(.+)\z/

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

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

Returns:

  • (Boolean)


204
205
206
207
208
209
210
211
212
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 204

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



197
198
199
200
201
202
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 197

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



190
191
192
193
194
195
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 190

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



178
179
180
181
182
183
184
185
186
187
188
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 178

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



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 214

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.
    elsif char == "(" || char == "{"
      depth += 1
    elsif 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)


140
141
142
# File 'lib/hecks/bluebook/expression/evaluator.rb', line 140

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