canon 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +19 -10
  3. data/Rakefile +22 -2
  4. data/lib/canon/cache.rb +16 -27
  5. data/lib/canon/comparison/html_comparator.rb +2 -0
  6. data/lib/canon/comparison/node_inspector.rb +13 -48
  7. data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +19 -2
  8. data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +30 -0
  9. data/lib/canon/comparison/xml_comparator/node_parser.rb +2 -2
  10. data/lib/canon/comparison/xml_comparator.rb +2 -2
  11. data/lib/canon/comparison.rb +1 -1
  12. data/lib/canon/diff/diff_line_builder.rb +2 -0
  13. data/lib/canon/diff/diff_node_mapper.rb +10 -8
  14. data/lib/canon/diff/formatting_detector.rb +3 -2
  15. data/lib/canon/diff/xml_serialization_formatter.rb +0 -3
  16. data/lib/canon/diff_formatter/by_object/base_formatter.rb +12 -2
  17. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +119 -1
  18. data/lib/canon/diff_formatter/by_object_formatter.rb +1 -5
  19. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +3 -3
  20. data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +26 -27
  21. data/lib/canon/diff_formatter.rb +15 -11
  22. data/lib/canon/html/data_model.rb +1 -1
  23. data/lib/canon/tree_diff/operation_converter.rb +7 -7
  24. data/lib/canon/tree_diff/operations/operation_detector.rb +4 -0
  25. data/lib/canon/validators/base_validator.rb +5 -8
  26. data/lib/canon/validators/html_validator.rb +2 -7
  27. data/lib/canon/validators/xml_validator.rb +2 -7
  28. data/lib/canon/version.rb +1 -1
  29. data/lib/canon/xml/data_model.rb +5 -5
  30. data/lib/canon/xml/sax_builder.rb +1 -1
  31. data/lib/canon/xml/whitespace_normalizer.rb +2 -2
  32. data/lib/canon/xml_parsing.rb +28 -16
  33. metadata +6 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a13457a67f3e2ab91e00cec19684c502605ab807bdd87eb1120e77d190a99c2e
4
- data.tar.gz: 35c0c873340e12c63048adf2222fda2f8c2ae3972337dcc212b26d391191ac35
3
+ metadata.gz: 8fcacddf719d0cb69472bc55b2a23d724c938fdf76f995eb77be962aeda42182
4
+ data.tar.gz: 3a1d14c811209e8353c7539d12c2f7a4d23d44c1e6742b6f48a8d94f65282bd5
5
5
  SHA512:
6
- metadata.gz: 8db915564eebd4ca4dfadd65358f721aa70bca318c22dc1c02eff5e3527cf646ea19722b760072851f358b3fabefd12fc5f6dfc216bce146423c7091f3bf7eac
7
- data.tar.gz: f92e7491d781c8762483335558ede985a1653bcfb88613858115aa87e50bb326f95b0b76b845c54154e657fb9f25b3d1f348bf8e9baa926ea1c6bfbbd77d6ca6
6
+ metadata.gz: f4685b05d13d6cedad780751a8011a722511a9a5623a3a3fa14ce548b95d036c8fd36cabb10d8793ede0a97cf5a1e72d8f53729022c90c8b316c510dc133983e
7
+ data.tar.gz: 5201625d93a1ed9c2efb929905c1d14f4a292e914604149f8e5902a61d1ea6059b987c8875b073ec71340adb1833a33bbce450f58006f29736a37d810f326d6d
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
- # `rubocop --auto-gen-config`
3
- # on 2026-05-24 10:34:05 UTC using RuboCop version 1.86.0.
2
+ # `rubocop --auto-gen-config --no-auto-gen-timestamp`
3
+ # using RuboCop version 1.86.0.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -11,7 +11,7 @@ Gemspec/RequiredRubyVersion:
11
11
  Exclude:
12
12
  - 'canon.gemspec'
13
13
 
14
- # Offense count: 1358
14
+ # Offense count: 1357
15
15
  # This cop supports safe autocorrection (--autocorrect).
16
16
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
17
17
  # URISchemes: http, https
