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,616 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Comparison
5
+ # Matching Options for Canon Comparison
6
+ #
7
+ # Provides a two-phase architecture for controlling comparison behavior:
8
+ # 1. Preprocessing Phase: What to compare (none/c14n/normalize/format)
9
+ # 2. Matching Phase: How to compare (dimensions × behaviors)
10
+ #
11
+ # Format-specific modules define appropriate dimensions for each format:
12
+ # - Xml/Html: text_content, structural_whitespace, attribute_whitespace, comments
13
+ # - Json/Yaml: text_content, structural_whitespace, key_order, comments
14
+
15
+ # Wrapper class for resolved match options
16
+ # Provides convenient methods for accessing behaviors by dimension
17
+ class ResolvedMatchOptions
18
+ attr_reader :options, :format
19
+
20
+ def initialize(options, format:)
21
+ @options = options
22
+ @format = format
23
+ end
24
+
25
+ # Get the behavior for a specific dimension
26
+ # @param dimension [Symbol] The match dimension
27
+ # @return [Symbol] The behavior (:strict, :normalize, :ignore)
28
+ def behavior_for(dimension)
29
+ @options[dimension]
30
+ end
31
+
32
+ # Get the preprocessing option
33
+ # @return [Symbol] The preprocessing option
34
+ def preprocessing
35
+ @options[:preprocessing]
36
+ end
37
+
38
+ def to_h
39
+ @options.dup
40
+ end
41
+ end
42
+
43
+ # Module containing match option utilities and format-specific modules
44
+ module MatchOptions
45
+ # Preprocessing options - what to do before comparison
46
+ PREPROCESSING_OPTIONS = %i[none c14n normalize format rendered].freeze
47
+
48
+ # Matching behaviors (mutually exclusive)
49
+ MATCH_BEHAVIORS = %i[strict strip compact normalize ignore].freeze
50
+
51
+ class << self
52
+ # Apply match behavior to text comparison
53
+ #
54
+ # @param text1 [String] First text
55
+ # @param text2 [String] Second text
56
+ # @param behavior [Symbol] Match behavior (:strict, :normalize, :ignore)
57
+ # @return [Boolean] true if texts match according to behavior
58
+ def match_text?(text1, text2, behavior)
59
+ case behavior
60
+ when :strict
61
+ text1 == text2
62
+ when :normalize
63
+ normalize_text(text1) == normalize_text(text2)
64
+ when :ignore
65
+ true
66
+ else
67
+ raise Canon::Error, "Unknown match behavior: #{behavior}"
68
+ end
69
+ end
70
+
71
+ # Normalize text by collapsing whitespace and trimming
72
+ # Mimics HTML whitespace collapsing
73
+ #
74
+ # Handles both ASCII and Unicode whitespace characters including:
75
+ # - Regular space (U+0020)
76
+ # - Non-breaking space (U+00A0)
77
+ # - Other Unicode whitespace per \p{Space}
78
+ #
79
+ # @param text [String] Text to normalize
80
+ # @return [String] Normalized text
81
+ def normalize_text(text)
82
+ return "" if text.nil?
83
+
84
+ text.to_s
85
+ .gsub(/[\p{Space}\u00a0]+/, " ") # Collapse all whitespace to single space
86
+ .strip # Remove leading/trailing whitespace
87
+ end
88
+
89
+ # Process attribute value according to match behavior
90
+ #
91
+ # @param value [String] Attribute value to process
92
+ # @param behavior [Symbol] Match behavior (:strict, :strip, :compact, :normalize, :ignore)
93
+ # @return [String] Processed value
94
+ def process_attribute_value(value, behavior)
95
+ case behavior
96
+ when :strict
97
+ value.to_s
98
+ when :strip
99
+ value.to_s.strip
100
+ when :compact
101
+ value.to_s.gsub(/[\p{Space}\u00a0]+/, " ")
102
+ when :normalize
103
+ normalize_text(value)
104
+ when :ignore
105
+ ""
106
+ else
107
+ raise Canon::Error, "Unknown attribute value behavior: #{behavior}"
108
+ end
109
+ end
110
+ end
111
+
112
+ # XML/HTML-specific matching options
113
+ module Xml
114
+ # Matching dimensions for XML/HTML (collectively exhaustive)
115
+ MATCH_DIMENSIONS = %i[
116
+ text_content
117
+ structural_whitespace
118
+ attribute_presence
119
+ attribute_values
120
+ comments
121
+ ].freeze
122
+
123
+ # Format-specific defaults
124
+ FORMAT_DEFAULTS = {
125
+ html: {
126
+ preprocessing: :rendered,
127
+ text_content: :normalize,
128
+ structural_whitespace: :normalize,
129
+ attribute_presence: :strict,
130
+ attribute_values: :strict,
131
+ comments: :ignore,
132
+ },
133
+ xml: {
134
+ preprocessing: :none,
135
+ text_content: :strict,
136
+ structural_whitespace: :strict,
137
+ attribute_presence: :strict,
138
+ attribute_values: :strict,
139
+ comments: :strict,
140
+ },
141
+ }.freeze
142
+
143
+ # Predefined match profiles for XML/HTML
144
+ MATCH_PROFILES = {
145
+ # Strict: Match exactly as written in source (XML default)
146
+ strict: {
147
+ preprocessing: :none,
148
+ text_content: :strict,
149
+ structural_whitespace: :strict,
150
+ attribute_presence: :strict,
151
+ attribute_values: :strict,
152
+ comments: :strict,
153
+ },
154
+
155
+ # Rendered: Match rendered output (HTML default)
156
+ # Mimics CSS whitespace collapsing
157
+ rendered: {
158
+ preprocessing: :none,
159
+ text_content: :normalize,
160
+ structural_whitespace: :normalize,
161
+ attribute_presence: :strict,
162
+ attribute_values: :strict,
163
+ comments: :ignore,
164
+ },
165
+
166
+ # HTML4: Match HTML4 rendered output
167
+ # HTML4 rendering normalizes attribute whitespace
168
+ html4: {
169
+ preprocessing: :rendered,
170
+ text_content: :normalize,
171
+ structural_whitespace: :normalize,
172
+ attribute_presence: :strict,
173
+ attribute_values: :normalize,
174
+ comments: :ignore,
175
+ },
176
+
177
+ # HTML5: Match HTML5 rendered output (same as rendered)
178
+ html5: {
179
+ preprocessing: :rendered,
180
+ text_content: :normalize,
181
+ structural_whitespace: :normalize,
182
+ attribute_presence: :strict,
183
+ attribute_values: :strict,
184
+ comments: :ignore,
185
+ },
186
+
187
+ # Spec-friendly: Formatting doesn't matter
188
+ # Uses :rendered preprocessing for HTML which normalizes via to_html
189
+ spec_friendly: {
190
+ preprocessing: :rendered,
191
+ text_content: :normalize,
192
+ structural_whitespace: :ignore,
193
+ attribute_presence: :strict,
194
+ attribute_values: :normalize,
195
+ comments: :ignore,
196
+ },
197
+
198
+ # Content-only: Only content matters
199
+ content_only: {
200
+ preprocessing: :c14n,
201
+ text_content: :normalize,
202
+ structural_whitespace: :ignore,
203
+ attribute_presence: :strict,
204
+ attribute_values: :normalize,
205
+ comments: :ignore,
206
+ },
207
+ }.freeze
208
+
209
+ class << self
210
+ # Resolve match options with precedence handling
211
+ #
212
+ # Precedence order (highest to lowest):
213
+ # 1. Explicit match parameter
214
+ # 2. Profile from match_profile parameter
215
+ # 3. Global configuration
216
+ # 4. Format-specific defaults
217
+ #
218
+ # @param format [Symbol] Format type (:xml or :html)
219
+ # @param match_profile [Symbol, nil] Profile name
220
+ # @param match [Hash, nil] Explicit options per dimension
221
+ # @param preprocessing [Symbol, nil] Preprocessing option
222
+ # @param global_profile [Symbol, nil] Global configured profile
223
+ # @param global_options [Hash, nil] Global configured options
224
+ # @return [Hash] Resolved options for all dimensions
225
+ def resolve(
226
+ format:,
227
+ match_profile: nil,
228
+ match: nil,
229
+ preprocessing: nil,
230
+ global_profile: nil,
231
+ global_options: nil
232
+ )
233
+ # Start with format-specific defaults
234
+ options = FORMAT_DEFAULTS[format]&.dup || FORMAT_DEFAULTS[:xml].dup
235
+
236
+ # Apply global profile if specified
237
+ if global_profile
238
+ profile_opts = get_profile_options(global_profile)
239
+ options.merge!(profile_opts)
240
+ end
241
+
242
+ # Apply global options if specified
243
+ if global_options
244
+ validate_match_options!(global_options)
245
+ options.merge!(global_options)
246
+ end
247
+
248
+ # Apply per-call profile if specified (overrides global)
249
+ if match_profile
250
+ profile_opts = get_profile_options(match_profile)
251
+ options.merge!(profile_opts)
252
+ end
253
+
254
+ # Apply per-call preprocessing if specified (overrides profile)
255
+ if preprocessing
256
+ validate_preprocessing!(preprocessing)
257
+ options[:preprocessing] = preprocessing
258
+ end
259
+
260
+ # Apply per-call explicit options if specified (highest priority)
261
+ if match
262
+ validate_match_options!(match)
263
+ options.merge!(match)
264
+ end
265
+
266
+ options
267
+ end
268
+
269
+ # Get options for a named profile
270
+ #
271
+ # @param profile [Symbol] Profile name
272
+ # @return [Hash] Profile options
273
+ # @raise [Canon::Error] If profile is unknown
274
+ def get_profile_options(profile)
275
+ unless MATCH_PROFILES.key?(profile)
276
+ raise Canon::Error,
277
+ "Unknown match profile: #{profile}. " \
278
+ "Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
279
+ end
280
+ MATCH_PROFILES[profile].dup
281
+ end
282
+
283
+ private
284
+
285
+ # Validate preprocessing option
286
+ def validate_preprocessing!(preprocessing)
287
+ unless MatchOptions::PREPROCESSING_OPTIONS.include?(preprocessing)
288
+ raise Canon::Error,
289
+ "Unknown preprocessing option: #{preprocessing}. " \
290
+ "Valid options: #{MatchOptions::PREPROCESSING_OPTIONS.join(', ')}"
291
+ end
292
+ end
293
+
294
+ # Validate match options
295
+ def validate_match_options!(match_options)
296
+ match_options.each do |dimension, behavior|
297
+ # Skip preprocessing as it's validated separately
298
+ next if dimension == :preprocessing
299
+
300
+ unless MATCH_DIMENSIONS.include?(dimension)
301
+ raise Canon::Error,
302
+ "Unknown match dimension: #{dimension}. " \
303
+ "Valid dimensions: #{MATCH_DIMENSIONS.join(', ')}"
304
+ end
305
+
306
+ unless MatchOptions::MATCH_BEHAVIORS.include?(behavior)
307
+ raise Canon::Error,
308
+ "Unknown match behavior: #{behavior} for #{dimension}. " \
309
+ "Valid behaviors: #{MatchOptions::MATCH_BEHAVIORS.join(', ')}"
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
315
+
316
+ # JSON-specific matching options
317
+ module Json
318
+ # Matching dimensions for JSON (collectively exhaustive)
319
+ MATCH_DIMENSIONS = %i[
320
+ text_content
321
+ structural_whitespace
322
+ key_order
323
+ ].freeze
324
+
325
+ # Format defaults for JSON
326
+ FORMAT_DEFAULTS = {
327
+ json: {
328
+ preprocessing: :none,
329
+ text_content: :strict,
330
+ structural_whitespace: :ignore,
331
+ key_order: :strict,
332
+ },
333
+ }.freeze
334
+
335
+ # Predefined match profiles for JSON
336
+ MATCH_PROFILES = {
337
+ # Strict: Match exactly
338
+ strict: {
339
+ preprocessing: :none,
340
+ text_content: :strict,
341
+ structural_whitespace: :strict,
342
+ key_order: :strict,
343
+ },
344
+
345
+ # Spec-friendly: Formatting and order don't matter
346
+ spec_friendly: {
347
+ preprocessing: :normalize,
348
+ text_content: :strict,
349
+ structural_whitespace: :ignore,
350
+ key_order: :ignore,
351
+ },
352
+
353
+ # Content-only: Only values matter
354
+ content_only: {
355
+ preprocessing: :normalize,
356
+ text_content: :normalize,
357
+ structural_whitespace: :ignore,
358
+ key_order: :ignore,
359
+ },
360
+ }.freeze
361
+
362
+ class << self
363
+ # Resolve match options with precedence handling
364
+ #
365
+ # @param format [Symbol] Format type (:json)
366
+ # @param match_profile [Symbol, nil] Profile name
367
+ # @param match [Hash, nil] Explicit options per dimension
368
+ # @param preprocessing [Symbol, nil] Preprocessing option
369
+ # @param global_profile [Symbol, nil] Global configured profile
370
+ # @param global_options [Hash, nil] Global configured options
371
+ # @return [Hash] Resolved options for all dimensions
372
+ def resolve(
373
+ format:,
374
+ match_profile: nil,
375
+ match: nil,
376
+ preprocessing: nil,
377
+ global_profile: nil,
378
+ global_options: nil
379
+ )
380
+ # Start with format-specific defaults
381
+ options = FORMAT_DEFAULTS[:json].dup
382
+
383
+ # Apply global profile if specified
384
+ if global_profile
385
+ profile_opts = get_profile_options(global_profile)
386
+ options.merge!(profile_opts)
387
+ end
388
+
389
+ # Apply global options if specified
390
+ if global_options
391
+ validate_match_options!(global_options)
392
+ options.merge!(global_options)
393
+ end
394
+
395
+ # Apply per-call profile if specified (overrides global)
396
+ if match_profile
397
+ profile_opts = get_profile_options(match_profile)
398
+ options.merge!(profile_opts)
399
+ end
400
+
401
+ # Apply per-call preprocessing if specified (overrides profile)
402
+ if preprocessing
403
+ validate_preprocessing!(preprocessing)
404
+ options[:preprocessing] = preprocessing
405
+ end
406
+
407
+ # Apply per-call explicit options if specified (highest priority)
408
+ if match
409
+ validate_match_options!(match)
410
+ options.merge!(match)
411
+ end
412
+
413
+ options
414
+ end
415
+
416
+ # Get options for a named profile
417
+ #
418
+ # @param profile [Symbol] Profile name
419
+ # @return [Hash] Profile options
420
+ # @raise [Canon::Error] If profile is unknown
421
+ def get_profile_options(profile)
422
+ unless MATCH_PROFILES.key?(profile)
423
+ raise Canon::Error,
424
+ "Unknown match profile: #{profile}. " \
425
+ "Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
426
+ end
427
+ MATCH_PROFILES[profile].dup
428
+ end
429
+
430
+ private
431
+
432
+ # Validate preprocessing option
433
+ def validate_preprocessing!(preprocessing)
434
+ unless MatchOptions::PREPROCESSING_OPTIONS.include?(preprocessing)
435
+ raise Canon::Error,
436
+ "Unknown preprocessing option: #{preprocessing}. " \
437
+ "Valid options: #{MatchOptions::PREPROCESSING_OPTIONS.join(', ')}"
438
+ end
439
+ end
440
+
441
+ # Validate match options
442
+ def validate_match_options!(match_options)
443
+ match_options.each do |dimension, behavior|
444
+ # Skip preprocessing as it's validated separately
445
+ next if dimension == :preprocessing
446
+
447
+ unless MATCH_DIMENSIONS.include?(dimension)
448
+ raise Canon::Error,
449
+ "Unknown match dimension: #{dimension}. " \
450
+ "Valid dimensions: #{MATCH_DIMENSIONS.join(', ')}"
451
+ end
452
+
453
+ unless MatchOptions::MATCH_BEHAVIORS.include?(behavior)
454
+ raise Canon::Error,
455
+ "Unknown match behavior: #{behavior} for #{dimension}. " \
456
+ "Valid behaviors: #{MatchOptions::MATCH_BEHAVIORS.join(', ')}"
457
+ end
458
+ end
459
+ end
460
+ end
461
+ end
462
+
463
+ # YAML-specific matching options
464
+ module Yaml
465
+ # Matching dimensions for YAML (collectively exhaustive)
466
+ MATCH_DIMENSIONS = %i[
467
+ text_content
468
+ structural_whitespace
469
+ key_order
470
+ comments
471
+ ].freeze
472
+
473
+ # Format defaults for YAML
474
+ FORMAT_DEFAULTS = {
475
+ yaml: {
476
+ preprocessing: :none,
477
+ text_content: :strict,
478
+ structural_whitespace: :ignore,
479
+ key_order: :strict,
480
+ comments: :ignore,
481
+ },
482
+ }.freeze
483
+
484
+ # Predefined match profiles for YAML
485
+ MATCH_PROFILES = {
486
+ # Strict: Match exactly
487
+ strict: {
488
+ preprocessing: :none,
489
+ text_content: :strict,
490
+ structural_whitespace: :strict,
491
+ key_order: :strict,
492
+ comments: :strict,
493
+ },
494
+
495
+ # Spec-friendly: Formatting and order don't matter
496
+ spec_friendly: {
497
+ preprocessing: :normalize,
498
+ text_content: :strict,
499
+ structural_whitespace: :ignore,
500
+ key_order: :ignore,
501
+ comments: :ignore,
502
+ },
503
+
504
+ # Content-only: Only values matter
505
+ content_only: {
506
+ preprocessing: :normalize,
507
+ text_content: :normalize,
508
+ structural_whitespace: :ignore,
509
+ key_order: :ignore,
510
+ comments: :ignore,
511
+ },
512
+ }.freeze
513
+
514
+ class << self
515
+ # Resolve match options with precedence handling
516
+ #
517
+ # @param format [Symbol] Format type (:yaml)
518
+ # @param match_profile [Symbol, nil] Profile name
519
+ # @param match [Hash, nil] Explicit options per dimension
520
+ # @param preprocessing [Symbol, nil] Preprocessing option
521
+ # @param global_profile [Symbol, nil] Global configured profile
522
+ # @param global_options [Hash, nil] Global configured options
523
+ # @return [Hash] Resolved options for all dimensions
524
+ def resolve(
525
+ format:,
526
+ match_profile: nil,
527
+ match: nil,
528
+ preprocessing: nil,
529
+ global_profile: nil,
530
+ global_options: nil
531
+ )
532
+ # Start with format-specific defaults
533
+ options = FORMAT_DEFAULTS[:yaml].dup
534
+
535
+ # Apply global profile if specified
536
+ if global_profile
537
+ profile_opts = get_profile_options(global_profile)
538
+ options.merge!(profile_opts)
539
+ end
540
+
541
+ # Apply global options if specified
542
+ if global_options
543
+ validate_match_options!(global_options)
544
+ options.merge!(global_options)
545
+ end
546
+
547
+ # Apply per-call profile if specified (overrides global)
548
+ if match_profile
549
+ profile_opts = get_profile_options(match_profile)
550
+ options.merge!(profile_opts)
551
+ end
552
+
553
+ # Apply per-call preprocessing if specified (overrides profile)
554
+ if preprocessing
555
+ validate_preprocessing!(preprocessing)
556
+ options[:preprocessing] = preprocessing
557
+ end
558
+
559
+ # Apply per-call explicit options if specified (highest priority)
560
+ if match
561
+ validate_match_options!(match)
562
+ options.merge!(match)
563
+ end
564
+
565
+ options
566
+ end
567
+
568
+ # Get options for a named profile
569
+ #
570
+ # @param profile [Symbol] Profile name
571
+ # @return [Hash] Profile options
572
+ # @raise [Canon::Error] If profile is unknown
573
+ def get_profile_options(profile)
574
+ unless MATCH_PROFILES.key?(profile)
575
+ raise Canon::Error,
576
+ "Unknown match profile: #{profile}. " \
577
+ "Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
578
+ end
579
+ MATCH_PROFILES[profile].dup
580
+ end
581
+
582
+ private
583
+
584
+ # Validate preprocessing option
585
+ def validate_preprocessing!(preprocessing)
586
+ unless MatchOptions::PREPROCESSING_OPTIONS.include?(preprocessing)
587
+ raise Canon::Error,
588
+ "Unknown preprocessing option: #{preprocessing}. " \
589
+ "Valid options: #{MatchOptions::PREPROCESSING_OPTIONS.join(', ')}"
590
+ end
591
+ end
592
+
593
+ # Validate match options
594
+ def validate_match_options!(match_options)
595
+ match_options.each do |dimension, behavior|
596
+ # Skip preprocessing as it's validated separately
597
+ next if dimension == :preprocessing
598
+
599
+ unless MATCH_DIMENSIONS.include?(dimension)
600
+ raise Canon::Error,
601
+ "Unknown match dimension: #{dimension}. " \
602
+ "Valid dimensions: #{MATCH_DIMENSIONS.join(', ')}"
603
+ end
604
+
605
+ unless MatchOptions::MATCH_BEHAVIORS.include?(behavior)
606
+ raise Canon::Error,
607
+ "Unknown match behavior: #{behavior} for #{dimension}. " \
608
+ "Valid behaviors: #{MatchOptions::MATCH_BEHAVIORS.join(', ')}"
609
+ end
610
+ end
611
+ end
612
+ end
613
+ end
614
+ end
615
+ end
616
+ end