canon 0.1.7 → 0.1.9

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +69 -92
  3. data/README.adoc +13 -13
  4. data/docs/.lycheeignore +69 -0
  5. data/docs/Gemfile +1 -0
  6. data/docs/_config.yml +90 -1
  7. data/docs/advanced/diff-classification.adoc +82 -2
  8. data/docs/advanced/extending-canon.adoc +193 -0
  9. data/docs/features/match-options/index.adoc +239 -1
  10. data/docs/internals/diffnode-enrichment.adoc +611 -0
  11. data/docs/internals/index.adoc +251 -0
  12. data/docs/lychee.toml +13 -6
  13. data/docs/understanding/architecture.adoc +749 -33
  14. data/docs/understanding/comparison-pipeline.adoc +122 -0
  15. data/lib/canon/cache.rb +129 -0
  16. data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +68 -0
  17. data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +68 -0
  18. data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +171 -0
  19. data/lib/canon/comparison/dimensions/base_dimension.rb +107 -0
  20. data/lib/canon/comparison/dimensions/comments_dimension.rb +121 -0
  21. data/lib/canon/comparison/dimensions/element_position_dimension.rb +90 -0
  22. data/lib/canon/comparison/dimensions/registry.rb +77 -0
  23. data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +119 -0
  24. data/lib/canon/comparison/dimensions/text_content_dimension.rb +96 -0
  25. data/lib/canon/comparison/dimensions.rb +54 -0
  26. data/lib/canon/comparison/format_detector.rb +87 -0
  27. data/lib/canon/comparison/html_comparator.rb +70 -26
  28. data/lib/canon/comparison/html_compare_profile.rb +8 -2
  29. data/lib/canon/comparison/html_parser.rb +80 -0
  30. data/lib/canon/comparison/json_comparator.rb +12 -0
  31. data/lib/canon/comparison/json_parser.rb +19 -0
  32. data/lib/canon/comparison/markup_comparator.rb +293 -0
  33. data/lib/canon/comparison/match_options/base_resolver.rb +150 -0
  34. data/lib/canon/comparison/match_options/json_resolver.rb +82 -0
  35. data/lib/canon/comparison/match_options/xml_resolver.rb +151 -0
  36. data/lib/canon/comparison/match_options/yaml_resolver.rb +87 -0
  37. data/lib/canon/comparison/match_options.rb +68 -463
  38. data/lib/canon/comparison/profile_definition.rb +149 -0
  39. data/lib/canon/comparison/ruby_object_comparator.rb +180 -0
  40. data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +7 -10
  41. data/lib/canon/comparison/whitespace_sensitivity.rb +208 -0
  42. data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +177 -0
  43. data/lib/canon/comparison/xml_comparator/attribute_filter.rb +136 -0
  44. data/lib/canon/comparison/xml_comparator/child_comparison.rb +197 -0
  45. data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +115 -0
  46. data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +186 -0
  47. data/lib/canon/comparison/xml_comparator/node_parser.rb +79 -0
  48. data/lib/canon/comparison/xml_comparator/node_type_comparator.rb +102 -0
  49. data/lib/canon/comparison/xml_comparator.rb +97 -684
  50. data/lib/canon/comparison/xml_node_comparison.rb +319 -0
  51. data/lib/canon/comparison/xml_parser.rb +19 -0
  52. data/lib/canon/comparison/yaml_comparator.rb +3 -3
  53. data/lib/canon/comparison.rb +265 -110
  54. data/lib/canon/diff/diff_classifier.rb +101 -2
  55. data/lib/canon/diff/diff_node.rb +32 -2
  56. data/lib/canon/diff/formatting_detector.rb +1 -1
  57. data/lib/canon/diff/node_serializer.rb +191 -0
  58. data/lib/canon/diff/path_builder.rb +143 -0
  59. data/lib/canon/diff_formatter/by_line/base_formatter.rb +251 -0
  60. data/lib/canon/diff_formatter/by_line/html_formatter.rb +6 -248
  61. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +38 -229
  62. data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +30 -0
  63. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +579 -0
  64. data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +121 -0
  65. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +253 -0
  66. data/lib/canon/diff_formatter/diff_detail_formatter/text_utils.rb +61 -0
  67. data/lib/canon/diff_formatter/diff_detail_formatter.rb +31 -1028
  68. data/lib/canon/diff_formatter.rb +1 -1
  69. data/lib/canon/rspec_matchers.rb +38 -9
  70. data/lib/canon/tree_diff/operation_converter.rb +92 -338
  71. data/lib/canon/tree_diff/operation_converter_helpers/metadata_enricher.rb +71 -0
  72. data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +103 -0
  73. data/lib/canon/tree_diff/operation_converter_helpers/reason_builder.rb +168 -0
  74. data/lib/canon/tree_diff/operation_converter_helpers/update_change_handler.rb +188 -0
  75. data/lib/canon/version.rb +1 -1
  76. data/lib/canon/xml/data_model.rb +24 -13
  77. metadata +48 -2