@@ -62,7 +62,7 @@ Lint/UselessConstantScoping:
62
62
  Exclude:
63
63
  - 'lib/canon/diff_formatter/theme.rb'
64
64
 
65
- # Offense count: 313
65
+ # Offense count: 316
66
66
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
67
67
  Metrics/AbcSize:
68
68
  Enabled: false
@@ -83,7 +83,7 @@ Metrics/BlockNesting:
83
83
  Metrics/CyclomaticComplexity:
84
84
  Enabled: false
85
85
 
86
- # Offense count: 523
86
+ # Offense count: 527
87
87
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
88
88
  Metrics/MethodLength:
89
89
  Max: 146
@@ -107,6 +107,14 @@ Naming/MethodParameterName:
107
107
  - 'lib/canon/comparison/xml_comparator/attribute_comparator.rb'
108
108
  - 'lib/canon/xml/namespace_handler.rb'
109
109
 
110
+ # Offense count: 1
111
+ # Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
112
+ # AllowedMethods: call
113
+ # WaywardPredicates: infinite?, nonzero?
114
+ Naming/PredicateMethod:
115
+ Exclude:
116
+ - 'spec/canon/comparison/xml_attribute_diff_spec.rb'
117
+
110
118
  # Offense count: 6
111
119
  # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
112
120
  # SupportedStyles: snake_case, normalcase, non_integer
@@ -132,7 +140,7 @@ Performance/CollectionLiteralInLoop:
132
140
  RSpec/ContextWording:
133
141
  Enabled: false
134
142
 
135
- # Offense count: 47
143
+ # Offense count: 48
136
144
  # Configuration parameters: IgnoredMetadata.
137
145
  RSpec/DescribeClass:
138
146
  Enabled: false
@@ -143,7 +151,7 @@ RSpec/DescribeMethod:
143
151
  - 'spec/canon/comparison/multiple_differences_spec.rb'
144
152
  - 'spec/canon/diff_formatter/character_map_customization_spec.rb'
145
153
 
146
- # Offense count: 874
154
+ # Offense count: 893
147
155
  # Configuration parameters: CountAsOne.
148
156
  RSpec/ExampleLength:
149
157
  Max: 44
@@ -195,7 +203,7 @@ RSpec/MultipleDescribes:
195
203
  Exclude:
196
204
  - 'spec/canon/comparison/match_options_spec.rb'
197
205
 
198
- # Offense count: 736
206
+ # Offense count: 749
199
207
  RSpec/MultipleExpectations:
200
208
  Max: 15
201
209
 
@@ -248,7 +256,7 @@ RSpec/SpecFilePathFormat:
248
256
  - 'spec/canon/yaml/formatter_spec.rb'
249
257
  - 'spec/xml_c14n_spec.rb'
250
258
 
251
- # Offense count: 72
259
+ # Offense count: 18
252
260
  # Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
253
261
  RSpec/VerifiedDoubles:
254
262
  Exclude:
@@ -274,12 +282,13 @@ Style/HashLikeCase:
274
282
  - 'lib/canon/diff/diff_block_builder.rb'
275
283
  - 'lib/canon/xml/character_encoder.rb'
276
284
 
277
- # Offense count: 4
285
+ # Offense count: 6
278
286
  # This cop supports unsafe autocorrection (--autocorrect-all).
279
287
  Style/IdenticalConditionalBranches:
280
288
  Exclude:
281
289
  - 'lib/canon/diff_formatter/by_object/base_formatter.rb'
282
290
  - 'lib/canon/diff_formatter/legend.rb'
291
+ - 'lib/canon/xml/whitespace_normalizer.rb'
283
292
 
284
293
  # Offense count: 1
285
294
  # Configuration parameters: AllowedMethods.
data/Rakefile CHANGED
@@ -7,6 +7,26 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  begin
9
9
  require "opal/rspec/rake_task"
