canon 0.1.22 → 0.2.0

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +174 -25
  3. data/docs/INDEX.adoc +4 -0
  4. data/docs/advanced/diff-classification.adoc +3 -2
  5. data/docs/features/configuration-profiles.adoc +288 -0
  6. data/docs/features/diff-formatting/character-visualization.adoc +153 -454
  7. data/docs/features/diff-formatting/display-filtering.adoc +44 -0
  8. data/docs/features/diff-formatting/display-preprocessing.adoc +656 -0
  9. data/docs/features/diff-formatting/index.adoc +47 -0
  10. data/docs/features/diff-formatting/pretty-diff-mode.adoc +154 -0
  11. data/docs/features/environment-configuration/override-system.adoc +10 -3
  12. data/docs/features/index.adoc +9 -0
  13. data/docs/features/match-options/index.adoc +32 -42
  14. data/docs/features/match-options/pretty-printed-fixtures.adoc +270 -0
  15. data/docs/guides/choosing-configuration.adoc +22 -0
  16. data/docs/reference/environment-variables.adoc +121 -1
  17. data/docs/reference/options-across-interfaces.adoc +182 -2
  18. data/lib/canon/cli.rb +20 -0
  19. data/lib/canon/commands/diff_command.rb +7 -2
  20. data/lib/canon/commands/format_command.rb +1 -1
  21. data/lib/canon/comparison/html_comparator.rb +20 -15
  22. data/lib/canon/comparison/html_compare_profile.rb +4 -4
  23. data/lib/canon/comparison/markup_comparator.rb +12 -3
  24. data/lib/canon/comparison/match_options/base_resolver.rb +29 -7
  25. data/lib/canon/comparison/match_options/json_resolver.rb +9 -0
  26. data/lib/canon/comparison/match_options/xml_resolver.rb +16 -2
  27. data/lib/canon/comparison/match_options/yaml_resolver.rb +10 -0
  28. data/lib/canon/comparison/match_options.rb +4 -1
  29. data/lib/canon/comparison/whitespace_sensitivity.rb +189 -137
  30. data/lib/canon/comparison/xml_comparator/child_comparison.rb +21 -4
  31. data/lib/canon/comparison/xml_comparator.rb +14 -12
  32. data/lib/canon/comparison/xml_node_comparison.rb +51 -6
  33. data/lib/canon/comparison.rb +52 -9
  34. data/lib/canon/config/env_schema.rb +32 -4
  35. data/lib/canon/config/override_resolver.rb +16 -3
  36. data/lib/canon/config/profile_loader.rb +135 -0
  37. data/lib/canon/config/profiles/metanorma.yml +74 -0
  38. data/lib/canon/config/profiles/metanorma_debug.yml +8 -0
  39. data/lib/canon/config/type_converter.rb +8 -0
  40. data/lib/canon/config.rb +469 -5
  41. data/lib/canon/diff/diff_classifier.rb +41 -11
  42. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +48 -17
  43. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +58 -0
  44. data/lib/canon/diff_formatter/diff_detail_formatter.rb +22 -7
  45. data/lib/canon/diff_formatter/theme.rb +24 -17
  46. data/lib/canon/diff_formatter.rb +493 -36
  47. data/lib/canon/pretty_printer/xml_normalized.rb +395 -0
  48. data/lib/canon/rspec_matchers.rb +36 -0
  49. data/lib/canon/tree_diff/matchers/hash_matcher.rb +26 -11
  50. data/lib/canon/version.rb +1 -1
  51. data/lib/canon/xml/nodes/namespace_node.rb +4 -0
  52. data/lib/canon/xml/nodes/processing_instruction_node.rb +4 -0
  53. data/lib/canon/xml/nodes/root_node.rb +4 -0
  54. data/lib/canon/xml/nodes/text_node.rb +4 -0
  55. data/lib/tasks/performance_helpers.rb +2 -2
  56. metadata +24 -2
@@ -11,8 +11,10 @@ module Canon
11
11
  #
