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
@@ -24,6 +24,9 @@ These documents are intended for:
24
24
  link:diffnode-enrichment[**DiffNode Enrichment**]::
25
25
  How DiffNode objects carry location, serialized content, and attribute metadata through the comparison pipeline. Covers PathBuilder (canonical paths with ordinal indices), NodeSerializer (library-agnostic serialization), and how Layer 2 algorithms populate metadata for Layer 4 rendering.
26
26
 
27
+ link:diff-char-range-pipeline[**DiffCharRange Pipeline**]::
28
+ How enriched DiffNodes are processed into character-level diff ranges for precise rendering. Covers DiffNodeEnricher (Phase 1: locating serialized content and decomposing changes), DiffLineBuilder (Phase 2: assembling DiffLines from enriched DiffNodes), DiffCharRange (character position value objects), TextDecomposer (prefix/suffix decomposition), and SourceLocator (finding content positions in source text).
29
+
27
30
  link:../advanced/dom-diff-internals[**DOM Diff Internals**]::
28
31
  Deep dive into Canon's default DOM diff algorithm: position-based matching, operation detection, and diff generation.
29
32
 
@@ -80,17 +83,23 @@ graph LR
80
83
  B -->|Enriches| D[DiffNode.path]
81
84
  C -->|Enriches| E[DiffNode.serialized_before/after]
82
85
  C -->|Enriches| F[DiffNode.attributes_before/after]
83
- D --> G[Layer 4: Rendering]
86
+ D --> G[Phase 1: DiffNodeEnricher]
84
87
  E --> G
85
88
  F --> G
86
- G --> H[Accurate diff output]
89
+ G -->|Enriches| H[DiffNode.char_ranges]
90
+ G -->|Enriches| I[DiffNode.line_range_before/after]
91
+ H --> J[Phase 2: DiffLineBuilder]
92
+ I --> J
93
+ J --> K[DiffLine with DiffCharRange[]]
94
+ K --> L[Layer 4: Rendering]
87
95
 
88
96
  style A fill:#fff4e1
89
97
  style D fill:#e1f5ff
90
- style G fill:#e1ffe1
98
+ style G fill:#ffe1f5
99
+ style K fill:#e1ffe1
91
100
  ----
92
101
 
93
- Key insight: Metadata is captured at diff creation time (Layer 2) and carried through to rendering (Layer 4), ensuring accurate display even if nodes are modified during comparison.
102
+ Key insight: Metadata is captured at diff creation time (Layer 2), enriched with character positions (Phase 1), assembled into display lines (Phase 2), and rendered without further computation (Layer 4).
94
103
 
95
104
  == Data Structures
96
105
 
@@ -112,11 +121,50 @@ class DiffNode
112
121
  attr_accessor :serialized_after # Serialized "after" content
113
122
  attr_accessor :attributes_before # Normalized "before" attributes
114
123
  attr_accessor :attributes_after # Normalized "after" attributes
124
+
125
+ # Character-level enrichment (Phase 1 enrichment pipeline)
126
+ attr_accessor :char_ranges # Array<DiffCharRange>
127
+ attr_accessor :line_range_before # [start_line, end_line] in text1
128
+ attr_accessor :line_range_after # [start_line, end_line] in text2
115
129
  end
116
130
  ----
117
131
 
118
132
  See link:diffnode-enrichment[DiffNode Enrichment] for details.
119
133
 
134
+ === DiffLine
135
+
136
+ Represents a single line in the diff output, linked to semantic DiffNode and character-level DiffCharRanges:
137
+
138
+ [source,ruby]
139
+ ----
140
+ class DiffLine
141
+ attr_reader :line_number, :new_position, :content, :type, :diff_node,
142
+ :char_ranges, :new_char_ranges, :new_content
143
+ attr_accessor :old_content, :formatting
144
+
145
+ # type: :unchanged, :added, :removed, :changed
146
+ # char_ranges: text1 side DiffCharRange[]
147
+ # new_char_ranges: text2 side DiffCharRange[]
148
+ # new_content: text2 line text (for :changed lines)
149
+ end
150
+ ----
151
+
152
+ === DiffCharRange
153
+
154
+ Character-level position within a source line, linked to a DiffNode:
155
+
156
+ [source,ruby]
157
+ ----
158
+ class DiffCharRange
159
+ attr_reader :line_number, :start_col, :end_col, :side, :status, :role,
160
+ :diff_node
161
+
162
+ # side: :old (text1) or :new (text2)
163
+ # status: :unchanged, :removed, :added, :changed_old, :changed_new
164
+ # role: :before, :changed, :after
165
+ end
166
+ ----
167
+
120
168
  === TreeNode (Semantic Diff)