10
+
11
+ # Configure Opal load paths at load time (same pattern as moxml)
12
+ if defined?(Opal)
13
+ Opal.append_path File.expand_path("lib", __dir__)
14
+
15
+ # moxml gem
16
+ begin
17
+ moxml_spec = Gem::Specification.find_by_name("moxml")
18
+ moxml_lib = moxml_spec.full_require_paths.first
19
+ Opal.append_path moxml_lib
20
+ moxml_compat = File.expand_path("compat/opal", moxml_lib)
21
+ Opal.append_path moxml_compat if File.directory?(moxml_compat)
22
+ rescue Gem::MissingSpecError
23
+ # moxml not installed
24
+ end
25
+
26
+ # REXML: bundled gem since Ruby 3.4
27
+ rexml_lib = $LOAD_PATH.find { |p| File.exist?(File.join(p, "rexml", "document.rb")) }
28
+ Opal.append_path rexml_lib if rexml_lib
29
+ end
10
30
  rescue LoadError
11
31
  # Opal not available or incompatible with current Ruby version
12
32
  end
@@ -20,9 +40,9 @@ Dir.glob("lib/tasks/**/*.rake").each { |r| load r }
20
40
  namespace :spec do
21
41
  if defined?(Opal::RSpec::RakeTask)
22
42
  desc "Run Opal (JavaScript) tests"
23
- Opal::RSpec::RakeTask.new(:opal) do |server, runner|
24
- server.append_path "lib"
43
+ Opal::RSpec::RakeTask.new(:opal) do |_server, runner|
25
44
  runner.default_path = "spec"
45
+ runner.requires = %w[rexml_compat rexml/document rexml/xpath moxml/adapter/rexml spec_helper]
26
46
  runner.pattern = "spec/canon/opal_xml_smoke_spec.rb"
27
47
  end
28
48
  end
data/lib/canon/cache.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require "digest" unless RUBY_ENGINE == "opal"
4
4
 
5
5
  module Canon
6
6
  # Cache for expensive operations during document comparison
@@ -73,50 +73,39 @@ module Canon
73
73
  end
74
74
 
75
75
  # Generate cache key for document parsing
76
- #
77
- # @param content [String] Document content
78
- # @param format [Symbol] Document format
79
- # @param preprocessing [Symbol] Preprocessing option
80
- # @return [String] Cache key
81
76
  def key_for_document(content, format, preprocessing)
82
- digest = Digest::SHA256.hexdigest(content)
83
- "doc:#{format}:#{preprocessing}:#{digest[0..16]}"
77
+ "doc:#{format}:#{preprocessing}:#{content_hash(content)}"
84
78
  end
85
79
 
86
80
  # Generate cache key for format detection
87
- #
88
- # @param content [String] Document content
89
- # @return [String] Cache key
90
81
  def key_for_format_detection(content)
91
- # Use first 100 chars for quick key, plus length
92
- # Force to binary to avoid encoding compatibility issues
93
82
  preview = content[0..100].b
94
- digest = Digest::SHA256.hexdigest(preview + content.length.to_s)
95
- "fmt:#{digest[0..16]}"
83
+ "fmt:#{content_hash(preview + content.length.to_s)}"
96
84
  end
97
85
 
98
86
  # Generate cache key for XML canonicalization
99
- #
100
- # @param content [String] XML content
101
- # @param with_comments [Boolean] Whether to include comments
102
- # @return [String] Cache key
103
87
  def key_for_c14n(content, with_comments)
104
- digest = Digest::SHA256.hexdigest(content)
105
- "c14n:#{with_comments}:#{digest[0..16]}"
88
+ "c14n:#{with_comments}:#{content_hash(content)}"
106
89
  end
107
90
 
108
91
  # Generate cache key for preprocessing
109
- #
110
- # @param content [String] Original content
111
- # @param preprocessing [Symbol] Preprocessing type
112
- # @return [String] Cache key
113
92
  def key_for_preprocessing(content, preprocessing)
114
- digest = Digest::SHA256.hexdigest(content)
115
- "pre:#{preprocessing}:#{digest[0..16]}"
93
+ "pre:#{preprocessing}:#{content_hash(content)}"
116
94
  end
117
95
 
118
96
  private
119
97
 