@@ -45,7 +45,7 @@ module Canon
45
45
  return ""
46
46
  end
47
47
 
48
- require_relative "../../xml/data_model"
48
+ require_relative "../../html/data_model"
49
49
  require_relative "../../xml/element_matcher"
50
50
  require_relative "../../xml/line_range_mapper"
51
51
  require_relative "../../pretty_printer/html"
@@ -54,10 +54,10 @@ module Canon
54
54
 
55
55
  begin
56
56
  # Parse to DOM using HTML parser
57
- root1 = Canon::Xml::DataModel.from_html(doc1,
58
- version: @html_version)
59
- root2 = Canon::Xml::DataModel.from_html(doc2,
60
- version: @html_version)
57
+ root1 = Canon::Html::DataModel.from_html(doc1,
58
+ version: @html_version)
59
+ root2 = Canon::Html::DataModel.from_html(doc2,
60
+ version: @html_version)
61
61
 
62
62
  # Match elements semantically
63
63
  matcher = Canon::Xml::ElementMatcher.new
@@ -78,22 +78,8 @@ module Canon
78
78
  lines1 = pretty1.split("\n")
79
79
  lines2 = pretty2.split("\n")
80
80
 
81
- # DEBUG
82
- warn "DEBUG: HTML Formatter - lines1.length=#{lines1.length}, lines2.length=#{lines2.length}"
83
- warn "DEBUG: HTML Formatter - matches.length=#{matches.length}"
84
- warn "DEBUG: HTML Formatter - map1.size=#{map1.size}, map2.size=#{map2.size}"
85
- warn "DEBUG: Mapped elements in map1: #{map1.keys.map(&:name).join(', ')}"
86
- warn "DEBUG: Match types: matched=#{matches.count do |m|
87
- m.status == :matched
88
- end}, deleted=#{matches.count do |m|
89
- m.status == :deleted
90
- end}, inserted=#{matches.count do |m|
91
- m.status == :inserted
92
- end}"
93
-
94
81
  # Display diffs based on element matches
95
82
  result = format_element_matches(matches, map1, map2, lines1, lines2)
96
- warn "DEBUG: HTML Formatter - result.length=#{result.length}"
97
83
  output << result
98
84
  rescue StandardError => e
99
85
  # Fall back to simple diff on error
@@ -271,30 +257,6 @@ module Canon
271
257
 
272
258
  private
273
259
 
274
- # Check if diff display should be skipped
275
- # Returns true when:
276
- # 1. show_diffs is :normative AND there are no normative differences
277
- # 2. show_diffs is :informative AND there are no informative differences
278
- def should_skip_diff_display?
279
- return false if @differences.nil? || @differences.empty?
280
-
281
- case @show_diffs
282
- when :normative
283
- # Skip if no normative diffs
284
- @differences.none? do |diff|
285
- diff.is_a?(Canon::Diff::DiffNode) && diff.normative?
286
- end
287
- when :informative
288
- # Skip if no informative diffs
289
- @differences.none? do |diff|
290
- diff.is_a?(Canon::Diff::DiffNode) && diff.informative?
291
- end
292
- else
293
- # :all or other - never skip
294
- false
295
- end
296
- end
297
-
298
260
  # Format element matches for display
