canon 0.1.21 → 0.1.23

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +50 -26
  3. data/README.adoc +8 -3
  4. data/docs/advanced/diff-pipeline.adoc +36 -9
  5. data/docs/features/diff-formatting/colors-and-symbols.adoc +82 -0
  6. data/docs/features/diff-formatting/index.adoc +12 -0
  7. data/docs/features/diff-formatting/themes.adoc +353 -0
  8. data/docs/features/environment-configuration/index.adoc +23 -0
  9. data/docs/internals/diff-char-range-pipeline.adoc +249 -0
  10. data/docs/internals/diffnode-enrichment.adoc +1 -0
  11. data/docs/internals/index.adoc +52 -4
  12. data/docs/reference/environment-variables.adoc +6 -0
  13. data/docs/understanding/architecture.adoc +5 -0
  14. data/examples/show_themes.rb +217 -0
  15. data/lib/canon/comparison/comparison_result.rb +9 -4
  16. data/lib/canon/config/env_schema.rb +3 -1
  17. data/lib/canon/config.rb +11 -0
  18. data/lib/canon/diff/diff_block.rb +7 -0
  19. data/lib/canon/diff/diff_block_builder.rb +2 -2
  20. data/lib/canon/diff/diff_char_range.rb +140 -0
  21. data/lib/canon/diff/diff_line.rb +42 -4
  22. data/lib/canon/diff/diff_line_builder.rb +907 -0
  23. data/lib/canon/diff/diff_node.rb +5 -1
  24. data/lib/canon/diff/diff_node_enricher.rb +1418 -0
  25. data/lib/canon/diff/diff_node_mapper.rb +54 -0
  26. data/lib/canon/diff/source_locator.rb +105 -0
  27. data/lib/canon/diff/text_decomposer.rb +103 -0
  28. data/lib/canon/diff_formatter/by_line/base_formatter.rb +264 -24
  29. data/lib/canon/diff_formatter/by_line/html_formatter.rb +35 -20
  30. data/lib/canon/diff_formatter/by_line/json_formatter.rb +36 -19
  31. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +33 -19
  32. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +583 -98
  33. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +36 -19
  34. data/lib/canon/diff_formatter/by_object/base_formatter.rb +62 -13
  35. data/lib/canon/diff_formatter/by_object/json_formatter.rb +59 -24
  36. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +74 -34
  37. data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +4 -5
  38. data/lib/canon/diff_formatter/diff_detail_formatter.rb +1 -1
  39. data/lib/canon/diff_formatter/legend.rb +4 -2
  40. data/lib/canon/diff_formatter/theme.rb +864 -0
  41. data/lib/canon/diff_formatter.rb +11 -6
  42. data/lib/canon/tree_diff/matchers/hash_matcher.rb +16 -1
  43. data/lib/canon/tree_diff/matchers/similarity_matcher.rb +10 -0
  44. data/lib/canon/tree_diff/operations/operation_detector.rb +5 -1
  45. data/lib/canon/tree_diff/tree_diff_integrator.rb +1 -1
  46. data/lib/canon/version.rb +1 -1
  47. metadata +11 -2
@@ -67,6 +67,7 @@ module Canon
67
67
  when "="
