Class: Canon::Comparison::DiffNodeBuilder
- Inherits:
-
Object
- Object
- Canon::Comparison::DiffNodeBuilder
- Defined in:
- lib/canon/comparison/diff_node_builder.rb
Overview
Single factory for DiffNode creation in the DOM comparison path.
Centralises reason building, metadata enrichment (path, serialization, attributes), and whitespace visualization — previously duplicated across MarkupComparator and XmlComparator.
Class Method Summary collapse
-
.build(node1:, node2:, diff1:, diff2:, dimension:, verbose: false, **_opts) ⇒ Object
Build a DiffNode.
-
.build_attribute_difference_reason(attrs1, attrs2) ⇒ Object
--- Attribute reason builders -----------------------------------------.
- .build_attribute_order_reason(node1, node2) ⇒ Object
- .build_attribute_values_reason(node1, node2) ⇒ Object
-
.build_comment_difference_reason(node1, node2) ⇒ Object
--- Comment reason ----------------------------------------------------.
-
.build_reason(node1, node2, diff1, diff2, dimension) ⇒ Object
--- Reason building ---------------------------------------------------.
-
.build_text_difference_reason(text1, text2) ⇒ Object
--- Text diff reason --------------------------------------------------.
-
.build_whitespace_adjacency_reason(node1, node2) ⇒ Object
--- Whitespace adjacency reason (#137) --------------------------------.
- .comment_text(node) ⇒ Object
- .describe_whitespace(text) ⇒ Object
-
.enrich_metadata(node1, node2) ⇒ Object
--- Metadata enrichment -----------------------------------------------.
- .extract_attributes(node) ⇒ Object
-
.extract_text_content(node) ⇒ Object
--- Text content extraction -------------------------------------------.
-
.serialize(node) ⇒ Object
--- Node queries (delegate to NodeSerializer) -------------------------.
- .truncate(text, max_length = 40) ⇒ Object
-
.visualize_whitespace(text) ⇒ Object
--- Whitespace visualization ------------------------------------------.
- .whitespace_only?(text) ⇒ Boolean
Class Method Details
.build(node1:, node2:, diff1:, diff2:, dimension:, verbose: false, **_opts) ⇒ Object
Build a DiffNode. Verbose callers get the fully enriched form — reason, path, serialized content, attributes; non-verbose callers only ever ask normative?, and DiffClassifier reads node refs and dimension alone, so reason building and metadata enrichment (five display operations per difference) are skipped entirely.
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 19 def self.build(node1:, node2:, diff1:, diff2:, dimension:, verbose: false, **_opts) raise ArgumentError, "dimension required for DiffNode" if dimension.nil? unless verbose return Canon::Diff::DiffNode.new(node1: node1, node2: node2, dimension: dimension) end reason = build_reason(node1, node2, diff1, diff2, dimension) = (node1, node2) Canon::Diff::DiffNode.new( node1: node1, node2: node2, dimension: dimension, reason: reason, **, ) end |
.build_attribute_difference_reason(attrs1, attrs2) ⇒ Object
--- Attribute reason builders -----------------------------------------
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 107 def self.build_attribute_difference_reason(attrs1, attrs2) unless attrs1 && attrs2 return "#{attrs1&.keys&.size || 0} vs #{attrs2&.keys&.size || 0} attributes" end keys1 = attrs1.keys.to_set keys2 = attrs2.keys.to_set only_in_first = keys1 - keys2 only_in_second = keys2 - keys1 different_values = (keys1 & keys2).reject { |k| attrs1[k] == attrs2[k] } parts = [] parts << "only in first: #{only_in_first.to_a.sort.join(', ')}" if only_in_first.any? parts << "only in second: #{only_in_second.to_a.sort.join(', ')}" if only_in_second.any? parts << "different values: #{different_values.sort.join(', ')}" if different_values.any? parts.empty? ? "#{keys1.size} vs #{keys2.size} attributes (same names)" : parts.join("; ") end |
.build_attribute_order_reason(node1, node2) ⇒ Object
142 143 144 145 146 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 142 def self.build_attribute_order_reason(node1, node2) keys1 = extract_attributes(node1)&.keys || [] keys2 = extract_attributes(node2)&.keys || [] "Attribute order changed: [#{keys1.join(', ')}] → [#{keys2.join(', ')}]" end |
.build_attribute_values_reason(node1, node2) ⇒ Object
127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 127 def self.build_attribute_values_reason(node1, node2) attrs1 = extract_attributes(node1) || {} attrs2 = extract_attributes(node2) || {} differing = (attrs1.keys | attrs2.keys).sort.reject do |k| attrs1[k.to_s] == attrs2[k.to_s] end changed = differing.map do |k| "Changed: #{k}=\"#{attrs1[k.to_s]}\" → \"#{attrs2[k.to_s]}\"" end changed.empty? ? "attributes differ" : "Attributes differ (#{changed.join('; ')})" end |
.build_comment_difference_reason(node1, node2) ⇒ Object
--- Comment reason ----------------------------------------------------
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 196 def self.build_comment_difference_reason(node1, node2) cm1 = node1 && NodeInspector.comment_node?(node1) cm2 = node2 && NodeInspector.comment_node?(node2) return nil unless cm1 || cm2 if cm1 && !cm2 "Comment present on EXPECTED only: <!--#{truncate(comment_text(node1))}-->" elsif cm2 && !cm1 "Comment present on ACTUAL only: <!--#{truncate(comment_text(node2))}-->" else t1 = truncate(comment_text(node1)) t2 = truncate(comment_text(node2)) "Comment text differs: <!--#{t1}--> vs <!--#{t2}-->" end end |
.build_reason(node1, node2, diff1, diff2, dimension) ⇒ Object
--- Reason building ---------------------------------------------------
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 42 def self.build_reason(node1, node2, diff1, diff2, dimension) # Nil-node text content with namespace info if dimension == :text_content && (node1.nil? || node2.nil?) node = node1 || node2 if node.is_a?(Canon::Xml::Node) || Canon::XmlParsing.xml_node?(node) ns = Canon::XmlParsing.namespace_uri(node) ns_info = ns.nil? || ns.empty? ? "" : " (namespace: #{ns})" label = Canon::Comparison.code_pair_label(diff1, diff2) return "element '#{node.name}'#{ns_info}: #{label}" end end case dimension when :attribute_presence build_attribute_difference_reason( extract_attributes(node1), extract_attributes(node2) ) when :attribute_values build_attribute_values_reason(node1, node2) when :text_content build_text_difference_reason( extract_text_content(node1), extract_text_content(node2) ) when :attribute_order build_attribute_order_reason(node1, node2) when :comments build_comment_difference_reason(node1, node2) || fallback_reason(diff1, diff2, dimension, node1, node2) when :whitespace_adjacency build_whitespace_adjacency_reason(node1, node2) else fallback_reason(diff1, diff2, dimension, node1, node2) end end |
.build_text_difference_reason(text1, text2) ⇒ Object
--- Text diff reason --------------------------------------------------
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 173 def self.build_text_difference_reason(text1, text2) return "missing vs '#{truncate(text2)}'" if text1.nil? && text2 return "'#{truncate(text1)}' vs missing" if text1 && text2.nil? return "both missing" if text1.nil? && text2.nil? if whitespace_only?(text1) && whitespace_only?(text2) return "whitespace: #{describe_whitespace(text1)} vs #{describe_whitespace(text2)}" end # Identical content modulo whitespace: two full visualized # copies read as materially different text when the only # delta is trailing/invisible whitespace (#94) — the compact # character-count description says what actually differs. if text1.strip == text2.strip return "whitespace-only: #{describe_whitespace(text1)} vs " \ "#{describe_whitespace(text2)}" end "Text: \"#{visualize_whitespace(text1)}\" vs \"#{visualize_whitespace(text2)}\"" end |
.build_whitespace_adjacency_reason(node1, node2) ⇒ Object
--- Whitespace adjacency reason (#137) --------------------------------
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 219 def self.build_whitespace_adjacency_reason(node1, node2) text1 = extract_text_content(node1) text2 = extract_text_content(node2) ws_on_first = NodeInspector.whitespace_only_text?(node1) && !NodeInspector.whitespace_only_text?(node2) ws_on_second = NodeInspector.whitespace_only_text?(node2) && !NodeInspector.whitespace_only_text?(node1) unless ws_on_first || ws_on_second return build_text_difference_reason(text1, text2) end if ws_on_first build_adjacency_side(text1, text2, node1, "EXPECTED", "ACTUAL") else build_adjacency_side(text2, text1, node2, "ACTUAL", "EXPECTED") end end |
.comment_text(node) ⇒ Object
213 214 215 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 213 def self.comment_text(node) NodeInspector.text_content(node).to_s end |
.describe_whitespace(text) ⇒ Object
248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 248 def self.describe_whitespace(text) return "0 chars" if text.nil? || text.empty? char_count = text.length parts = [] parts << "#{text.count("\n")} newlines" if text.include?("\n") parts << "#{text.count(' ')} spaces" if text.include?(" ") parts << "#{text.count("\t")} tabs" if text.include?("\t") "#{char_count} chars (#{parts.join(', ')})" end |
.enrich_metadata(node1, node2) ⇒ Object
--- Metadata enrichment -----------------------------------------------
80 81 82 83 84 85 86 87 88 89 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 80 def self.(node1, node2) { path: Canon::Diff::PathBuilder.build(node1 || node2, format: :document), serialized_before: serialize(node1), serialized_after: serialize(node2), attributes_before: extract_attributes(node1), attributes_after: extract_attributes(node2), } end |
.extract_attributes(node) ⇒ Object
99 100 101 102 103 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 99 def self.extract_attributes(node) return nil if node.nil? Canon::Diff::NodeSerializer.extract_attributes(node) end |
.extract_text_content(node) ⇒ Object
--- Text content extraction -------------------------------------------
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 150 def self.extract_text_content(node) return nil if node.nil? case node when Canon::Xml::Nodes::TextNode node.value when Canon::Xml::Node node.text_content else if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node) node.content.to_s elsif Canon::XmlParsing.xml_node?(node) Canon::XmlParsing.text_content(node) else node.to_s end end rescue StandardError nil end |
.serialize(node) ⇒ Object
--- Node queries (delegate to NodeSerializer) -------------------------
93 94 95 96 97 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 93 def self.serialize(node) return nil if node.nil? Canon::Diff::NodeSerializer.serialize(node) end |
.truncate(text, max_length = 40) ⇒ Object
266 267 268 269 270 271 272 273 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 266 def self.truncate(text, max_length = 40) return "" if text.nil? text = text.to_s return text if text.length <= max_length "#{text[0...max_length]}..." end |
.visualize_whitespace(text) ⇒ Object
--- Whitespace visualization ------------------------------------------
241 242 243 244 245 246 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 241 def self.visualize_whitespace(text) return "" if text.nil? viz_map = character_visualization_map text.chars.map { |char| viz_map[char] || char }.join end |
.whitespace_only?(text) ⇒ Boolean
260 261 262 263 264 |
# File 'lib/canon/comparison/diff_node_builder.rb', line 260 def self.whitespace_only?(text) return false if text.nil? text.to_s.strip.empty? end |