299
261
  def format_element_matches(matches, map1, map2, lines1, lines2)
300
262
  output = []
@@ -321,11 +283,6 @@ module Canon
321
283
  lines2, elements_to_skip,
322
284
  children_of_matched_parents)
323
285
 
324
- # DEBUG
325
- warn "DEBUG: format_element_matches - diff_sections.length=#{diff_sections.length}"
326
- warn "DEBUG: format_element_matches - elements_to_skip.size=#{elements_to_skip.size}"
327
- warn "DEBUG: format_element_matches - children_of_matched_parents.size=#{children_of_matched_parents.size}"
328
-
329
286
  # Sort by line number
330
287
  diff_sections.sort_by! do |section|
331
288
  section[:start_line1] || section[:start_line2] || 0
@@ -335,14 +292,13 @@ module Canon
335
292
  formatted_diffs = if @diff_grouping_lines
336
293
  groups = group_diff_sections(diff_sections,
337
294
  @diff_grouping_lines)
338
- format_diff_groups(groups, lines1, lines2)
295
+ format_diff_groups(groups)
339
296
  else
340
297
  diff_sections.map do |s|
341
298
  s[:formatted]
342
299
  end.compact.join("\n\n")
343
300
  end
344
301
 
345
- warn "DEBUG: format_element_matches - formatted_diffs.length=#{formatted_diffs.length}"
346
302
  output << formatted_diffs
347
303
  output.join("\n")
348
304
  end
@@ -391,57 +347,6 @@ module Canon
391
347
  elements_to_skip
392
348
  end
393
349
 
394
- # Check if an element or its children have semantic diffs
395
- def has_semantic_diff_in_subtree?(element, elements_with_semantic_diffs)
396
- # Check the element itself
397
- return true if elements_with_semantic_diffs.include?(element)
398
-
399
- # Check all descendants
400
- if element.respond_to?(:children)
401
- element.children.any? do |child|
402
- has_semantic_diff_in_subtree?(child, elements_with_semantic_diffs)
403
- end
404
- else
405
- false
406
- end
407
- end
408
-
409
- # Build set of individual elements (not pairs) that have semantic diffs
410
- def build_elements_with_semantic_diffs_set
411
- elements = Set.new
412
-
413
- return elements if @differences.nil? || @differences.empty?
414
-
415
- @differences.each do |diff|
416
- next unless diff.is_a?(Canon::Diff::DiffNode)
417
-
418
- # Add both nodes if they exist
419
- elements.add(diff.node1) if diff.node1
420
- elements.add(diff.node2) if diff.node2
421
- end
422
-
423
- elements
424
- end
425
-
426
- # Build set of children of matched parents
427
- def build_children_set(matches)
428
- children = Set.new
429
-
430
- matches.each do |match|
431
- next unless match.status == :matched
432
-
433
- [match.elem1, match.elem2].compact.each do |elem|
434
- next unless elem.respond_to?(:children)
435
-
436
- elem.children.each do |child|
437
- children.add(child) if child.respond_to?(:name)
438
- end
439
- end
440
- end
441
-
442
- children
443
- end
444
-
445
350
  # Collect diff sections with metadata
446
351
  def collect_diff_sections(matches, map1, map2, lines1, lines2,
447
352
  elements_to_skip, _children_of_matched_parents)
@@ -473,7 +378,6 @@ module Canon
473
378
  range2 = map2[match.elem2]
474
379
  if !range1 || !range2
475
380
  no_range_count += 1
476
- warn "DEBUG: No range for #{match.elem1.name} (path: #{match.path.join('/')})" if no_range_count <= 5
477
381
  end
478
382
 
479
383
  section = format_matched_element_with_metadata(match, map1,
@@ -481,7 +385,6 @@ module Canon
481
385
  lines2)
482
386
  if range1 && range2 && !section
483
387
  no_diff_count += 1
484
- warn "DEBUG: No diff for #{match.elem1.name} (path: #{match.path.join('/')})" if no_diff_count <= 5
485
388
  end