12
12
  # @param diff [DiffNode, Hash] Difference node
13
13
  # @param use_color [Boolean] Whether to use colors
14
+ # @param compact [Boolean] Whether to serialize element nodes as compact XML
14
15
  # @return [String] Formatted dimension details
15
- def self.format_dimension_details(diff, use_color)
16
+ def self.format_dimension_details(diff, use_color, compact: false,
17
+ expand_difference: false)
16
18
  dimension = extract_dimension(diff)
17
19
 
18
20
  case dimension
@@ -21,7 +23,8 @@ module Canon
21
23
  when :namespace_declarations
22
24
  format_namespace_declarations_details(diff, use_color)
23
25
  when :element_structure
24
- format_element_structure_details(diff, use_color)
26
+ format_element_structure_details(diff, use_color,
27
+ expand_difference: expand_difference)
25
28
  when :attribute_presence
26
29
  format_attribute_presence_details(diff, use_color)
27
30
  when :attribute_values
@@ -29,7 +32,7 @@ module Canon
29
32
  when :attribute_order
30
33
  format_attribute_order_details(diff, use_color)
31
34
  when :text_content
32
- format_text_content_details(diff, use_color)
35
+ format_text_content_details(diff, use_color, compact: compact)
33
36
  when :structural_whitespace
34
37
  format_structural_whitespace_details(diff, use_color)
35
38
  when :comments
@@ -37,7 +40,7 @@ module Canon
37
40
  when :hash_diff
38
41
  format_hash_diff_details(diff, use_color)
39
42
  else
40
- format_fallback_details(diff, use_color)
43
+ format_fallback_details(diff, use_color, compact: compact)
41
44
  end
42
45
  end
43
46
 
@@ -163,7 +166,8 @@ module Canon
163
166
  # @param diff [DiffNode, Hash] Difference node
164
167
  # @param use_color [Boolean] Whether to use colors
165
168
  # @return [Array] Tuple of [detail1, detail2, changes]
166
- def self.format_element_structure_details(diff, use_color)
169
+ def self.format_element_structure_details(diff, use_color,
170
+ expand_difference: false)
167
171
  require_relative "color_helper"
168
172
  require_relative "node_utils"
169
173
 
@@ -173,8 +177,15 @@ module Canon
173
177
  name1 = NodeUtils.get_element_name_for_display(node1)
174
178
  name2 = NodeUtils.get_element_name_for_display(node2)
175
179
 
176
- detail1 = "<#{ColorHelper.colorize(name1, :red, use_color)}>"
177
- detail2 = "<#{ColorHelper.colorize(name2, :green, use_color)}>"
180
+ if expand_difference
181
+ display1 = NodeUtils.serialize_node_compact(node1)
182
+ display2 = NodeUtils.serialize_node_compact(node2)
183
+ detail1 = ColorHelper.colorize(display1, :red, use_color)
184
+ detail2 = ColorHelper.colorize(display2, :green, use_color)
185
+ else
186
+ detail1 = "<#{ColorHelper.colorize(name1, :red, use_color)}>"
187
+ detail2 = "<#{ColorHelper.colorize(name2, :green, use_color)}>"
188
+ end
178
189
 
179
190
  changes = "Element differs: #{ColorHelper.colorize(name1, :red,
180
191
  use_color)} → " \
@@ -311,8 +322,9 @@ module Canon
311
322
  #
312
323
  # @param diff [DiffNode, Hash] Difference node
313
324
  # @param use_color [Boolean] Whether to use colors
325
+ # @param compact [Boolean] Whether to serialize element nodes as compact XML
314
326
  # @return [Array] Tuple of [detail1, detail2, changes]
315
- def self.format_text_content_details(diff, use_color)
327
+ def self.format_text_content_details(diff, use_color, compact: false)
316
328
  require_relative "color_helper"
317
329
  require_relative "node_utils"
318
330
  require_relative "text_utils"
@@ -320,15 +332,15 @@ module Canon
320
332
  node1 = extract_node1(diff)
