canon 0.1.23 → 0.2.1

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 +155 -30
  3. data/docs/INDEX.adoc +4 -0
  4. data/docs/advanced/diff-classification.adoc +3 -2
  5. data/docs/advanced/verbose-mode-architecture.adoc +23 -0
  6. data/docs/features/configuration-profiles.adoc +288 -0
  7. data/docs/features/diff-formatting/character-visualization.adoc +153 -454
  8. data/docs/features/diff-formatting/display-filtering.adoc +44 -0
  9. data/docs/features/diff-formatting/display-preprocessing.adoc +656 -0
  10. data/docs/features/diff-formatting/index.adoc +47 -0
  11. data/docs/features/diff-formatting/pretty-diff-mode.adoc +154 -0
  12. data/docs/features/environment-configuration/override-system.adoc +10 -3
  13. data/docs/features/index.adoc +9 -0
  14. data/docs/features/match-options/html-policies.adoc +3 -0
  15. data/docs/features/match-options/index.adoc +32 -42
  16. data/docs/features/match-options/pretty-printed-fixtures.adoc +270 -0
  17. data/docs/guides/choosing-configuration.adoc +22 -0
  18. data/docs/reference/environment-variables.adoc +121 -1
  19. data/docs/reference/options-across-interfaces.adoc +182 -2
  20. data/lib/canon/cli.rb +20 -0
  21. data/lib/canon/commands/diff_command.rb +7 -2
  22. data/lib/canon/commands/format_command.rb +1 -1
  23. data/lib/canon/comparison/html_comparator.rb +29 -19
  24. data/lib/canon/comparison/html_compare_profile.rb +4 -4
  25. data/lib/canon/comparison/markup_comparator.rb +12 -3
  26. data/lib/canon/comparison/match_options/base_resolver.rb +29 -7
  27. data/lib/canon/comparison/match_options/json_resolver.rb +9 -0
  28. data/lib/canon/comparison/match_options/xml_resolver.rb +16 -2
  29. data/lib/canon/comparison/match_options/yaml_resolver.rb +10 -0
  30. data/lib/canon/comparison/match_options.rb +4 -1
  31. data/lib/canon/comparison/whitespace_sensitivity.rb +189 -137
  32. data/lib/canon/comparison/xml_comparator/child_comparison.rb +21 -4
  33. data/lib/canon/comparison/xml_comparator.rb +14 -12
  34. data/lib/canon/comparison/xml_node_comparison.rb +51 -6
  35. data/lib/canon/comparison.rb +52 -9
  36. data/lib/canon/config/env_schema.rb +32 -4
  37. data/lib/canon/config/override_resolver.rb +16 -3
  38. data/lib/canon/config/profile_loader.rb +135 -0
  39. data/lib/canon/config/profiles/metanorma.yml +74 -0
  40. data/lib/canon/config/profiles/metanorma_debug.yml +8 -0
  41. data/lib/canon/config/type_converter.rb +8 -0
  42. data/lib/canon/config.rb +469 -5
  43. data/lib/canon/diff/diff_classifier.rb +41 -11
  44. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +48 -17
  45. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +58 -0
  46. data/lib/canon/diff_formatter/diff_detail_formatter.rb +73 -17
  47. data/lib/canon/diff_formatter.rb +493 -36
  48. data/lib/canon/pretty_printer/xml_normalized.rb +395 -0
  49. data/lib/canon/rspec_matchers.rb +36 -0
  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
@@ -14,13 +14,22 @@ module Canon
14
14
  # Formats dimension-specific detail for individual differences
15
15
  # Provides actionable, colorized output showing exactly what changed
16
16
  module DiffDetailFormatter