486
389
  diff_sections << section if section
487
390
  when :deleted
@@ -497,67 +400,9 @@ module Canon
497
400
  end
498
401
  end
499
402
 
500
- warn "DEBUG: collect_diff_sections - no_range_count=#{no_range_count}, no_diff_count=#{no_diff_count}"
501
403
  diff_sections
502
404
  end
503
405
 
504
- # Format matched element with metadata
505
- def format_matched_element_with_metadata(match, map1, map2, lines1,
506
- lines2)
507
- range1 = map1[match.elem1]
508
- range2 = map2[match.elem2]
509
- return nil unless range1 && range2
510
-
511
- formatted = format_matched_element(match, map1, map2, lines1,
512
- lines2)
513
- return nil unless formatted
514
-
515
- {
516
- formatted: formatted,
517
- start_line1: range1.start_line,
518
- end_line1: range1.end_line,
519
- start_line2: range2.start_line,
520
- end_line2: range2.end_line,
521
- path: match.path.join("/"),
522
- }
523
- end
524
-
525
- # Format deleted element with metadata
526
- def format_deleted_element_with_metadata(match, map1, lines1)
527
- range1 = map1[match.elem1]
528
- return nil unless range1
529
-
530
- formatted = format_deleted_element(match, map1, lines1)
531
- return nil unless formatted
532
-
533
- {
534
- formatted: formatted,
535
- start_line1: range1.start_line,
536
- end_line1: range1.end_line,
537
- start_line2: nil,
538
- end_line2: nil,
539
- path: match.path.join("/"),
540
- }
541
- end
542
-
543
- # Format inserted element with metadata
544
- def format_inserted_element_with_metadata(match, map2, lines2)
545
- range2 = map2[match.elem2]
546
- return nil unless range2
547
-
548
- formatted = format_inserted_element(match, map2, lines2)
549
- return nil unless formatted
550
-
551
- {
552
- formatted: formatted,
553
- start_line1: nil,
554
- end_line1: nil,
555
- start_line2: range2.start_line,
556
- end_line2: range2.end_line,
557
- path: match.path.join("/"),
558
- }
559
- end
560
-
561
406
  # Format a matched element showing differences
562
407
  def format_matched_element(match, map1, map2, lines1, lines2)
563
408
  range1 = map1[match.elem1]
@@ -631,93 +476,6 @@ module Canon
631
476
 
632
477
  output.join("\n")
633
478
  end
634
-
635
- # Group diff sections by proximity
636
- def group_diff_sections(sections, grouping_lines)
637
- return [] if sections.empty?
638
-
639
- groups = []
640
- current_group = [sections[0]]
641
-
642
- sections[1..].each do |section|
643
- last_section = current_group.last
644
-
645
- # Calculate gap
646
- gap1 = if last_section[:end_line1] && section[:start_line1]
647
- section[:start_line1] - last_section[:end_line1] - 1
648
- else
649
- Float::INFINITY
650
- end
651
-
652
- gap2 = if last_section[:end_line2] && section[:start_line2]
653
- section[:start_line2] - last_section[:end_line2] - 1
654
- else
655
- Float::INFINITY
656
- end
657
-
658
- max_gap = [gap1, gap2].max
659
-
660
- if max_gap <= grouping_lines
661
- current_group << section
662
- else
663
- groups << current_group
664
- current_group = [section]
665
- end
666
- end
667
-
668
- groups << current_group unless current_group.empty?
669
- groups
670
- end
671
-
672
- # Format groups of diffs
673
- def format_diff_groups(groups, _lines1, _lines2)
674
- output = []
675
-
676
- groups.each_with_index do |group, group_idx|
677
- output << "" if group_idx.positive?
678
-
679
- if group.length > 1
680
- output << colorize("Context block has #{group.length} diffs",
681
- :yellow, :bold)
682
- output << ""
683
- group.each do |section|
684
- output << section[:formatted] if section[:formatted]
685
- end
686
- elsif group[0][:formatted]
687
- output << group[0][:formatted]
688
- end
689
- end
690
-
691
- output.join("\n")
692
- end
693
-
694
- # Check if an element or its children have semantic diffs
695
- def has_semantic_diff_in_subtree?(element, elements_with_semantic_diffs)
696
- return true if elements_with_semantic_diffs.include?(element)
697
-
698
- if element.respond_to?(:children)
699
- element.children.any? do |child|
700
- has_semantic_diff_in_subtree?(child, elements_with_semantic_diffs)
701
- end
702
- else
703
- false
704
- end
705
- end
706
-
707
- # Build set of individual elements that have semantic diffs
708
- def build_elements_with_semantic_diffs_set
709
- elements = Set.new
710
- return elements if @differences.nil? || @differences.empty?
711
-
712
- @differences.each do |diff|
713
- next unless diff.is_a?(Canon::Diff::DiffNode)
714
-
715
- elements.add(diff.node1) if diff.node1
716
- elements.add(diff.node2) if diff.node2
717
- end
718
-
719
- elements
720
- end
721
479
  end