321
333
  node2 = extract_node2(diff)
322
334
 
323
- text1 = NodeUtils.get_node_text(node1)
324
- text2 = NodeUtils.get_node_text(node2)
335
+ text1 = NodeUtils.node_to_display(node1, compact: compact)
336
+ text2 = NodeUtils.node_to_display(node2, compact: compact)
325
337
 
326
338
  # Handle cases where one node is missing (e.g. text added or removed)
327
339
  if node1.nil? || node2.nil?
328
340
  if node1.nil?
329
- text2 = NodeUtils.get_node_text(node2)
341
+ text2 = NodeUtils.node_to_display(node2, compact: compact)
330
342
  else
331
- text1 = NodeUtils.get_node_text(node1)
343
+ text1 = NodeUtils.node_to_display(node1, compact: compact)
332
344
  end
333
345
  end
334
346
 
@@ -339,6 +351,13 @@ module Canon
339
351
  detail2 = ColorHelper.colorize(
340
352
  TextUtils.visualize_whitespace(text2), :green, use_color
341
353
  )
354
+ elsif compact && (node1.is_a?(Canon::Xml::Nodes::ElementNode) ||
355
+ node2.is_a?(Canon::Xml::Nodes::ElementNode))
356
+ # In compact mode with element nodes, display as raw XML without
357
+ # JSON-string quoting. In normal mode (or for plain text), apply
358
+ # the standard escaping/quoting logic.
359
+ detail1 = ColorHelper.colorize(text1, :red, use_color)
360
+ detail2 = ColorHelper.colorize(text2, :green, use_color)
342
361
  else
343
362
  # Escape non-ASCII characters for better terminal display
344
363
  # JSON.generate doesn't escape chars like NBSP (U+00A0) or em-dash (U+2014)
@@ -453,18 +472,30 @@ module Canon
453
472
  #
454
473
  # @param diff [DiffNode, Hash] Difference node
455
474
  # @param use_color [Boolean] Whether to use colors
475
+ # @param compact [Boolean] Whether to serialize element nodes as compact XML
456
476
  # @return [Array] Tuple of [detail1, detail2, changes]
457
- def self.format_fallback_details(diff, use_color)
477
+ def self.format_fallback_details(diff, use_color, compact: false)
458
478
  require_relative "color_helper"
459
479
  require_relative "node_utils"
460
480
 
461
481
  node1 = extract_node1(diff)
462
482
  node2 = extract_node2(diff)
463
483
 
464
- detail1 = ColorHelper.colorize(NodeUtils.format_node_brief(node1),
465
- :red, use_color)
466
- detail2 = ColorHelper.colorize(NodeUtils.format_node_brief(node2),
467
- :green, use_color)
484
+ raw1 = if compact
485
+ NodeUtils.node_to_display(node1,
486
+ compact: true)
487
+ else
488
+ NodeUtils.format_node_brief(node1)
489
+ end
490
+ raw2 = if compact
491
+ NodeUtils.node_to_display(node2,
492
+ compact: true)
493
+ else
494
+ NodeUtils.format_node_brief(node2)
495
+ end
496
+
497
+ detail1 = ColorHelper.colorize(raw1, :red, use_color)
498
+ detail2 = ColorHelper.colorize(raw2, :green, use_color)
468
499
 
469
500
  dimension = extract_dimension(diff)
470
501
  changes = "#{dimension.to_s.capitalize} differs: #{detail1} → #{detail2}"
@@ -260,6 +260,64 @@ module Canon
260
260
  end
261
261
  end
262
262
 
