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
data/docs/OPTIONS.adoc ADDED
@@ -0,0 +1,1387 @@
1
+ = Comparison options reference
2
+ :toc: left
3
+ :toclevels: 3
4
+ :sectanchors:
5
+ :sectlinks:
6
+
7
+ == Overview
8
+
9
+ Canon provides a flexible, format-aware semantic comparison system for XML, HTML, JSON, and YAML documents. The comparison process follows a three-phase architecture that allows fine-grained control over how documents are compared.
10
+
11
+ This document describes all available options for controlling Canon's comparison behavior across three interfaces: CLI, Ruby API, and RSpec matchers.
12
+
13
+ == Architecture
14
+
15
+ Canon's comparison flow consists of three distinct phases:
16
+
17
+ [source]
18
+ ----
19
+ ┌──────────────────────────────────────────────────────────────────┐
20
+ │ CANON COMPARISON FLOW │
21
+ └──────────────────────────────────────────────────────────────────┘
22
+
23
+ ┌─────────────────────┐
24
+ │ Input Documents │
25
+ │ (File 1, File 2) │
26
+ └──────────┬──────────┘
27
+
28
+
29
+ ╔══════════════════════════════════════════════════════════════════╗
30
+ ║ PHASE 1: PREPROCESSING ║
31
+ ╠══════════════════════════════════════════════════════════════════╣
32
+ ║ Options: ║
33
+ ║ • none - No preprocessing ║
34
+ ║ • c14n - Canonical form (XML C14N, JSON/YAML sorted) ║
35
+ ║ • normalize - Normalize whitespace ║
36
+ ║ • format - Pretty-print with standard formatting ║
37
+ ╚══════════════════════════════════════════════════════════════════╝
38
+
39
+
40
+ ┌─────────────────────┐
41
+ │ Preprocessed Docs │
42
+ └──────────┬──────────┘
43
+
44
+
45
+ ╔══════════════════════════════════════════════════════════════════╗
46
+ ║ PHASE 2: SEMANTIC MATCHING ║
47
+ ╠══════════════════════════════════════════════════════════════════╣
48
+ ║ Match Dimensions: ║
49
+ ║ • text_content (strict|normalize|ignore) ║
50
+ ║ • structural_whitespace (strict|normalize|ignore) ║
51
+ ║ • attribute_whitespace (strict|normalize|ignore) [XML/HTML] ║
52
+ ║ • attribute_order (strict|ignore) [XML/HTML] ║
53
+ ║ • attribute_values (strict|normalize|ignore) [XML/HTML] ║
54
+ ║ • key_order (strict|ignore) [JSON/YAML] ║
55
+ ║ • comments (strict|normalize|ignore) ║
56
+ ║ ║
57
+ ║ Match Profiles: ║
58
+ ║ • strict - All dimensions strict (exact matching) ║
59
+ ║ • rendered - Mimics browser/CSS rendering behavior ║
60
+ ║ • spec_friendly - Test-friendly (ignores formatting diffs) ║
61
+ ║ • content_only - Only semantic content matters ║
62
+ ╚══════════════════════════════════════════════════════════════════╝
63
+
64
+ ├─ Equivalent? ──► Return true
65
+
66
+ ├─ Different? ──┐
67
+ │ │
68
+ ▼ ▼
69
+ ╔══════════════════════════════════════════════════════════════════╗
70
+ ║ PHASE 3: DIFF RENDERING ║
71
+ ╠══════════════════════════════════════════════════════════════════╣
72
+ ║ Diff Options: ║
73
+ ║ • mode - by_line (XML/HTML) | by_object (JSON/YAML) ║
74
+ ║ • use_color - Colorized output (terminal colors) ║
75
+ ║ • context_lines - Lines of context around changes ║
76
+ ║ • grouping_lines - Group nearby changes into blocks ║
77
+ ╚══════════════════════════════════════════════════════════════════╝
78
+
79
+
80
+ ┌─────────────────────┐
81
+ │ Formatted Diff │
82
+ │ Output │
83
+ └─────────────────────┘
84
+ ----
85
+
86
+ == Match Dimensions Reference
87
+
88
+ Match dimensions control which aspects of documents are compared and how strictly they are compared. Each dimension can be set to one of several behaviors.
89
+
90
+ === text_content
91
+
92
+ *Purpose*: Controls how text content within elements is compared.
93
+
94
+ *Applies to*: XML, HTML, JSON, YAML
95
+
96
+ *Behaviors*:
97
+
98
+ * `strict` - Text must match exactly, character-for-character including all whitespace
99
+ * `normalize` - Whitespace is normalized (collapsed/trimmed) before comparison
100
+ * `ignore` - Text content is completely ignored in comparison
101
+
102
+ *XML Example*:
103
+
104
+ .Input Files
105
+ [source,xml]
106
+ ----
107
+ <!-- File 1 -->
108
+ <message>Hello World</message>
109
+
110
+ <!-- File 2 -->
111
+ <message>Hello World</message>
112
+ ----
113
+
114
+ .Comparison Results
115
+ |===
116
+ |Behavior |Result |Explanation
117
+
118
+ |`strict`
119
+ |Different
120
+ |Whitespace differs (3 spaces vs 1 space)
121
+
122
+ |`normalize`
123
+ |Equivalent
124
+ |Both normalize to "Hello World"
125
+
126
+ |`ignore`
127
+ |Equivalent
128
+ |Text content ignored, structure matches
129
+ |===
130
+
131
+ *JSON Example*:
132
+
133
+ .Input Files
134
+ [source,json]
135
+ ----
136
+ // File 1
137
+ {"message": "Hello World"}
138
+
139
+ // File 2
140
+ {"message": "Hello World"}
141
+ ----
142
+
143
+ .Comparison Results
144
+ |===
145
+ |Behavior |Result |Explanation
146
+
147
+ |`strict`
148
+ |Different
149
+ |String values differ
150
+
151
+ |`normalize`
152
+ |Equivalent
153
+ |Whitespace normalized in strings
154
+
155
+ |`ignore`
156
+ |Equivalent
157
+ |Only structure compared
158
+ |===
159
+
160
+ === structural_whitespace
161
+
162
+ *Purpose*: Controls how whitespace between elements (indentation, newlines) is handled.
163
+
164
+ *Applies to*: XML, HTML, JSON, YAML
165
+
166
+ *Behaviors*:
167
+
168
+ * `strict` - All structural whitespace must match exactly
169
+ * `normalize` - Structural whitespace is normalized
170
+ * `ignore` - Structural whitespace is completely ignored
171
+
172
+ *XML Example*:
173
+
174
+ .Input Files
175
+ [source,xml]
176
+ ----
177
+ <!-- File 1 -->
178
+ <root>
179
+ <item>A</item>
180
+ <item>B</item>
181
+ </root>
182
+
183
+ <!-- File 2 -->
184
+ <root><item>A</item><item>B</item></root>
185
+ ----
186
+
187
+ .Comparison Results
188
+ |===
189
+ |Behavior |Result |Explanation
190
+
191
+ |`strict`
192
+ |Different
193
+ |Indentation and newlines differ
194
+
195
+ |`normalize`
196
+ |Equivalent
197
+ |Whitespace between elements normalized
198
+
199
+ |`ignore`
200
+ |Equivalent
201
+ |Only element structure compared
202
+ |===
203
+
204
+ *JSON Example*:
205
+
206
+ .Input Files
207
+ [source,json]
208
+ ----
209
+ // File 1
210
+ {
211
+ "items": [
212
+ "A",
213
+ "B"
214
+ ]
215
+ }
216
+
217
+ // File 2
218
+ {"items":["A","B"]}
219
+ ----
220
+
221
+ .Comparison Results
222
+ |===
223
+ |Behavior |Result |Explanation
224
+
225
+ |`strict`
226
+ |Different
227
+ |Formatting differs
228
+
229
+ |`normalize`
230
+ |Equivalent
231
+ |Both have same structure
232
+
233
+ |`ignore`
234
+ |Equivalent
235
+ |Structure identical
236
+ |===
237
+
238
+ === attribute_whitespace
239
+
240
+ *Purpose*: Controls how whitespace in attribute values is handled.
241
+
242
+ *Applies to*: XML, HTML only
243
+
244
+ *Behaviors*:
245
+
246
+ * `strict` - Attribute value whitespace must match exactly
247
+ * `normalize` - Whitespace in attribute values is normalized
248
+ * `ignore` - Whitespace in attribute values is ignored
249
+
250
+ *XML Example*:
251
+
252
+ .Input Files
253
+ [source,xml]
254
+ ----
255
+ <!-- File 1 -->
256
+ <div class="item active">Content</div>
257
+
258
+ <!-- File 2 -->
259
+ <div class="item active">Content</div>
260
+ ----
261
+
262
+ .Comparison Results
263
+ |===
264
+ |Behavior |Result |Explanation
265
+
266
+ |`strict`
267
+ |Different
268
+ |Attribute value whitespace differs (2 spaces vs 1)
269
+
270
+ |`normalize`
271
+ |Equivalent
272
+ |"item active" normalizes to "item active"
273
+
274
+ |`ignore`
275
+ |Equivalent
276
+ |Only attribute presence compared
277
+ |===
278
+
279
+ *HTML Special Behavior*:
280
+
281
+ HTML's `class` attribute is space-separated, so normalization is particularly useful:
282
+
283
+ [source,html]
284
+ ----
285
+ <!-- These are equivalent with normalize -->
286
+ <div class="btn primary active">Click</div>
287
+ <div class="btn primary active">Click</div>
288
+ ----
289
+
290
+ === attribute_order
291
+
292
+ *Purpose*: Controls whether attribute order matters.
293
+
294
+ *Applies to*: XML, HTML only
295
+
296
+ *Behaviors*:
297
+
298
+ * `strict` - Attributes must appear in the same order
299
+ * `ignore` - Attribute order doesn't matter (set-based comparison)
300
+
301
+ *XML Example*:
302
+
303
+ .Input Files
304
+ [source,xml]
305
+ ----
306
+ <!-- File 1 -->
307
+ <element id="123" class="active" data-value="test"/>
308
+
309
+ <!-- File 2 -->
310
+ <element class="active" data-value="test" id="123"/>
311
+ ----
312
+
313
+ .Comparison Results
314
+ |===
315
+ |Behavior |Result |Explanation
316
+
317
+ |`strict`
318
+ |Different
319
+ |Attribute order differs
320
+
321
+ |`ignore`
322
+ |Equivalent
323
+ |Same attributes present (unordered comparison)
324
+ |===
325
+
326
+ *HTML Special Behavior*:
327
+
328
+ HTML attributes are inherently unordered by the HTML spec, so the default for HTML formats is `ignore`:
329
+
330
+ [source,html]
331
+ ----
332
+ <!-- These are always equivalent for HTML -->
333
+ <input type="text" id="name" class="form-control">
334
+ <input class="form-control" id="name" type="text">
335
+ ----
336
+
337
+ === attribute_values
338
+
339
+ *Purpose*: Controls how attribute values are compared.
340
+
341
+ *Applies to*: XML, HTML only
342
+
343
+ *Behaviors*:
344
+
345
+ * `strict` - Attribute values must match exactly
346
+ * `normalize` - Whitespace in values is normalized
347
+ * `ignore` - Only attribute presence is checked, values ignored
348
+
349
+ *XML Example*:
350
+
351
+ .Input Files
352
+ [source,xml]
353
+ ----
354
+ <!-- File 1 -->
355
+ <element id="123" class="normative"/>
356
+
357
+ <!-- File 2 -->
358
+ <element id="456" class="informative"/>
359
+ ----
360
+
361
+ .Comparison Results
362
+ |===
363
+ |Behavior |Result |Explanation
364
+
365
+ |`strict`
366
+ |Different
367
+ |Attribute values differ
368
+
369
+ |`normalize`
370
+ |Different
371
+ |Values still differ after normalization
372
+
373
+ |`ignore`
374
+ |Equivalent
375
+ |Both have `id` and `class` attributes (values ignored)
376
+ |===
377
+
378
+ *Use Case*: Useful when you want to verify that certain attributes exist but don't care about their specific values (e.g., testing that generated IDs are present).
379
+
380
+ === key_order
381
+
382
+ *Purpose*: Controls whether object key order matters.
383
+
384
+ *Applies to*: JSON, YAML only
385
+
386
+ *Behaviors*:
387
+
388
+ * `strict` - Keys must appear in the same order
389
+ * `ignore` - Key order doesn't matter (unordered comparison)
390
+
391
+ *JSON Example*:
392
+
393
+ .Input Files
394
+ [source,json]
395
+ ----
396
+ // File 1
397
+ {
398
+ "name": "John",
399
+ "age": 30,
400
+ "city": "NYC"
401
+ }
402
+
403
+ // File 2
404
+ {
405
+ "city": "NYC",
406
+ "name": "John",
407
+ "age": 30
408
+ }
409
+ ----
410
+
411
+ .Comparison Results
412
+ |===
413
+ |Behavior |Result |Explanation
414
+
415
+ |`strict`
416
+ |Different
417
+ |Key order differs
418
+
419
+ |`ignore`
420
+ |Equivalent
421
+ |Same keys and values (unordered)
422
+ |===
423
+
424
+ *YAML Example*:
425
+
426
+ .Input Files
427
+ [source,yaml]
428
+ ----
429
+ # File 1
430
+ name: John
431
+ age: 30
432
+ city: NYC
433
+
434
+ # File 2
435
+ city: NYC
436
+ name: John
437
+ age: 30
438
+ ----
439
+
440
+ .Comparison Results
441
+ |===
442
+ |Behavior |Result |Explanation
443
+
444
+ |`strict`
445
+ |Different
446
+ |Key order differs
447
+
448
+ |`ignore`
449
+ |Equivalent
450
+ |Same structure and values
451
+ |===
452
+
453
+ === comments
454
+
455
+ *Purpose*: Controls how comments are compared.
456
+
457
+ *Applies to*: XML, HTML, YAML (JSON doesn't support comments in standard spec)
458
+
459
+ *Behaviors*:
460
+
461
+ * `strict` - Comments must match exactly (including whitespace)
462
+ * `normalize` - Whitespace in comments is normalized
463
+ * `ignore` - Comments are completely ignored
464
+
465
+ *XML Example*:
466
+
467
+ .Input Files
468
+ [source,xml]
469
+ ----
470
+ <!-- File 1 -->
471
+ <root>
472
+ <!-- This is a comment -->
473
+ <element>Value</element>
474
+ </root>
475
+
476
+ <!-- File 2 -->
477
+ <root>
478
+ <element>Value</element>
479
+ </root>
480
+ ----
481
+
482
+ .Comparison Results
483
+ |===
484
+ |Behavior |Result |Explanation
485
+
486
+ |`strict`
487
+ |Different
488
+ |File 1 has a comment, File 2 doesn't
489
+
490
+ |`normalize`
491
+ |Different
492
+ |Still different (comment present vs absent)
493
+
494
+ |`ignore`
495
+ |Equivalent
496
+ |Comments ignored, structure matches
497
+ |===
498
+
499
+ *YAML Example*:
500
+
501
+ .Input Files
502
+ [source,yaml]
503
+ ----
504
+ # File 1
505
+ # Configuration file
506
+ name: test
507
+ # Database settings
508
+ database: prod
509
+
510
+ # File 2
511
+ name: test
512
+ database: prod
513
+ ----
514
+
515
+ .Comparison Results
516
+ |===
517
+ |Behavior |Result |Explanation
518
+
519
+ |`strict`
520
+ |Different
521
+ |Comments differ
522
+
523
+ |`normalize`
524
+ |Different
525
+ |Comments still differ
526
+
527
+ |`ignore`
528
+ |Equivalent
529
+ |Comments ignored
530
+ |===
531
+
532
+ == Preprocessing Options
533
+
534
+ Preprocessing transforms documents before comparison. This happens in Phase 1 of the comparison flow.
535
+
536
+ === none
537
+
538
+ *Description*: No preprocessing applied. Documents are compared as-is.
539
+
540
+ *Use when*: You want to compare the exact input without any modifications.
541
+
542
+ === c14n
543
+
544
+ *Description*: Apply canonical form:
545
+
546
+ * *XML/HTML*: W3C XML Canonicalization (C14N) 1.1
547
+ * *JSON*: Sort keys alphabetically, normalize whitespace
548
+ * *YAML*: Sort keys, normalize to standard YAML format
549
+
550
+ *Use when*: You want to eliminate all formatting differences before comparison.
551
+
552
+ === normalize
553
+
554
+ *Description*: Normalize whitespace throughout the document:
555
+
556
+ * Collapse multiple whitespace to single space
557
+ * Trim leading/trailing whitespace
558
+ * Normalize line endings
559
+
560
+ *Use when*: You want to ignore whitespace differences but preserve structure.
561
+
562
+ === format
563
+
564
+ *Description*: Pretty-print the document with standard formatting:
565
+
566
+ * *XML/HTML*: 2-space indentation, one element per line
567
+ * *JSON*: 2-space indentation, standard JSON formatting
568
+ * *YAML*: Standard YAML formatting
569
+
570
+ *Use when*: You want both documents formatted consistently before comparison.
571
+
572
+ == Match Profiles
573
+
574
+ Match profiles are predefined combinations of match dimension settings. They provide convenient shortcuts for common comparison scenarios.
575
+
576
+ === strict
577
+
578
+ *Description*: Exact matching - all dimensions are set to `strict`.
579
+
580
+ *Settings*:
581
+ [source,ruby]
582
+ ----
583
+ {
584
+ preprocessing: :none,
585
+ text_content: :strict,
586
+ structural_whitespace: :strict,
587
+ attribute_whitespace: :strict,
588
+ attribute_order: :strict,
589
+ attribute_values: :strict,
590
+ key_order: :strict,
591
+ comments: :strict
592
+ }
593
+ ----
594
+
595
+ *Use when*: You need character-perfect matching.
596
+
597
+ === rendered
598
+
599
+ *Description*: Mimics how browsers/CSS engines render content.
600
+
601
+ *Settings*:
602
+ [source,ruby]
603
+ ----
604
+ {
605
+ preprocessing: :none,
606
+ text_content: :normalize,
607
+ structural_whitespace: :normalize,
608
+ attribute_whitespace: :normalize,
609
+ attribute_order: :ignore,
610
+ attribute_values: :strict,
611
+ key_order: :ignore,
612
+ comments: :ignore
613
+ }
614
+ ----
615
+
616
+ *Use when*: Comparing rendered output (HTML) where formatting doesn't affect display.
617
+
618
+ === spec_friendly
619
+
620
+ *Description*: Test-friendly comparison that ignores most formatting differences.
621
+
622
+ *Settings*:
623
+ [source,ruby]
624
+ ----
625
+ {
626
+ preprocessing: :normalize,
627
+ text_content: :normalize,
628
+ structural_whitespace: :ignore,
629
+ attribute_whitespace: :normalize,
630
+ attribute_order: :ignore,
631
+ attribute_values: :strict,
632
+ key_order: :ignore,
633
+ comments: :ignore
634
+ }
635
+ ----
636
+
637
+ *Use when*: Writing tests where you care about content but not formatting.
638
+
639
+ === content_only
640
+
641
+ *Description*: Only semantic content matters - maximum tolerance for formatting.
642
+
643
+ *Settings*:
644
+ [source,ruby]
645
+ ----
646
+ {
647
+ preprocessing: :normalize,
648
+ text_content: :normalize,
649
+ structural_whitespace: :ignore,
650
+ attribute_whitespace: :ignore,
651
+ attribute_order: :ignore,
652
+ attribute_values: :ignore,
653
+ key_order: :ignore,
654
+ comments: :ignore
655
+ }
656
+ ----
657
+
658
+ *Use when*: You only care about the structural content, not any formatting or attribute details.
659
+
660
+ == Format Defaults
661
+
662
+ Each format has sensible defaults based on its typical usage:
663
+
664
+ [cols="1,1,1,1,1,1,1,1",options="header"]
665
+ |===
666
+ |Dimension |XML |HTML |HTML4 |HTML5 |JSON |YAML
667
+
668
+ |preprocessing
669
+ |none
670
+ |none
671
+ |none
672
+ |none
673
+ |none
674
+ |none
675
+
676
+ |text_content
677
+ |strict
678
+ |normalize
679
+ |normalize
680
+ |normalize
681
+ |strict
682
+ |strict
683
+
684
+ |structural_whitespace
685
+ |strict
686
+ |normalize
687
+ |normalize
688
+ |normalize
689
+ |strict
690
+ |strict
691
+
692
+ |attribute_whitespace
693
+ |strict
694
+ |normalize
695
+ |normalize
696
+ |normalize
697
+ |—
698
+ |—
699
+
700
+ |attribute_order
701
+ |strict
702
+ |ignore
703
+ |ignore
704
+ |ignore
705
+ |—
706
+ |—
707
+
708
+ |attribute_values
709
+ |strict
710
+ |strict
711
+ |strict
712
+ |strict
713
+ |—
714
+ |—
715
+
716
+ |key_order
717
+ |—
718
+ |—
719
+ |—
720
+ |—
721
+ |strict
722
+ |strict
723
+
724
+ |comments
725
+ |strict
726
+ |ignore
727
+ |ignore
728
+ |ignore
729
+ |—
730
+ |strict
731
+ |===
732
+
733
+ *Diff Mode Defaults*:
734
+
735
+ * XML/HTML: `by_line`
736
+ * JSON/YAML: `by_object`
737
+
738
+ == Verbose Mode
739
+
740
+ When `verbose: true` is specified, Canon returns detailed information about the comparison results in a Hash structure instead of a simple boolean.
741
+
742
+ === Return Structure
743
+
744
+ Verbose mode returns a Hash with two keys:
745
+
746
+ [source,ruby]
747
+ ----
748
+ {
749
+ differences: Array, # Array of difference objects
750
+ preprocessed: Array # Two-element array of preprocessed content
751
+ }
752
+ ----
753
+
754
+ === differences
755
+
756
+ An array of difference objects. Each difference object contains:
757
+
758
+ * *For XML/HTML*:
759
+ - `:node1` - The node from the first document
760
+ - `:node2` - The node from the second document (or nil if missing)
761
+ - `:diff1` - Difference code (e.g., `Canon::Comparison::UNEQUAL_ELEMENTS`)
762
+ - `:diff2` - Corresponding code for second document
763
+ - Additional context depending on the difference type
764
+
765
+ * *For JSON/YAML*:
766
+ - `:path` - Path to the difference (e.g., "user.name" or "[2].id")
767
+ - `:value1` - Value from first document
768
+ - `:value2` - Value from second document
769
+ - `:diff_code` - Type of difference (e.g., `Canon::Comparison::UNEQUAL_PRIMITIVES`)
770
+
771
+ If documents are equivalent, `differences` will be an empty array.
772
+
773
+ === preprocessed
774
+
775
+ A two-element array containing the preprocessed versions of both documents that were used for comparison.
776
+
777
+ **Important**: The preprocessed content respects match options. For example:
778
+
779
+ * When `comments: :ignore` is set, comments are removed from the preprocessed XML
780
+ * When `structural_whitespace: :ignore` is set, whitespace-only text nodes are filtered from XML
781
+ * For HTML with `preprocessing: :rendered`, the content is normalized to match browser rendering
782
+
783
+ This ensures that diff rendering shows only the content that was actually compared, avoiding confusion from showing differences in ignored content.
784
+
785
+ .Example: XML with comments ignored
786
+ [source,ruby]
787
+ ----
788
+ xml1 = <<~XML
789
+ <root>
790
+ <!-- This comment will be filtered -->
791
+ <item>Value</item>
792
+ </root>
793
+ XML
794
+
795
+ xml2 = "<root><item>Value</item></root>"
796
+
797
+ result = Canon::Comparison.equivalent?(xml1, xml2,
798
+ match: { comments: :ignore },
799
+ verbose: true
800
+ )
801
+
802
+ # result[:preprocessed][0] will have the comment removed
803
+ # This ensures diff rendering doesn't show comment differences
804
+ # that were ignored during comparison
805
+ ----
806
+
807
+ === Using Verbose Results
808
+
809
+ .Check for equivalence
810
+ [source,ruby]
811
+ ----
812
+ result = Canon::Comparison.equivalent?(doc1, doc2, verbose: true)
813
+
814
+ if result[:differences].empty?
815
+ puts "Documents are equivalent"
816
+ else
817
+ puts "Found #{result[:differences].size} differences"
818
+ end
819
+ ----
820
+
821
+ .Access preprocessed content for custom processing
822
+ [source,ruby]
823
+ ----
824
+ result = Canon::Comparison.equivalent?(xml1, xml2,
825
+ match: { structural_whitespace: :ignore },
826
+ verbose: true
827
+ )
828
+
829
+ preprocessed1, preprocessed2 = result[:preprocessed]
830
+
831
+ # Use preprocessed content for custom diff rendering
832
+ # or further analysis
833
+ ----
834
+
835
+ .Iterate through differences
836
+ [source,ruby]
837
+ ----
838
+ result = Canon::Comparison.equivalent?(json1, json2, verbose: true)
839
+
840
+ result[:differences].each do |diff|
841
+ puts "Path: #{diff[:path]}"
842
+ puts " Expected: #{diff[:value1].inspect}"
843
+ puts " Got: #{diff[:value2].inspect}"
844
+ end
845
+ ----
846
+
847
+ == Usage
848
+
849
+ === CLI Interface
850
+
851
+ ==== Command Syntax
852
+
853
+ [source,bash]
854
+ ----
855
+ canon diff FILE1 FILE2 [OPTIONS]
856
+ ----
857
+
858
+ ==== Match Control Options
859
+
860
+ [source,bash]
861
+ ----
862
+ --match-profile PROFILE # strict|rendered|spec_friendly|content_only
863
+ --preprocessing MODE # none|c14n|normalize|format
864
+
865
+ # Match dimensions
866
+ --text-content BEHAVIOR # strict|normalize|ignore
867
+ --structural-whitespace BEHAVIOR # strict|normalize|ignore
868
+ --attribute-whitespace BEHAVIOR # strict|normalize|ignore (XML/HTML only)
869
+ --attribute-order BEHAVIOR # strict|ignore (XML/HTML only)
870
+ --attribute-values BEHAVIOR # strict|normalize|ignore (XML/HTML only)
871
+ --key-order BEHAVIOR # strict|ignore (JSON/YAML only)
872
+ --comments BEHAVIOR # strict|normalize|ignore
873
+ ----
874
+
875
+ ==== Diff Display Options
876
+
877
+ [source,bash]
878
+ ----
879
+ --diff-mode MODE # by_line|by_object
880
+ --color / --no-color # Enable/disable colorized output
881
+ --context-lines N # Number of context lines around changes
882
+ --diff-grouping-lines N # Group changes within N lines
883
+ ----
884
+
885
+ ==== Format Specification
886
+
887
+ [source,bash]
888
+ ----
889
+ --format FORMAT # xml|html|json|yaml (for both files)
890
+ --format1 FORMAT # Format of first file
891
+ --format2 FORMAT # Format of second file
892
+ ----
893
+
894
+ ==== CLI Examples
895
+
896
+ .Use a match profile
897
+ [source,bash]
898
+ ----
899
+ canon diff file1.xml file2.xml --match-profile spec_friendly
900
+ ----
901
+
902
+ .Override a specific dimension
903
+ [source,bash]
904
+ ----
905
+ canon diff file1.xml file2.xml --text-content normalize
906
+ ----
907
+
908
+ .Combine profile with overrides
909
+ [source,bash]
910
+ ----
911
+ canon diff file1.xml file2.xml \
912
+ --match-profile spec_friendly \
913
+ --comments strict \
914
+ --diff-mode by_line
915
+ ----
916
+
917
+ .Preprocess before comparing
918
+ [source,bash]
919
+ ----
920
+ canon diff file1.xml file2.xml --preprocessing normalize
921
+ ----
922
+
923
+ .Multiple dimension overrides
924
+ [source,bash]
925
+ ----
926
+ canon diff file1.xml file2.xml \
927
+ --text-content normalize \
928
+ --structural-whitespace ignore \
929
+ --attribute-order ignore
930
+ ----
931
+
932
+ .JSON comparison with custom diff settings
933
+ [source,bash]
934
+ ----
935
+ canon diff config1.json config2.json \
936
+ --match-profile spec_friendly \
937
+ --key-order ignore \
938
+ --context-lines 5 \
939
+ --no-color
940
+ ----
941
+
942
+ .HTML comparison with rendered profile
943
+ [source,bash]
944
+ ----
945
+ canon diff page1.html page2.html \
946
+ --match-profile rendered \
947
+ --diff-grouping-lines 2
948
+ ----
949
+
950
+ === Ruby API Interface
951
+
952
+ ==== Method Signature
953
+
954
+ [source,ruby]
955
+ ----
956
+ Canon::Comparison.equivalent?(obj1, obj2, options = {})
957
+ ----
958
+
959
+ ==== Options Hash
960
+
961
+ [source,ruby]
962
+ ----
963
+ {
964
+ # Match control
965
+ match_profile: Symbol, # :strict, :rendered, :spec_friendly, :content_only
966
+ preprocessing: Symbol, # :none, :c14n, :normalize, :format
967
+ match: Hash, # Hash of dimension => behavior
968
+
969
+ # Diff control
970
+ verbose: Boolean, # Return diff details if different
971
+ diff: Hash # Diff rendering options
972
+ }
973
+ ----
974
+
975
+ ==== Match Options Hash
976
+
977
+ [source,ruby]
978
+ ----
979
+ {
980
+ text_content: Symbol, # :strict, :normalize, :ignore
981
+ structural_whitespace: Symbol, # :strict, :normalize, :ignore
982
+ attribute_whitespace: Symbol, # :strict, :normalize, :ignore (XML/HTML)
983
+ attribute_order: Symbol, # :strict, :ignore (XML/HTML)
984
+ attribute_values: Symbol, # :strict, :normalize, :ignore (XML/HTML)
985
+ key_order: Symbol, # :strict, :ignore (JSON/YAML)
986
+ comments: Symbol # :strict, :normalize, :ignore
987
+ }
988
+ ----
989
+
990
+ ==== Diff Options Hash
991
+
992
+ [source,ruby]
993
+ ----
994
+ {
995
+ mode: Symbol, # :by_line, :by_object
996
+ use_color: Boolean, # Enable/disable colors
997
+ context_lines: Integer, # Lines of context
998
+ grouping_lines: Integer # Group changes within N lines
999
+ }
1000
+ ----
1001
+
1002
+ ==== Ruby API Examples
1003
+
1004
+ .Basic comparison with auto-detection
1005
+ [source,ruby]
1006
+ ----
1007
+ require "canon/comparison"
1008
+
1009
+ xml1 = File.read("file1.xml")
1010
+ xml2 = File.read("file2.xml")
1011
+
1012
+ Canon::Comparison.equivalent?(xml1, xml2)
1013
+ # => true or false
1014
+ ----
1015
+
1016
+ .Use a match profile
1017
+ [source,ruby]
1018
+ ----
1019
+ Canon::Comparison.equivalent?(xml1, xml2,
1020
+ match_profile: :spec_friendly
1021
+ )
1022
+ ----
1023
+
1024
+ .Preprocess before comparison
1025
+ [source,ruby]
1026
+ ----
1027
+ Canon::Comparison.equivalent?(xml1, xml2,
1028
+ preprocessing: :normalize
1029
+ )
1030
+ ----
1031
+
1032
+ .Override specific match dimensions
1033
+ [source,ruby]
1034
+ ----
1035
+ Canon::Comparison.equivalent?(xml1, xml2,
1036
+ match: {
1037
+ text_content: :normalize,
1038
+ structural_whitespace: :ignore,
1039
+ comments: :ignore
1040
+ }
1041
+ )
1042
+ ----
1043
+
1044
+ .Combine profile with dimension overrides
1045
+ [source,ruby]
1046
+ ----
1047
+ Canon::Comparison.equivalent?(xml1, xml2,
1048
+ match_profile: :spec_friendly,
1049
+ match: {
1050
+ comments: :strict # Override profile setting
1051
+ }
1052
+ )
1053
+ ----
1054
+
1055
+ .Get verbose diff output
1056
+ [source,ruby]
1057
+ ----
1058
+ result = Canon::Comparison.equivalent?(xml1, xml2,
1059
+ match_profile: :spec_friendly,
1060
+ verbose: true
1061
+ )
1062
+
1063
+ if result.is_a?(Hash)
1064
+ # Verbose mode returns a Hash with :differences and :preprocessed keys
1065
+ if result[:differences].empty?
1066
+ # Documents are equivalent
1067
+ puts "Files match!"
1068
+ else
1069
+ # Documents differ, result[:differences] contains diff details
1070
+ puts "Differences found:"
1071
+ puts result[:differences]
1072
+
1073
+ # result[:preprocessed] contains the preprocessed content
1074
+ # that was used for comparison, respecting match options
1075
+ # (e.g., whitespace-only nodes filtered for XML)
1076
+ preprocessed1, preprocessed2 = result[:preprocessed]
1077
+ end
1078
+ else
1079
+ # Non-verbose mode returns boolean
1080
+ puts result ? "Files match!" : "Files differ"
1081
+ end
1082
+ ----
1083
+
1084
+ .Verbose with custom diff options
1085
+ [source,ruby]
1086
+ ----
1087
+ result = Canon::Comparison.equivalent?(xml1, xml2,
1088
+ match_profile: :spec_friendly,
1089
+ verbose: true,
1090
+ diff: {
1091
+ mode: :by_line,
1092
+ use_color: true,
1093
+ context_lines: 5,
1094
+ grouping_lines: 2
1095
+ }
1096
+ )
1097
+ ----
1098
+
1099
+ .JSON comparison
1100
+ [source,ruby]
1101
+ ----
1102
+ json1 = File.read("config1.json")
1103
+ json2 = File.read("config2.json")
1104
+
1105
+ Canon::Comparison.equivalent?(json1, json2,
1106
+ match_profile: :spec_friendly,
1107
+ match: {
1108
+ key_order: :ignore
1109
+ }
1110
+ )
1111
+ ----
1112
+
1113
+ .Explicit format specification
1114
+ [source,ruby]
1115
+ ----
1116
+ Canon::Comparison::XmlComparator.equivalent?(xml1, xml2,
1117
+ match_profile: :strict,
1118
+ match: {
1119
+ attribute_order: :ignore
1120
+ }
1121
+ )
1122
+ ----
1123
+
1124
+ === RSpec Interface
1125
+
1126
+ ==== Global Configuration
1127
+
1128
+ [source,ruby]
1129
+ ----
1130
+ # spec/spec_helper.rb or spec/support/canon.rb
1131
+ require "canon/rspec_matchers"
1132
+
1133
+ Canon::RSpecMatchers.configure do |config|
1134
+ # Format-specific configuration
1135
+ config.<format>.match.profile = Symbol
1136
+ config.<format>.match.options = Hash
1137
+ config.<format>.preprocessing = Symbol
1138
+ config.<format>.diff.mode = Symbol
1139
+ config.<format>.diff.use_color = Boolean
1140
+ config.<format>.diff.context_lines = Integer
1141
+ config.<format>.diff.grouping_lines = Integer
1142
+ end
1143
+ ----
1144
+
1145
+ *Supported formats*: `xml`, `html`, `json`, `yaml`
1146
+
1147
+ ==== Match Configuration
1148
+
1149
+ For each format, set match profile or individual dimension options:
1150
+
1151
+ [source,ruby]
1152
+ ----
1153
+ # Using a profile
1154
+ config.<format>.match.profile = :spec_friendly
1155
+
1156
+ # OR using individual dimension options
1157
+ config.<format>.match.options = {
1158
+ text_content: :normalize,
1159
+ structural_whitespace: :ignore,
1160
+ attribute_whitespace: :normalize, # XML/HTML only
1161
+ attribute_order: :ignore, # XML/HTML only
1162
+ attribute_values: :strict, # XML/HTML only
1163
+ key_order: :ignore, # JSON/YAML only
1164
+ comments: :ignore
1165
+ }
1166
+ ----
1167
+
1168
+ ==== Diff Configuration
1169
+
1170
+ For each format:
1171
+
1172
+ [source,ruby]
1173
+ ----
1174
+ config.<format>.diff.mode = :by_line # :by_line or :by_object
1175
+ config.<format>.diff.use_color = true
1176
+ config.<format>.diff.context_lines = 3
1177
+ config.<format>.diff.grouping_lines = 10
1178
+ ----
1179
+
1180
+ ==== Matcher Syntax
1181
+
1182
+ [source,ruby]
1183
+ ----
1184
+ # Basic matchers
1185
+ expect(actual).to be_equivalent_xml_to(expected)
1186
+ expect(actual).to be_equivalent_html_to(expected)
1187
+ expect(actual).to be_equivalent_json_to(expected)
1188
+ expect(actual).to be_equivalent_yaml_to(expected)
1189
+
1190
+ # With verbose output (shows diff on failure)
1191
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1192
+
1193
+ # Override with profile
1194
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1195
+ .with_profile(:rendered)
1196
+
1197
+ # Override with specific options
1198
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1199
+ .with_options(
1200
+ text_content: :strict,
1201
+ structural_whitespace: :strict
1202
+ )
1203
+
1204
+ # Combine profile and option overrides
1205
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1206
+ .with_profile(:spec_friendly)
1207
+ .with_options(comments: :strict)
1208
+ ----
1209
+
1210
+ ==== RSpec Examples
1211
+
1212
+ .Global configuration for XML
1213
+ [source,ruby]
1214
+ ----
1215
+ Canon::RSpecMatchers.configure do |config|
1216
+ config.xml.match.profile = :spec_friendly
1217
+ config.xml.preprocessing = :normalize
1218
+ config.xml.match.options = {
1219
+ text_content: :normalize,
1220
+ structural_whitespace: :ignore,
1221
+ comments: :ignore
1222
+ }
1223
+ config.xml.diff.mode = :by_line
1224
+ config.xml.diff.use_color = true
1225
+ config.xml.diff.context_lines = 3
1226
+ end
1227
+ ----
1228
+
1229
+ .Global configuration for HTML
1230
+ [source,ruby]
1231
+ ----
1232
+ Canon::RSpecMatchers.configure do |config|
1233
+ config.html.match.profile = :rendered
1234
+ config.html.match.options = {
1235
+ structural_whitespace: :ignore
1236
+ }
1237
+ config.html.diff.mode = :by_line
1238
+ config.html.diff.grouping_lines = 2
1239
+ end
1240
+ ----
1241
+
1242
+ .Global configuration for JSON
1243
+ [source,ruby]
1244
+ ----
1245
+ Canon::RSpecMatchers.configure do |config|
1246
+ config.json.match.profile = :spec_friendly
1247
+ config.json.match.options = {
1248
+ key_order: :ignore
1249
+ }
1250
+ config.json.diff.mode = :by_object
1251
+ config.json.diff.context_lines = 5
1252
+ end
1253
+ ----
1254
+
1255
+ .Basic spec usage
1256
+ [source,ruby]
1257
+ ----
1258
+ RSpec.describe "XML comparison" do
1259
+ it "compares XML documents" do
1260
+ actual = File.read("actual.xml")
1261
+ expected = File.read("expected.xml")
1262
+
1263
+ expect(actual).to be_equivalent_xml_to(expected)
1264
+ end
1265
+ end
1266
+ ----
1267
+
1268
+ .With verbose output
1269
+ [source,ruby]
1270
+ ----
1271
+ it "shows diff on failure" do
1272
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1273
+ end
1274
+ ----
1275
+
1276
+ .Override global config with profile
1277
+ [source,ruby]
1278
+ ----
1279
+ it "uses strict matching for this test" do
1280
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1281
+ .with_profile(:strict)
1282
+ end
1283
+ ----
1284
+
1285
+ .Override specific dimensions
1286
+ [source,ruby]
1287
+ ----
1288
+ it "requires strict whitespace for this test" do
1289
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1290
+ .with_options(
1291
+ structural_whitespace: :strict,
1292
+ text_content: :strict
1293
+ )
1294
+ end
1295
+ ----
1296
+
1297
+ .Combine profile and overrides
1298
+ [source,ruby]
1299
+ ----
1300
+ it "uses spec_friendly but checks comments" do
1301
+ expect(actual).to be_equivalent_xml_to(expected, verbose: true)
1302
+ .with_profile(:spec_friendly)
1303
+ .with_options(comments: :strict)
1304
+ end
1305
+ ----
1306
+
1307
+ .HTML comparison
1308
+ [source,ruby]
1309
+ ----
1310
+ it "compares HTML with rendered profile" do
1311
+ expect(actual_html).to be_equivalent_html_to(expected_html, verbose: true)
1312
+ .with_profile(:rendered)
1313
+ end
1314
+ ----
1315
+
1316
+ .JSON comparison
1317
+ [source,ruby]
1318
+ ----
1319
+ it "compares JSON ignoring key order" do
1320
+ expect(actual_json).to be_equivalent_json_to(expected_json, verbose: true)
1321
+ .with_options(key_order: :ignore)
1322
+ end
1323
+ ----
1324
+
1325
+ == Configuration Precedence
1326
+
1327
+ When options are specified in multiple places, Canon resolves them using the following precedence hierarchy (highest to lowest):
1328
+
1329
+ [source]
1330
+ ----
1331
+ 1. Per-test explicit options (highest priority)
1332
+
1333
+ 2. Per-test profile
1334
+
1335
+ 3. Global config explicit options
1336
+
1337
+ 4. Global config profile
1338
+
1339
+ 5. Format defaults (lowest priority)
1340
+ ----
1341
+
1342
+ === Precedence Example
1343
+
1344
+ .Global configuration
1345
+ [source,ruby]
1346
+ ----
1347
+ Canon::RSpecMatchers.configure do |config|
1348
+ config.xml.match.profile = :spec_friendly # Sets multiple dimensions
1349
+ config.xml.match.options = { comments: :strict } # Explicit override
1350
+ end
1351
+ ----
1352
+
1353
+ The `:spec_friendly` profile sets:
1354
+ - `text_content: :normalize`
1355
+ - `structural_whitespace: :ignore`
1356
+ - `comments: :ignore`
1357
+
1358
+ But the explicit `comments: :strict` in options overrides the profile setting.
1359
+
1360
+ .Per-test usage
1361
+ [source,ruby]
1362
+ ----
1363
+ expect(actual).to be_equivalent_xml_to(expected)
1364
+ .with_profile(:rendered) # Sets: text_content=normalize, structural_whitespace=normalize
1365
+ .with_options(structural_whitespace: :ignore) # Explicit override
1366
+ ----
1367
+
1368
+ *Final resolved options*:
1369
+ - `text_content: :normalize` (from `:rendered` per-test profile)
1370
+ - `structural_whitespace: :ignore` (from per-test explicit option)
1371
+ - `comments: :strict` (from global explicit option)
1372
+ - Other dimensions use `:rendered` profile or format defaults
1373
+
1374
+ === Resolution Rules
1375
+
1376
+ . Explicit options always override profile settings
1377
+ . Per-test options override global configuration
1378
+ . Profile settings are merged (not replaced) - explicit options from lower precedence levels are preserved
1379
+ . Missing dimensions fall back to the next level in the hierarchy
1380
+ . At the bottom, format defaults provide the final fallback
1381
+
1382
+ This allows you to:
1383
+
1384
+ * Set sensible defaults globally
1385
+ * Use profiles for common scenarios
1386
+ * Override specific dimensions when needed
1387
+ * Maintain fine-grained control per test