68
68
  DiffLine.new(
69
69
  line_number: change.old_position,
70
+ new_position: change.new_position,
70
71
  content: change.old_element,
71
72
  type: :unchanged,
72
73
  diff_node: nil,
@@ -133,6 +134,12 @@ module Canon
133
134
  # element reflow with different line counts).
134
135
  apply_block_formatting!(diff_lines, lcs_diffs)
135
136
 
137
+ # Post-process: merge adjacent "-" lines into preceding "!" changes
138
+ # when the removed content already appears in the new line.
139
+ # This handles the case where N old lines map to 1 new line
140
+ # (e.g., closing tag on its own line merged into previous line).
141
+ merge_adjacent_removals!(diff_lines, lines1)
142
+
136
143
  diff_lines
137
144
  end
138
145
 
@@ -188,6 +195,7 @@ module Canon
188
195
 
189
196
  lcs_diffs.each_with_index do |change, idx|
190
197
  if change.action == "="
198
+ blocks << current_block if current_block
191
199
  current_block = nil
192
200
  next
193
201
  end
@@ -261,6 +269,52 @@ module Canon
261
269
  FormattingDetector.formatting_only?(line1, line2)
262
270
  end
263
271
 
272
+ # Merge adjacent "-" lines into a preceding "!" change when the
273
+ # removed content already appears in the changed line's new content.
274
+ #
275
+ # This handles the common case where N old lines map to 1 new line
276
+ # (e.g., a closing tag on its own line gets merged into the previous
277
+ # line in the reformatted document). Without this merge, the closing
278
+ # tag would appear as a spurious deletion even though it still exists.
279
+ #
280
+ # @param diff_lines [Array<DiffLine>] The diff lines to update in place
281
+ # @param lines1 [Array<String>] Lines from text1 for content lookup
282
+ def merge_adjacent_removals!(diff_lines, lines1)
283
+ i = 0
284
+ while i < diff_lines.length
285
+ dl = diff_lines[i]
286
+ unless dl.changed?
287
+ i += 1
288
+ next
289
+ end
290
+
291
+ j = i + 1
292
+ while j < diff_lines.length && diff_lines[j].removed?
293
+ removed = diff_lines[j]
294
+ removed_stripped = removed.content.strip
295
+
296
+ # Only merge if the removed content actually appears in the
297
+ # new line — it's not a real deletion, just a line-wrap change
298
+ if removed_stripped.empty? || !dl.content.include?(removed_stripped)
299
+ j += 1
300
+ next
301
+ end
302
+
303
+ # Extend old_content to span multiple old lines
304
+ dl.old_content ||= lines1[dl.line_number]
305
+ dl.old_content += "\n#{lines1[removed.line_number]}"
306
+
307
+ # Remove the absorbed line
308
+ diff_lines[j] = nil
309
+ j += 1
310
+ end
311
+
312
+ i = j
313
+ end
314
+
315
+ diff_lines.compact!
316
+ end
317
+
264
318
  # Determine formatting status for a changed line.
265
319
  # Checks: DiffNode formatting flag → line-level formatting → comment-only heuristic
266
320
  #
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Diff
5
+ # Locates serialized content within source text and maps character offsets
6
+ # to line/column positions. Used during DiffNode enrichment (Phase 1).
7
+ #
8
+ # The SourceLocator uses String#index on the full source text (not LCS on
9
+ # lines) to find where a DiffNode's serialized content appears. It then
10
+ # maps the character offset to a line number and column position using
11
+ # a pre-built line offset map.
12
+ #
13
+ # @example
14
+ # line_map = SourceLocator.build_line_map("line1\nline2\nline3")
15
+ # SourceLocator.locate("line2", "line1\nline2\nline3", line_map)
16
+ # # => { char_offset: 6, line_number: 1, col: 0 }
17
+ class SourceLocator
18
+ # Build a line offset map from source text.
19
+ # Each entry records the start and end character offset of a line.
20
+ #
21
+ # @param text [String] the full source text
22
+ # @return [Array<Hash>] array of { start_offset:, end_offset: } hashes,
23
+ # one per line (0-indexed)
24
+ def self.build_line_map(text)
25
+ return [] if text.nil? || text.empty?
26
+
27
+ map = []
28
+ offset = 0
29
+ text.each_line do |line|
30
+ line_end = offset + line.length
31
+ map << { start_offset: offset, end_offset: line_end }
32
+ offset = line_end
33
+ end
34
+ map
35
+ end
36
+
37
+ # Locate a substring within source text and return its position.
38
+ #
39
+ # @param substring [String] the content to find (e.g., serialized_before)
40
+ # @param text [String] the full source text
41
+ # @param line_map [Array<Hash>] pre-built line offset map
42
+ # @param start_from [Integer, nil] character offset to start searching from
43
+ # @return [Hash, nil] { char_offset:, line_number:, col: } or nil if not found
44
+ def self.locate(substring, text, line_map, start_from: nil)
45
+ return nil if substring.nil? || substring.empty?
46
+ return nil if text.nil? || line_map.empty?
47
+
48
+ char_offset = if start_from
49
+ text.index(substring, start_from)
50
+ else
51
+ text.index(substring)
52
+ end
53
+ return nil if char_offset.nil?
54
+
55
+ line_idx = find_line_for_offset(char_offset, line_map)
56
+ return nil if line_idx.nil?
57
+
58
+ col = char_offset - line_map[line_idx][:start_offset]
59
+
60
+ { char_offset: char_offset, line_number: line_idx, col: col }
61
+ end
62
+
63
+ # Locate ALL occurrences of a substring within source text.
64
+ #
65
+ # @param substring [String] the content to find
66
+ # @param text [String] the full source text
67
+ # @param line_map [Array<Hash>] pre-built line offset map
68
+ # @return [Array<Hash>] array of { char_offset:, line_number:, col: } hashes
69
+ def self.locate_all(substring, text, line_map)
70
+ return [] if substring.nil? || substring.empty?
71
+ return [] if text.nil? || line_map.empty?
72
+
73
+ results = []
74
+ offset = 0
75
+
76
+ while (pos = text.index(substring, offset))
77
+ line_idx = find_line_for_offset(pos, line_map)
78
+ break if line_idx.nil?
79
+
80
+ col = pos - line_map[line_idx][:start_offset]
81
+ results << { char_offset: pos, line_number: line_idx, col: col }
82
+ offset = pos + 1
83
+ end
84
+
85
+ results
86
+ end
87
+
88
+ class << self
89
+ private
90
+
91
+ # Binary search for the line containing a character offset.
92
+ #
93
+ # @param char_offset [Integer] the character offset
94
+ # @param line_map [Array<Hash>] the line offset map
95
+ # @return [Integer, nil] the 0-based line index, or nil
96
+ def find_line_for_offset(char_offset, line_map)
97
+ # Use bsearch for efficiency on large files
98
+ line_map.bsearch_index do |entry|
99
+ entry[:end_offset] > char_offset
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Diff
5
+ # Decomposes two strings into their common prefix, changed portion, and
6
+ # common suffix. Used during DiffNode enrichment (Phase 1) to produce
7
+ # the 3-part decomposition: before-text, changed-text, after-text.
8
+ #
9
+ # This is a pure function with no side effects. It operates on short
10
+ # serialized strings (e.g., "Hello World" vs "Hello Universe"), NOT
11
+ # on full document text.
12
+ #
13
+ # @example Simple substitution
14
+ # TextDecomposer.decompose("Hello World", "Hello Universe")
15
+ # # => { common_prefix: "Hello ", changed_old: "World",
16
+ # # changed_new: "Universe", common_suffix: "" }
17
+ #
18
+ # @example Mid-string insertion
19
+ # TextDecomposer.decompose("abc", "aXbc")
20
+ # # => { common_prefix: "a", changed_old: "",
21
+ # # changed_new: "X", common_suffix: "bc" }
22
+ #
23
+ # @example Full replacement
24
+ # TextDecomposer.decompose("foo", "bar")
25
+ # # => { common_prefix: "", changed_old: "foo",
26
+ # # changed_new: "bar", common_suffix: "" }
27
+ class TextDecomposer
28
+ # Decompose two strings into common prefix / changed / common suffix.
29
+ #
30
+ # Algorithm: character-by-character prefix scan from the start,
31
+ # then reverse suffix scan from the end. The middle portion is
32
+ # the actual change. O(n) where n is the string length.
33
+ #
34
+ # @param text1 [String] the old text (serialized_before)
35
+ # @param text2 [String] the new text (serialized_after)
36
+ # @return [Hash] with keys :common_prefix, :changed_old, :changed_new, :common_suffix
37
+ def self.decompose(text1, text2)
38
+ return empty_result if text1.nil? && text2.nil?
39
+
40
+ if text2.nil?
41
+ return { common_prefix: "", changed_old: text1.to_s,
42
+ changed_new: "", common_suffix: "" }
43
+ end
44
+ if text1.nil?
45
+ return { common_prefix: "", changed_old: "",
46
+ changed_new: text2.to_s, common_suffix: "" }
47
+ end
48
+
49
+ prefix_len = find_common_prefix_length(text1, text2)
50
+ suffix_len = find_common_suffix_length(text1, text2, prefix_len)
51
+
52
+ {
53
+ common_prefix: text1[0...prefix_len],
54
+ changed_old: text1[prefix_len...(text1.length - suffix_len)],
55
+ changed_new: text2[prefix_len...(text2.length - suffix_len)],
56
+ common_suffix: text1[(text1.length - suffix_len)..],
57
+ }
58
+ end
59
+
60
+ class << self
61
+ private
62
+
63
+ def empty_result
64
+ { common_prefix: "", changed_old: "", changed_new: "",
65
+ common_suffix: "" }
66
+ end
67
+
68
+ # Find the length of the common prefix of two strings.
69
+ # @param text1 [String]
70
+ # @param text2 [String]
71
+ # @return [Integer] number of matching characters from the start
72
+ def find_common_prefix_length(text1, text2)
73
+ max_len = [text1.length, text2.length].min
74
+ count = 0
75
+ while count < max_len && text1[count] == text2[count]
76
+ count += 1
77
+ end
78
+ count
79
+ end
80
+
81
+ # Find the length of the common suffix of two strings,
82
+ # excluding the common prefix.
83
+ # @param text1 [String]
84
+ # @param text2 [String]
85
+ # @param prefix_len [Integer] length of already-matched prefix
86
+ # @return [Integer] number of matching characters from the end
87
+ def find_common_suffix_length(text1, text2, prefix_len)
88
+ remaining1 = text1.length - prefix_len
89
+ remaining2 = text2.length - prefix_len
90
+ max_suffix = [remaining1, remaining2].min
91
+ return 0 if max_suffix <= 0
92
+
93
+ count = 0
94
+ while count < max_suffix &&
95
+ text1[text1.length - 1 - count] == text2[text2.length - 1 - count]
96
+ count += 1
97
+ end
98
+ count
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -3,6 +3,7 @@
3
3
  require "diff/lcs"
4
4
  require "diff/lcs/hunk"
5
5
  require_relative "../debug_output"
6
+ require_relative "../theme"
6
7
 
7
8
  module Canon
8
9
  class DiffFormatter
@@ -11,7 +12,7 @@ module Canon
11
12
  # Provides common LCS diff logic and hunk building
12
13
  class BaseFormatter
13
14
  attr_reader :use_color, :context_lines, :diff_grouping_lines,
14
- :visualization_map, :show_diffs
15
+ :visualization_map, :show_diffs, :diff_mode, :legacy_terminal
15
16
 
16
17
  # Create a format-specific by-line formatter
17
18
  #
@@ -46,13 +47,210 @@ module Canon
46
47
 
47
48
  def initialize(use_color: true, context_lines: 3,
48
49
  diff_grouping_lines: nil, visualization_map: nil,
49
- show_diffs: :all, differences: [])
50
+ show_diffs: :all, differences: [],
51
+ diff_mode: :separate, legacy_terminal: false,
52
+ equivalent: nil, theme: nil)
50
53
  @use_color = use_color