121
169
 
122
170
  Canonical node representation from semantic diff:
@@ -142,6 +142,12 @@ export CANON_JSON_FORMAT_PREPROCESSING=normalize
142
142
  |`10000`
143
143
  |Maximum number of diff output lines
144
144
  |All formats
145
+
146
+ |`CANON_DIFF_THEME`
147
+ |symbol
148
+ |`:dark`
149
+ |Diff display theme: `light`, `dark`, `retro`, `claude`, `cyberpunk`
150
+ |All formats
145
151
  |===
146
152
 
147
153
  === Match Configuration Variables
@@ -889,6 +889,11 @@ class DiffNode
889
889
  attr_accessor :serialized_after # Serialized "after" content
890
890
  attr_accessor :attributes_before # Normalized "before" attributes
891
891
  attr_accessor :attributes_after # Normalized "after" attributes
892
+
893
+ # Character-level enrichment (populated by DiffNodeEnricher)
894
+ attr_accessor :char_ranges # Array<DiffCharRange> char positions
895
+ attr_accessor :line_range_before # [start_line, end_line] in text1
896
+ attr_accessor :line_range_after # [start_line, end_line] in text2
892
897
  end
893
898
  ----
894
899
 
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example script demonstrating all available diff display themes.
5
+ # Run with: bundle exec ruby examples/show_themes.rb
6
+ #
7
+ # This script shows how the same XML diff renders under each theme:
8
+ # - :light - Light terminal backgrounds
9
+ # - :dark - Dark terminal backgrounds (default)
10
+ # - :retro - Amber CRT phosphor look
11
+ # - :claude - Claude Code diff style (red/green backgrounds)
12
+ # - :cyberpunk - Neon on black, high contrast, futuristic
13
+
14
+ require "bundler/setup"
15
+ require "canon"
16
+ require "canon/diff_formatter"
17
+
18
+ # Sample XML documents with meaningful differences
19
+ # These documents have:
20
+ # - A deleted element (the <old-item>)
21
+ # - An added element (the <new-item>)
22
+ # - A changed attribute (version="1.0" -> version="2.0")
23
+ # - A changed text content ("Old Text" -> "New Text")
24
+ # - Whitespace-only change (formatting)
25
+ # - An informative comment difference
26
+
27
+ XML1 = <<~XML
28
+ <?xml version="1.0" encoding="UTF-8"?>
29
+ <document version="1.0">
30
+ <header>
31
+ <!-- Original comment -->
32
+ <title>Document Title</title>
33
+ </header>
34
+ <content>
35
+ <old-item name="thing1">Old Text</old-item>
36
+ <common-item>Shared content</common-item>
37
+ <common-item>More shared content</common-item>
38
+ </content>
39
+ <footer>
40
+ <note>Footer note</note>
41
+ </footer>
42
+ </document>
43
+ XML
44
+
45
+ XML2 = <<~XML
46
+ <?xml version="1.0" encoding="UTF-8"?>
47
+ <document version="2.0">
48
+ <header>
49
+ <!-- Updated comment -->
50
+ <title>Document Title</title>
51
+ </header>
52
+ <content>
53
+ <new-item name="thing2">New Text</new-item>
54
+ <common-item>Shared content</common-item>
55
+ <common-item>More shared content</common-item>
56
+ <extra-item>Additional item</extra-item>
57
+ </content>
58
+ <footer>
59
+ <note>Footer note</note>
60
+ </footer>
61
+ </document>
62
+ XML
63
+
64
+ # Helper to run diff with a specific theme and display mode
65
+ def run_diff_with_theme(theme_name, xml1, xml2, display_mode: :separate)
66
+ Canon::Config.reset!
67
+ Canon::Config.configure do |config|
68
+ config.xml.diff.theme = theme_name
69
+ end
70
+
71
+ # Use semantic diff to get all differences reported, even when documents
72
+ # are not equivalent. DOM diff stops at the first difference.
73
+ result = Canon::Comparison.equivalent?(xml1, xml2, diff_algorithm: :semantic,
74
+ verbose: true)
75
+
76
+ formatter = Canon::DiffFormatter.new(
77
+ use_color: true,
78
+ mode: :by_line,
79
+ show_diffs: :all,
80
+ diff_mode: display_mode,
81
+ )
82
+
83
+ formatter.format(result, :xml, doc1: xml1, doc2: xml2)
84
+ end
85
+
86
+ # Helper to print section header
87
+ def print_header(title)
88
+ puts
89
+ puts "=" * 70
90
+ puts title
91
+ puts "=" * 70
92
+ end
93
+
94
+ # Helper to strip ANSI codes for plain text display
95
+ def strip_ansi(text)
96
+ text.gsub(/\e\[[0-9;]*m/, "")
97
+ end
98
+
99
+ # Main demonstration
100
+ puts
101
+ puts "DIFF DISPLAY THEME DEMONSTRATION"
102
+ puts "=" * 70
103
+ puts
104
+ puts "This script demonstrates how the same XML diff renders under"
105
+ puts "each of the 5 available themes. Each theme has different:"
106
+ puts " - Color schemes (light vs dark backgrounds)"
107
+ puts " - Marker styles ([, ], <, >, -, +)"
108
+ puts " - Visual emphasis (backgrounds vs foreground colors)"
109
+ puts
110
+ puts "Sample documents have these differences:"
111
+ puts " - Attribute change: version=\"1.0\" -> version=\"2.0\""
112
+ puts " - Text change: <old-item> -> <new-item>"
113
+ puts " - Content change: \"Old Text\" -> \"New Text\""
114
+ puts " - Addition: <extra-item> added"
115
+ puts " - Comment change: informative difference"
116
+ puts
117
+
118
+ # Demonstrate each theme in both display modes
119
+ themes = {
120
+ light: "Light Theme (light backgrounds, red/green markers with light bg)",
121
+ dark: "Dark Theme (dark backgrounds, saturated red/green foregrounds)",
122
+ retro: "Retro Theme (amber CRT phosphor, monochrome amber with inverse video)",
123
+ claude: "Claude Theme (red/green backgrounds + white text, maximum visual pop)",
124
+ cyberpunk: "Cyberpunk Theme (neon magenta/cyan on black, electric, futuristic)",
125
+ }
126
+
127
+ display_modes = {
128
+ separate: "Separate lines (- / + on separate lines)",
129
+ inline: "Inline mode (* on same line, old→new)",
130
+ }
131
+
132
+ themes.each do |theme_name, description|
133
+ print_header("#{theme_name.upcase} THEME: #{description}")
134
+
135
+ puts
136
+ puts "Theme configuration:"
137
+ puts " Canon::Config.xml.diff.theme = :#{theme_name}"
138
+
139
+ display_modes.each do |mode, mode_description|
140
+ puts
141
+ puts "-" * 70
142
+ puts "DISPLAY MODE: #{mode.upcase} - #{mode_description}"
143
+ puts "-" * 70
144
+ puts "COLOR OUTPUT (with ANSI escape sequences):"
145
+
146
+ output = run_diff_with_theme(theme_name, XML1, XML2, display_mode: mode)
147
+ puts output
148
+
149
+ puts
150
+ puts "-" * 70
151
+ puts "PLAIN TEXT OUTPUT (without ANSI codes):"
152
+ puts strip_ansi(output)
153
+ end
154
+ end
155
+
156
+ # Summary table
157
+ print_header("THEME SUMMARY")
158
+ puts
159
+ puts "| Theme | Best For | Key Characteristics |"
160
+ puts "|------------|-------------------------------|--------------------------------|"
161
+ puts "| :light | Light terminal backgrounds | Light marker backgrounds |"
162
+ puts "| :dark | Dark terminals (default) | Saturated foreground colors |"
163
+ puts "| :retro | Low blue light / accessibility| Amber monochrome + inverse |"
164
+ puts "| :claude | Maximum visual pop | Red/green backgrounds |"
165
+ puts "| :cyberpunk | Neon / futuristic terminals | Magenta/cyan neon on black |"
166
+ puts
167
+
168
+ # How to use programmatically
169
+ print_header("HOW TO USE IN CODE")
170
+ puts
171
+ puts "# Set theme via configuration:"
172
+ puts "Canon::Config.configure do |config|"
173
+ puts " config.xml.diff.theme = :claude"
174
+ puts "end"
175
+ puts
176
+ puts "# Or via ENV variable:"
177
+ puts "ENV['CANON_DIFF_THEME'] = 'claude'"
178
+ puts
179
+ puts "# Or for a single diff, pass theme to formatter:"
180
+ puts "formatter = Canon::DiffFormatter.new("
181
+ puts " use_color: true,"
182
+ puts " mode: :by_line,"
183
+ puts " show_diffs: :all,"
184
+ puts " theme: :retro # if supported by the formatter"
185
+ puts ")"
186
+ puts
187
+
188
+ # Theme inheritance example
189
+ print_header("THEME INHERITANCE (advanced)")
190
+ puts
191
+ puts "# Create custom theme inheriting from :dark with overrides:"
192
+ puts "Canon::Config.configure do |config|"
193
+ puts " config.xml.diff.theme_inheritance = {"
194
+ puts " base: :dark,"
195
+ puts " overrides: {"
196
+ puts " diff: {"
197
+ puts " removed: { content: { bg: :light_red } }"
198
+ puts " }"
199
+ puts " }"
200
+ puts " }"
201
+ puts "end"
202
+ puts
203
+
204
+ # Note about Rainbow gem limitations
205
+ print_header("NOTE: RAINBOW GEM LIMITATIONS")
206
+ puts
207
+ puts "The Rainbow gem (used for terminal colors) doesn't support"
208
+ puts ":bright_black or :bright_white in standard 16-color mode."
209
+ puts "Themes substitute compatible colors:"
210
+ puts " - DARK theme uses :white instead of :bright_white"
211
+ puts " - LIGHT theme uses :black instead of :bright_black"
212
+ puts " - Comments use :cyan or :magenta instead of :bright_black"
213
+ puts
214
+
215
+ puts "=" * 70
216
+ puts "END OF THEME DEMONSTRATION"
217
+ puts "=" * 70
@@ -90,9 +90,11 @@ html_version: nil, match_options: nil, algorithm: :dom, original_strings: nil)
90
90
  # @param context_lines [Integer] Number of context lines to show