98
+ # Generate a hash string for cache keys
99
+ def content_hash(content)
100
+ if defined?(Digest::SHA256)
101
+ Digest::SHA256.hexdigest(content)[0..16]
102
+ else
103
+ # Opal fallback: simple string hash
104
+ h = content.each_char.reduce(0) { |acc, c| ((acc * 31) + c.ord) & 0xFFFFFFFF }
105
+ h.to_s(16).rjust(8, "0")
106
+ end
107
+ end
108
+
120
109
  # Get or create cache for a category
121
110
  #
122
111
  # @param category [Symbol] Cache category
@@ -558,6 +558,8 @@ module Canon
558
558
  end
559
559
  end
560
560
 
561
+ public :detect_html_version
562
+
561
563
  # Detect HTML version from node
562
564
  #
563
565
  # @param node [Canon::Xml::Node, Nokogiri::XML::Node] HTML node
@@ -10,37 +10,25 @@ module Canon
10
10
  # * Canon::TreeDiff::Core::TreeNode — semantic tree diff nodes.
11
11
  # * Backend-specific nodes (Nokogiri or Moxml) — live parsed nodes.
12
12
  #
13
- # All type dispatch uses backend-branching (`if XmlBackend.nokogiri?`)
14
- # rather than `case/when` with constant references. This prevents
15
- # NameError when Nokogiri constants are undefined under Opal.
16
- #
17
- # Every node query in the codebase should go through this module.
18
- # Do not create private dispatch methods in consumers.
13
+ # Architecture: NodeInspector handles Canon-native types (Canon::Xml::Node,
14
+ # TreeNode) directly, then delegates ALL backend-specific queries to
15
+ # XmlParsing. No Moxml/Nokogiri constants are referenced here — that
16
+ # knowledge lives exclusively in XmlParsing.
19
17
  module NodeInspector
20
- NOKOGIRI_TEXT_TYPE = defined?(Nokogiri::XML::Node::TEXT_NODE) ? Nokogiri::XML::Node::TEXT_NODE : 3
21
-
22
18
  # --- Type predicates ---
23
19
 
24
20
  def self.text_node?(node)
25
21
  return false unless node
26
22
  return node.node_type == :text if node.is_a?(Canon::Xml::Node)
27
23
 
28
- if XmlBackend.nokogiri?
29
- node.is_a?(Nokogiri::XML::Text) || node.is_a?(Moxml::Text)
30
- else
31
- node.is_a?(Moxml::Text)
32
- end
24
+ XmlParsing.text_node?(node)
33
25
  end
34
26
 
35
27
  def self.element_node?(node)
36
28
  return false unless node
37
29
  return node.node_type == :element if node.is_a?(Canon::Xml::Node)
38
30
 
39
- if XmlBackend.nokogiri?
40
- node.is_a?(Nokogiri::XML::Element) || node.is_a?(Moxml::Element)
41
- else
42
- node.is_a?(Moxml::Element)
43
- end
31
+ XmlParsing.element?(node)
44
32
  end
45
33
 
46
34
  def self.comment_node?(node)
@@ -57,7 +45,7 @@ module Canon
57
45
  end
58
46
  false
59
47
  else
60
- node.is_a?(Moxml::Comment)
48
+ XmlParsing.comment?(node)
61
49
  end
62
50
  end
63
51
 
@@ -100,7 +88,6 @@ module Canon
100
88
 
101
89
  # --- Node queries ---
102
90
 
103
- # Unified node name extraction across all node types.
104
91
  def self.name(node)
105
92
  return nil unless node
106
93
  return node.name if node.is_a?(Canon::Xml::Node)
@@ -109,7 +96,6 @@ module Canon
109
96
  XmlParsing.name(node)
110
97
  end
111
98
 
112
- # Unified parent access across all node types.
113
99
  def self.parent(node)
114
100
  return nil unless node
115
101
  return node.parent if node.is_a?(Canon::Xml::Node)
@@ -118,7 +104,6 @@ module Canon
118
104
  XmlParsing.parent(node)
119
105
  end
120
106
 
121
- # Unified children access across all node types.
122
107
  def self.children(node)
123
108
  return [] unless node
124
109
  return node.children if node.is_a?(Canon::Xml::Node)
@@ -127,34 +112,21 @@ module Canon
127
112
  XmlParsing.children(node)
128
113
  end