722
480
  end
723
481
  end
@@ -252,30 +252,6 @@ module Canon
252
252
 
253
253
  private
254
254
 
255
- # Check if diff display should be skipped
256
- # Returns true when:
257
- # 1. show_diffs is :normative AND there are no normative differences
258
- # 2. show_diffs is :informative AND there are no informative differences
259
- def should_skip_diff_display?
260
- return false if @differences.nil? || @differences.empty?
261
-
262
- case @show_diffs
263
- when :normative
264
- # Skip if no normative diffs
265
- @differences.none? do |diff|
266
- diff.is_a?(Canon::Diff::DiffNode) && diff.normative?
267
- end
268
- when :informative
269
- # Skip if no informative diffs
270
- @differences.none? do |diff|
271
- diff.is_a?(Canon::Diff::DiffNode) && diff.informative?
272
- end
273
- else
274
- # :all or other - never skip
275
- false
276
- end
277
- end
278
-
279
255
  # Format element matches for display
280
256
  def format_element_matches(matches, map1, map2, lines1, lines2)
281
257
  output = []
@@ -311,7 +287,7 @@ module Canon
311
287
  formatted_diffs = if @diff_grouping_lines
312
288
  groups = group_diff_sections(diff_sections,
313
289
  @diff_grouping_lines)
314
- format_diff_groups(groups, lines1, lines2)
290
+ format_diff_groups(groups)
315
291
  else
316
292
  diff_sections.map do |s|
317
293
  s[:formatted]
@@ -322,101 +298,6 @@ module Canon
322
298
  output.join("\n")
323
299
  end
324
300
 