91
91
  # @param diff_grouping_lines [Integer] Maximum gap for grouping diffs
92
92
  # @param show_diffs [Symbol] Which diffs to show (:all, :normative, :informative)
93
+ # @param diff_mode [Symbol] Diff display mode (:separate, :inline)
94
+ # @param legacy_terminal [Boolean] Force legacy mode (no ANSI, separate-line only)
93
95
  # @return [String] Formatted diff output
94
96
  def diff(use_color: true, context_lines: 3, diff_grouping_lines: nil,
95
- show_diffs: :all)
97
+ show_diffs: :all, diff_mode: :separate, legacy_terminal: false)
96
98
  require_relative "../diff_formatter"
97
99
 
98
100
  formatter = Canon::DiffFormatter.new(
@@ -101,13 +103,16 @@ show_diffs: :all)
101
103
  context_lines: context_lines,
102
104
  diff_grouping_lines: diff_grouping_lines,
103
105
  show_diffs: show_diffs,
106
+ diff_mode: diff_mode,
107
+ legacy_terminal: legacy_terminal,
104
108
  )
105
109
 
110
+ # Pass self (ComparisonResult) so formatter can check equivalent? status
106
111
  formatter.format(
107
- @differences,
112
+ self,
108
113
  @format,
109
- doc1: @original_strings[0],
110
- doc2: @original_strings[1],
114
+ doc1: @preprocessed_strings[0],
115
+ doc2: @preprocessed_strings[1],
111
116
  html_version: @html_version,
112
117
  )