129
114
 
130
- # Extract the text content of +node+ as a String.
131
115
  def self.text_content(node)
132
- case node
133
- when Canon::Xml::Nodes::TextNode
134
- node.value.to_s
135
- when Canon::Xml::Node
136
- node.text_content.to_s
137
- when Moxml::Text
138
- node.content.to_s
139
- else
140
- XmlParsing.text_content(node).to_s
141
- end
116
+ return node.value.to_s if node.is_a?(Canon::Xml::Nodes::TextNode)
117
+ return node.text_content.to_s if node.is_a?(Canon::Xml::Node)
118
+
119
+ XmlParsing.text_content(node).to_s
142
120
  end
143
121
 
144
- # Unified node type that always returns a symbol.
145
- # Returns nil for unrecognised nodes.
146
122
  def self.node_type(node)
147
123
  return nil unless node
148
124
  return node.node_type if node.is_a?(Canon::Xml::Node)
125
+ return node.type&.to_sym if node.is_a?(Canon::TreeDiff::Core::TreeNode)
149
126
 
150
- if node.is_a?(Canon::TreeDiff::Core::TreeNode)
151
- node.type&.to_sym
152
- else
153
- XmlParsing.node_type(node)
154
- end
127
+ XmlParsing.node_type(node)
155
128
  end
156
129
 
157
- # Unified attribute value access.
158
130
  def self.attribute_value(node, attr_name)
159
131
  return nil unless node
160
132
 
@@ -168,7 +140,6 @@ module Canon
168
140
  end
169
141
  end
170
142
 
171
- # Unified namespace URI access.
172
143
  def self.namespace_uri(node)
173
144
  return nil unless node
174
145
 
@@ -179,7 +150,6 @@ module Canon
179
150
  end
180
151
  end
181
152
 
182
- # Extract parse-time errors carried on a node or its owning document.
183
153
  def self.parse_errors(node)
184
154
  return [] if node.nil?
185
155
  return Array(node.parse_errors).map(&:to_s) if node.is_a?(Canon::Xml::Node)
@@ -194,11 +164,6 @@ module Canon
194
164
  []
195
165
  end
196
166
  end
197
-
198
- # Deprecated: use NodeInspector.parent instead.
199
- def self.parent_of(node)
200
- parent(node)
201
- end
202
167
  end
203
168
  end
204
169
  end
@@ -15,8 +15,8 @@ module Canon
15
15
  # @return [Symbol] Comparison result
16
16
  def self.compare(node1, node2, opts, differences)
17
17
  # Get attributes using the appropriate method for each node type
18
- raw_attrs1 = node1.respond_to?(:attribute_nodes) ? node1.attribute_nodes : node1.attributes
19
- raw_attrs2 = node2.respond_to?(:attribute_nodes) ? node2.attribute_nodes : node2.attributes
18
+ raw_attrs1 = get_raw_attributes(node1)
19
+ raw_attrs2 = get_raw_attributes(node2)
20
20
 
21
21
  attrs1 = XmlComparatorHelpers::AttributeFilter.filter(raw_attrs1,
22
22
  opts)
@@ -171,6 +171,23 @@ dimension:, differences:, **opts)
171
171
  )
172
172
  differences << diff_node if diff_node
173
173
  end
174
+
175
+ # Get raw attributes from a node, dispatching by type.
176
+ # Nokogiri::XML::Element has attribute_nodes (NodeSet),
177
+ # Canon::Xml::Nodes::ElementNode has attribute_nodes (Array),
178
+ # Moxml::Element has attributes (Hash-like).
179
+ def self.get_raw_attributes(node)
180
+ case node
181
+ when Canon::Xml::Nodes::ElementNode
182
+ node.attribute_nodes
183
+ else
184
+ if Canon::XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Element)
185
+ node.attribute_nodes
186
+ else
187
+ node.attributes
188
+ end
189
+ end
190
+ end
174
191
  end
175
192
  end
176
193
  end
@@ -72,6 +72,11 @@ module Canon
72
72
  return build_attribute_difference_reason(attrs1, attrs2)
73
73
  end
74
74
 