325
- # Build set of elements to skip (children with parents showing diffs)
326
- def build_skip_set(matches, map1, map2, lines1, lines2)
327
- elements_to_skip = Set.new
328
- elements_with_diffs = Set.new
329
-
330
- # Build set of element pairs that have semantic diffs
331
- build_elements_with_semantic_diffs_set
332
-
333
- # First pass: identify elements with line differences
334
- # (semantic filtering happens in collect_diff_sections)
335
- matches.each do |match|
336
- next unless match.status == :matched
337
-
338
- range1 = map1[match.elem1]
339
- range2 = map2[match.elem2]
340
- next unless range1 && range2
341
-
342
- elem_lines1 = lines1[range1.start_line..range1.end_line]
343
- elem_lines2 = lines2[range2.start_line..range2.end_line]
344
-
345
- # Add if there are line diffs
346
- # Semantic filtering is done in collect_diff_sections
347
- if elem_lines1 != elem_lines2
348
- elements_with_diffs.add(match.elem1)
349
- end
350
- end
351
-
352
- # Second pass: skip children of elements with diffs
353
- elements_with_diffs.each do |elem|
354
- if elem.respond_to?(:parent)
355
- current = elem.parent
356
- while current
357
- if current.respond_to?(:name) && elements_with_diffs.include?(current)
358
- elements_to_skip.add(elem)
359
- break
360
- end
361
- current = current.respond_to?(:parent) ? current.parent : nil
362
- end
363
- end
364
- end
365
-
366
- elements_to_skip
367
- end
368
-
369
- # Check if an element or its children have semantic diffs
370
- def has_semantic_diff_in_subtree?(element, elements_with_semantic_diffs)
371
- # Check the element itself
372
- return true if elements_with_semantic_diffs.include?(element)
373
-
374
- # Check all descendants
375
- if element.respond_to?(:children)
376
- element.children.any? do |child|
377
- has_semantic_diff_in_subtree?(child, elements_with_semantic_diffs)
378
- end
379
- else
380
- false
381
- end
382
- end
383
-
384
- # Build set of individual elements (not pairs) that have semantic diffs
385
- def build_elements_with_semantic_diffs_set
386
- elements = Set.new
387
-
388
- return elements if @differences.nil? || @differences.empty?
389
-
390
- @differences.each do |diff|
391
- next unless diff.is_a?(Canon::Diff::DiffNode)
392
-
393
- # Add both nodes if they exist
394
- elements.add(diff.node1) if diff.node1
395
- elements.add(diff.node2) if diff.node2
396
- end
397
-
398
- elements
399
- end
400
-
401
- # Build set of children of matched parents
402
- def build_children_set(matches)
403
- children = Set.new
404
-
405
- matches.each do |match|
406
- next unless match.status == :matched
407
-
408
- [match.elem1, match.elem2].compact.each do |elem|
409
- next unless elem.respond_to?(:children)
410
-
411
- elem.children.each do |child|
412
- children.add(child) if child.respond_to?(:name)
413
- end
414
- end
415
- end
416
-
417
- children
418
- end
419
-
420
301
  # Collect diff sections with metadata
421
302
  def collect_diff_sections(matches, map1, map2, lines1, lines2,
422
303
  elements_to_skip, children_of_matched_parents)
@@ -463,61 +344,48 @@ module Canon
463
344
  diff_sections
464
345
  end
465
346
 
466
- # Format matched element with metadata
467
- def format_matched_element_with_metadata(match, map1, map2, lines1,
468
- lines2)
469
- range1 = map1[match.elem1]
470
- range2 = map2[match.elem2]
471
- return nil unless range1 && range2
347
+ # Build set of elements to skip (children with parents showing diffs)
348
+ def build_skip_set(matches, map1, map2, lines1, lines2)
349
+ elements_to_skip = Set.new
350
+ elements_with_diffs = Set.new
472
351
 
473
- formatted = format_matched_element(match, map1, map2, lines1,
474
- lines2)
475
- return nil unless formatted
476
-
477
- {
478
- formatted: formatted,
479
- start_line1: range1.start_line,
480
- end_line1: range1.end_line,
481
- start_line2: range2.start_line,
482
- end_line2: range2.end_line,
483
- path: match.path.join("/"),
484
- }
485
- end
352
+ # Build set of element pairs that have semantic diffs
353
+ build_elements_with_semantic_diffs_set
486
354
 
487
- # Format deleted element with metadata
488
- def format_deleted_element_with_metadata(match, map1, lines1)
489
- range1 = map1[match.elem1]
490
- return nil unless range1
355
+ # First pass: identify elements with line differences
356
+ # (semantic filtering happens in collect_diff_sections)
357
+ matches.each do |match|
358
+ next unless match.status == :matched
491
359
 
492
- formatted = format_deleted_element(match, map1, lines1)
493
- return nil unless formatted
494
-
495
- {
496
- formatted: formatted,
497
- start_line1: range1.start_line,
498
- end_line1: range1.end_line,
499
- start_line2: nil,
500
- end_line2: nil,
501
- path: match.path.join("/"),
502
- }
503
- end
360
+ range1 = map1[match.elem1]
361
+ range2 = map2[match.elem2]
362
+ next unless range1 && range2
504
363
 
505
- # Format inserted element with metadata
506
- def format_inserted_element_with_metadata(match, map2, lines2)
507
- range2 = map2[match.elem2]
508
- return nil unless range2
364
+ elem_lines1 = lines1[range1.start_line..range1.end_line]
365
+ elem_lines2 = lines2[range2.start_line..range2.end_line]
509
366
 
