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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +50 -26
- data/README.adoc +8 -3
- data/docs/advanced/diff-pipeline.adoc +36 -9
- data/docs/features/diff-formatting/colors-and-symbols.adoc +82 -0
- data/docs/features/diff-formatting/index.adoc +12 -0
- data/docs/features/diff-formatting/themes.adoc +353 -0
- data/docs/features/environment-configuration/index.adoc +23 -0
- data/docs/internals/diff-char-range-pipeline.adoc +249 -0
- data/docs/internals/diffnode-enrichment.adoc +1 -0
- data/docs/internals/index.adoc +52 -4
- data/docs/reference/environment-variables.adoc +6 -0
- data/docs/understanding/architecture.adoc +5 -0
- data/examples/show_themes.rb +217 -0
- data/lib/canon/comparison/comparison_result.rb +9 -4
- data/lib/canon/config/env_schema.rb +3 -1
- data/lib/canon/config.rb +11 -0
- data/lib/canon/diff/diff_block.rb +7 -0
- data/lib/canon/diff/diff_block_builder.rb +2 -2
- data/lib/canon/diff/diff_char_range.rb +140 -0
- data/lib/canon/diff/diff_line.rb +42 -4
- data/lib/canon/diff/diff_line_builder.rb +907 -0
- data/lib/canon/diff/diff_node.rb +5 -1
- data/lib/canon/diff/diff_node_enricher.rb +1418 -0
- data/lib/canon/diff/diff_node_mapper.rb +54 -0
- data/lib/canon/diff/source_locator.rb +105 -0
- data/lib/canon/diff/text_decomposer.rb +103 -0
- data/lib/canon/diff_formatter/by_line/base_formatter.rb +264 -24
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +35 -20
- data/lib/canon/diff_formatter/by_line/json_formatter.rb +36 -19
- data/lib/canon/diff_formatter/by_line/simple_formatter.rb +33 -19
- data/lib/canon/diff_formatter/by_line/xml_formatter.rb +583 -98
- data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +36 -19
- data/lib/canon/diff_formatter/by_object/base_formatter.rb +62 -13
- data/lib/canon/diff_formatter/by_object/json_formatter.rb +59 -24
- data/lib/canon/diff_formatter/by_object/xml_formatter.rb +74 -34
- data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +4 -5
- data/lib/canon/diff_formatter/diff_detail_formatter.rb +1 -1
- data/lib/canon/diff_formatter/legend.rb +4 -2
- data/lib/canon/diff_formatter/theme.rb +864 -0
- data/lib/canon/diff_formatter.rb +11 -6
- data/lib/canon/tree_diff/matchers/hash_matcher.rb +16 -1
- data/lib/canon/tree_diff/matchers/similarity_matcher.rb +10 -0
- data/lib/canon/tree_diff/operations/operation_detector.rb +5 -1
- data/lib/canon/tree_diff/tree_diff_integrator.rb +1 -1
- data/lib/canon/version.rb +1 -1
- 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>]
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 ? "
|
|
353
|
-
new_str = 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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
"#{
|
|
585
|
+
"#{ln_old}#{pipe1}#{ln_new}#{colored_marker} #{pipe2} #{visualized_content}"
|
|
376
586
|
else
|
|
377
|
-
"#{
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 << "#{
|
|
421
|
-
output << "
|
|
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 << "#{
|
|
424
|
-
output << "
|
|
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 "
|
|
444
|
-
|
|
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
|