113
118
  end
@@ -18,6 +18,7 @@ module Canon
18
18
  show_preprocessed_inputs: :boolean,
19
19
  show_line_numbered_inputs: :boolean,
20
20
  display_format: :symbol,
21
+ theme: :symbol,
21
22
 
22
23
  # MatchConfig attributes
23
24
  profile: :symbol,
@@ -47,7 +48,8 @@ module Canon
47
48
  def all_diff_attributes
48
49
  %i[mode use_color context_lines grouping_lines show_diffs
49
50
  verbose_diff algorithm show_raw_inputs show_preprocessed_inputs
50
- show_line_numbered_inputs display_format max_file_size max_node_count max_diff_lines]
51
+ show_line_numbered_inputs display_format max_file_size max_node_count max_diff_lines
52
+ theme]
51
53
  end
52
54
 
53
55
  def all_match_attributes
data/lib/canon/config.rb CHANGED
@@ -262,6 +262,15 @@ module Canon
262
262
  @resolver.set_programmatic(:algorithm, value)
263
263
  end
264
264
 
265
+ # Theme name (:light, :dark, :retro, :claude)
266
+ def theme
267
+ @resolver.resolve(:theme)
268
+ end
269
+
270
+ def theme=(value)
271
+ @resolver.set_programmatic(:theme, value)
272
+ end
273
+
265
274
  # File size limit in bytes (default 5MB)