510
- formatted = format_inserted_element(match, map2, lines2)
511
- return nil unless formatted
512
-
513
- {
514
- formatted: formatted,
515
- start_line1: nil,
516
- end_line1: nil,
517
- start_line2: range2.start_line,
518
- end_line2: range2.end_line,
519
- path: match.path.join("/"),
520
- }
367
+ # Add if there are line diffs
368
+ # Semantic filtering is done in collect_diff_sections
369
+ if elem_lines1 != elem_lines2
370
+ elements_with_diffs.add(match.elem1)
371
+ end
372
+ end
373
+
374
+ # Second pass: skip children of elements with diffs
375
+ elements_with_diffs.each do |elem|
376
+ if elem.respond_to?(:parent)
377
+ current = elem.parent
378
+ while current
379
+ if current.respond_to?(:name) && elements_with_diffs.include?(current)
380
+ elements_to_skip.add(elem)
381
+ break
382
+ end
383
+ current = current.respond_to?(:parent) ? current.parent : nil
384
+ end
385
+ end
386
+ end
387
+
388
+ elements_to_skip
521
389
  end
522
390
 
523
391
  # Format a matched element showing differences
@@ -746,65 +614,6 @@ module Canon
746
614
  output.join("\n")
747
615
  end
748
616
 
749
- # Group diff sections by proximity
750
- def group_diff_sections(sections, grouping_lines)
751
- return [] if sections.empty?
752
-
753
- groups = []
754
- current_group = [sections[0]]
755
-
756
- sections[1..].each do |section|
757
- last_section = current_group.last
758
-
759
- # Calculate gap
760
- gap1 = if last_section[:end_line1] && section[:start_line1]
761
- section[:start_line1] - last_section[:end_line1] - 1
762
- else
763
- Float::INFINITY
764
- end
765
-
766
- gap2 = if last_section[:end_line2] && section[:start_line2]
767
- section[:start_line2] - last_section[:end_line2] - 1
768
- else
769
- Float::INFINITY
770
- end
771
-
772
- max_gap = [gap1, gap2].max
773
-
774
- if max_gap <= grouping_lines
775
- current_group << section
776
- else
777
- groups << current_group
778
- current_group = [section]
779
- end
780
- end
781
-
782
- groups << current_group unless current_group.empty?
783
- groups
784
- end
785
-
786
- # Format groups of diffs
787
- def format_diff_groups(groups, _lines1, _lines2)
788
- output = []
789
-
790
- groups.each_with_index do |group, group_idx|
791
- output << "" if group_idx.positive?
792
-
793
- if group.length > 1
794
- output << colorize("Context block has #{group.length} diffs",
795
- :yellow, :bold)
796
- output << ""
797
- group.each do |section|
798
- output << section[:formatted] if section[:formatted]
799
- end
800
- elsif group[0][:formatted]
801
- output << group[0][:formatted]
802
- end
803
- end
804
-
805
- output.join("\n")
806
- end
807
-
808
617
  # Format a unified diff line
809
618
  def format_unified_line(old_num, new_num, marker, content, color = nil,
810
619
  informative: false, formatting: false)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paint"
4
+
5
+ module Canon
6
+ class DiffFormatter
7
+ module DiffDetailFormatterHelpers
8
+ # Color helper for diff formatting
9
+ #
10
+ # Provides consistent colorization for diff output.
11
+ module ColorHelper
12
+ # Colorize text with optional bold formatting
13
+ #
14
+ # @param text [String] Text to colorize
15
+ # @param color [Symbol] Color name
16
+ # @param use_color [Boolean] Whether to use colors
17
+ # @param bold [Boolean] Whether to make text bold
18
+ # @return [String] Colorized text (or plain text if use_color is false)
19
+ def self.colorize(text, color, use_color, bold: false)
20
+ return text unless use_color
21
+
22
+ args = [color]
23
+ args << :bold if bold
24
+
25
+ Paint[text, *args]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end