51
54
  @context_lines = context_lines
52
55
  @diff_grouping_lines = diff_grouping_lines
53
56
  @visualization_map = visualization_map
54
57
  @show_diffs = show_diffs
55
58
  @differences = differences
59
+ @line_num_width = 4
60
+ @diff_mode = legacy_terminal ? :separate : diff_mode
61
+ @legacy_terminal = legacy_terminal
62
+ @equivalent = equivalent
63
+ @theme = theme
64
+ end
65
+
66
+ # Get the resolved theme hash
67
+ # @return [Hash] Theme hash
68
+ def theme
69
+ @theme ||= Theme.resolver(Canon::Config.instance).resolve
70
+ end
71
+
72
+ # Get theme by section and type
73
+ # @param section [Symbol] e.g., :diff, :xml, :structure
74
+ # @param diff_type [Symbol] e.g., :removed, :added, :changed
75
+ # @param element [Symbol] e.g., :marker, :content
76
+ # @return [Hash] Style properties
77
+ def theme_style(section, diff_type, element)
78
+ theme.dig(section, diff_type, element) || {}
79
+ end
80
+
81
+ # Apply full theme styling to text
82
+ # @param text [String] Text to style
83
+ # @param style [Hash] Style properties from theme (color, bg, bold, underline, strikethrough)
84
+ # @return [String] Styled text
85
+ def apply_theme_style(text, style)
86
+ return text if style.empty? || !@use_color
87
+
88
+ color = style[:color]
89
+ bg = style[:bg]
90
+ bold = style[:bold]
91
+ underline = style[:underline]
92
+ strikethrough = style[:strikethrough]
93
+
94
+ # Apply visualization first
95
+ visual = apply_visualization(text)
96
+
97
+ return visual unless color || bg || bold || underline || strikethrough
98
+
99
+ require "rainbow"
100
+ rainbow = Rainbow.new
101
+ rainbow.enabled = true
102
+ presenter = rainbow.wrap(visual)
103
+
104
+ if color && color != :default
105
+ presenter = apply_color(presenter,
106
+ color)
107
+ end
108
+ presenter = apply_bg(presenter, bg) if bg
109
+ presenter = presenter.bold if bold
110
+ presenter = presenter.underline if underline
111
+ presenter = presenter.cross_out if strikethrough
112
+
113
+ presenter.to_s
114
+ end
115
+
116
+ # Compute line number column width from document line counts
117
+ def compute_line_num_width(doc1, doc2)
118
+ max_lines = [doc1.count("\n"), doc2.count("\n")].max
119
+ @line_num_width = [max_lines.to_s.length, 4].max
120
+ end
121
+
122
+ # =====================================================================
123
+ # Theme Style Helpers
124
+ # These methods look up theme styles for different diff types
125
+ # =====================================================================
126
+
127
+ # Get marker style for a diff type
128
+ # @param diff_type [Symbol] :removed, :added, :changed, :formatting, :informative
129
+ # @return [Hash] Style properties
130
+ def marker_style(diff_type)
131
+ theme_style(:diff, diff_type, :marker)
132
+ end
133
+
134
+ # Get content style for a diff type
135
+ # @param diff_type [Symbol] :removed, :added, :formatting, :informative
136
+ # @return [Hash] Style properties
137
+ def content_style(diff_type)
138
+ theme_style(:diff, diff_type, :content)
139
+ end
140
+
141
+ # Get changed content styles (old and new)
142
+ # @return [Hash] Keys: :content_old, :content_new
143
+ def changed_content_styles
144
+ {
145
+ content_old: theme_style(:diff, :changed, :content_old),
146
+ content_new: theme_style(:diff, :changed, :content_new),
147
+ }
148
+ end
149
+
150
+ # Get style for unchanged content
151
+ # @return [Hash] Style properties
152
+ def unchanged_content_style
153
+ theme_style(:diff, :unchanged, :content)
154
+ end
155
+
156
+ # Get structure styles
157
+ # @return [Hash] Keys: :line_number, :pipe, :context
158
+ def structure_styles
159
+ theme[:structure] || {}
160
+ end
161
+
162
+ # Get visualization characters
163
+ # @return [Hash] Keys: :space, :tab, :newline, :nbsp
164
+ def visualization_chars
165
+ theme[:visualization] || {}
166
+ end
167
+
168
+ # Get display mode
169
+ # @return [Symbol] :separate, :inline, :mixed
170
+ def display_mode
171
+ theme[:display_mode] || :separate
172
+ end
173
+
174
+ # Apply marker styling using theme
175
+ # @param text [String] Marker text (e.g., "-", "+", "*")
176
+ # @param diff_type [Symbol] Type of diff
177
+ # @return [String] Styled marker
178
+ def styled_marker(text, diff_type)
179
+ style = marker_style(diff_type)
180
+ return text unless @use_color && style[:color]
181
+
182
+ apply_theme_style(text, style)
183
+ end
184
+
185
+ # Get theme color for a specific diff type and element
186
+ # @param diff_type [Symbol] :removed, :added, :changed, :formatting, :informative
187
+ # @param element [Symbol] :marker, :content, :content_old, :content_new
188
+ # @return [Symbol, nil] Color value
189
+ def theme_color(diff_type, element)
190
+ theme_style(:diff, diff_type, element)[:color]
191
+ end
192
+
193
+ # Get structure color
194
+ # @param element [Symbol] :line_number, :pipe, :context
195
+ # @return [Symbol, nil] Color value
196
+ def structure_color(element)
197
+ theme.dig(:structure, element, :color)
198
+ end
199
+
200
+ # Normalize a color symbol for Rainbow presenter.
201
+ # Rainbow doesn't support :bright_blue directly - instead it uses
202
+ # chained methods like .blue.bright or .bright.blue.
203
+ # This returns an array of method symbols to chain.
204
+ #
205
+ # @param color [Symbol] Color like :bright_blue, :light_red, etc.
206
+ # @return [Array<Symbol>] Method chain for Rainbow
207
+ def normalize_color_for_rainbow(color)
208
+ return [] if color.nil?
209
+
210
+ case color.to_s
211
+ when /^bright_(.+)$/
212
+ # :bright_blue -> [:blue, :bright]
213
+ base = $1.to_sym
214
+ [base, :bright]
215
+ when /^light_(.+)$/
216
+ # :light_red -> Rainbow doesn't support light_, treat as white
217
+ [:white]
218
+ when "default", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
219
+ [color]
220
+ else
221
+ # Unknown color, return as-is and let Rainbow raise
222
+ [color]
223
+ end
224
+ end
225
+
226
+ # Apply a color to a Rainbow presenter, normalizing bright_/light_ colors.
227
+ # @param presenter [Rainbow::Presenter] The presenter to colorize
228
+ # @param color [Symbol] Color like :bright_blue, :red, etc.
229
+ # @return [Rainbow::Presenter] Colorized presenter
230
+ def apply_color(presenter, color)
231
+ valid_colors = normalize_color_for_rainbow(color)
232
+ valid_colors.each { |c| presenter = presenter.send(c) }
233
+ presenter
234
+ end
235
+
236
+ # Apply a background color to a Rainbow presenter.
237
+ # @param presenter [Rainbow::Presenter] The presenter to colorize
238
+ # @param bg_color [Symbol] Background color like :red, :light_blue, etc.
239
+ # @return [Rainbow::Presenter] Colorized presenter
240
+ def apply_bg(presenter, bg_color)
241
+ return presenter unless bg_color
242
+
243
+ case bg_color.to_s
244
+ when /^light_(.+)$/
245
+ # Rainbow doesn't support light_ backgrounds, use the base color
246
+ base = $1.to_sym
247
+ presenter.background(base)
248
+ when "default", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"
249
+ presenter.background(bg_color)
250
+ else
251
+ # Try as-is and let Rainbow handle unknown colors
252
+ presenter.background(bg_color)
253
+ end
56
254
  end
