Module: Hecks::Bluebook::Expression::CanonicalForm

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

Defined Under Namespace

Classes: Rule

Constant Summary collapse

STRATEGIES =
Hecks::Vocabulary.fetch("NormalisationStrategy")
RULES =

READ, NOT RESTATED — the admitted normalisation rules, projected from the grammar chapter by bin/expression_projection exactly as the evaluator's operator table is. See Evaluator::PROJECTION for why a projection rather than a boot.

JSON.parse(
  File.read(File.join(__dir__, "projection.json")), symbolize_names: true
).fetch(:normalisations).map { |row| Rule.new(**row) }.freeze

Class Method Summary collapse

Class Method Details

.apply(source) ⇒ Object



34
35
36
# File 'lib/hecks/bluebook/expression/canonical_form.rb', line 34

def apply(source)
  RULES.sort_by(&:position).reduce(source.to_s) { |text, rule| step(text, rule) }.strip
end

.map_outside_strings(text) ⇒ Object

Applies a normalisation rule to the text OUTSIDE quoted string literals only, copying every quoted run through byte-for-byte. Every rule here (collapse_whitespace, the .length.size fold) used to run quote-blind — "a b" collapsed to "a b" and "a.length" folded to "a.size" just as readily as the real source outside the quotes, silently rewriting what a predicate compares a string attribute against, not merely how the predicate itself is spelled. A canonical string literal's CONTENTS are data, never syntax to normalise.

Handles both " and ' delimiters (this grammar's own Resolver.quoted? admits either), quote-aware exactly the way Evaluator.top_level_index/Resolver.array_elements already are elsewhere in this sublanguage. An unterminated quote (malformed input) is passed through raw rather than risk mangling it further.



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

def map_outside_strings(text)
  result = +""
  buffer = +""
  quote = nil

  text.each_char do |char|
    if quote
      buffer << char
      if char == quote
        result << buffer
        buffer = +""
        quote = nil
      end
    elsif ['"', "'"].include?(char)
      result << yield(buffer)
      # `char.dup`, not `char.to_s` (a no-op on a String — always
      # returns self, never a copy) and not `+char` either
      # (`String#+@` only dups a FROZEN receiver; `each_char`'s
      # yielded strings aren't frozen, so `+char` is just as
      # much a no-op here). Without a REAL copy, `buffer` and
      # `quote` alias the same mutable object: the very next
      # `buffer << char` grows `quote` right along with it, so
      # `char == quote` can only ever compare a single character
      # against an ever-lengthening string and never closes the
      # literal — everything after a predicate's first quoted
      # string silently skipped normalisation for the rest of
      # the text, undetected because passing text through
      # unnormalised is silent. MASKED by every existing spec
      # here, which only checks that quoted CONTENTS survive
      # untouched (the M7 fix this method exists for) — that
      # still holds by accident once the bug makes the "outside"
      # branch unreachable. Found live: a multi-line `given`/
      # `ensures` block whose ONLY quoted literal closes before a
      # later line — the newline and that later line's own
      # indentation went uncollapsed, diverging from
      # `hecks-parse`'s own (correct) single-space join.
      # `spec/parser_parity_spec.rb`, roster's "a front-row seat
      # takes a member of age".
      buffer = char.dup
      quote = char.dup
    else
      buffer << char
    end
  end

  result << (quote ? buffer : yield(buffer))
  result
end

.replace(text, rule) ⇒ Object



47
48
49
50
51
52
53
54
55
# File 'lib/hecks/bluebook/expression/canonical_form.rb', line 47

def replace(text, rule)
  map_outside_strings(text) do |segment|
    if rule.boundary == "none"
      segment.gsub(rule.source_token, rule.replacement)
    else
      segment.gsub(/#{Regexp.escape(rule.source_token)}(?![[:alnum:]_])/, rule.replacement)
    end
  end
end

.step(text, rule) ⇒ Object



38
39
40
41
42
43
44
45
# File 'lib/hecks/bluebook/expression/canonical_form.rb', line 38

def step(text, rule)
  case rule.strategy
  when "collapse_whitespace" then map_outside_strings(text) { |segment| segment.gsub(/\s+/, " ") }
  when "replace"             then replace(text, rule)
  else
    raise ArgumentError, "#{rule.strategy.inspect} is not a linked normalisation strategy"
  end
end

.tableObject



22
23
24
25
26
27
28
29
30
31
32
# File 'lib/hecks/bluebook/expression/canonical_form.rb', line 22

def table
  RULES.sort_by(&:position).map do |rule|
    {
      strategy:     rule.strategy,
      source_token: rule.source_token,
      replacement:  rule.replacement,
      boundary:     rule.boundary,
      position:     rule.position.to_s
    }
  end
end