263
+ # Serialize a Canon Xml node tree as compact XML for display.
264
+ #
265
+ # Produces a human-readable inline XML string without namespace
266
+ # declarations and without indentation — suitable for use in Semantic
267
+ # Diff Report entries. Only handles Canon::Xml::Nodes types; for any
268
+ # other node (Nokogiri, etc.) falls back to +get_node_text+.
269
+ #
270
+ # @param node [Object] Node to serialize
271
+ # @return [String] Compact XML string
272
+ def self.serialize_node_compact(node)
273
+ require "cgi"
274
+ return "" unless node
275
+
276
+ case node
277
+ when Canon::Xml::Nodes::TextNode
278
+ CGI.escapeHTML(node.value.to_s)
279
+ when Canon::Xml::Nodes::ElementNode
280
+ tag = node.name.to_s
281
+ attrs = node.attribute_nodes.map do |attr|
282
+ attr_name = attr.respond_to?(:name) ? attr.name.to_s : attr.to_s
283
+ attr_value = attr.respond_to?(:value) ? attr.value.to_s : ""
284
+ " #{attr_name}=\"#{CGI.escapeHTML(attr_value)}\""
285
+ end.join
286
+ children_xml = node.children.map do |c|
287
+ serialize_node_compact(c)
288
+ end.join
289
+ if children_xml.empty?
290
+ "<#{tag}#{attrs}/>"
291
+ else
292
+ "<#{tag}#{attrs}>#{children_xml}</#{tag}>"
293
+ end
294
+ when Canon::Xml::Nodes::CommentNode
295
+ text = node.respond_to?(:value) ? node.value.to_s : ""
296
+ "<!--#{CGI.escapeHTML(text)}-->"
297
+ else
298
+ # Nokogiri nodes or other unknown types — fall back to text extraction
299
+ get_node_text(node)
300
+ end
301
+ end
302
+
303
+ # Return the best display string for a node.
304
+ #
305
+ # When +compact: true+ and the node is a Canon ElementNode, returns a
306
+ # compact XML serialization (e.g. +<strong>Annex</strong>+) instead of
307
+ # the +node_info+ description string that +get_node_text+ would produce.
308
+ # In all other cases, delegates to +get_node_text+.
309
+ #
310
+ # @param node [Object] Node to display
311
+ # @param compact [Boolean] Whether to use compact XML for element nodes
312
+ # @return [String] Display string
313
+ def self.node_to_display(node, compact: false)
314
+ if compact && node.is_a?(Canon::Xml::Nodes::ElementNode)
315
+ serialize_node_compact(node)
316
+ else
317
+ get_node_text(node)
318
+ end
319
+ end
320
+
263
321
  # Check if node is inside a preserve-whitespace element
264
322
  #
265
323
  # @param node [Object] Node to check
@@ -19,8 +19,14 @@ module Canon
19
19
  #
20
20
  # @param differences [Array<DiffNode>] Array of differences
21
21
  # @param use_color [Boolean] Whether to use colors
22
+ # @param show_diffs [Symbol] Filter: :all (default), :normative, :informative
23
+ # @param compact_semantic_report [Boolean] When true, serialize element nodes
24
+ # as compact XML (e.g. <strong>Annex</strong>) instead of the verbose
25
+ # node_info description (e.g. "name: strong namespace_uri: …")
22
26
  # @return [String] Formatted semantic diff report
23
- def format_report(differences, use_color: true)
27
+ def format_report(differences, use_color: true, show_diffs: :all,
28
+ compact_semantic_report: false,
29
+ expand_difference: false)
24
30
  return "" if differences.empty?
25
31
 
26
32
  # Group differences by normative status
@@ -31,6 +37,10 @@ module Canon
31
37
  diff.respond_to?(:normative?) && !diff.normative?
32
38
  end
33
39
 
40
+ # Apply show_diffs filter — same semantics as the line-diff filter
41
+ show_normative = show_diffs != :informative
42
+ show_informative = show_diffs != :normative
43
+
34
44
  output = []
35
45
  output << ""
36
46
  output << colorize("=" * 70, :cyan, use_color, bold: true)
@@ -40,7 +50,7 @@ module Canon
40
50
  output << colorize("=" * 70, :cyan, use_color, bold: true)
41
51
 
42
52
  # Show normative differences first
43
- if normative.any?
53
+ if normative.any? && show_normative
44
54
  output << ""