17
+ ANSI_ESCAPE = /\e\[[0-9;]*m/
18
+ COMPACT_DETAIL_MAX = 30
19
+
17
20
  class << self
18
21
  # Format all differences as a semantic diff report
19
22
  #
20
23
  # @param differences [Array<DiffNode>] Array of differences
21
24
  # @param use_color [Boolean] Whether to use colors
25
+ # @param show_diffs [Symbol] Filter: :all (default), :normative, :informative
26
+ # @param compact_semantic_report [Boolean] When true, serialize element nodes
27
+ # as compact XML (e.g. <strong>Annex</strong>) instead of the verbose
28
+ # node_info description (e.g. "name: strong namespace_uri: …")
22
29
  # @return [String] Formatted semantic diff report
23
- def format_report(differences, use_color: true)
30
+ def format_report(differences, use_color: true, show_diffs: :all,
31
+ compact_semantic_report: false,
32
+ expand_difference: false)
24
33
  return "" if differences.empty?
25
34
 
26
35
  # Group differences by normative status
@@ -31,6 +40,10 @@ module Canon
31
40
  diff.respond_to?(:normative?) && !diff.normative?
32
41
  end
33
42
 
43
+ # Apply show_diffs filter — same semantics as the line-diff filter
44
+ show_normative = show_diffs != :informative
45
+ show_informative = show_diffs != :normative
46
+
34
47
  output = []
35
48
  output << ""
36
49
  output << colorize("=" * 70, :cyan, use_color, bold: true)
@@ -40,7 +53,7 @@ module Canon
40
53
  output << colorize("=" * 70, :cyan, use_color, bold: true)
41
54
 
42
55
  # Show normative differences first
43
- if normative.any?
56
+ if normative.any? && show_normative
44
57
  output << ""
45
58
  output << colorize(
46
59
  "┌─ NORMATIVE DIFFERENCES (#{normative.length}) ─┐", :green, use_color, bold: true
@@ -49,12 +62,14 @@ module Canon
49
62
  normative.each_with_index do |diff, i|
50
63
  output << ""
51
64
  output << format_single_diff(diff, i + 1, normative.length,
52
- use_color, section: "NORMATIVE")
65
+ use_color, section: "NORMATIVE",
66
+ compact: compact_semantic_report,
67
+ expand_difference: expand_difference)
53
68
  end
54
69
  end
55
70
 
56
71
  # Show informative differences second
57
- if informative.any?
72
+ if informative.any? && show_informative
58
73
  output << ""
59
74
  output << ""
60
75
  output << colorize(
@@ -64,7 +79,9 @@ module Canon
64
79
  informative.each_with_index do |diff, i|
65
80
  output << ""
66
81
  output << format_single_diff(diff, i + 1, informative.length,
67
- use_color, section: "INFORMATIVE")
82
+ use_color, section: "INFORMATIVE",
83
+ compact: compact_semantic_report,
84
+ expand_difference: expand_difference)
68
85
  end
69
86
  end
70
87
 
@@ -78,7 +95,8 @@ module Canon
78
95
  private
79
96
 
80
97
  # Format a single difference with dimension-specific details
81
- def format_single_diff(diff, number, total, use_color, section: nil)
98
+ def format_single_diff(diff, number, total, use_color, section: nil,
99
+ compact: false, expand_difference: false)
82
100
  output = []
83
101
 
84
102
  # Header - handle both DiffNode and Hash
@@ -112,24 +130,16 @@ module Canon
112
130
 
113
131
  # show reason if available
114
132
  if diff.respond_to?(:reason) && diff.reason
115
- output << "#{colorize('Reason:', :cyan, use_color,
116
- bold: true)} #{colorize(diff.reason,
117
- :yellow, use_color)}"
133
+ format_reason_line(output, diff.reason, use_color)
118
134
  end
119
135
  output << ""
120
136
 
121
137
  # Dimension-specific details
122
138
  detail1, detail2, changes = DiffDetailFormatterHelpers::DimensionFormatter.format_dimension_details(
123
- diff, use_color
139
+ diff, use_color, compact: compact, expand_difference: expand_difference
124
140
  )
125
141
 
126
- output << colorize("⊖ Expected (File 1):", :red, use_color,
127
- bold: true)
128
- output << " #{detail1}"
129
- output << ""
130
- output << colorize("⊕ Actual (File 2):", :green, use_color,
131
- bold: true)
132
- output << " #{detail2}"
142
+ format_expected_actual(output, detail1, detail2, use_color)
133
143
 
134
144
  if changes && !changes.empty?
135
145
  output << ""
@@ -167,6 +177,52 @@ module Canon
167
177
  colorize(error_msg, :red, use_color, bold: true)
168
178
  end
169
179
 
180
+ # Format the Reason line. When the reason contains visualized
181
+ # spaces (░), split into two vertically-aligned lines so the
182
+ # before/after text can be compared visually.
183
+ def format_reason_line(output, reason_text, use_color)
184
+ if reason_text.include?("\u2591") &&
185
+ reason_text.match?(/\A(Text|whitespace): .*\bvs\b/)
186
+ parts = reason_text.split(" vs ", 2)
187
+ if parts.length == 2
188
+ output << "#{colorize('Reason:', :cyan, use_color,
189
+ bold: true)} #{colorize(parts[0],
190
+ :yellow, use_color)}"
191
+ output << "#{' ' * 10}#{colorize("vs.: #{parts[1]}",
192
+ :yellow, use_color)}"
193
+ return
194
+ end
195
+ end
196
+ output << "#{colorize('Reason:', :cyan, use_color,
197
+ bold: true)} #{colorize(reason_text,
198
+ :yellow, use_color)}"
199
+ end
200
+
201
+ # Format the Expected/Actual block. Short values (both under 30
202
+ # chars) are rendered as compact single lines with aligned colons;
203
+ # longer values use the multi-line layout without a blank line gap.
204
+ def format_expected_actual(output, detail1, detail2, use_color)
205
+ plain1 = detail1.gsub(ANSI_ESCAPE, "")
206
+ plain2 = detail2.gsub(ANSI_ESCAPE, "")
207
+
208
+ if plain1.length < COMPACT_DETAIL_MAX &&
209
+ plain2.length < COMPACT_DETAIL_MAX
210
+ lbl1 = colorize("\u2296 Expected (File 1)", :red, use_color,
211
+ bold: true)
212
+ lbl2 = colorize("\u2295 Actual (File 2) ", :green, use_color,
213
+ bold: true)
214
+ output << "#{lbl1}: #{detail1}"
215
+ output << "#{lbl2}: #{detail2}"
216
+ else
217
+ output << colorize("\u2296 Expected (File 1):", :red, use_color,
218
+ bold: true)
219
+ output << " #{detail1}"
220
+ output << colorize("\u2295 Actual (File 2):", :green, use_color,
221
+ bold: true)
222
+ output << " #{detail2}"
223
+ end
224
+ end
225
+
170
226
  # Helper: Colorize text
171
227
  def colorize(text, color, use_color, bold: false)
172
228
  DiffDetailFormatterHelpers::ColorHelper.colorize(text, color,