75
+ # For attribute value differences, show which attributes changed
76
+ if dimension == :attribute_values
77
+ return build_attribute_values_reason(node1, node2)
78
+ end
79
+
75
80
  # For text content differences, show the actual text (truncated if needed)
76
81
  if dimension == :text_content
77
82
  text1 = extract_text_content(node1)
@@ -186,6 +191,31 @@ module Canon
186
191
  end
187
192
  end
188
193
 
194
+ # Build a reason message for attribute value differences
195
+ # Shows each changed attribute with its before/after values
196
+ #
197
+ # @param node1 [Object, nil] First node
198
+ # @param node2 [Object, nil] Second node
199
+ # @return [String] Clear explanation of the attribute value differences
200
+ def self.build_attribute_values_reason(node1, node2)
201
+ attrs1 = extract_attributes(node1) || {}
202
+ attrs2 = extract_attributes(node2) || {}
203
+
204
+ differing = (attrs1.keys | attrs2.keys).sort.reject do |k|
205
+ attrs1[k.to_s] == attrs2[k.to_s]
206
+ end
207
+
208
+ changed_parts = differing.map do |k|
209
+ "Changed: #{k}=\"#{attrs1[k.to_s]}\" → \"#{attrs2[k.to_s]}\""
210
+ end
211
+
212
+ if changed_parts.empty?
213
+ "attributes differ"
214
+ else
215
+ "Attributes differ (#{changed_parts.join('; ')})"
216
+ end
217
+ end
218
+
189
219
  # Extract text content from a node
190
220
  #
191
221
  # @param node [Object, nil] Node to extract text from
@@ -37,7 +37,7 @@ module Canon
37
37
  # Select parser backend
38
38
  resolved_parser = parser || resolve_parser_config
39
39
 
40
- if resolved_parser == :sax
40
+ if resolved_parser == :sax && RUBY_ENGINE != "opal"
41
41
  require_relative "../../xml/sax_builder"
42
42
  Canon::Xml::SaxBuilder.parse(xml_string,
43
43
  preserve_whitespace: preserve_whitespace)
@@ -97,7 +97,7 @@ parser: nil)
97
97
 
98
98
  resolved_parser = parser || resolve_parser_config
99
99
 
100
- if resolved_parser == :sax
100
+ if resolved_parser == :sax && RUBY_ENGINE != "opal"
101
101
  require_relative "../../xml/sax_builder"
102
102
  Canon::Xml::SaxBuilder.parse(xml_str,
103
103
  preserve_whitespace: preserve_whitespace)
@@ -875,7 +875,7 @@ differences)
875
875
  end
876
876
 
877
877
  def whitespace_adjacency_parent_label(ws_node)
878
- parent = NodeInspector.parent_of(ws_node)
878
+ parent = NodeInspector.parent(ws_node)
879
879
  return "(unknown parent)" unless parent
880
880
 
881
881
  name = parent.name
@@ -890,7 +890,7 @@ differences)
890
890
  # "adjacent to" as a degenerate fallback when neither neighbour
891
891
  # exists.
892
892
  def whitespace_partner_direction(ws_node)
893
- parent = NodeInspector.parent_of(ws_node)
893
+ parent = NodeInspector.parent(ws_node)
894
894
  return "adjacent to" unless parent
895
895
 
896
896
  siblings = parent.children
@@ -284,7 +284,7 @@ module Canon
284
284
  (custom + presets).sort.uniq
285
285
  end
286
286
 
287
- private
287
+ # --- Internal methods (public for testability) ---
288
288
 
289
289
  # Perform semantic tree diff comparison
290
290
  def semantic_diff(obj1, obj2, opts = {})
@@ -843,6 +843,8 @@ new_line_ranges)
843
843
  index
844
844
  end
845
845
 
846
+ public :build_line_index
847
+
846
848
  # Sort char ranges by start_col for consistent rendering.
847
849
  def sort_ranges(ranges)
848
850
  (ranges || []).sort_by(&:start_col)
@@ -323,7 +323,7 @@ module Canon
323
323
  # @param line2 [String] New line content (for +/!)
324
324
  # @return [Boolean] true if formatting-only