45
55
  output << colorize(
46
56
  "┌─ NORMATIVE DIFFERENCES (#{normative.length}) ─┐", :green, use_color, bold: true
@@ -49,12 +59,14 @@ module Canon
49
59
  normative.each_with_index do |diff, i|
50
60
  output << ""
51
61
  output << format_single_diff(diff, i + 1, normative.length,
52
- use_color, section: "NORMATIVE")
62
+ use_color, section: "NORMATIVE",
63
+ compact: compact_semantic_report,
64
+ expand_difference: expand_difference)
53
65
  end
54
66
  end
55
67
 
56
68
  # Show informative differences second
57
- if informative.any?
69
+ if informative.any? && show_informative
58
70
  output << ""
59
71
  output << ""
60
72
  output << colorize(
@@ -64,7 +76,9 @@ module Canon
64
76
  informative.each_with_index do |diff, i|
65
77
  output << ""
66
78
  output << format_single_diff(diff, i + 1, informative.length,
67
- use_color, section: "INFORMATIVE")
79
+ use_color, section: "INFORMATIVE",
80
+ compact: compact_semantic_report,
81
+ expand_difference: expand_difference)
68
82
  end
69
83
  end
70
84
 
@@ -78,7 +92,8 @@ module Canon
78
92
  private
79
93
 
80
94
  # Format a single difference with dimension-specific details
81
- def format_single_diff(diff, number, total, use_color, section: nil)
95
+ def format_single_diff(diff, number, total, use_color, section: nil,
96
+ compact: false, expand_difference: false)
82
97
  output = []
83
98
 
84
99
  # Header - handle both DiffNode and Hash
@@ -120,7 +135,7 @@ module Canon
120
135
 
121
136
  # Dimension-specific details
122
137
  detail1, detail2, changes = DiffDetailFormatterHelpers::DimensionFormatter.format_dimension_details(
123
- diff, use_color
138
+ diff, use_color, compact: compact, expand_difference: expand_difference
124
139
  )
125
140
 
126
141
  output << colorize("⊖ Expected (File 1):", :red, use_color,
@@ -497,22 +497,9 @@ module Canon
497
497
 
498
498
  private
499
499
 
500
- # Deep dup a hash, handling nested hashes and arrays
500
+ # Delegate to module-level deep_dup
501
501
  def deep_dup(obj)
502
- case obj
503
- when Hash
504
- obj.transform_values { |v| deep_dup(v) }
505
- when Array
506
- obj.map { |v| deep_dup(v) }
507
- when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
508
- obj
509
- else
510
- begin
511
- obj.dup
512
- rescue StandardError
513
- obj
514
- end
515
- end
502
+ Theme.deep_dup(obj)
516
503
  end
517
504
 
518
505
  def deep_merge!(target, source)
@@ -606,9 +593,29 @@ module Canon
606
593
  # @param name [Symbol] Theme name
607
594
  # @return [Hash] Theme hash
608
595
  # @raise [ArgumentError] if theme not found
596
+ # Deep copy a value, handling nested hashes and arrays
597
+ def self.deep_dup(obj)
598
+ case obj
599
+ when Hash
600
+ obj.transform_values { |v| deep_dup(v) }
601
+ when Array
602
+ obj.map { |v| deep_dup(v) }
603
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
604
+ obj
605
+ else
606
+ begin
607
+ obj.dup
608
+ rescue StandardError
609
+ obj
610
+ end
611
+ end
612
+ end
613
+
609
614
  def self.[](name)
610
- THEMES[name] || raise(ArgumentError,
611
- "Unknown theme: #{name}. Valid: #{THEMES.keys}")
615
+ theme = THEMES[name] || raise(ArgumentError,
616
+ "Unknown theme: #{name}. Valid: #{THEMES.keys}")
617
+ # Return a deep copy to prevent mutation of theme constants
618
+ deep_dup(theme)
612
619
  end
613
620
 
614
621
  # List available theme names