57
255
 
58
256
  # Format line-by-line diff
@@ -167,14 +365,24 @@ module Canon
167
365
  # RSpec-aware: resets any existing ANSI codes before applying new colors
168
366
  #
169
367
  # @param text [String] Text to colorize
170
- # @param colors [Array<Symbol>] Paint color arguments
368
+ # @param colors [Array<Symbol>] Rainbow color/effect arguments
171
369
  # @return [String] Colorized or plain text
172
370
  def colorize(text, *colors)
173
371
  return text unless @use_color
174
372
 
175
- require "paint"
176
- # Reset ANSI codes first to prevent RSpec's initial red from interfering
177
- "\e[0m#{Paint[text, *colors]}"
373
+ # Filter out nil colors and normalize bright_/light_ colors
374
+ valid_colors = colors.compact.flat_map do |c|
375
+ normalize_color_for_rainbow(c)
376
+ end
377
+ return text if valid_colors.empty?
378
+
379
+ require "rainbow"
380
+ # Use a local Rainbow instance that ignores global TTY detection
381
+ rainbow = Rainbow.new
382
+ rainbow.enabled = true
383
+ presenter = rainbow.wrap(text)
384
+ valid_colors.each { |c| presenter = presenter.send(c) }
385
+ presenter.to_s
178
386
  end