266
275
  def max_file_size
267
276
  @resolver.resolve(:max_file_size)
@@ -306,6 +315,7 @@ module Canon
306
315
  max_file_size: max_file_size,
307
316
  max_node_count: max_node_count,
308
317
  max_diff_lines: max_diff_lines,
318
+ theme: theme,
309
319
  }
310
320
  end
311
321
 
@@ -327,6 +337,7 @@ module Canon
327
337
  max_file_size: 5_242_880, # 5MB in bytes
328
338
  max_node_count: 10_000, # Maximum nodes in tree
329
339
  max_diff_lines: 10_000, # Maximum diff output lines
340
+ theme: :dark, # Default theme
330
341
  }
331
342
 
332
343
  env = format ? EnvProvider.load_diff_for_format(format) : {}
@@ -42,6 +42,13 @@ diff_node: nil)
42
42
  !normative?
43
43
  end
44
44
 
45
+ # @return [Boolean] true if all lines in this block are formatting-only
46
+ def formatting?
47
+ return false if diff_lines.empty?
48
+
49
+ diff_lines.all?(&:formatting?)
50
+ end
51
+
45
52
  # Check if this block contains a specific type of change
46
53
  def includes_type?(type)
47
54
  types.include?(type)
@@ -95,9 +95,9 @@ module Canon
95
95
  when :normative
96
96
  blocks.select(&:normative?)
97
97
  when :informative
98
- blocks.select(&:informative?)
98
+ blocks.select { |b| b.informative? && !b.formatting? }
99
99
  else # :all
100
- blocks
100
+ blocks.reject(&:formatting?)
101
101
  end
102
102
  end
