Module: Hecks::Bluebook::Expression::Resolver
- Defined in:
- lib/hecks/bluebook/expression/resolver.rb,
lib/hecks/bluebook/expression/resolver/block_predicates.rb
Defined Under Namespace
Classes: Addition, ArrayLiteral, BlockPredicate, BoolLiteral, Empty, EndsWith, Find, First, FloatLiteral, IntegerLiteral, Last, Lookup, MatchesRegex, Modulo, Presence, SignTest, Size, Split, StartsWith, StringLiteral, ToS
Constant Summary collapse
- SIGN_TESTS =
Hecks::Vocabulary.fetch("SignTest")
- SIGN_TEST_OPERATORS =
Which Comparison operator each sign test is sugar for, against the literal 0 — declared the same way in Vocabulary::SignTest's compares_via (language/bluebook/vocabulary.bluebook) ; spec/vocabulary_conformance_spec holds the two tables equal.
{ "positive?" => ">", "negative?" => "<", "zero?" => "==" }.freeze
- NilLiteral =
A plain class, not
Struct.new(keyword_init: true)— every sibling leaf node here carries at least one field, but this one carries none by nature (a nil literal has no data to hold), andStruct.newwith zero member names ahead ofkeyword_init:is real, live Ruby-version-dependent behavior: works on 3.3, raises "wrong number of arguments (given 0, expected 1+)" on 3.2 (caught deploying to Lambda's own ruby3.2 runtime)..new/case ... when NilLiteralbelow are the only two things this type is ever used for, and a bare class answers both identically. Class.new
- SIZED_TYPES =
Declared the same way in Vocabulary::SizedType (language/bluebook/vocabulary.bluebook) — spec/vocabulary_conformance_spec holds this equal to the language. Shared by .size and .empty?, which admit the same set for the same reason.
Hecks::Vocabulary.fetch("SizedType")
- TO_STRING_TYPES =
Declared the same way in Vocabulary::ToStringType (language/bluebook/vocabulary.bluebook) — spec/vocabulary_conformance_spec holds this equal to the language.
Hecks::Vocabulary.fetch("ToStringType")
- BLOCK_PREDICATE_MODES =
Which Array method each block-predicate suffix maps to, and which Ruby Enumerable method decides the aggregate result -- declared as data, not a three-way
case, the same shape SIGN_TEST_OPERATORS above already uses for its own suffix family. { "all?" => :all, "any?" => :any, "none?" => :none }.freeze
- BLOCK_OPENER_SUFFIXES =
Every suffix that opens a
{ |x| ... }block,.findincluded -- shared byparse_block_openerbelow, and by nothing else (this is NOTBLOCK_PREDICATE_MODES—.findisn't a modeevaluate_block_predicateaggregates through, it builds aFindnode instead, see below). (BLOCK_PREDICATE_MODES.keys + ["find"]).freeze
Class Method Summary collapse
- .add(left, right) ⇒ Object
-
.apply_modulo(receiver_value, divisor_value) ⇒ Object
Both operands are coerced to a real Integer/Float BEFORE the zero-check, and the check reads the COERCED divisor — not the raw
divisor_value(which might not even respond to.zero?, a String for instance) and not a.to_i-truncated stand-in for it either. - .apply_sign_test(node, value) ⇒ Object
-
.array_elements(expr) ⇒ Object
The elements of a bracketed literal, or nil if this isn't one.
-
.blank?(value) ⇒ Boolean
.present?/.blank?-- vendored addition, see thePresencestruct's own comment above. - .describe(value) ⇒ Object
- .emptiness_of(value) ⇒ Object
-
.ends_with?(value, substring) ⇒ Boolean
.end_with?("suffix")-- vendored addition, see theEndsWithstruct's own comment above. -
.evaluate_block_predicate(node, collection, state, attrs) ⇒ Object
.all?/.any?/.none?-- vendored addition, see theBlockPredicatestruct's own comment above. - .fetch(name, state, attrs) ⇒ Object
-
.first_of(value) ⇒ Object
.first-- see theFirststruct's own comment above. -
.found_of(node, collection, state, attrs) ⇒ Object
.find { |x| PREDICATE }-- see theFindstruct's own comment above. - .interpret(node, state, attrs) ⇒ Object
-
.interpret_with_element(node, element, state, attrs) ⇒ Object
Binds the block parameter for exactly one element's predicate evaluation --
attrswins overstateinfetch(see below), so the bound name shadows any same-named state/attrs field for the span of this one call only ; nothing persists past it. - .known?(state, key) ⇒ Boolean
-
.last_of(value) ⇒ Object
.last-- vendored addition, see theLaststruct's own comment above. - .lookup(expr, state, attrs) ⇒ Object
-
.match_call(expr, marker) ⇒ Object
FOUND LIVE via the type-directed bounded-exhaustive expression generator (Phase 7, equivalence-gap plan — spec/ bounded_exhaustive_expression_spec.rb):
.modulo('s own argument position accepts any numeric sub-expression, including ANOTHER.modulo(...)call —0.modulo(num_b.modulo(-1))is perfectly well-typed — butexpr.rindex(marker)finds the RIGHTMOST (innermost).modulo(in the whole string, not the OUTERMOST one a nested call needs split at. For that expression it found the INNER.modulo((insidenum_b.modulo(-1)) and split there, producing a receiver of"0.modulo(num_b"and a divisor of"-1)"— both garbage, both re-parsed as bogusLookuppaths, both then refusing with "cannot resolve" — a SILENT MISPARSE that happened to fail safe into a realEvaluationErrorrather than a raw crash, which is exactly why this had gone unnoticed: nothing before this generator existed ever fed.moduloa nested.modulocall, random fuzzing essentially never manufactures that specific shape by chance, and the resulting refusal LOOKS like an ordinary, correct one unless you already know every name this generator's own synthetic state declares (real corpus authors would see this as a mysterious "cannot resolve" on text they never wrote). - .match_suffix(expr, suffixes) ⇒ Object
-
.matches_regex?(receiver_value, pattern, flags) ⇒ Boolean
receiver.match?(/pattern/)-- vendored addition, see theMatchesRegexstruct's own comment above. -
.matching_brace(expr, start) ⇒ Object
The index of the
}that closes the{implicitly opened just beforestart(the caller's own header match already consumed that opening brace, so depth begins at 1) -- nil if the string runs out before depth returns to 0 (a caller-error shape, not a valid expression). -
.matching_paren(expr, start) ⇒ Object
matching_brace(resolver/block_predicates.rb)'s own twin, one bracket pair over:startis the index just past the OPENING(already consumed by the caller (depth starts at 1, not 0, for the same reason).. - .numeric(value) ⇒ Object
- .parse(expr) ⇒ Object
-
.parse_block_opener(expr) ⇒ Object
.all?/.any?/.none?/.find-- vendored addition, see theBlockPredicate/Findstructs' own comments above. - .quoted?(expr) ⇒ Boolean
- .require_number(value, operation) ⇒ Object
- .resolve(expr, state, attrs) ⇒ Object
- .sign_test_node(parts) ⇒ Object
- .size_of(value) ⇒ Object
-
.split_addition(expr) ⇒ Object
BRACES COUNT TOWARD DEPTH, exactly as parens do — a
+inside a block predicate's own{ |x| ... }body is not this expression's own addition. -
.split_value(value, separator) ⇒ Object
.split("SEP")-- vendored addition, see theSplitstruct's own comment above. -
.starts_with?(value, substring) ⇒ Boolean
.start_with?("prefix")-- vendored addition, see theStartsWithstruct's own comment above. - .string_of(value) ⇒ Object
-
.unwrap_scalar(value) ⇒ Object
field == "literal"-- vendored addition, not (yet) upstream hecks (migration plan task 8): the third-most pervasive dispatch-time gap this pass found, same family as.match?/.present?above -- a lookup of a single-field scalar-convenience value object (the exact shapeValue. from_identifier/Value::Coercion#fields_for's own single- field auto-unwrap already treats as "this VO IS its scalar" everywhere else in this runtime) came back as theValuewrapper itself, never unwrapped for READING -- soValue#==(which only ever equals anotherValueinstance) silently refused everyguarantees "..." do status == "active" end/expects "..." do trash_day.present? end-shaped bare comparison against a raw literal. -
.walk_path(value, segments) ⇒ Object
Walks a list of dotted segments through an already-resolved Hash-like value — extracted from
lookup's own reduce sofound_of(theFindnode's own path projection) can walk a.find { ... }-produced element the identical waylookupwalks a plain attribute path, rather than duplicating the symbol-or-string key step twice in this file.
Class Method Details
.add(left, right) ⇒ Object
445 446 447 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 445 def add(left, right) require_number(left, "addition") + require_number(right, "addition") end |
.apply_modulo(receiver_value, divisor_value) ⇒ Object
Both operands are coerced to a real Integer/Float BEFORE the
zero-check, and the check reads the COERCED divisor — not the
raw divisor_value (which might not even respond to .zero?,
a String for instance) and not a .to_i-truncated stand-in for
it either. The old order checked a truncated divisor.to_i
AFTER already validating the untruncated value wasn't zero, so
a divisor merely small (0.3, truncating to 0) sailed past
the guard and then blew up Integer#% with a raw
ZeroDivisionError the moment it reached zero anyway.
The modulo itself is plain % on the coerced values, matching
add's own no-truncation precedent just above — Ruby's native
% already handles every Integer/Float combination correctly
(promoting to Float when either side is one), so rounding both
operands down to Integer first was pure data loss with no
purpose: 7.5.modulo(2.5) silently became 7 % 2 (1)
instead of the real 0.0.
657 658 659 660 661 662 663 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 657 def apply_modulo(receiver_value, divisor_value) receiver = require_number(receiver_value, "modulo") divisor = require_number(divisor_value, "modulo") raise EvaluationError, "divided by 0" if divisor.zero? receiver % divisor end |
.apply_sign_test(node, value) ⇒ Object
553 554 555 556 557 558 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 553 def apply_sign_test(node, value) number = numeric(value) raise EvaluationError, "#{node.test} expects a number, got #{describe(value)}" unless number Evaluator.apply(node.operator, number, 0) end |
.array_elements(expr) ⇒ Object
The elements of a bracketed literal, or nil if this isn't one.
Splits on TOP-LEVEL commas only — quote-aware and depth-aware,
the same discipline split_addition already applies, so a
nested array or a comma inside a string element stays whole.
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 370 def array_elements(expr) return nil unless expr.start_with?("[") && expr.end_with?("]") inner = expr[1..-2].strip return [] if inner.empty? elements = [] depth = 0 quote = nil current = +"" inner.each_char do |char| if quote quote = nil if char == quote current << char next end case char when '"', "'" then quote = char when "[", "(" then depth += 1 when "]", ")" then depth -= 1 end if char == "," && depth.zero? elements << current.strip current = +"" else current << char end end elements << current.strip elements.reject(&:empty?) end |
.blank?(value) ⇒ Boolean
.present?/.blank? -- vendored addition, see the
Presence struct's own comment above. nil and false are
blank ; a String/Array/Hash is blank when EMPTY, not merely
falsy -- a VO-wrapped field that IS assigned ({value: "x"},
Value#to_h'd first) is present regardless of what its own
inner value holds, matching how every VO-typed field in this
corpus is actually shaped once set at all.
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 316 def blank?(value) return true if value.nil? || value == false # Duck-typed, not `value.is_a?(Runtime::Value)` -- this module # is `Bluebook::Expression`, a different namespace tree from # `Runtime::Value` entirely, and reaching across for a single # class check is the exact cross-module coupling that already # broke `Modulo`'s own `match_call` reference elsewhere in # this file (found live while building this fix, not assumed). value = value.to_h if value.respond_to?(:to_h) && !value.is_a?(Hash) case value when String, Array, Hash then value.empty? else false end end |
.describe(value) ⇒ Object
802 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 802 def describe(value) = Rendering.describe(value) |
.emptiness_of(value) ⇒ Object
468 469 470 471 472 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 468 def emptiness_of(value) return value.empty? if value.is_a?(Array) || value.is_a?(String) || value.is_a?(Hash) raise EvaluationError, "empty? expects a list or string, got #{describe(value)}" end |
.ends_with?(value, substring) ⇒ Boolean
.end_with?("suffix") -- vendored addition, see the EndsWith
struct's own comment above. Same String-only reasoning as
start_with? immediately above.
523 524 525 526 527 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 523 def ends_with?(value, substring) raise EvaluationError, "end_with? expects a string, got #{describe(value)}" unless value.is_a?(String) value.end_with?(substring) end |
.evaluate_block_predicate(node, collection, state, attrs) ⇒ Object
.all?/.any?/.none? -- vendored addition, see the
BlockPredicate struct's own comment above. collection is
already-interpreted (a real Array, produced by whatever
receiver expression came before it -- typically Split's
output), so this only has to run the per-element predicate and
aggregate. interpret_with_element is the "smallest correct
thing" the migration plan asked for : no persistent iteration-
variable concept added anywhere else in Resolver's state model,
just attrs extended with the bound name for the span of that
one predicate evaluation, discarded immediately after.
186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 186 def evaluate_block_predicate(node, collection, state, attrs) unless collection.is_a?(Array) raise EvaluationError, "#{node.mode}? expects a list, got #{describe(collection)}" end outcomes = collection.map { |element| interpret_with_element(node, element, state, attrs) } case node.mode when :all then outcomes.all? when :any then outcomes.any? when :none then outcomes.none? end end |
.fetch(name, state, attrs) ⇒ Object
779 780 781 782 783 784 785 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 779 def fetch(name, state, attrs) key = name.to_sym return attrs[key] if attrs.key?(key) return state[key] if known?(state, key) raise EvaluationError, "cannot resolve #{name.inspect} — no such attribute or argument" end |
.first_of(value) ⇒ Object
.first -- see the First struct's own comment above.
last_of with the one method swapped, same duck-typed
reasoning.
503 504 505 506 507 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 503 def first_of(value) return value.first if value.respond_to?(:first) raise EvaluationError, "first expects a list, got #{describe(value)}" end |
.found_of(node, collection, state, attrs) ⇒ Object
.find { |x| PREDICATE } -- see the Find struct's own
comment above. Reuses interpret_with_element unchanged
(below, shared with BlockPredicate — both bind node.param
to one element and interpret node.predicate against it) to
find the FIRST element the predicate accepts, then projects
node.path through it via walk_path, the same dotted-
segment walk lookup uses for a plain attribute path. nil
(no matching element, or a path segment that doesn't
resolve) flows through rather than raising — the same "a
dispatch-time given just refuses" shape every other missing-
value case in this grammar already has, and the one a re-
routing check like "is there a leg after this one" needs :
not finding one is a normal outcome, not an error.
221 222 223 224 225 226 227 228 229 |
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 221 def found_of(node, collection, state, attrs) raise EvaluationError, "find expects a list, got #{describe(collection)}" unless collection.is_a?(Array) found = collection.find { |element| interpret_with_element(node, element, state, attrs) } return unwrap_scalar(found) if node.path.empty? return nil if found.nil? unwrap_scalar(walk_path(found, node.path)) end |
.interpret(node, state, attrs) ⇒ Object
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 301 302 303 304 305 306 307 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 255 def interpret(node, state, attrs) case node when IntegerLiteral, FloatLiteral, StringLiteral, BoolLiteral then node.value when ArrayLiteral then node.elements.map { |element| interpret(element, state, attrs) } when NilLiteral then nil when Addition add(interpret(node.left, state, attrs), interpret(node.right, state, attrs)) when SignTest apply_sign_test(node, interpret(node.receiver, state, attrs)) when Empty emptiness_of(interpret(node.receiver, state, attrs)) when ToS string_of(interpret(node.receiver, state, attrs)) when Modulo apply_modulo(interpret(node.receiver, state, attrs), interpret(node.divisor, state, attrs)) when Size size_of(interpret(node.receiver, state, attrs)) when MatchesRegex matches_regex?(interpret(node.receiver, state, attrs), node.pattern, node.flags) when Presence present = !blank?(interpret(node.receiver, state, attrs)) node.negated ? !present : present when Split split_value(interpret(node.receiver, state, attrs), node.separator) when Last last_of(interpret(node.receiver, state, attrs)) when First first_of(interpret(node.receiver, state, attrs)) when Find found_of(node, interpret(node.receiver, state, attrs), state, attrs) when StartsWith starts_with?(interpret(node.receiver, state, attrs), node.substring) when EndsWith ends_with?(interpret(node.receiver, state, attrs), node.substring) when BlockPredicate evaluate_block_predicate(node, interpret(node.receiver, state, attrs), state, attrs) when Lookup lookup(node.path, state, attrs) else # Every leaf node `parse` can produce has a `when` above — # a backstop against the day this grammar grows a new leaf # type (this file's own history: MatchesRegex/Presence/ # Split/Last/First/Find/StartsWith/EndsWith/BlockPredicate # were each added exactly this way, and each one — before it had an # `interpret` arm — fell all the way through to the # `Lookup` catch-all in `parse` and crashed downstream with # an opaque type error, never here). A missing arm here # would instead return bare `nil` silently, the one wrong- # answer shape this leaf grammar has otherwise never # allowed. raise EvaluationError, "no interpreter handles #{node.class} — add a case before parse can produce it" end end |
.interpret_with_element(node, element, state, attrs) ⇒ Object
Binds the block parameter for exactly one element's predicate
evaluation -- attrs wins over state in fetch (see below),
so the bound name shadows any same-named state/attrs field for
the span of this one call only ; nothing persists past it.
204 205 206 |
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 204 def interpret_with_element(node, element, state, attrs) Evaluator.interpret(node.predicate, state, attrs.merge(node.param.to_sym => element)) end |
.known?(state, key) ⇒ Boolean
787 788 789 790 791 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 787 def known?(state, key) return state.key?(key) if state.respond_to?(:key?) !state[key].nil? end |
.last_of(value) ⇒ Object
.last -- vendored addition, see the Last struct's own
comment above. Duck-typed on respond_to?(:last) rather than
hard-coding Array -- the one corpus usage found this pass
(Query::Phrase's .split("::").last) always receives a
Split-produced Array, but nothing about .last itself is
Array-specific, and this matches Empty/Size's own
duck-typed-over-a-known-set precedent without inventing a
narrower rule than the method needs.
494 495 496 497 498 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 494 def last_of(value) return value.last if value.respond_to?(:last) raise EvaluationError, "last expects a list, got #{describe(value)}" end |
.lookup(expr, state, attrs) ⇒ Object
665 666 667 668 669 670 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 665 def lookup(expr, state, attrs) return unwrap_scalar(fetch(expr, state, attrs)) unless expr.include?(".") head, *rest = expr.split(".") unwrap_scalar(walk_path(fetch(head, state, attrs), rest)) end |
.match_call(expr, marker) ⇒ Object
FOUND LIVE via the type-directed bounded-exhaustive expression
generator (Phase 7, equivalence-gap plan — spec/
bounded_exhaustive_expression_spec.rb): .modulo('s own
argument position accepts any numeric sub-expression, including
ANOTHER .modulo(...) call — 0.modulo(num_b.modulo(-1)) is
perfectly well-typed — but expr.rindex(marker) finds the
RIGHTMOST (innermost) .modulo( in the whole string, not the
OUTERMOST one a nested call needs split at. For that expression
it found the INNER .modulo( (inside num_b.modulo(-1)) and
split there, producing a receiver of "0.modulo(num_b" and a
divisor of "-1)" — both garbage, both re-parsed as bogus
Lookup paths, both then refusing with "cannot resolve" — a
SILENT MISPARSE that happened to fail safe into a real
EvaluationError rather than a raw crash, which is exactly why
this had gone unnoticed: nothing before this generator existed
ever fed .modulo a nested .modulo call, random fuzzing
essentially never manufactures that specific shape by chance,
and the resulting refusal LOOKS like an ordinary, correct one
unless you already know every name this generator's own
synthetic state declares (real corpus authors would see this as
a mysterious "cannot resolve" on text they never wrote).
Fixed the same way split_addition/Evaluator.top_level_index
already handle nested (/{ elsewhere in this exact file:
find the FIRST (leftmost, outermost) occurrence of the marker,
then track paren/quote depth from there to find ITS OWN
matching close — not just strip the string's own trailing )
and hope it belongs to this call.
Not just the FIRST occurrence, either — .modulo also CHAINS
(x.modulo(a).modulo(b), the receiver of the OUTER call itself
ending in a .modulo(...) call), a second real shape the
leftmost-occurrence-only version of this fix still mis-parsed:
the first .modulo('s own matching close paren lands mid-
string (right after a), before the second .modulo(b)), so
it correctly fails the "reaches the end" check below and must
be tried again at the NEXT occurrence rather than giving up.
Trying occurrences strictly left to right and taking the FIRST
one whose matching close reaches the string's last character
handles both shapes with the same rule: for NESTING
(.modulo(x.modulo(y))), the leftmost (outer) occurrence's own
paren-depth tracking already walks straight through the inner
call to the true final ); for CHAINING, the leftmost
occurrence's close lands short and is rejected, so the next
occurrence (the true outermost call) is tried instead.
604 605 606 607 608 609 610 611 612 613 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 604 def match_call(expr, marker) start = 0 while (index = expr.index(marker, start)) close = 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 |
.match_suffix(expr, suffixes) ⇒ Object
545 546 547 548 549 550 551 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 545 def match_suffix(expr, suffixes) suffixes.each do |suffix| marker = ".#{suffix}" return [expr[0...-marker.length], suffix] if expr.end_with?(marker) end nil end |
.matches_regex?(receiver_value, pattern, flags) ⇒ Boolean
receiver.match?(/pattern/) -- vendored addition, see the
MatchesRegex struct's own comment above. receiver_value is
coerced to a plain String first -- inlined here rather than
calling Evaluator#string_of (a DIFFERENT module_function
module; not actually in scope from inside Resolver despite
Modulo's own parse rule above calling a same-named
match_call that has the identical cross-module problem --
found live while building this, not assumed).
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 340 def matches_regex?(receiver_value, pattern, flags) text = case receiver_value when String, Symbol then receiver_value.to_s when Integer, Float then receiver_value.to_s when NilClass then "" else raise EvaluationError, "match? expects a scalar, got #{receiver_value.class}" end = 0 |= Regexp::IGNORECASE if flags.include?("i") |= Regexp::MULTILINE if flags.include?("m") |= Regexp::EXTENDED if flags.include?("x") Regexp.new(pattern, ).match?(text) rescue RegexpError => e # M9: a malformed pattern between the slashes (an unclosed # character class, say) is a defect in the EXPRESSION TEXT # itself, exactly the same category of author mistake an # unresolvable attribute name already refuses for — `Regexp.new` # raising a raw `RegexpError` crossed this sublanguage's own # refusal boundary the same way the `ZeroDivisionError`/ # `TypeError` cases elsewhere in this file did. raise EvaluationError, "match? given an invalid pattern #{pattern.inspect} — #{e.}" end |
.matching_brace(expr, start) ⇒ Object
The index of the } that closes the {` implicitly opened just before `start` (the caller's own header match already consumed that opening brace, so depth begins at 1) -- nil if the string runs out before depth returns to 0 (a caller-error shape, not a valid expression). Quote-aware so a `} inside a
quoted substring (.start_with?("}")) never miscounts, the
same discipline split_addition/array_elements already
apply for their own depth tracking.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 155 def matching_brace(expr, start) depth = 1 quote = nil index = start while index < expr.length char = expr[index] if quote quote = nil if char == quote elsif ['"', "'"].include?(char) quote = char elsif char == "{" depth += 1 elsif char == "}" depth -= 1 return index if depth.zero? end index += 1 end nil end |
.matching_paren(expr, start) ⇒ Object
matching_brace (resolver/block_predicates.rb)'s own twin, one
bracket pair over: start is the index just past the OPENING
( already consumed by the caller (depth starts at 1, not 0,
for the same reason).
619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 619 def matching_paren(expr, start) depth = 1 quote = nil index = start while index < expr.length char = expr[index] if quote quote = nil if char == quote elsif ['"', "'"].include?(char) quote = char elsif char == "(" depth += 1 elsif char == ")" depth -= 1 return index if depth.zero? end index += 1 end nil end |
.numeric(value) ⇒ Object
793 794 795 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 793 def numeric(value) value if value.is_a?(Integer) || value.is_a?(Float) end |
.parse(expr) ⇒ Object
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 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 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 189 def parse(expr) expr = expr.to_s.strip return Size.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.length\z/ return IntegerLiteral.new(value: Integer(expr, 10)) if expr.match?(/\A-?\d+\z/) return FloatLiteral.new(value: Float(expr)) if expr.match?(/\A-?\d*\.\d+\z/) return StringLiteral.new(value: expr[1..-2]) if quoted?(expr) return BoolLiteral.new(value: true) if expr == "true" return BoolLiteral.new(value: false) if expr == "false" return NilLiteral.new if expr == "nil" elements = array_elements(expr) return ArrayLiteral.new(elements: elements.map { |element| parse(element) }) if elements arithmetic = split_addition(expr) return Addition.new(left: parse(arithmetic[0]), right: parse(arithmetic[1])) if arithmetic sign = match_suffix(expr, SIGN_TESTS) return sign_test_node(sign) if sign return Empty.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.empty\?\z/ return ToS.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.to_s\z/ modulo = match_call(expr, ".modulo(") return Modulo.new(receiver: parse(modulo[0]), divisor: parse(modulo[1])) if modulo return Size.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.size\z/ if expr =~ /\A(.+)\.match\?\(\/(.*)\/([a-z]*)\)\z/m return MatchesRegex.new(receiver: parse(Regexp.last_match(1)), pattern: Regexp.last_match(2), flags: Regexp.last_match(3)) end return Presence.new(receiver: parse(Regexp.last_match(1)), negated: false) if expr =~ /\A(.+)\.present\?\z/ return Presence.new(receiver: parse(Regexp.last_match(1)), negated: true) if expr =~ /\A(.+)\.blank\?\z/ if expr =~ /\A(.+)\.split\("([^"]*)"\)\z/ return Split.new(receiver: parse(Regexp.last_match(1)), separator: Regexp.last_match(2)) end return First.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.first\z/ return Last.new(receiver: parse(Regexp.last_match(1))) if expr =~ /\A(.+)\.last\z/ if expr =~ /\A(.+)\.start_with\?\("([^"]*)"\)\z/ return StartsWith.new(receiver: parse(Regexp.last_match(1)), substring: Regexp.last_match(2)) end if expr =~ /\A(.+)\.end_with\?\("([^"]*)"\)\z/ return EndsWith.new(receiver: parse(Regexp.last_match(1)), substring: Regexp.last_match(2)) end block_opener = parse_block_opener(expr) return block_opener if block_opener Lookup.new(path: expr) end |
.parse_block_opener(expr) ⇒ Object
.all?/.any?/.none?/.find -- vendored addition, see the
BlockPredicate/Find structs' own comments above. Matched
last among the suffix rules (right before the Lookup
catch-all) since a block's own predicate text can itself
contain almost anything a leaf expression can, including
ANOTHER block-opening suffix -- letting every more specific
rule above try first avoids this one accidentally swallowing a
receiver another rule was meant to parse.
ONE combined header regex over ALL FOUR suffixes together,
not .find and .all?/any?/none? scanned separately (that
was this file's own first cut, and it broke the moment a
block predicate's own predicate text contained a DIFFERENT
kind of block-opener than the one being scanned for --
legs.any? { |leg| ... legs.find { |o| ... } ... } : scanning
for .find FIRST found the INNER .find, not the outer
.any?, because a non-greedy receiver capture only guarantees
the FIRST occurrence of ITS OWN suffix, not the first
occurrence of ANY block-opening suffix -- confirmed live via
the shipping domain's own re-routing rules, the same
"no implicit conversion of Symbol into Integer" signature the
original nested-.any? bug had, not inferred). Scanning for
all four AT ONCE and letting the regex engine's own leftmost
match win fixes both directions (.find nested in .any? OR
.any? nested in .find) with the SAME one rule, since the
true receiver never itself contains ANY of these four words
followed by {.
matching_brace walks forward counting {`/`} depth from
there, the same quote-aware, depth-aware discipline
split_addition/array_elements already apply, so a }
inside a nested block (or a quoted start_with? substring)
never miscounts. .find's own trailing dotted path (path)
is captured from whatever follows the closing brace ; every
other suffix instead requires nothing follow it at all (the
BlockPredicate shape, unchanged from before this rewrite).
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 |
# File 'lib/hecks/bluebook/expression/resolver/block_predicates.rb', line 119 def parse_block_opener(expr) pattern = /\A(.+?)\.(#{BLOCK_OPENER_SUFFIXES.map { |suffix| Regexp.escape(suffix) }.join('|')})\s*\{\s*\|(\w+)\|\s*/m header = expr.match(pattern) return nil unless header receiver_text = header[1] suffix = header[2] param = header[3] body_start = header.end(0) body_end = matching_brace(expr, body_start) return nil unless body_end predicate = Evaluator.parse(expr[body_start...body_end].strip) if suffix == "find" trailing = expr[(body_end + 1)..].strip return nil unless trailing.empty? || trailing.start_with?(".") Find.new(receiver: parse(receiver_text), param: param, predicate: predicate, path: trailing.empty? ? [] : trailing[1..].split(".")) else return nil unless expr[(body_end + 1)..].strip.empty? BlockPredicate.new(mode: BLOCK_PREDICATE_MODES.fetch(suffix), receiver: parse(receiver_text), param: param, predicate: predicate) end end |
.quoted?(expr) ⇒ Boolean
449 450 451 452 453 454 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 449 def quoted?(expr) return false if expr.length < 2 (expr.start_with?('"') && expr.end_with?('"')) || (expr.start_with?("'") && expr.end_with?("'")) end |
.require_number(value, operation) ⇒ Object
797 798 799 800 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 797 def require_number(value, operation) numeric(value) || raise(EvaluationError, "#{operation} expects a number, got #{describe(value)}") end |
.resolve(expr, state, attrs) ⇒ Object
185 186 187 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 185 def resolve(expr, state, attrs) interpret(parse(expr), state, attrs) end |
.sign_test_node(parts) ⇒ Object
248 249 250 251 252 253 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 248 def sign_test_node(parts) receiver, test = parts symbol = SIGN_TEST_OPERATORS.fetch(test) operator = Evaluator::OPERATORS.find { |candidate| candidate.symbol == symbol } SignTest.new(operator: operator, test: test, receiver: parse(receiver)) end |
.size_of(value) ⇒ Object
462 463 464 465 466 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 462 def size_of(value) return value.size if value.is_a?(Array) || value.is_a?(String) || value.is_a?(Hash) raise EvaluationError, "size expects a list or string, got #{describe(value)}" end |
.split_addition(expr) ⇒ Object
BRACES COUNT TOWARD DEPTH, exactly as parens do — a + inside a
block predicate's own { |x| ... } body is not this expression's
own addition. parse tries addition BEFORE parse_block_opener,
so a paren-only depth count split
kings.any? { |k| k.square.file == to.file + 1 && ... }
at that inner +, turning the whole expression into a nonsense
Addition whose left operand then walked "any? { |k| k" into an
Array as an attribute path — "TypeError: no implicit conversion
of Symbol into Integer", the same signature every unsupported
construct raises, which is what let this hide. Found live in a
downstream chess domain's castling given; the evaluator's own
top_level_index has counted braces since its own version of this
exact lesson.
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 415 def split_addition(expr) depth = 0 quote = nil expr.each_char.with_index do |char, index| if quote quote = nil if char == quote elsif ['"', "'"].include?(char) quote = char # `[`/`]` -- the identical lesson this method's own `(`/`{` # comment already names, a third time (found live via the # type-directed bounded-exhaustive expression generator, # Phase 7 of the equivalence-gap plan): `ArrayLiteral` can # appear as a general sub-expression now, not only as # `.include?`'s own haystack, so an array element containing # its own top-level `+` (`[0, 0 + 0]`) used to read as THIS # expression's own addition split point -- the whole # receiver before `.all?`/`.any?`/etc. torn in half before # `parse_block_opener` ever saw it as one atomic leaf. elsif ["(", "{", "["].include?(char) depth += 1 elsif [")", "}", "]"].include?(char) depth -= 1 elsif char == "+" && depth.zero? return [expr[0...index].strip, expr[(index + 1)..].strip] end end nil end |
.split_value(value, separator) ⇒ Object
.split("SEP") -- vendored addition, see the Split struct's
own comment above. Only a String receiver makes sense to
split -- unlike .length/.size/.empty?, which are already
meaningful over Array/Hash too, .split is a String-only
method in the corpus's own usage (every occurrence found this
pass splits a Phrase's own string value).
480 481 482 483 484 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 480 def split_value(value, separator) raise EvaluationError, "split expects a string, got #{describe(value)}" unless value.is_a?(String) value.split(separator) end |
.starts_with?(value, substring) ⇒ Boolean
.start_with?("prefix") -- vendored addition, see the
StartsWith struct's own comment above. String-only, same
reasoning as .split above -- every corpus usage found this
pass (Params's own JSON-object-shape invariant) receives a
plain String field.
514 515 516 517 518 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 514 def starts_with?(value, substring) raise EvaluationError, "start_with? expects a string, got #{describe(value)}" unless value.is_a?(String) value.start_with?(substring) end |
.string_of(value) ⇒ Object
534 535 536 537 538 539 540 541 542 543 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 534 def string_of(value) case value when String then value when Integer, Float then value.to_s when TrueClass, FalseClass then value.to_s when NilClass then "" else raise EvaluationError, "to_s expects a scalar, got #{describe(value)}" end end |
.unwrap_scalar(value) ⇒ Object
field == "literal" -- vendored addition, not (yet) upstream
hecks (migration plan task 8): the third-most pervasive
dispatch-time gap this pass found, same family as .match?/
.present? above -- a lookup of a single-field
scalar-convenience value object (the exact shape Value. from_identifier/Value::Coercion#fields_for's own single-
field auto-unwrap already treats as "this VO IS its scalar"
everywhere else in this runtime) came back as the Value
wrapper itself, never unwrapped for READING -- so Value#==
(which only ever equals another Value instance) silently
refused every guarantees "..." do status == "active" end /
expects "..." do trash_day.present? end-shaped bare
comparison against a raw literal. Confirmed corpus-wide, not
one file's mistake: bin-buddy alone has this exact field == "literal" shape in plan.bluebook, service_task.bluebook,
route.bluebook, and subscription.bluebook, all equally silent
until a real dispatch (never validate) exercised the
predicate. Scoped narrowly to the single-field {value: X}
shape only.
UPDATE 2026-08-18: originally scoped to unwrap ONLY the bare
(undotted) case, on the belief that a dotted lookup only ever
reaches into a VO's OWN field (field.value, field.sub_ field) and so should keep walking #[] untouched. That
belief held for the single-hop case but not for the general
one: a dotted lookup that NAVIGATES THROUGH an entity/list
element to a nested field (leg.voyage, where voyage is
itself a single-field VO) landed on the very same unwrapped-
Value shape the bare case fixed, and hit the identical
silent Value#== failure -- comparing it against a raw
literal or another unwrapped VO returned false for everything,
no error. The terminal value of a dotted walk deserves the
same "this VO IS its scalar" treatment as a bare lookup's
result; only the INTERMEDIATE hops need raw #[] addressing
to keep navigating. unwrap_scalar is idempotent on an
already-raw scalar (a String/Integer doesn't respond to
#to_h), so this is safe for the existing field.value-
shaped dotted lookups too -- they already returned a raw
scalar and are unaffected.
UPDATE (single-element value objects strictly answer .value):
the unwrap used to gate on the sole key being literally NAMED
:value — correct for the shorthand/closed-set shapes that
motivated it, but a lie of omission for Money{amount} and
every other single-field value object whose author picked a
domain name for the field: the SAME "this VO IS its scalar"
- reading ([[feedback_name_the_scalar_field]], `Behaviour
ValueObject#sole_attribute) applies regardless of what the sole field happens to be called, and the name gate made a bare balance > 0work for aBalancevaluewhile silently comparing a whole VO for aBalanceamount. Now the COUNT is the gate, never the name. A declared Runtime::Valuereads its ownsole_attribute(the declaration's answer, not the stored hash's); any OTHER to_h-able (a Struct, a bespoke wrapper with no declaration to consult) keeps the originalX-only unwrap, so nothing that never was a value object gains a surprise unwrapping. rust/src/kernel/json.rs's impl Fielded for Json` mirrors the count-only reading on the
Rust side — change them in lockstep or rust_conformance
diverges.
767 768 769 770 771 772 773 774 775 776 777 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 767 def unwrap_scalar(value) return value unless value.respond_to?(:to_h) && !value.is_a?(Hash) && !value.is_a?(Array) if value.respond_to?(:value_object) sole = value.value_object.sole_attribute return sole ? value[sole.name] : value end hash = value.to_h hash.size == 1 && hash.key?(:value) ? hash[:value] : value end |
.walk_path(value, segments) ⇒ Object
Walks a list of dotted segments through an already-resolved
Hash-like value — extracted from lookup's own reduce so
found_of (the Find node's own path projection) can walk a
.find { ... }-produced element the identical way lookup
walks a plain attribute path, rather than duplicating the
symbol-or-string key step twice in this file. key? decides
which spelling answers — a bare || between the two would
treat a genuinely-held false the same as an absent key and
fall through to the other spelling, landing on nil.
681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 |
# File 'lib/hecks/bluebook/expression/resolver.rb', line 681 def walk_path(value, segments) segments.reduce(value) do |current, segment| break nil unless current.respond_to?(:[]) if current.is_a?(Hash) sym = segment.to_sym current.key?(sym) ? current[sym] : current[segment] else begin current[segment] rescue TypeError # M9 (docs/audits/2026-08-10-main-bug-audit.md): a dotted # path can walk onto an Array (e.g. the result of `.split`, # or a `list_of` attribute) — Array#[] demands an # Integer/Range and raises a raw TypeError for a String # segment ("no implicit conversion of String into # Integer"), which used to cross straight past this # sublanguage's own refusal boundary and crash the # runtime instead of reading as "this predicate doesn't # apply here." raise EvaluationError, "cannot read #{segment.inspect} from #{describe(current)}" end end end end |