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
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Comparison
5
+ module MatchOptions
6
+ # Base class for match option resolvers
7
+ # Provides common resolve logic with format-specific customization
8
+ class BaseResolver
9
+ class << self
10
+ # Resolve match options with precedence handling
11
+ #
12
+ # Precedence order (highest to lowest):
13
+ # 1. Explicit match parameter
14
+ # 2. Profile from match_profile parameter
15
+ # 3. Global configuration
16
+ # 4. Format-specific defaults
17
+ #
18
+ # @param format [Symbol] Format type
19
+ # @param match_profile [Symbol, nil] Profile name
20
+ # @param match [Hash, nil] Explicit options per dimension
21
+ # @param preprocessing [Symbol, nil] Preprocessing option
22
+ # @param global_profile [Symbol, nil] Global configured profile
23
+ # @param global_options [Hash, nil] Global configured options
24
+ # @return [Hash] Resolved options for all dimensions
25
+ def resolve(format:, match_profile: nil, match: nil, preprocessing: nil,
26
+ global_profile: nil, global_options: nil)
27
+ # Start with format-specific defaults
28
+ options = format_defaults(format).dup
29
+
30
+ # Store format for later use (e.g., WhitespaceSensitivity needs it)
31
+ options[:format] = format
32
+
33
+ # Apply global profile if specified
34
+ if global_profile
35
+ profile_opts = get_profile_options(global_profile)
36
+ options.merge!(profile_opts)
37
+ end
38
+
39
+ # Apply global options if specified
40
+ if global_options
41
+ validate_match_options!(global_options)
42
+ options.merge!(global_options)
43
+ end
44
+
45
+ # Apply per-call profile if specified (overrides global)
46
+ if match_profile
47
+ profile_opts = get_profile_options(match_profile)
48
+ options.merge!(profile_opts)
49
+ end
50
+
51
+ # Apply per-call preprocessing if specified (overrides profile)
52
+ if preprocessing
53
+ validate_preprocessing!(preprocessing)
54
+ options[:preprocessing] = preprocessing
55
+ end
56
+
57
+ # Apply per-call explicit options if specified (highest priority)
58
+ if match
59
+ validate_match_options!(match)
60
+ options.merge!(match)
61
+ end
62
+
63
+ options
64
+ end
65
+
66
+ # Get format-specific default options
67
+ # Subclasses should override this
68
+ #
69
+ # @param format [Symbol] Format type
70
+ # @return [Hash] Default options for the format
71
+ def format_defaults(format)
72
+ raise NotImplementedError,
73
+ "#{self.class} must implement #format_defaults"
74
+ end
75
+
76
+ # Get options for a named profile
77
+ # Subclasses should override this
78
+ #
79
+ # @param profile [Symbol] Profile name
80
+ # @return [Hash] Profile options
81
+ # @raise [Canon::Error] If profile is unknown
82
+ def get_profile_options(profile)
83
+ raise NotImplementedError,
84
+ "#{self.class} must implement #get_profile_options"
85
+ end
86
+
87
+ # Get valid match dimensions for this format
88
+ # Subclasses should override this
89
+ #
90
+ # @return [Array<Symbol>] Valid dimensions
91
+ def match_dimensions
92
+ raise NotImplementedError,
93
+ "#{self.class} must implement #match_dimensions"
94
+ end
95
+
96
+ protected
97
+
98
+ # Validate preprocessing option
99
+ #
100
+ # @param preprocessing [Symbol] Preprocessing option
101
+ # @raise [Canon::Error] If invalid
102
+ def validate_preprocessing!(preprocessing)
103
+ unless MatchOptions::PREPROCESSING_OPTIONS.include?(preprocessing)
104
+ raise Canon::Error,
105
+ "Unknown preprocessing option: #{preprocessing}. " \
106
+ "Valid options: #{MatchOptions::PREPROCESSING_OPTIONS.join(', ')}"
107
+ end
108
+ end
109
+
110
+ # Validate match options
111
+ #
112
+ # @param match_options [Hash] Options to validate
113
+ # @raise [Canon::Error] If invalid dimension or behavior
114
+ def validate_match_options!(match_options)
115
+ # Special options that don't need validation as dimensions
116
+ special_options = %i[
117
+ format
118
+ preprocessing
119
+ semantic_diff
120
+ similarity_threshold
121
+ hash_matching
122
+ similarity_matching
123
+ propagation
124
+ whitespace_sensitive_elements
125
+ whitespace_insensitive_elements
126
+ respect_xml_space
127
+ ]
128
+
129
+ match_options.each do |dimension, behavior|
130
+ # Skip special options (validated elsewhere or passed through)
131
+ next if special_options.include?(dimension)
132
+
133
+ unless match_dimensions.include?(dimension)
134
+ raise Canon::Error,
135
+ "Unknown match dimension: #{dimension}. " \
136
+ "Valid dimensions: #{match_dimensions.join(', ')}"
137
+ end
138
+
139
+ unless MatchOptions::MATCH_BEHAVIORS.include?(behavior)
140
+ raise Canon::Error,
141
+ "Unknown match behavior: #{behavior} for #{dimension}. " \
142
+ "Valid behaviors: #{MatchOptions::MATCH_BEHAVIORS.join(', ')}"
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_resolver"
4
+
5
+ module Canon
6
+ module Comparison
7
+ module MatchOptions
8
+ # JSON-specific match options resolver
9
+ class JsonResolver < BaseResolver
10
+ # Format defaults for JSON
11
+ FORMAT_DEFAULTS = {
12
+ json: {
13
+ preprocessing: :none,
14
+ text_content: :strict,
15
+ structural_whitespace: :ignore,
16
+ key_order: :ignore,
17
+ },
18
+ }.freeze
19
+
20
+ # Predefined match profiles for JSON
21
+ MATCH_PROFILES = {
22
+ # Strict: Match exactly
23
+ strict: {
24
+ preprocessing: :none,
25
+ text_content: :strict,
26
+ structural_whitespace: :strict,
27
+ key_order: :strict,
28
+ },
29
+
30
+ # Spec-friendly: Formatting and order don't matter
31
+ spec_friendly: {
32
+ preprocessing: :normalize,
33
+ text_content: :strict,
34
+ structural_whitespace: :ignore,
35
+ key_order: :ignore,
36
+ },
37
+
38
+ # Content-only: Only values matter
39
+ content_only: {
40
+ preprocessing: :normalize,
41
+ text_content: :normalize,
42
+ structural_whitespace: :ignore,
43
+ key_order: :ignore,
44
+ },
45
+ }.freeze
46
+
47
+ class << self
48
+ # Matching dimensions for JSON (collectively exhaustive)
49
+ def match_dimensions
50
+ %i[
51
+ text_content
52
+ structural_whitespace
53
+ key_order
54
+ ].freeze
55
+ end
56
+
57
+ # Get format-specific default options
58
+ #
59
+ # @param format [Symbol] Format type (:json)
60
+ # @return [Hash] Default options for the format
61
+ def format_defaults(format)
62
+ FORMAT_DEFAULTS[format]&.dup || FORMAT_DEFAULTS[:json].dup
63
+ end
64
+
65
+ # Get options for a named profile
66
+ #
67
+ # @param profile [Symbol] Profile name
68
+ # @return [Hash] Profile options
69
+ # @raise [Canon::Error] If profile is unknown
70
+ def get_profile_options(profile)
71
+ unless MATCH_PROFILES.key?(profile)
72
+ raise Canon::Error,
73
+ "Unknown match profile: #{profile}. " \
74
+ "Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
75
+ end
76
+ MATCH_PROFILES[profile].dup
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_resolver"
4
+
5
+ module Canon
6
+ module Comparison
7
+ module MatchOptions
8
+ # XML/HTML-specific match options resolver
9
+ class XmlResolver < BaseResolver
10
+ # Format-specific defaults for XML/HTML
11
+ FORMAT_DEFAULTS = {
12
+ html: {
13
+ preprocessing: :rendered,
14
+ text_content: :normalize,
15
+ structural_whitespace: :normalize,
16
+ attribute_presence: :strict,
17
+ attribute_order: :ignore,
18
+ attribute_values: :strict,
19
+ element_position: :ignore,
20
+ comments: :ignore,
21
+ },
22
+ xml: {
23
+ preprocessing: :none,
24
+ text_content: :strict,
25
+ structural_whitespace: :strict,
26
+ attribute_presence: :strict,
27
+ attribute_order: :ignore,
28
+ attribute_values: :strict,
29
+ element_position: :strict,
30
+ comments: :strict,
31
+ },
32
+ }.freeze
33
+
34
+ # Predefined match profiles for XML/HTML
35
+ MATCH_PROFILES = {
36
+ # Strict: Match exactly as written in source (XML default)
37
+ strict: {
38
+ preprocessing: :none,
39
+ text_content: :strict,
40
+ structural_whitespace: :strict,
41
+ attribute_presence: :strict,
42
+ attribute_order: :strict,
43
+ attribute_values: :strict,
44
+ element_position: :strict,
45
+ comments: :strict,
46
+ },
47
+
48
+ # Rendered: Match rendered output (HTML default)
49
+ # Mimics CSS whitespace collapsing
50
+ rendered: {
51
+ preprocessing: :none,
52
+ text_content: :normalize,
53
+ structural_whitespace: :normalize,
54
+ attribute_presence: :strict,
55
+ attribute_order: :strict,
56
+ attribute_values: :strict,
57
+ element_position: :strict,
58
+ comments: :ignore,
59
+ },
60
+
61
+ # HTML4: Match HTML4 rendered output
62
+ # HTML4 rendering normalizes attribute whitespace
63
+ html4: {
64
+ preprocessing: :rendered,
65
+ text_content: :normalize,
66
+ structural_whitespace: :normalize,
67
+ attribute_presence: :strict,
68
+ attribute_order: :strict,
69
+ attribute_values: :normalize,
70
+ element_position: :ignore,
71
+ comments: :ignore,
72
+ },
73
+
74
+ # HTML5: Match HTML5 rendered output (same as rendered)
75
+ html5: {
76
+ preprocessing: :rendered,
77
+ text_content: :normalize,
78
+ structural_whitespace: :normalize,
79
+ attribute_presence: :strict,
80
+ attribute_order: :strict,
81
+ attribute_values: :strict,
82
+ element_position: :ignore,
83
+ comments: :ignore,
84
+ },
85
+
86
+ # Spec-friendly: Formatting doesn't matter
87
+ # Uses :rendered preprocessing for HTML which normalizes via to_html
88
+ spec_friendly: {
89
+ preprocessing: :rendered,
90
+ text_content: :normalize,
91
+ structural_whitespace: :ignore,
92
+ attribute_presence: :strict,
93
+ attribute_order: :ignore,
94
+ attribute_values: :normalize,
95
+ element_position: :ignore,
96
+ comments: :ignore,
97
+ },
98
+
99
+ # Content-only: Only content matters
100
+ content_only: {
101
+ preprocessing: :c14n,
102
+ text_content: :normalize,
103
+ structural_whitespace: :ignore,
104
+ attribute_presence: :strict,
105
+ attribute_order: :ignore,
106
+ attribute_values: :normalize,
107
+ element_position: :ignore,
108
+ comments: :ignore,
109
+ },
110
+ }.freeze
111
+
112
+ class << self
113
+ # Matching dimensions for XML/HTML (collectively exhaustive)
114
+ def match_dimensions
115
+ %i[
116
+ text_content
117
+ structural_whitespace
118
+ attribute_presence
119
+ attribute_order
120
+ attribute_values
121
+ element_position
122
+ comments
123
+ ].freeze
124
+ end
125
+
126
+ # Get format-specific default options
127
+ #
128
+ # @param format [Symbol] Format type (:xml or :html)
129
+ # @return [Hash] Default options for the format
130
+ def format_defaults(format)
131
+ FORMAT_DEFAULTS[format]&.dup || FORMAT_DEFAULTS[:xml].dup
132
+ end
133
+
134
+ # Get options for a named profile
135
+ #
136
+ # @param profile [Symbol] Profile name
137
+ # @return [Hash] Profile options
138
+ # @raise [Canon::Error] If profile is unknown
139
+ def get_profile_options(profile)
140
+ unless MATCH_PROFILES.key?(profile)
141
+ raise Canon::Error,
142
+ "Unknown match profile: #{profile}. " \
143
+ "Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
144
+ end
145
+ MATCH_PROFILES[profile].dup
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_resolver"
4
+
5
+ module Canon
6
+ module Comparison
7
+ module MatchOptions
8
+ # YAML-specific match options resolver
9
+ class YamlResolver < BaseResolver
10
+ # Format defaults for YAML
11
+ FORMAT_DEFAULTS = {
12
+ yaml: {
13
+ preprocessing: :none,
14
+ text_content: :strict,
15
+ structural_whitespace: :ignore,
16
+ key_order: :ignore,
17
+ comments: :ignore,
18
+ },
19
+ }.freeze
20
+
21
+ # Predefined match profiles for YAML
22
+ MATCH_PROFILES = {
23
+ # Strict: Match exactly
24
+ strict: {
25
+ preprocessing: :none,
26
+ text_content: :strict,
27
+ structural_whitespace: :strict,
28
+ key_order: :strict,
29
+ comments: :strict,
30
+ },
31
+
32
+ # Spec-friendly: Formatting and order don't matter
33
+ spec_friendly: {
34
+ preprocessing: :normalize,
35
+ text_content: :strict,
36
+ structural_whitespace: :ignore,
37
+ key_order: :ignore,
38
+ comments: :ignore,
39
+ },
40
+
41
+ # Content-only: Only values matter
42
+ content_only: {
43
+ preprocessing: :normalize,
44
+ text_content: :normalize,
45
+ structural_whitespace: :ignore,
46
+ key_order: :ignore,
47
+ comments: :ignore,
48
+ },
49
+ }.freeze
50
+
51
+ class << self
52
+ # Matching dimensions for YAML (collectively exhaustive)
53
+ def match_dimensions
54
+ %i[
55
+ text_content
56
+ structural_whitespace
57
+ key_order
58
+ comments
59
+ ].freeze
60
+ end
61
+
62
+ # Get format-specific default options
63
+ #
64
+ # @param format [Symbol] Format type (:yaml)
65
+ # @return [Hash] Default options for the format
66
+ def format_defaults(format)
67
+ FORMAT_DEFAULTS[format]&.dup || FORMAT_DEFAULTS[:yaml].dup
68
+ end
69
+
70
+ # Get options for a named profile
71
+ #
72
+ # @param profile [Symbol] Profile name
73
+ # @return [Hash] Profile options
74
+ # @raise [Canon::Error] If profile is unknown
75
+ def get_profile_options(profile)
76
+ unless MATCH_PROFILES.key?(profile)
77
+ raise Canon::Error,
78
+ "Unknown match profile: #{profile}. " \
79
+ "Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
80
+ end
81
+ MATCH_PROFILES[profile].dup
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end