103
103
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Diff
5
+ # Represents a character range within a source line, linked to a DiffNode.
6
+ #
7
+ # DiffCharRange is the core abstraction for character-level diff rendering.
8
+ # Each DiffNode produces one or more DiffCharRanges (before-text, changed-text,
9
+ # after-text) that tell the formatter exactly which characters to highlight.
10
+ #
11
+ # The formatter reads DiffCharRanges and applies colors — no computation needed.
12
+ #
13
+ # @example Text change "Hello World" → "Hello Universe"
14
+ # # Before-text (unchanged):
15
+ # DiffCharRange.new(line_number: 0, start_col: 9, end_col: 15,
16
+ # side: :old, status: :unchanged, role: :before, diff_node: dn)
17
+ # # Changed-text (old side):
18
+ # DiffCharRange.new(line_number: 0, start_col: 15, end_col: 20,
19
+ # side: :old, status: :changed_old, role: :changed, diff_node: dn)
20
+ # # Changed-text (new side):
21
+ # DiffCharRange.new(line_number: 0, start_col: 15, end_col: 23,
22
+ # side: :new, status: :changed_new, role: :changed, diff_node: dn)
23
+ class DiffCharRange
24
+ # @return [Integer] 0-based line index in text1 or text2
25
+ attr_reader :line_number
26
+
27
+ # @return [Integer] 0-based column offset (inclusive start)
28
+ attr_reader :start_col
29
+
30
+ # @return [Integer] exclusive end (half-open [start_col, end_col))
31
+ attr_reader :end_col
32
+
33
+ # @return [Symbol] :old (text1) or :new (text2)
34
+ attr_reader :side
35
+
36
+ # @return [Symbol] :unchanged, :removed, :added, :changed_old, :changed_new
37
+ attr_reader :status
38
+
39
+ # @return [Symbol] :before, :changed, :after
40
+ attr_reader :role
41
+
42
+ # @return [DiffNode, nil] the originating DiffNode
43
+ attr_reader :diff_node
44
+
45
+ # @param line_number [Integer] 0-based line index
46
+ # @param start_col [Integer] 0-based column (inclusive)
47
+ # @param end_col [Integer] exclusive end
48
+ # @param side [Symbol] :old or :new
49
+ # @param status [Symbol] :unchanged, :removed, :added, :changed_old, :changed_new
50
+ # @param role [Symbol] :before, :changed, :after
51
+ # @param diff_node [DiffNode, nil] originating DiffNode
52
+ def initialize(line_number:, start_col:, end_col:, side:, status:,
53
+ role: nil, diff_node: nil)
54
+ @line_number = line_number
55
+ @start_col = start_col
56
+ @end_col = end_col
57
+ @side = side
58
+ @status = status
59
+ @role = role
60
+ @diff_node = diff_node
61
+ end
62
+
63
+ # @return [Boolean] true if this range has zero length
64
+ def empty?
65
+ start_col >= end_col
66
+ end
67
+
68
+ # @return [Integer] number of characters in this range
69
+ def length
70
+ end_col - start_col
71
+ end
72
+
73
+ # @param line_length [Integer] total length of the line
74
+ # @return [Boolean] true if this range covers the entire line
75
+ def covers_entire_line?(line_length)
76
+ start_col.zero? && end_col >= line_length
77
+ end
78
+
79
+ # Extract the substring this range covers from a line
80
+ # @param line_text [String] the full line text
81
+ # @return [String] the substring, or "" if out of bounds
82
+ def extract_from(line_text)
83
+ return "" if line_text.nil? || empty?
84
+
85
+ line_text[start_col...end_col] || ""
86
+ end
87
+
88
+ # @return [Boolean] true if this is an old-side (text1) range
89
+ def old_side?
90
+ side == :old
91
+ end
92
+
93
+ # @return [Boolean] true if this is a new-side (text2) range
94
+ def new_side?
95
+ side == :new
96
+ end
97
+
98
+ # @return [Boolean] true if this range represents unchanged content
99
+ def unchanged?
100
+ status == :unchanged
101
+ end
102
+
103
+ # @return [Boolean] true if this range represents a change on the old side
104
+ def changed_old?
105
+ status == :changed_old
106
+ end
107
+
108
+ # @return [Boolean] true if this range represents a change on the new side
109
+ def changed_new?
110
+ status == :changed_new
111
+ end
112
+
113
+ # @return [Boolean] true if this range should be highlighted
114
+ def highlighted?
115
+ %i[changed_old changed_new removed added].include?(status)
116
+ end
117
+
118
+ def to_h
119
+ {
120
+ line_number: line_number,
121
+ start_col: start_col,
122
+ end_col: end_col,
123
+ side: side,
124
+ status: status,
125
+ role: role,
126
+ }
127
+ end
128
+
129
+ def ==(other)
130
+ other.is_a?(DiffCharRange) &&
131
+ line_number == other.line_number &&
132
+ start_col == other.start_col &&
133
+ end_col == other.end_col &&
134
+ side == other.side &&
135
+ status == other.status &&
136
+ role == other.role
137
+ end
138
+ end
139
+ end
140
+ end
@@ -3,26 +3,61 @@
3
3
  module Canon
