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.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -1
- data/.rubocop_todo.yml +276 -7
- data/README.adoc +203 -138
- data/_config.yml +116 -0
- data/docs/ADVANCED_TOPICS.adoc +20 -0
- data/docs/BASIC_USAGE.adoc +16 -0
- data/docs/CHARACTER_VISUALIZATION.adoc +567 -0
- data/docs/CLI.adoc +493 -0
- data/docs/CUSTOMIZING_BEHAVIOR.adoc +19 -0
- data/docs/DIFF_ARCHITECTURE.adoc +435 -0
- data/docs/DIFF_FORMATTING.adoc +540 -0
- data/docs/FORMATS.adoc +447 -0
- data/docs/INDEX.adoc +222 -0
- data/docs/INPUT_VALIDATION.adoc +477 -0
- data/docs/MATCH_ARCHITECTURE.adoc +463 -0
- data/docs/MATCH_OPTIONS.adoc +719 -0
- data/docs/MODES.adoc +432 -0
- data/docs/NORMATIVE_INFORMATIVE_DIFFS.adoc +219 -0
- data/docs/OPTIONS.adoc +1387 -0
- data/docs/PREPROCESSING.adoc +491 -0
- data/docs/RSPEC.adoc +605 -0
- data/docs/RUBY_API.adoc +478 -0
- data/docs/SEMANTIC_DIFF_REPORT.adoc +528 -0
- data/docs/UNDERSTANDING_CANON.adoc +17 -0
- data/docs/VERBOSE.adoc +482 -0
- data/exe/canon +7 -0
- data/lib/canon/cli.rb +179 -0
- data/lib/canon/commands/diff_command.rb +195 -0
- data/lib/canon/commands/format_command.rb +113 -0
- data/lib/canon/comparison/base_comparator.rb +39 -0
- data/lib/canon/comparison/comparison_result.rb +79 -0
- data/lib/canon/comparison/html_comparator.rb +410 -0
- data/lib/canon/comparison/json_comparator.rb +212 -0
- data/lib/canon/comparison/match_options.rb +616 -0
- data/lib/canon/comparison/xml_comparator.rb +566 -0
- data/lib/canon/comparison/yaml_comparator.rb +93 -0
- data/lib/canon/comparison.rb +239 -0
- data/lib/canon/config.rb +172 -0
- data/lib/canon/diff/diff_block.rb +71 -0
- data/lib/canon/diff/diff_block_builder.rb +105 -0
- data/lib/canon/diff/diff_classifier.rb +46 -0
- data/lib/canon/diff/diff_context.rb +85 -0
- data/lib/canon/diff/diff_context_builder.rb +107 -0
- data/lib/canon/diff/diff_line.rb +77 -0
- data/lib/canon/diff/diff_node.rb +56 -0
- data/lib/canon/diff/diff_node_mapper.rb +148 -0
- data/lib/canon/diff/diff_report.rb +133 -0
- data/lib/canon/diff/diff_report_builder.rb +62 -0
- data/lib/canon/diff_formatter/by_line/base_formatter.rb +407 -0
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +672 -0
- data/lib/canon/diff_formatter/by_line/json_formatter.rb +284 -0
- data/lib/canon/diff_formatter/by_line/simple_formatter.rb +190 -0
- data/lib/canon/diff_formatter/by_line/xml_formatter.rb +860 -0
- data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +292 -0
- data/lib/canon/diff_formatter/by_object/base_formatter.rb +199 -0
- data/lib/canon/diff_formatter/by_object/json_formatter.rb +305 -0
- data/lib/canon/diff_formatter/by_object/xml_formatter.rb +248 -0
- data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +17 -0
- data/lib/canon/diff_formatter/character_map.yml +197 -0
- data/lib/canon/diff_formatter/debug_output.rb +431 -0
- data/lib/canon/diff_formatter/diff_detail_formatter.rb +551 -0
- data/lib/canon/diff_formatter/legend.rb +141 -0
- data/lib/canon/diff_formatter.rb +520 -0
- data/lib/canon/errors.rb +56 -0
- data/lib/canon/formatters/html4_formatter.rb +17 -0
- data/lib/canon/formatters/html5_formatter.rb +17 -0
- data/lib/canon/formatters/html_formatter.rb +37 -0
- data/lib/canon/formatters/html_formatter_base.rb +163 -0
- data/lib/canon/formatters/json_formatter.rb +3 -0
- data/lib/canon/formatters/xml_formatter.rb +20 -55
- data/lib/canon/formatters/yaml_formatter.rb +4 -1
- data/lib/canon/pretty_printer/html.rb +57 -0
- data/lib/canon/pretty_printer/json.rb +25 -0
- data/lib/canon/pretty_printer/xml.rb +29 -0
- data/lib/canon/rspec_matchers.rb +222 -80
- data/lib/canon/validators/base_validator.rb +49 -0
- data/lib/canon/validators/html_validator.rb +138 -0
- data/lib/canon/validators/json_validator.rb +89 -0
- data/lib/canon/validators/xml_validator.rb +53 -0
- data/lib/canon/validators/yaml_validator.rb +73 -0
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/attribute_handler.rb +80 -0
- data/lib/canon/xml/c14n.rb +36 -0
- data/lib/canon/xml/character_encoder.rb +38 -0
- data/lib/canon/xml/data_model.rb +225 -0
- data/lib/canon/xml/element_matcher.rb +196 -0
- data/lib/canon/xml/line_range_mapper.rb +158 -0
- data/lib/canon/xml/namespace_handler.rb +86 -0
- data/lib/canon/xml/node.rb +32 -0
- data/lib/canon/xml/nodes/attribute_node.rb +54 -0
- data/lib/canon/xml/nodes/comment_node.rb +23 -0
- data/lib/canon/xml/nodes/element_node.rb +56 -0
- data/lib/canon/xml/nodes/namespace_node.rb +38 -0
- data/lib/canon/xml/nodes/processing_instruction_node.rb +24 -0
- data/lib/canon/xml/nodes/root_node.rb +16 -0
- data/lib/canon/xml/nodes/text_node.rb +23 -0
- data/lib/canon/xml/processor.rb +151 -0
- data/lib/canon/xml/whitespace_normalizer.rb +72 -0
- data/lib/canon/xml/xml_base_handler.rb +188 -0
- data/lib/canon.rb +14 -3
- 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
|