canon 0.1.3 → 0.1.5

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -1
  3. data/.rubocop_todo.yml +276 -7
  4. data/README.adoc +203 -138
  5. data/_config.yml +116 -0
  6. data/docs/ADVANCED_TOPICS.adoc +20 -0
  7. data/docs/BASIC_USAGE.adoc +16 -0
  8. data/docs/CHARACTER_VISUALIZATION.adoc +567 -0
  9. data/docs/CLI.adoc +493 -0
  10. data/docs/CUSTOMIZING_BEHAVIOR.adoc +19 -0
  11. data/docs/DIFF_ARCHITECTURE.adoc +435 -0
  12. data/docs/DIFF_FORMATTING.adoc +540 -0
  13. data/docs/FORMATS.adoc +447 -0
  14. data/docs/INDEX.adoc +222 -0
  15. data/docs/INPUT_VALIDATION.adoc +477 -0
  16. data/docs/MATCH_ARCHITECTURE.adoc +463 -0
  17. data/docs/MATCH_OPTIONS.adoc +719 -0
  18. data/docs/MODES.adoc +432 -0
  19. data/docs/NORMATIVE_INFORMATIVE_DIFFS.adoc +219 -0
  20. data/docs/OPTIONS.adoc +1387 -0
  21. data/docs/PREPROCESSING.adoc +491 -0
  22. data/docs/RSPEC.adoc +605 -0
  23. data/docs/RUBY_API.adoc +478 -0
  24. data/docs/SEMANTIC_DIFF_REPORT.adoc +528 -0
  25. data/docs/UNDERSTANDING_CANON.adoc +17 -0
  26. data/docs/VERBOSE.adoc +482 -0
  27. data/exe/canon +7 -0
  28. data/lib/canon/cli.rb +179 -0
  29. data/lib/canon/commands/diff_command.rb +195 -0
  30. data/lib/canon/commands/format_command.rb +113 -0
  31. data/lib/canon/comparison/base_comparator.rb +39 -0
  32. data/lib/canon/comparison/comparison_result.rb +79 -0
  33. data/lib/canon/comparison/html_comparator.rb +410 -0
  34. data/lib/canon/comparison/json_comparator.rb +212 -0
  35. data/lib/canon/comparison/match_options.rb +616 -0
  36. data/lib/canon/comparison/xml_comparator.rb +566 -0
  37. data/lib/canon/comparison/yaml_comparator.rb +93 -0
  38. data/lib/canon/comparison.rb +239 -0
  39. data/lib/canon/config.rb +172 -0
  40. data/lib/canon/diff/diff_block.rb +71 -0
  41. data/lib/canon/diff/diff_block_builder.rb +105 -0
  42. data/lib/canon/diff/diff_classifier.rb +46 -0
  43. data/lib/canon/diff/diff_context.rb +85 -0
  44. data/lib/canon/diff/diff_context_builder.rb +107 -0
  45. data/lib/canon/diff/diff_line.rb +77 -0
  46. data/lib/canon/diff/diff_node.rb +56 -0
  47. data/lib/canon/diff/diff_node_mapper.rb +148 -0
  48. data/lib/canon/diff/diff_report.rb +133 -0
  49. data/lib/canon/diff/diff_report_builder.rb +62 -0
  50. data/lib/canon/diff_formatter/by_line/base_formatter.rb +407 -0
  51. data/lib/canon/diff_formatter/by_line/html_formatter.rb +672 -0
  52. data/lib/canon/diff_formatter/by_line/json_formatter.rb +284 -0
  53. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +190 -0
  54. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +860 -0
  55. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +292 -0
  56. data/lib/canon/diff_formatter/by_object/base_formatter.rb +199 -0
  57. data/lib/canon/diff_formatter/by_object/json_formatter.rb +305 -0
  58. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +248 -0
  59. data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +17 -0
  60. data/lib/canon/diff_formatter/character_map.yml +197 -0
  61. data/lib/canon/diff_formatter/debug_output.rb +431 -0
  62. data/lib/canon/diff_formatter/diff_detail_formatter.rb +551 -0
  63. data/lib/canon/diff_formatter/legend.rb +141 -0
  64. data/lib/canon/diff_formatter.rb +520 -0
  65. data/lib/canon/errors.rb +56 -0
  66. data/lib/canon/formatters/html4_formatter.rb +17 -0
  67. data/lib/canon/formatters/html5_formatter.rb +17 -0
  68. data/lib/canon/formatters/html_formatter.rb +37 -0
  69. data/lib/canon/formatters/html_formatter_base.rb +163 -0
  70. data/lib/canon/formatters/json_formatter.rb +3 -0
  71. data/lib/canon/formatters/xml_formatter.rb +20 -55
  72. data/lib/canon/formatters/yaml_formatter.rb +4 -1
  73. data/lib/canon/pretty_printer/html.rb +57 -0
  74. data/lib/canon/pretty_printer/json.rb +25 -0
  75. data/lib/canon/pretty_printer/xml.rb +29 -0
  76. data/lib/canon/rspec_matchers.rb +222 -80
  77. data/lib/canon/validators/base_validator.rb +49 -0
  78. data/lib/canon/validators/html_validator.rb +138 -0
  79. data/lib/canon/validators/json_validator.rb +89 -0
  80. data/lib/canon/validators/xml_validator.rb +53 -0
  81. data/lib/canon/validators/yaml_validator.rb +73 -0
  82. data/lib/canon/version.rb +1 -1
  83. data/lib/canon/xml/attribute_handler.rb +80 -0
  84. data/lib/canon/xml/c14n.rb +36 -0
  85. data/lib/canon/xml/character_encoder.rb +38 -0
  86. data/lib/canon/xml/data_model.rb +225 -0
  87. data/lib/canon/xml/element_matcher.rb +196 -0
  88. data/lib/canon/xml/line_range_mapper.rb +158 -0
  89. data/lib/canon/xml/namespace_handler.rb +86 -0
  90. data/lib/canon/xml/node.rb +32 -0
  91. data/lib/canon/xml/nodes/attribute_node.rb +54 -0
  92. data/lib/canon/xml/nodes/comment_node.rb +23 -0
  93. data/lib/canon/xml/nodes/element_node.rb +56 -0
  94. data/lib/canon/xml/nodes/namespace_node.rb +38 -0
  95. data/lib/canon/xml/nodes/processing_instruction_node.rb +24 -0
  96. data/lib/canon/xml/nodes/root_node.rb +16 -0
  97. data/lib/canon/xml/nodes/text_node.rb +23 -0
  98. data/lib/canon/xml/processor.rb +151 -0
  99. data/lib/canon/xml/whitespace_normalizer.rb +72 -0
  100. data/lib/canon/xml/xml_base_handler.rb +188 -0
  101. data/lib/canon.rb +14 -3
  102. metadata +116 -21
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diff_block"
4
+
5
+ module Canon
6
+ module Diff
7
+ # Represents a context - a group of diff blocks with surrounding context lines
8
+ # A context is created by grouping nearby diff blocks and expanding with context lines
9
+ class DiffContext
10
+ attr_reader :start_idx, :end_idx, :blocks, :lines
11
+ attr_accessor :normative
12
+
13
+ def initialize(start_line: nil, end_line: nil, start_idx: nil,
14
+ end_idx: nil, blocks: [], lines: [], normative: nil)
15
+ # Support both old (start_idx/end_idx) and new (start_line/end_line) signatures
16
+ @start_idx = start_line || start_idx
17
+ @end_idx = end_line || end_idx
18
+ @blocks = blocks
19
+ @lines = lines
20
+ @normative = normative
21
+ end
22
+
23
+ # @return [Boolean] true if this context contains normative diffs
24
+ def normative?
25
+ @normative == true
26
+ end
27
+
28
+ # @return [Boolean] true if this context contains only informative diffs
29
+ def informative?
30
+ @normative == false
31
+ end
32
+
33
+ # Number of lines in this context (including context lines)
34
+ def size
35
+ end_idx - start_idx + 1
36
+ end
37
+
38
+ # Number of diff blocks in this context
39
+ def block_count
40
+ blocks.length
41
+ end
42
+
43
+ # Check if this context contains changes of a specific type
44
+ def includes_type?(type)
45
+ blocks.any? { |block| block.includes_type?(type) }
46
+ end
47
+
48
+ # Calculate gap to another context
49
+ def gap_to(other_context)
50
+ return Float::INFINITY if other_context.nil?
51
+ return 0 if overlaps?(other_context)
52
+
53
+ if other_context.start_idx > end_idx
54
+ other_context.start_idx - end_idx - 1
55
+ elsif start_idx > other_context.end_idx
56
+ start_idx - other_context.end_idx - 1
57
+ else
58
+ 0
59
+ end
60
+ end
61
+
62
+ # Check if this context overlaps with another
63
+ def overlaps?(other_context)
64
+ return false if other_context.nil?
65
+
66
+ !(end_idx < other_context.start_idx || start_idx > other_context.end_idx)
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ start_idx: start_idx,
72
+ end_idx: end_idx,
73
+ blocks: blocks.map(&:to_h),
74
+ }
75
+ end
76
+
77
+ def ==(other)
78
+ other.is_a?(DiffContext) &&
79
+ start_idx == other.start_idx &&
80
+ end_idx == other.end_idx &&
81
+ blocks == other.blocks
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diff_context"
4
+
5
+ module Canon
6
+ module Diff
7
+ # Builds DiffContexts from DiffBlocks
8
+ # Groups nearby blocks and adds surrounding context lines
9
+ class DiffContextBuilder
10
+ # Build contexts from diff blocks
11
+ #
12
+ # @param diff_blocks [Array<DiffBlock>] The diff blocks to group
13
+ # @param all_lines [Array<DiffLine>] All diff lines (for context)
14
+ # @param context_lines [Integer] Number of context lines to show
15
+ # @param grouping_lines [Integer, nil] Max lines between blocks to group them
16
+ # @return [Array<DiffContext>] Grouped contexts with context lines
17
+ def self.build_contexts(diff_blocks, all_lines, context_lines: 3,
18
+ grouping_lines: nil)
19
+ new(diff_blocks, all_lines, context_lines, grouping_lines).build
20
+ end
21
+
22
+ def initialize(diff_blocks, all_lines, context_lines, grouping_lines)
23
+ @diff_blocks = diff_blocks
24
+ @all_lines = all_lines
25
+ @context_lines = context_lines
26
+ @grouping_lines = grouping_lines
27
+ end
28
+
29
+ def build
30
+ return [] if @diff_blocks.empty?
31
+
32
+ # Group nearby blocks if grouping_lines is specified
33
+ grouped_blocks = if @grouping_lines
34
+ group_nearby_blocks(@diff_blocks, @grouping_lines)
35
+ else
36
+ @diff_blocks.map { |block| [block] }
37
+ end
38
+
39
+ # Create contexts with context lines
40
+ contexts = grouped_blocks.map do |block_group|
41
+ create_context_for_blocks(block_group)
42
+ end
43
+
44
+ # Filter out all-informative contexts if show_diffs was :normative
45
+ # Note: The filtering based on show_diffs happens at the block level
46
+ # in DiffBlockBuilder, so we don't need to re-filter here.
47
+ # However, we should filter out contexts that have NO blocks
48
+ # (which could happen if all blocks were filtered out)
49
+ contexts.reject { |ctx| ctx.blocks.empty? }
50
+ end
51
+
52
+ private
53
+
54
+ # Group blocks that are close together
55
+ def group_nearby_blocks(blocks, max_gap)
56
+ return [] if blocks.empty?
57
+
58
+ groups = []
59
+ current_group = [blocks.first]
60
+
61
+ blocks[1..].each do |block|
62
+ prev_block = current_group.last
63
+ gap = block.start_idx - prev_block.end_idx - 1
64
+
65
+ if gap <= max_gap
66
+ # Close enough, add to current group
67
+ current_group << block
68
+ else
69
+ # Too far apart, start new group
70
+ groups << current_group
71
+ current_group = [block]
72
+ end
73
+ end
74
+
75
+ # Don't forget the last group
76
+ groups << current_group unless current_group.empty?
77
+ groups
78
+ end
79
+
80
+ # Create a context for a group of blocks
81
+ def create_context_for_blocks(block_group)
82
+ first_block = block_group.first
83
+ last_block = block_group.last
84
+
85
+ # Calculate context range
86
+ context_start = [first_block.start_idx - @context_lines, 0].max
87
+ context_end = [last_block.end_idx + @context_lines,
88
+ @all_lines.length - 1].min
89
+
90
+ # Extract lines for this context
91
+ context_lines = @all_lines[context_start..context_end]
92
+
93
+ # Determine if context is normative
94
+ # A context is normative if ANY of its blocks are normative
95
+ normative = block_group.any?(&:normative?)
96
+
97
+ DiffContext.new(
98
+ start_line: context_start,
99
+ end_line: context_end,
100
+ blocks: block_group,
101
+ lines: context_lines,
102
+ normative: normative,
103
+ )
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Diff
5
+ # Represents a single line in the diff output
6
+ # Links textual representation to semantic DiffNode
7
+ class DiffLine
8
+ attr_reader :line_number, :content, :type, :diff_node
9
+
10
+ # @param line_number [Integer] The line number in the original text
11
+ # @param content [String] The text content of the line
12
+ # @param type [Symbol] The type of line (:unchanged, :added, :removed, :changed)
13
+ # @param diff_node [DiffNode, nil] The semantic diff node this line belongs to
14
+ def initialize(line_number:, content:, type:, diff_node: nil)
15
+ @line_number = line_number
16
+ @content = content
17
+ @type = type
18
+ @diff_node = diff_node
19
+ end
20
+
21
+ # @return [Boolean] true if this line represents a normative difference
22
+ # If diff_node is nil (not linked to any semantic difference), the line
23
+ # is considered informative (cosmetic/unchanged)
24
+ def normative?
25
+ return false if diff_node.nil?
26
+
27
+ diff_node.normative?
28
+ end
29
+
30
+ # @return [Boolean] true if this line represents an informative-only difference
31
+ # If diff_node is nil (not linked), it's not informative either (it's unchanged/cosmetic)
32
+ def informative?
33
+ return false if diff_node.nil?
34
+
35
+ diff_node.informative?
36
+ end
37
+
38
+ # @return [Boolean] true if this line is unchanged
39
+ def unchanged?
40
+ type == :unchanged
41
+ end
42
+
43
+ # @return [Boolean] true if this line was added
44
+ def added?
45
+ type == :added
46
+ end
47
+
48
+ # @return [Boolean] true if this line was removed
49
+ def removed?
50
+ type == :removed
51
+ end
52
+
53
+ # @return [Boolean] true if this line was changed
54
+ def changed?
55
+ type == :changed
56
+ end
57
+
58
+ def to_h
59
+ {
60
+ line_number: line_number,
61
+ content: content,
62
+ type: type,
63
+ diff_node: diff_node&.to_h,
64
+ normative: normative?,
65
+ }
66
+ end
67
+
68
+ def ==(other)
69
+ other.is_a?(DiffLine) &&
70
+ line_number == other.line_number &&
71
+ content == other.content &&
72
+ type == other.type &&
73
+ diff_node == other.diff_node
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Diff
5
+ # Represents a semantic difference between two nodes in a comparison tree
6
+ # This is created during the Comparison Layer and carries information about
7
+ # which dimension caused the difference and whether it's normative or informative
8
+ class DiffNode
9
+ attr_reader :node1, :node2, :dimension, :reason
10
+ attr_accessor :normative
11
+
12
+ # @param node1 [Object] The first node being compared
13
+ # @param node2 [Object] The second node being compared
14
+ # @param dimension [Symbol] The match dimension that caused this diff
15
+ # (e.g., :text_content, :attribute_whitespace, :structural_whitespace,
16
+ # :comments, :key_order)
17
+ # @param reason [String] Human-readable explanation of the difference
18
+ def initialize(node1:, node2:, dimension:, reason:)
19
+ @node1 = node1
20
+ @node2 = node2
21
+ @dimension = dimension
22
+ @reason = reason
23
+ @normative = nil # Will be set by DiffClassifier
24
+ end
25
+
26
+ # @return [Boolean] true if this diff is normative (affects equivalence)
27
+ def normative?
28
+ @normative == true
29
+ end
30
+
31
+ # @return [Boolean] true if this diff is informative only (doesn't affect equivalence)
32
+ def informative?
33
+ @normative == false
34
+ end
35
+
36
+ def to_h
37
+ {
38
+ node1: node1,
39
+ node2: node2,
40
+ dimension: dimension,
41
+ reason: reason,
42
+ normative: normative,
43
+ }
44
+ end
45
+
46
+ def ==(other)
47
+ other.is_a?(DiffNode) &&
48
+ node1 == other.node1 &&
49
+ node2 == other.node2 &&
50
+ dimension == other.dimension &&
51
+ reason == other.reason &&
52
+ normative == other.normative
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diff_line"
4
+
5
+ module Canon
6
+ module Diff
7
+ # Maps semantic DiffNodes to textual DiffLines
8
+ # This is Layer 2 of the diff pipeline, bridging semantic differences
9
+ # (from comparators) to textual representation (for formatters)
10
+ class DiffNodeMapper
11
+ # Map diff nodes to diff lines
12
+ #
13
+ # @param diff_nodes [Array<DiffNode>] The semantic differences
14
+ # @param text1 [String] The first text being compared
15
+ # @param text2 [String] The second text being compared
16
+ # @param options [Hash] Mapping options
17
+ # @option options [Hash] :line_map1 Pre-built line range map for text1
18
+ # @option options [Hash] :line_map2 Pre-built line range map for text2
19
+ # @return [Array<DiffLine>] Diff lines with semantic linkage
20
+ def self.map(diff_nodes, text1, text2, options = {})
21
+ new(diff_nodes, text1, text2, options).map
22
+ end
23
+
24
+ def initialize(diff_nodes, text1, text2, options = {})
25
+ @diff_nodes = diff_nodes
26
+ @text1 = text1
27
+ @text2 = text2
28
+ @line_map1 = options[:line_map1]
29
+ @line_map2 = options[:line_map2]
30
+ end
31
+
32
+ def map
33
+ lines1 = @text1.split("\n")
34
+ lines2 = @text2.split("\n")
35
+
36
+ # Use LCS to get structural diff
37
+ require "diff/lcs"
38
+ lcs_diffs = ::Diff::LCS.sdiff(lines1, lines2)
39
+
40
+ # Check if ALL DiffNodes are informative
41
+ all_informative = @diff_nodes && !@diff_nodes.empty? &&
42
+ @diff_nodes.all?(&:informative?)
43
+
44
+ # Convert LCS diffs to DiffLines
45
+ # If all DiffNodes are informative, we create a single shared informative DiffNode
46
+ # for all changed lines (this avoids complex linking)
47
+ shared_informative_node = if all_informative
48
+ @diff_nodes.first # Use any informative node
49
+ end
50
+
51
+ diff_lines = []
52
+ line_num = 0
53
+
54
+ lcs_diffs.each do |change|
55
+ diff_line = case change.action
56
+ when "="
57
+ DiffLine.new(
58
+ line_number: line_num,
59
+ content: change.old_element,
60
+ type: :unchanged,
61
+ diff_node: nil,
62
+ )
63
+ when "-"
64
+ DiffLine.new(
65
+ line_number: line_num,
66
+ content: change.old_element,
67
+ type: :removed,
68
+ diff_node: shared_informative_node || find_diff_node_for_line(
69
+ line_num, lines1, :removed
70
+ ),
71
+ )
72
+ when "+"
73
+ DiffLine.new(
74
+ line_number: line_num,
75
+ content: change.new_element,
76
+ type: :added,
77
+ diff_node: shared_informative_node || find_diff_node_for_line(
78
+ line_num, lines2, :added
79
+ ),
80
+ )
81
+ when "!"
82
+ DiffLine.new(
83
+ line_number: line_num,
84
+ content: change.new_element,
85
+ type: :changed,
86
+ diff_node: shared_informative_node || find_diff_node_for_line(
87
+ line_num, lines2, :changed
88
+ ),
89
+ )
90
+ end
91
+
92
+ diff_lines << diff_line
93
+ line_num += 1
94
+ end
95
+
96
+ diff_lines
97
+ end
98
+
99
+ private
100
+
101
+ # Find the DiffNode associated with a line
102
+ # Uses element name matching for precise line-level linking
103
+ def find_diff_node_for_line(line_num, lines, change_type)
104
+ return nil if @diff_nodes.nil? || @diff_nodes.empty?
105
+
106
+ line_content = lines[line_num]
107
+ return nil if line_content.nil?
108
+
109
+ # Extract element name from the line
110
+ line_element_name = extract_element_name(line_content)
111
+ return nil unless line_element_name
112
+
113
+ # Find DiffNode whose element name matches this line's element
114
+ @diff_nodes.find do |diff_node|
115
+ # For changed lines, we need to check BOTH nodes since the line
116
+ # could represent either the old or new content
117
+ nodes_to_check = case change_type
118
+ when :removed
119
+ [diff_node.node1]
120
+ when :added
121
+ [diff_node.node2]
122
+ when :changed
123
+ # Check both old and new - the line could be either
124
+ [diff_node.node1, diff_node.node2]
125
+ end
126
+
127
+ nodes_to_check.any? do |node|
128
+ next unless node.respond_to?(:name)
129
+
130
+ node.name == line_element_name
131
+ end
132
+ end
133
+ end
134
+
135
+ # Extract element name from an XML line
136
+ # Examples:
137
+ # "<bibitem ...>" => "bibitem"
138
+ # "</bibitem>" => "bibitem"
139
+ # "<ns:element ...>" => "ns:element"
140
+ def extract_element_name(line)
141
+ # Match opening or closing tag: <element ...> or </element>
142
+ # Supports namespaces (e.g., ns:element)
143
+ match = line.match(/<\/?([a-zA-Z0-9_:-]+)/)
144
+ match[1] if match
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Diff
5
+ # Represents a complete diff report containing multiple contexts
6
+ #
7
+ # A DiffReport is the top-level container for diff results between two
8
+ # documents. It contains multiple DiffContext objects, each representing
9
+ # a region with changes and surrounding context lines.
10
+ #
11
+ # @example Creating a diff report
12
+ # report = DiffReport.new(
13
+ # element_name: "root",
14
+ # file1_name: "expected.xml",
15
+ # file2_name: "actual.xml"
16
+ # )
17
+ # report.add_context(context1)
18
+ # report.add_context(context2)
19
+ #
20
+ # @attr_reader element_name [String] name of the root element being compared
21
+ # @attr_reader file1_name [String] name/path of the first file
22
+ # @attr_reader file2_name [String] name/path of the second file
23
+ # @attr_reader contexts [Array<DiffContext>] array of diff contexts
24
+ class DiffReport
25
+ attr_reader :element_name, :file1_name, :file2_name, :contexts
26
+
27
+ # Initialize a new diff report
28
+ #
29
+ # @param element_name [String] name of the root element being compared
30
+ # @param file1_name [String, nil] name/path of the first file
31
+ # @param file2_name [String, nil] name/path of the second file
32
+ # @param contexts [Array<DiffContext>] initial array of contexts
33
+ def initialize(element_name:, file1_name: nil, file2_name: nil,
34
+ contexts: [])
35
+ @element_name = element_name
36
+ @file1_name = file1_name
37
+ @file2_name = file2_name
38
+ @contexts = contexts
39
+ end
40
+
41
+ # Add a context to the report
42
+ #
43
+ # @param context [DiffContext] the context to add
44
+ # @return [self] returns self for method chaining
45
+ def add_context(context)
46
+ @contexts << context
47
+ self
48
+ end
49
+
50
+ # Get the total number of contexts in the report
51
+ #
52
+ # @return [Integer] number of contexts
53
+ def context_count
54
+ contexts.length
55
+ end
56
+
57
+ # Get the total number of diff blocks across all contexts
58
+ #
59
+ # @return [Integer] total number of blocks
60
+ def block_count
61
+ contexts.sum(&:block_count)
62
+ end
63
+
64
+ # Get the total number of changes (sum of all block sizes)
65
+ #
66
+ # @return [Integer] total number of changed lines
67
+ def change_count
68
+ contexts.sum do |context|
69
+ context.blocks.sum(&:size)
70
+ end
71
+ end
72
+
73
+ # Check if the report has any differences
74
+ #
75
+ # @return [Boolean] true if there are any contexts with differences
76
+ def has_differences?
77
+ !contexts.empty?
78
+ end
79
+
80
+ # Check if the report contains a specific change type
81
+ #
82
+ # @param type [String] the change type to check for ('+', '-', '!')
83
+ # @return [Boolean] true if any context includes this type
84
+ def includes_type?(type)
85
+ contexts.any? { |context| context.includes_type?(type) }
86
+ end
87
+
88
+ # Filter contexts by change type
89
+ #
90
+ # @param type [String] the change type to filter by ('+', '-', '!')
91
+ # @return [Array<DiffContext>] contexts that include the given type
92
+ def contexts_with_type(type)
93
+ contexts.select { |context| context.includes_type?(type) }
94
+ end
95
+
96
+ # Get summary statistics about the diff
97
+ #
98
+ # @return [Hash] hash with keys: :contexts, :blocks, :changes
99
+ def summary
100
+ {
101
+ contexts: context_count,
102
+ blocks: block_count,
103
+ changes: change_count,
104
+ }
105
+ end
106
+
107
+ # Convert to hash representation
108
+ #
109
+ # @return [Hash] hash representation of the report
110
+ def to_h
111
+ {
112
+ element_name: element_name,
113
+ file1_name: file1_name,
114
+ file2_name: file2_name,
115
+ contexts: contexts.map(&:to_h),
116
+ summary: summary,
117
+ }
118
+ end
119
+
120
+ # Compare equality with another report
121
+ #
122
+ # @param other [DiffReport] the report to compare with
123
+ # @return [Boolean] true if reports are equal
124
+ def ==(other)
125
+ other.is_a?(DiffReport) &&
126
+ element_name == other.element_name &&
127
+ file1_name == other.file1_name &&
128
+ file2_name == other.file2_name &&
129
+ contexts == other.contexts
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diff_report"
4
+ require_relative "diff_block_builder"
5
+ require_relative "diff_context_builder"
6
+
7
+ module Canon
8
+ module Diff
9
+ # Builds a complete DiffReport from DiffLines
10
+ # Orchestrates the pipeline: DiffLines → DiffBlocks → DiffContexts → DiffReport
11
+ class DiffReportBuilder
12
+ # Build a diff report from diff lines
13
+ #
14
+ # @param diff_lines [Array<DiffLine>] The diff lines to process
15
+ # @param options [Hash] Build options
16
+ # @option options [Symbol] :show_diffs (:all) Filter setting (:normative, :informative, :all)
17
+ # @option options [Integer] :context_lines (3) Number of context lines
18
+ # @option options [Integer, nil] :grouping_lines (nil) Max lines between blocks to group
19
+ # @option options [String] :element_name ("root") Name of element being compared
20
+ # @option options [String, nil] :file1_name (nil) Name of first file
21
+ # @option options [String, nil] :file2_name (nil) Name of second file
22
+ # @return [DiffReport] The complete diff report
23
+ def self.build(diff_lines, options = {})
24
+ new(diff_lines, options).build
25
+ end
26
+
27
+ def initialize(diff_lines, options = {})
28
+ @diff_lines = diff_lines
29
+ @show_diffs = options[:show_diffs] || :all
30
+ @context_lines = options[:context_lines] || 3
31
+ @grouping_lines = options[:grouping_lines]
32
+ @element_name = options[:element_name] || "root"
33
+ @file1_name = options[:file1_name]
34
+ @file2_name = options[:file2_name]
35
+ end
36
+
37
+ def build
38
+ # Step 1: Build blocks from lines (with filtering)
39
+ diff_blocks = DiffBlockBuilder.build_blocks(
40
+ @diff_lines,
41
+ show_diffs: @show_diffs,
42
+ )
43
+
44
+ # Step 2: Build contexts from blocks
45
+ diff_contexts = DiffContextBuilder.build_contexts(
46
+ diff_blocks,
47
+ @diff_lines,
48
+ context_lines: @context_lines,
49
+ grouping_lines: @grouping_lines,
50
+ )
51
+
52
+ # Step 3: Wrap in DiffReport
53
+ DiffReport.new(
54
+ element_name: @element_name,
55
+ file1_name: @file1_name,
56
+ file2_name: @file2_name,
57
+ contexts: diff_contexts,
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end