179
387
 
180
388
  # Identify contiguous diff blocks
@@ -349,8 +557,8 @@ module Canon
349
557
  # @return [String] Formatted line
350
558
  def format_unified_line(old_num, new_num, marker, content, color = nil,
351
559
  informative: false, formatting: false)
352
- old_str = old_num ? "%4d" % old_num : " "
353
- new_str = new_num ? "%4d" % new_num : " "
560
+ old_str = old_num ? "%#{@line_num_width}d" % old_num : " " * @line_num_width
561
+ new_str = new_num ? "%#{@line_num_width}d" % new_num : " " * @line_num_width
354
562
 
355
563
  # Formatting and informative diffs use directional colors already passed in
356
564
  # No need to override since callers set the correct color
@@ -365,16 +573,18 @@ module Canon
365
573
  end
366
574
 
367
575
  if @use_color
368
- yellow_old = colorize(old_str, :yellow)
369
- yellow_pipe1 = colorize("|", :yellow)
370
- yellow_new = colorize(new_str, :yellow)
371
- yellow_pipe2 = colorize("|", :yellow)
576
+ ln_color = structure_color(:line_number) || :yellow
577
+ pipe_color = structure_color(:pipe) || :yellow
578
+ ln_old = colorize(old_str, ln_color)
579
+ pipe1 = colorize("|", pipe_color)
580
+ ln_new = colorize(new_str, ln_color)
581
+ pipe2 = colorize("|", pipe_color)
372
582
 
