Module: Canon::Comparison::WhitespaceSensitivity

Defined in:
lib/canon/comparison/whitespace_sensitivity.rb

Overview

Whitespace sensitivity utilities for element-level control

Constant Summary collapse

HTML_COLLAPSE_ELEMENTS =

HTML mixed-content "leaf block" elements where whitespace presence matters but all forms are equivalent (CSS block whitespace collapsing).

%w[
  p li dt dd td th caption figcaption label legend summary
  h1 h2 h3 h4 h5 h6
  blockquote address button
].freeze
HTML_PRESERVE_ELEMENTS =

HTML elements where every whitespace character is significant.

%w[pre code textarea script style].freeze
INLINE_ELEMENTS =

HTML inline elements — whitespace between these is semantically significant (renders as a visible space). Whitespace-only text nodes that sit between two inline siblings must not be stripped.

%w[
  a abbr acronym b bdo big br button cite code dfn em i img input kbd
  label map object output q s samp select small span strong sub sup
  time tt u var wbr
].freeze
HTML_PRESERVE_SYMBOLS =

Precomputed symbol forms of the default lists: format_default_* used to map(&:to_sym) per call — one array per classified node.

HTML_PRESERVE_ELEMENTS.map(&:to_sym).freeze
HTML_COLLAPSE_SYMBOLS =
HTML_COLLAPSE_ELEMENTS.map(&:to_sym).freeze
RESOLVED_SETS_LIMIT =

The resolved element sets depend only on match_opts, which is resolved once per comparison — but classification runs per node, so the sets are memoized per match_opts object (identity-keyed; side-flag merges produce distinct objects and their own entries). Bounded: stale entries of finished comparisons cost a few hundred bytes until the next clear.

1024

Class Method Summary collapse

Class Method Details

.classify_element(element, match_opts) ⇒ Object

Classify the whitespace behaviour for an element using ancestor walk. Results are cached per element per match_opts (classify runs per text node/pair otherwise — once per element is enough; the sets it depends on are memoized alongside in the same entry).



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 46

def classify_element(element, match_opts)
  return :strip unless element
  return :strip unless node_name(element)

  cache = classification_map(match_opts)
  cached = cache[element]
  return cached if cached

  classification = walk_ancestor_classification(
    element,
    resolved_preserve_elements_set(match_opts),
    resolved_collapse_elements_set(match_opts),
    resolved_strip_elements_set(match_opts),
  )
  cache[element] = classification
  classification
end

.classify_text_node(node, opts) ⇒ Object

Return the whitespace class for a text node used during comparison.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 92

def classify_text_node(node, opts)
  match_opts = opts[:match_opts]
  return :strip unless match_opts
  return :strip unless text_node_parent?(node)

  parent = node_parent(node)

  unless respect_xml_space?(match_opts)
    return user_config_sensitive?(parent,
                                  match_opts) ? :preserve : :strip
  end

  return :preserve if xml_space_preserve?(parent)
  return :strip if xml_space_default?(parent)

  classify_element(parent, match_opts)
end

.contains_nbsp?(text) ⇒ Boolean

Returns:

  • (Boolean)


198
199
200
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 198

def contains_nbsp?(text)
  text.to_s.include?(" ")
end

.default_sensitive_element?(element_name, match_opts) ⇒ Boolean

Returns:

  • (Boolean)


149
150
151
152
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 149

def default_sensitive_element?(element_name, match_opts)
  format_default_preserve_elements(match_opts)
    .include?(element_name.to_sym)
end

.element_sensitive?(node, opts) ⇒ Boolean

Check if an element is whitespace-sensitive based on configuration.

Returns:

  • (Boolean)


65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 65

def element_sensitive?(node, opts)
  match_opts = opts[:match_opts]
  return false unless match_opts
  return false unless text_node_parent?(node)

  parent = node_parent(node)

  unless respect_xml_space?(match_opts)
    return user_config_sensitive?(parent, match_opts)
  end

  return true if xml_space_preserve?(parent)
  return false if xml_space_default?(parent)

  classification = classify_element(parent, match_opts)
  %i[preserve collapse].include?(classification)
end

.format_default_collapse_elements(match_opts) ⇒ Object



139
140
141
142
143
144
145
146
147
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 139

def format_default_collapse_elements(match_opts)
  format = match_opts[:format] || :xml
  case format
  when :html, :html4, :html5
    HTML_COLLAPSE_SYMBOLS
  else
    [].freeze
  end
end

.format_default_preserve_elements(match_opts) ⇒ Object



129
130
131
132
133
134
135
136
137
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 129

def format_default_preserve_elements(match_opts)
  format = match_opts[:format] || :xml
  case format
  when :html, :html4, :html5
    HTML_PRESERVE_SYMBOLS
  else
    [].freeze
  end
end

.inline_whitespace_significant?(text_node) ⇒ Boolean

Check if whitespace-only text node sits between two inline element siblings, making the whitespace semantically significant.

Returns:

  • (Boolean)


156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 156

def inline_whitespace_significant?(text_node)
  parent = NodeInspector.parent(text_node)
  return false unless parent

  # One pass: find the node by identity while tracking the
  # nearest non-whitespace siblings on each side (index plus
  # two directional scans cost three passes before).
  siblings = NodeInspector.children(parent)
  prev_neighbour = nil
  next_neighbour = nil
  found = false

  siblings.each do |sibling|
    if found
      if next_neighbour.nil? && !whitespace_text_node?(sibling)
        next_neighbour = sibling
        break
      end
    elsif sibling.equal?(text_node)
      found = true
    elsif !whitespace_text_node?(sibling)
      prev_neighbour = sibling
    end
  end
  return false unless found

  inline_element?(prev_neighbour) && inline_element?(next_neighbour)
end

.nearest_non_whitespace_sibling(siblings, idx, direction) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 185

def nearest_non_whitespace_sibling(siblings, idx, direction)
  i = idx + direction
  while i >= 0 && i < siblings.length
    s = siblings[i]
    unless whitespace_text_node?(s)
      return s
    end

    i += direction
  end
  nil
end

.preserve_whitespace_node?(node, opts) ⇒ Boolean

Check if whitespace-only text node should be filtered

Returns:

  • (Boolean)


84
85
86
87
88
89
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 84

def preserve_whitespace_node?(node, opts)
  parent = node_parent(node)
  return false unless parent

  element_sensitive?(node, opts)
end

.resolved_collapse_elements(match_opts) ⇒ Object



125
126
127
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 125

def resolved_collapse_elements(match_opts)
  resolved_collapse_elements_set(match_opts).to_a
end

.resolved_preserve_elements(match_opts) ⇒ Object



121
122
123
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 121

def resolved_preserve_elements(match_opts)
  resolved_preserve_elements_set(match_opts).to_a
end

.whitespace_preserved?(element, match_opts) ⇒ Boolean

Check if structural whitespace is preserved (not stripped) for an element.

Returns:

  • (Boolean)


111
112
113
114
115
116
117
118
119
# File 'lib/canon/comparison/whitespace_sensitivity.rb', line 111

def whitespace_preserved?(element, match_opts)
  if respect_xml_space?(match_opts)
    return true  if xml_space_preserve?(element)
    return false if xml_space_default?(element)
  end

  classification = classify_element(element, match_opts)
  %i[preserve collapse].include?(classification)
end