325
325
  def formatting?(node, line1, line2)
326
- return true if node.respond_to?(:formatting?) && node.formatting?
326
+ return true if node.is_a?(Canon::Diff::DiffNode) && node.formatting?
327
327
  return false if node
328
328
  return true if comment_only_line?(line1) || comment_only_line?(line2)
329
329
 
@@ -394,14 +394,14 @@ module Canon
394
394
  end
395
395
 
396
396
  nodes_to_check.any? do |node|
397
- # Check if the node itself has the matching name
398
- if node.respond_to?(:name) && node.name == line_element_name
399
- true
400
- # Check if the node's parent has the matching name (for TextNode diffs)
401
- elsif node.respond_to?(:parent) && node.parent.respond_to?(:name) && node.parent.name == line_element_name # rubocop:disable Style/IfWithBooleanLiteralBranches
397
+ next false unless node
398
+
399
+ node_name = Canon::Comparison::NodeInspector.name(node)
400
+ if node_name == line_element_name
402
401
  true
403
402
  else
404
- false
403
+ parent = Canon::Comparison::NodeInspector.parent(node)
404
+ parent && Canon::Comparison::NodeInspector.name(parent) == line_element_name
405
405
  end
406
406
  end
407
407
  end
@@ -453,6 +453,8 @@ module Canon
453
453
  comment_lines
454
454
  end
455
455
 
456
+ public :build_comment_lines
457
+
456
458
  # Find a comment DiffNode for a line that falls within a comment range.
457
459
  # Matches by checking if the DiffNode's source node has name "comment".
458
460
  #
@@ -463,7 +465,7 @@ module Canon
463
465
  @comment_diff_nodes&.find do |diff_node|
464
466
  nodes_to_check = [diff_node.node1, diff_node.node2].compact
465
467
  nodes_to_check.any? do |node|
466
- node.respond_to?(:name) && node.name == "comment"
468
+ Canon::Comparison::NodeInspector.name(node) == "comment"
467
469
  end
468
470
  end
469
471
  end
@@ -297,8 +297,9 @@ module Canon
297
297
  [close_idx + 1, "<#{sorted}#{suffix}"]
298
298
  end
299
299
 
300
- private_class_method :normalize_for_comparison, :blank?,
301
- :decode_xml_entities, :normalize_attribute_order,
300
+ public_class_method :normalize_for_comparison, :blank?
301
+
302
+ private_class_method :decode_xml_entities, :normalize_attribute_order,
302
303
  :sort_tag_attributes, :tokenize_tag_content,
303
304
  :process_tag, :process_processing_instruction,
304
305
  :process_comment, :process_regular_tag
@@ -57,9 +57,6 @@ module Canon
57
57
  rescue StandardError
58
58
  nil
59
59
  end
60
-
61
- private_class_method :blank?, :text_node?, :extract_text_content,
62
- :empty_text_content_serialization_diff?
63
60
  end
64
61
  end
65
62
  end
@@ -270,9 +270,12 @@ show_diffs: :all, theme: nil)
270
270
 
271
271
  if diffs && !diffs.empty?
272
272
  # Render all differences at this path
273
+ has_children = value.is_a?(Hash) &&
274
+ (value.keys - [:__diffs__]).any?
275
+
273
276
  diffs.each_with_index do |diff, diff_idx|
274
- # Use proper connector for each diff
275
- current_connector = if diff_idx == diffs.length - 1
277
+ is_last_diff = diff_idx == diffs.length - 1
278
+ current_connector = if is_last_diff && !has_children
276
279
  connector
277
280
  else
278
281
  is_last_item ? "├── " : "├── "
@@ -282,6 +285,13 @@ show_diffs: :all, theme: nil)
282
285
  output << line
283
286
  @line_count += line.count("\n") + 1
284
287
  end
288
+
289
+ # Recurse into child diffs at this path
290
+ if has_children
291
+ subtree = render_tree(value, prefix: prefix + continuation,
292
+ is_last: is_last_item)
293
+ output << subtree
294
+ end
285
295
  else
286
296
  # Render intermediate path
287
297
  line = colorize("#{prefix}#{connector}#{key}:",