373
583
  if effective_color
374
584
  colored_marker = colorize(marker, effective_color)
375
- "#{yellow_old}#{yellow_pipe1}#{yellow_new}#{colored_marker} #{yellow_pipe2} #{visualized_content}"
585
+ "#{ln_old}#{pipe1}#{ln_new}#{colored_marker} #{pipe2} #{visualized_content}"
376
586
  else
377
- "#{yellow_old}#{yellow_pipe1}#{yellow_new}#{marker} #{yellow_pipe2} #{visualized_content}"
587
+ "#{ln_old}#{pipe1}#{ln_new}#{marker} #{pipe2} #{visualized_content}"
378
588
  end
379
589
  else
380
590
  "#{old_str}|#{new_str}#{marker_part}| #{visualized_content}"
@@ -409,19 +619,24 @@ module Canon
409
619
  old_visualized = apply_visualization(old_text, old_color)
410
620
  new_visualized = apply_visualization(new_text, new_color)
411
621
 
622
+ fmt = "%#{@line_num_width}d"
623
+ blank = " " * @line_num_width
624
+
412
625
  if @use_color
413
- yellow_old = colorize("%4d" % old_line, :yellow)
414
- yellow_pipe1 = colorize("|", :yellow)
415
- yellow_new = colorize("%4d" % new_line, :yellow)
416
- yellow_pipe2 = colorize("|", :yellow)
626
+ ln_color = structure_color(:line_number) || :yellow
627
+ pipe_color = structure_color(:pipe) || :yellow
628
+ ln_old = colorize(fmt % old_line, ln_color)
629
+ pipe1 = colorize("|", pipe_color)
630
+ ln_new = colorize(fmt % new_line, ln_color)
631
+ pipe2 = colorize("|", pipe_color)
417
632
  old_marker_colored = colorize(old_marker, old_color)