4
4
  module Diff
5
5
  # Represents a single line in the diff output
6
- # Links textual representation to semantic DiffNode
6
+ # Links textual representation to semantic DiffNode and DiffCharRanges
7
7
  class DiffLine
8
- attr_reader :line_number, :new_position, :content, :type, :diff_node
8
+ attr_reader :line_number, :new_position, :content, :type, :diff_node,
9
+ :char_ranges, :new_char_ranges, :new_content
10
+ attr_accessor :old_content
9
11
  attr_writer :formatting
10
12
 
11
13
  # @param line_number [Integer] The 0-based line number in text1 (old text)
12
14
  # @param new_position [Integer, nil] The 0-based line number in text2 (new text),
13
15
  # used for :changed lines where old and new positions differ
14
- # @param content [String] The text content of the line
16
+ # @param content [String] The text content of the line (from text1)
15
17
  # @param type [Symbol] The type of line (:unchanged, :added, :removed, :changed)
16
18
  # @param diff_node [DiffNode, nil] The semantic diff node this line belongs to
17
19
  # @param formatting [Boolean] Whether this is a formatting-only difference
20
+ # @param char_ranges [Array<DiffCharRange>] Character ranges for text1 side
21
+ # @param new_char_ranges [Array<DiffCharRange>] Character ranges for text2 side
22
+ # @param new_content [String, nil] The text2 line content (for :changed lines)
23
+ # @param old_content [String, nil] Deprecated: multi-line old content
18
24
  def initialize(line_number:, content:, type:, diff_node: nil,
19
- formatting: false, new_position: nil)
25
+ formatting: false, new_position: nil, old_content: nil,
26
+ char_ranges: nil, new_char_ranges: nil, new_content: nil)
20
27
  @line_number = line_number
21
28
  @new_position = new_position
22
29
  @content = content
23
30
  @type = type
24
31
  @diff_node = diff_node
25
32
  @formatting = formatting
33
+ @old_content = old_content
34
+ @char_ranges = char_ranges || []
35
+ @new_char_ranges = new_char_ranges || []
36
+ @new_content = new_content
37
+ end
38
+
39
+ # Add a character range for the text1 (old) side
40
+ # @param char_range [DiffCharRange]
41
+ def add_char_range(char_range)
42
+ @char_ranges << char_range
43
+ end
44
+
45
+ # Add a character range for the text2 (new) side
46
+ # @param char_range [DiffCharRange]
47
+ def add_new_char_range(char_range)
48
+ @new_char_ranges << char_range
49
+ end
50
+
51
+ # @return [Boolean] true if this line has any character ranges
52
+ def has_char_ranges?
53
+ !@char_ranges.empty? || !@new_char_ranges.empty?
54
+ end
55
+
56
+ # Get character ranges for a specific side
57
+ # @param side [Symbol] :old or :new
58
+ # @return [Array<DiffCharRange>]
59
+ def char_ranges_for_side(side)
60
+ side == :old ? @char_ranges : @new_char_ranges
26
61
  end
27
62
 
28
63
  # @return [Boolean] true if this line represents a normative difference
@@ -77,11 +112,14 @@ module Canon
77
112
  line_number: line_number,
78
113
  new_position: new_position,
79
114
  content: content,
115
+ new_content: new_content,
80
116
  type: type,
81
117
  diff_node: diff_node&.to_h,
82
118
  normative: normative?,
83
119
  informative: informative?,
84
120
  formatting: formatting?,
121
+ char_ranges: @char_ranges.map(&:to_h),
122
+ new_char_ranges: @new_char_ranges.map(&:to_h),
85
123
  }
86
124
  end
87
125