418
633
  new_marker_colored = colorize(new_marker, new_color)
419
634
 
420
- output << "#{yellow_old}#{yellow_pipe1} #{old_marker_colored} #{yellow_pipe2} #{old_visualized}"
421
- output << " #{yellow_pipe1}#{yellow_new}#{new_marker_colored} #{yellow_pipe2} #{new_visualized}"
635
+ output << "#{ln_old}#{pipe1}#{blank}#{old_marker_colored} #{pipe2} #{old_visualized}"
636
+ output << "#{blank}#{pipe1}#{ln_new}#{new_marker_colored} #{pipe2} #{new_visualized}"
422
637
  else
423
- output << "#{'%4d' % old_line}| #{old_marker} | #{old_visualized}"
424
- output << " |#{'%4d' % new_line}#{new_marker} | #{new_visualized}"
638
+ output << "#{fmt % old_line}|#{blank}#{old_marker} | #{old_visualized}"
639
+ output << "#{blank}|#{fmt % new_line}#{new_marker} | #{new_visualized}"
425
640
  end
426
641
 
427
642
  output.join("\n")
@@ -440,8 +655,24 @@ module Canon
440
655
  end.join
441
656
 
442
657
  if color && @use_color
443
- require "paint"
444
- Paint[visual, color, :bold]
658
+ require "rainbow"
659
+ rainbow = Rainbow.new
660
+ rainbow.enabled = true
661
+ presenter = rainbow.wrap(visual)
662
+
663
+ # Handle Rainbow color methods - :bright_blue -> .blue.bright, etc.
664
+ if color.to_s.start_with?("bright_")
665
+ base_color = color.to_s.sub(/^bright_/, "").to_sym
666
+ presenter = presenter.send(base_color).bright
667
+ elsif color.to_s.start_with?("light_")
668
+ # Rainbow doesn't have light_ versions, treat as white on bg
669
+ base_color = color.to_s.sub(/^light_/, "").to_sym
670
+ presenter = presenter.send(base_color)
671
+ else
672
+ presenter = presenter.send(color)
673
+ end
674
+
675
+ presenter.to_s
445
676
  else
446
677
  visual
447
678
  end
@@ -528,6 +759,15 @@ module Canon
528
759
  #
529
760
  # @return [Boolean] True if diff display should be skipped
530
761
  def should_skip_diff_display?
762
+ # If documents are equivalent and there are no normative diffs,
763
+ # skip display entirely - showing even informative diffs when
764
+ # equivalent is misleading
765
+ if @equivalent == true
766
+ return @differences.none? do |diff|
767
+ diff.is_a?(Canon::Diff::DiffNode) && diff.normative?
768
+ end
769
+ end
770
+
531
771
  return false if @differences.nil? || @differences.empty?
532
772
 
533
773
  case @show_diffs