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
@@ -0,0 +1,864 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ class DiffFormatter
5
+ # Theme definitions for diff display.
6
+ #
7
+ # Theme is a nested hash structure:
8
+ # - diff: removed/added/changed/unchanged/formatting/informative
9
+ # - xml: tag/attribute_name/attribute_value/text/comment/cdata
10
+ # - html: same as xml
11
+ # - structure: line_number/pipe/context
12
+ # - visualization: space/tab/newline/nbsp
13
+ # - display_mode: :separate/:inline/:mixed
14
+ #
15
+ # Each styled element has: color, bg, bold, underline, strikethrough, italic
16
+ module Theme
17
+ # Valid ANSI color values (standard 16 + common extended colors)
18
+ # Standard: 8 colors + 8 bright variants
19
+ # Extended: light_ variants (for backgrounds), amber (retro terminal)
20
+ VALID_COLORS = %i[
21
+ default black red green yellow blue magenta cyan white
22
+ bright_black bright_red bright_green bright_yellow bright_blue bright_magenta bright_cyan bright_white
23
+ light_red light_green light_blue light_cyan light_magenta light_yellow light_black light_white
24
+ amber
25
+ ].freeze
26
+
27
+ # Valid display modes
28
+ VALID_DISPLAY_MODES = %i[separate inline mixed].freeze
29
+
30
+ # Base properties for any styled element
31
+ STYLING_PROPERTIES = %i[color bg bold underline strikethrough
32
+ italic].freeze
33
+
34
+ # =====================================================================
35
+ # LIGHT THEME - Light terminal backgrounds, professional use
36
+ # =====================================================================
37
+ LIGHT = {
38
+ name: "Light",
39
+ description: "Light terminal backgrounds - professional, high contrast",
40
+
41
+ diff: {
42
+ removed: {
43
+ marker: { color: :red, bg: :light_red, bold: false },
44
+ content: { color: :red, bg: nil, bold: false, underline: false,
45
+ strikethrough: true },
46
+ },
47
+ added: {
48
+ marker: { color: :green, bg: :light_green, bold: false },
49
+ content: { color: :green, bg: nil, bold: false, underline: false,
50
+ strikethrough: false },
51
+ },
52
+ changed: {
53
+ marker: { color: :bright_red, bg: nil, bold: true },
54
+ content_old: { color: :bright_red, bg: nil, bold: true,
55
+ underline: false, strikethrough: true },
56
+ content_new: { color: :bright_green, bg: nil, bold: true,
57
+ underline: true, strikethrough: false },
58
+ },
59
+ unchanged: {
60
+ content: { color: :default, bg: nil, bold: false, underline: false,
61
+ strikethrough: false },
62
+ },
63
+ formatting: {
64
+ marker: { color: :bright_blue, bg: nil, bold: false },
65
+ content: { color: :bright_blue, bg: nil, bold: false,
66
+ underline: false, strikethrough: false },
67
+ },
68
+ informative: {
69
+ marker: { color: :bright_magenta, bg: nil, bold: false },
70
+ content: { color: :bright_magenta, bg: nil, bold: false,
71
+ underline: false, strikethrough: false },
72
+ },
73
+ },
74
+
75
+ xml: {
76
+ tag: { color: :bright_blue, bg: nil, bold: true, italic: false },
77
+ attribute_name: { color: :magenta, bg: nil, bold: false,
78
+ italic: false },
79
+ attribute_value: { color: :green, bg: nil, bold: false,
80
+ italic: false },
81
+ text: { color: :default, bg: nil, bold: false, italic: false },
82
+ comment: { color: :magenta, bg: nil, bold: false, italic: true },
83
+ cdata: { color: :yellow, bg: nil, bold: false, italic: false },
84
+ },
85
+
86
+ html: {
87
+ tag: { color: :bright_blue, bg: nil, bold: true, italic: false },
88
+ attribute_name: { color: :magenta, bg: nil, bold: false,
89
+ italic: false },
90
+ attribute_value: { color: :green, bg: nil, bold: false,
91
+ italic: false },
92
+ text: { color: :default, bg: nil, bold: false, italic: false },
93
+ comment: { color: :magenta, bg: nil, bold: false, italic: true },
94
+ cdata: { color: :yellow, bg: nil, bold: false, italic: false },
95
+ },
96
+
97
+ structure: {
98
+ line_number: { color: :black },
99
+ pipe: { color: :black },
100
+ context: { color: :default },
101
+ },
102
+
103
+ visualization: {
104
+ space: "░",
105
+ tab: "→",
106
+ newline: "¶",
107
+ nbsp: "␣",
108
+ },
109
+
110
+ display_mode: :separate,
111
+ }.freeze
112
+
113
+ # =====================================================================
114
+ # DARK THEME - Dark terminal backgrounds, developer favorite
115
+ # =====================================================================
116
+ DARK = {
117
+ name: "Dark",
118
+ description: "Dark terminal backgrounds - saturated colors, no backgrounds",
119
+
120
+ diff: {
121
+ removed: {
122
+ marker: { color: :red, bg: nil, bold: false },
123
+ content: { color: :red, bg: nil, bold: false, underline: false,
124
+ strikethrough: true },
125
+ },
126
+ added: {
127
+ marker: { color: :green, bg: nil, bold: false },
128
+ content: { color: :green, bg: nil, bold: false,
129
+ underline: false, strikethrough: false },
130
+ },
131
+ changed: {
132
+ marker: { color: :yellow, bg: nil, bold: true },
133
+ content_old: { color: :bright_red, bg: nil, bold: false,
134
+ underline: false, strikethrough: true },
135
+ content_new: { color: :bright_green, bg: nil, bold: false,
136
+ underline: true, strikethrough: false },
137
+ },
138
+ unchanged: {
139
+ content: { color: :default, bg: nil, bold: false, underline: false,
140
+ strikethrough: false },
141
+ },
142
+ formatting: {
143
+ marker: { color: :bright_blue, bg: nil, bold: false },
144
+ content: { color: :bright_blue, bg: nil, bold: false,
145
+ underline: false, strikethrough: false },
146
+ },
147
+ informative: {
148
+ marker: { color: :cyan, bg: nil, bold: false },
149
+ content: { color: :cyan, bg: nil, bold: false, underline: false,
150
+ strikethrough: false },
151
+ },
152
+ },
153
+
154
+ xml: {
155
+ tag: { color: :bright_blue, bg: nil, bold: true, italic: false },
156
+ attribute_name: { color: :magenta, bg: nil, bold: false,
157
+ italic: false },
158
+ attribute_value: { color: :green, bg: nil, bold: false,
159
+ italic: false },
160
+ text: { color: :default, bg: nil, bold: false, italic: false },
161
+ comment: { color: :cyan, bg: nil, bold: false, italic: true },
162
+ cdata: { color: :yellow, bg: nil, bold: false, italic: false },
163
+ },
164
+
165
+ html: {
166
+ tag: { color: :bright_blue, bg: nil, bold: true, italic: false },
167
+ attribute_name: { color: :magenta, bg: nil, bold: false,
168
+ italic: false },
169
+ attribute_value: { color: :green, bg: nil, bold: false,
170
+ italic: false },
171
+ text: { color: :default, bg: nil, bold: false, italic: false },
172
+ comment: { color: :cyan, bg: nil, bold: false, italic: true },
173
+ cdata: { color: :yellow, bg: nil, bold: false, italic: false },
174
+ },
175
+
176
+ structure: {
177
+ line_number: { color: :white },
178
+ pipe: { color: :white },
179
+ context: { color: :default },
180
+ },
181
+
182
+ visualization: {
183
+ space: "░",
184
+ tab: "→",
185
+ newline: "¶",
186
+ nbsp: "␣",
187
+ },
188
+
189
+ display_mode: :separate,
190
+ }.freeze
191
+
192
+ # =====================================================================
193
+ # RETRO THEME - Amber CRT, low blue light, accessibility
194
+ # =====================================================================
195
+ RETRO = {
196
+ name: "Retro",
197
+ description: "Amber CRT phosphor - monochromatic amber, low blue light, high accessibility",
198
+
199
+ diff: {
200
+ removed: {
201
+ # Bright amber on amber background = inverse video, highest emphasis
202
+ marker: { color: :bright_yellow, bg: :yellow, bold: true },
203
+ content: { color: :bright_yellow, bg: :yellow, bold: true,
204
+ underline: false, strikethrough: false },
205
+ },
206
+ added: {
207
+ # Bright white = less emphasis than removed, but distinct from normal text
208
+ marker: { color: :bright_white, bg: nil, bold: true },
209
+ content: { color: :bright_white, bg: nil, bold: false,
210
+ underline: false, strikethrough: false },
211
+ },
212
+ changed: {
213
+ marker: { color: :bright_yellow, bg: :yellow, bold: true },
214
+ content_old: { color: :bright_yellow, bg: :yellow, bold: true,
215
+ underline: false, strikethrough: true },
216
+ content_new: { color: :bright_white, bg: nil, bold: false,
217
+ underline: true, strikethrough: false },
218
+ },
219
+ unchanged: {
220
+ content: { color: :yellow, bg: nil, bold: false, underline: false,
221
+ strikethrough: false },
222
+ },
223
+ formatting: {
224
+ # Dimmer amber + strikethrough = clearly different from normal text
225
+ marker: { color: :yellow, bg: nil, bold: false,
226
+ strikethrough: true },
227
+ content: { color: :yellow, bg: nil, bold: false, underline: false,
228
+ strikethrough: true },
229
+ },
230
+ informative: {
231
+ # Bright amber + underline = distinct from formatting and normal
232
+ marker: { color: :bright_yellow, bg: nil, bold: true,
233
+ underline: true },
234
+ content: { color: :bright_yellow, bg: nil, bold: true,
235
+ underline: true, strikethrough: false },
236
+ },
237
+ },
238
+
239
+ xml: {
240
+ # Amber monochrome for all XML elements
241
+ tag: { color: :bright_yellow, bg: nil, bold: true, italic: false },
242
+ attribute_name: { color: :bright_yellow, bg: nil, bold: false,
243
+ italic: false },
244
+ attribute_value: { color: :bright_yellow, bg: nil, bold: false,
245
+ italic: false },
246
+ text: { color: :yellow, bg: nil, bold: false, italic: false },
247
+ comment: { color: :yellow, bg: nil, bold: false, italic: true },
248
+ cdata: { color: :bright_yellow, bg: nil, bold: false, italic: false },
249
+ },
250
+
251
+ html: {
252
+ tag: { color: :bright_yellow, bg: nil, bold: true, italic: false },
253
+ attribute_name: { color: :bright_yellow, bg: nil, bold: false,
254
+ italic: false },
255
+ attribute_value: { color: :bright_yellow, bg: nil, bold: false,
256
+ italic: false },
257
+ text: { color: :yellow, bg: nil, bold: false, italic: false },
258
+ comment: { color: :yellow, bg: nil, bold: false, italic: true },
259
+ cdata: { color: :bright_yellow, bg: nil, bold: false, italic: false },
260
+ },
261
+
262
+ structure: {
263
+ line_number: { color: :yellow },
264
+ pipe: { color: :yellow },
265
+ context: { color: :yellow },
266
+ },
267
+
268
+ visualization: {
269
+ space: "░",
270
+ tab: "→",
271
+ newline: "¶",
272
+ nbsp: "␣",
273
+ },
274
+
275
+ display_mode: :separate,
276
+ }.freeze
277
+
278
+ # =====================================================================
279
+ # CLAUDE THEME - Claude Code diff style, high contrast HUD
280
+ # =====================================================================
281
+ CLAUDE = {
282
+ name: "Claude",
283
+ description: "Claude Code diff style - red/green backgrounds, maximum visual pop",
284
+
285
+ diff: {
286
+ removed: {
287
+ # Red background + white text = immediate visual pop
288
+ marker: { color: :white, bg: :red, bold: true },
289
+ content: { color: :white, bg: :red, bold: false, underline: false,
290
+ strikethrough: false },
291
+ },
292
+ added: {
293
+ # Green background + white text (black invisible on dark terminals)
294
+ marker: { color: :white, bg: :green, bold: true },
295
+ content: { color: :white, bg: :green, bold: false,
296
+ underline: false, strikethrough: false },
297
+ },
298
+ changed: {
299
+ marker: { color: :white, bg: :magenta, bold: true },
300
+ content_old: { color: :bright_red, bg: nil, bold: false,
301
+ underline: false, strikethrough: true },
302
+ content_new: { color: :bright_green, bg: nil, bold: false,
303
+ underline: true, strikethrough: false },
304
+ },
305
+ unchanged: {
306
+ content: { color: :default, bg: nil, bold: false, underline: false,
307
+ strikethrough: false },
308
+ },
309
+ formatting: {
310
+ marker: { color: :yellow, bg: nil, bold: false },
311
+ content: { color: :yellow, bg: nil, bold: false, underline: false,
312
+ strikethrough: false },
313
+ },
314
+ informative: {
315
+ marker: { color: :bright_cyan, bg: nil, bold: false },
316
+ content: { color: :bright_cyan, bg: nil, bold: false,
317
+ underline: false, strikethrough: false },
318
+ },
319
+ },
320
+
321
+ xml: {
322
+ tag: { color: :bright_blue, bg: nil, bold: true, italic: false },
323
+ attribute_name: { color: :magenta, bg: nil, bold: false,
324
+ italic: false },
325
+ attribute_value: { color: :green, bg: nil, bold: false,
326
+ italic: false },
327
+ text: { color: :default, bg: nil, bold: false, italic: false },
328
+ comment: { color: :cyan, bg: nil, bold: false, italic: true },
329
+ cdata: { color: :yellow, bg: nil, bold: false, italic: false },
330
+ },
331
+
332
+ html: {
333
+ tag: { color: :bright_blue, bg: nil, bold: true, italic: false },
334
+ attribute_name: { color: :magenta, bg: nil, bold: false,
335
+ italic: false },
336
+ attribute_value: { color: :green, bg: nil, bold: false,
337
+ italic: false },
338
+ text: { color: :default, bg: nil, bold: false, italic: false },
339
+ comment: { color: :cyan, bg: nil, bold: false, italic: true },
340
+ cdata: { color: :yellow, bg: nil, bold: false, italic: false },
341
+ },
342
+
343
+ structure: {
344
+ line_number: { color: :yellow },
345
+ pipe: { color: :yellow },
346
+ context: { color: :default },
347
+ },
348
+
349
+ visualization: {
350
+ space: "░",
351
+ tab: "→",
352
+ newline: "¶",
353
+ nbsp: "␣",
354
+ },
355
+
356
+ display_mode: :separate,
357
+ }.freeze
358
+
359
+ # =====================================================================
360
+ # CYBERPUNK THEME - Neon on black, high contrast, futuristic
361
+ # =====================================================================
362
+ CYBERPUNK = {
363
+ name: "Cyberpunk",
364
+ description: "Neon on black - high contrast, futuristic, electric",
365
+
366
+ diff: {
367
+ removed: {
368
+ # Hot pink/magenta neon for deletions
369
+ marker: { color: :bright_magenta, bg: nil, bold: true },
370
+ content: { color: :bright_magenta, bg: nil, bold: true,
371
+ underline: false, strikethrough: true },
372
+ },
373
+ added: {
374
+ # Electric cyan neon for additions
375
+ marker: { color: :bright_cyan, bg: nil, bold: true },
376
+ content: { color: :bright_cyan, bg: nil, bold: true,
377
+ underline: false, strikethrough: false },
378
+ },
379
+ changed: {
380
+ # Yellow warning neon for change markers
381
+ marker: { color: :bright_yellow, bg: nil, bold: true },
382
+ content_old: { color: :bright_magenta, bg: nil, bold: false,
383
+ underline: false, strikethrough: true },
384
+ content_new: { color: :bright_cyan, bg: nil, bold: false,
385
+ underline: true, strikethrough: false },
386
+ },
387
+ unchanged: {
388
+ content: { color: :default, bg: nil, bold: false, underline: false,
389
+ strikethrough: false },
390
+ },
391
+ formatting: {
392
+ # Dim green for low-priority formatting
393
+ marker: { color: :green, bg: nil, bold: false },
394
+ content: { color: :green, bg: nil, bold: false, underline: false,
395
+ strikethrough: false },
396
+ },
397
+ informative: {
398
+ # Bright yellow neon for informative
399
+ marker: { color: :bright_yellow, bg: nil, bold: true },
400
+ content: { color: :bright_yellow, bg: nil, bold: false,
401
+ underline: false, strikethrough: false },
402
+ },
403
+ },
404
+
405
+ xml: {
406
+ # Tags in bright cyan, attributes in hot magenta
407
+ tag: { color: :bright_cyan, bg: nil, bold: true, italic: false },
408
+ attribute_name: { color: :bright_magenta, bg: nil, bold: false,
409
+ italic: false },
410
+ attribute_value: { color: :bright_green, bg: nil, bold: false,
411
+ italic: false },
412
+ text: { color: :default, bg: nil, bold: false, italic: false },
413
+ comment: { color: :green, bg: nil, bold: false, italic: true },
414
+ cdata: { color: :bright_yellow, bg: nil, bold: false, italic: false },
415
+ },
416
+
417
+ html: {
418
+ tag: { color: :bright_cyan, bg: nil, bold: true, italic: false },
419
+ attribute_name: { color: :bright_magenta, bg: nil, bold: false,
420
+ italic: false },
421
+ attribute_value: { color: :bright_green, bg: nil, bold: false,
422
+ italic: false },
423
+ text: { color: :default, bg: nil, bold: false, italic: false },
424
+ comment: { color: :green, bg: nil, bold: false, italic: true },
425
+ cdata: { color: :bright_yellow, bg: nil, bold: false, italic: false },
426
+ },
427
+
428
+ structure: {
429
+ line_number: { color: :bright_cyan },
430
+ pipe: { color: :bright_cyan },
431
+ context: { color: :default },
432
+ },
433
+
434
+ visualization: {
435
+ space: "░",
436
+ tab: "→",
437
+ newline: "¶",
438
+ nbsp: "␣",
439
+ },
440
+
441
+ display_mode: :separate,
442
+ }.freeze
443
+
444
+ # Registry of all themes
445
+ THEMES = {
446
+ light: LIGHT,
447
+ dark: DARK,
448
+ retro: RETRO,
449
+ claude: CLAUDE,
450
+ cyberpunk: CYBERPUNK,
451
+ }.freeze
452
+
453
+ # =====================================================================
454
+ # Theme Inheritance Helper
455
+ # =====================================================================
456
+
457
+ # Create a new theme by inheriting from a base theme and merging overrides
458
+ # @param base_name [Symbol] Name of base theme (:light, :dark, :retro, :claude)
459
+ # @return [ThemeInheritance] Inheritance builder for chaining
460
+ def self.inherit_from(base_name)
461
+ ThemeInheritance.new(base_name)
462
+ end
463
+
464
+ # Theme inheritance builder
465
+ class ThemeInheritance
466
+ def initialize(base_name)
467
+ unless THEMES.key?(base_name)
468
+ raise ArgumentError,
469
+ "Unknown theme: #{base_name}. Valid: #{THEMES.keys}"
470
+ end
471
+
472
+ @base_name = base_name
473
+ @overrides = {}
474
+ end
475
+
476
+ # Add overrides to the inherited theme
477
+ # @param overrides [Hash] Nested hash of overrides
478
+ # @return [self] for chaining
479
+ def merge(overrides)
480
+ deep_merge!(@overrides, overrides)
481
+ self
482
+ end
483
+
484
+ # Build the final theme hash
485
+ # @return [Hash] Merged theme
486
+ def build
487
+ base = deep_dup(THEMES[@base_name])
488
+ deep_merge!(base, @overrides)
489
+ base
490
+ end
491
+
492
+ # Shorthand for merge + build
493
+ def merge!(overrides)
494
+ merge(overrides)
495
+ build
496
+ end
497
+
498
+ private
499
+
500
+ # Delegate to module-level deep_dup
501
+ def deep_dup(obj)
502
+ Theme.deep_dup(obj)
503
+ end
504
+
505
+ def deep_merge!(target, source)
506
+ source.each do |key, value|
507
+ if value.is_a?(Hash) && target[key].is_a?(Hash)
508
+ deep_merge!(target[key], value)
509
+ else
510
+ target[key] = deep_dup(value)
511
+ end
512
+ end
513
+ end
514
+ end
515
+
516
+ # =====================================================================
517
+ # Theme Validation
518
+ # =====================================================================
519
+
520
+ # Validation result
521
+ ValidationResult = Struct.new(:valid, :missing_keys, :extra_keys,
522
+ :invalid_values, keyword_init: true)
523
+
524
+ # Validate a theme hash has all required keys and valid values
525
+ # @param theme [Hash] Theme hash to validate
526
+ # @return [ValidationResult]
527
+ def self.validate(theme)
528
+ missing_keys = []
529
+ extra_keys = []
530
+ invalid_values = []
531
+
532
+ # Check top-level keys
533
+ required_toplevel = %i[name description diff xml html structure
534
+ visualization display_mode]
535
+ required_toplevel.each do |key|
536
+ missing_keys << "top-level.#{key}" unless theme.key?(key)
537
+ end
538
+
539
+ # Validate diff section
540
+ if theme[:diff]
541
+ validate_diff_section(theme[:diff], missing_keys, extra_keys,
542
+ invalid_values)
543
+ end
544
+
545
+ # Validate xml section
546
+ if theme[:xml]
547
+ validate_xml_section(theme[:xml], missing_keys, extra_keys,
548
+ invalid_values)
549
+ end
550
+
551
+ # Validate html section
552
+ if theme[:html]
553
+ validate_xml_section(theme[:html], missing_keys, extra_keys,
554
+ invalid_values)
555
+ end
556
+
557
+ # Validate structure
558
+ if theme[:structure]
559
+ validate_structure_section(theme[:structure], missing_keys,
560
+ extra_keys, invalid_values)
561
+ end
562
+
563
+ # Validate visualization
564
+ if theme[:visualization]
565
+ validate_visualization_section(theme[:visualization], missing_keys,
566
+ extra_keys, invalid_values)
567
+ end
568
+
569
+ # Validate display_mode
570
+ if theme[:display_mode]
571
+ unless VALID_DISPLAY_MODES.include?(theme[:display_mode])
572
+ invalid_values << "display_mode must be one of #{VALID_DISPLAY_MODES}, got #{theme[:display_mode]}"
573
+ end
574
+ else
575
+ missing_keys << "display_mode"
576
+ end
577
+
578
+ ValidationResult.new(
579
+ valid: missing_keys.empty? && extra_keys.empty? && invalid_values.empty?,
580
+ missing_keys: missing_keys,
581
+ extra_keys: extra_keys,
582
+ invalid_values: invalid_values,
583
+ )
584
+ end
585
+
586
+ # Validate all predefined themes
587
+ # @return [Hash{Symbol => ValidationResult}]
588
+ def self.validate_all
589
+ THEMES.transform_values { |theme| validate(theme) }
590
+ end
591
+
592
+ # Get a theme by name
593
+ # @param name [Symbol] Theme name
594
+ # @return [Hash] Theme hash
595
+ # @raise [ArgumentError] if theme not found
596
+ # Deep copy a value, handling nested hashes and arrays
597
+ def self.deep_dup(obj)
598
+ case obj
599
+ when Hash
600
+ obj.transform_values { |v| deep_dup(v) }
601
+ when Array
602
+ obj.map { |v| deep_dup(v) }
603
+ when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
604
+ obj
605
+ else
606
+ begin
607
+ obj.dup
608
+ rescue StandardError
609
+ obj
610
+ end
611
+ end
612
+ end
613
+
614
+ def self.[](name)
615
+ theme = THEMES[name] || raise(ArgumentError,
616
+ "Unknown theme: #{name}. Valid: #{THEMES.keys}")
617
+ # Return a deep copy to prevent mutation of theme constants
618
+ deep_dup(theme)
619
+ end
620
+
621
+ # List available theme names
622
+ # @return [Array<Symbol>]
623
+ def self.names
624
+ THEMES.keys
625
+ end
626
+
627
+ # Check if theme name exists
628
+ # @param name [Symbol]
629
+ # @return [Boolean]
630
+ def self.include?(name)
631
+ THEMES.key?(name)
632
+ end
633
+
634
+ # =====================================================================
635
+ # Private: Section Validators
636
+ # =====================================================================
637
+
638
+ class << self
639
+ private
640
+
641
+ def validate_diff_section(diff, missing_keys, extra_keys,
642
+ invalid_values)
643
+ required_types = %i[removed added changed unchanged formatting
644
+ informative]
645
+
646
+ required_types.each do |type|
647
+ unless diff.key?(type)
648
+ missing_keys << "diff.#{type}"
649
+ next
650
+ end
651
+
652
+ section = diff[type]
653
+ validate_styling_section(section, "diff.#{type}", missing_keys,
654
+ extra_keys, invalid_values)
655
+ end
656
+
657
+ # Check for extra keys
658
+ extra = diff.keys - required_types
659
+ extra_keys.concat(extra.map { |k| "diff.#{k}" }) unless extra.empty?
660
+ end
661
+
662
+ def validate_xml_section(xml, missing_keys, extra_keys, invalid_values)
663
+ required_types = %i[tag attribute_name attribute_value text comment
664
+ cdata]
665
+
666
+ required_types.each do |type|
667
+ unless xml.key?(type)
668
+ missing_keys << "xml.#{type}"
669
+ next
670
+ end
671
+
672
+ section = xml[type]
673
+ validate_styling_section(section, "xml.#{type}", missing_keys,
674
+ extra_keys, invalid_values)
675
+ end
676
+ end
677
+
678
+ def validate_styling_section(section, path, missing_keys, _extra_keys,
679
+ invalid_values)
680
+ # Marker sections only need color, bg, bold
681
+ marker_props = %i[color bg bold]
682
+
683
+ if section.key?(:marker)
684
+ validate_style_properties(section[:marker], "#{path}.marker",
685
+ missing_keys, invalid_values, marker_props)
686
+ end
687
+
688
+ # Content sections need all styling properties except italic (not universally supported)
689
+ content_props = %i[color bg bold underline strikethrough]
690
+
691
+ if section.key?(:content)
692
+ validate_style_properties(section[:content], "#{path}.content",
693
+ missing_keys, invalid_values, content_props)
694
+ end
695
+
696
+ # changed section has content_old, content_new
697
+ if section.key?(:content_old)
698
+ validate_style_properties(section[:content_old],
699
+ "#{path}.content_old", missing_keys, invalid_values, content_props)
700
+ end
701
+
702
+ if section.key?(:content_new)
703
+ validate_style_properties(section[:content_new],
704
+ "#{path}.content_new", missing_keys, invalid_values, content_props)
705
+ end
706
+ end
707
+
708
+ # Required properties for structure elements (just color)
709
+ STRUCTURE_PROPERTIES = %i[color].freeze
710
+
711
+ def validate_structure_section(structure, missing_keys, _extra_keys,
712
+ invalid_values)
713
+ required = %i[line_number pipe context]
714
+
715
+ required.each do |key|
716
+ unless structure.key?(key)
717
+ missing_keys << "structure.#{key}"
718
+ next
719
+ end
720
+
721
+ section = structure[key]
722
+ validate_style_properties(section, "structure.#{key}",
723
+ missing_keys, invalid_values, STRUCTURE_PROPERTIES)
724
+ end
725
+ end
726
+
727
+ def validate_style_properties(style, path, missing_keys,
728
+ invalid_values, required_props = STYLING_PROPERTIES)
729
+ unless style.is_a?(Hash)
730
+ invalid_values << "#{path} must be a Hash, got #{style.class}"
731
+ return
732
+ end
733
+
734
+ required_props.each do |prop|
735
+ unless style.key?(prop)
736
+ missing_keys << "#{path}.#{prop}"
737
+ end
738
+ end
739
+
740
+ # Validate color (if present)
741
+ if style.key?(:color) && !VALID_COLORS.include?(style[:color])
742
+ invalid_values << "#{path}.color must be one of #{VALID_COLORS}, got #{style[:color]}"
743
+ end
744
+
745
+ # Validate bg
746
+ if style.key?(:bg) && !style[:bg].nil? && !VALID_COLORS.include?(style[:bg])
747
+ invalid_values << "#{path}.bg must be one of #{VALID_COLORS} or nil, got #{style[:bg]}"
748
+ end
749
+
750
+ # Validate booleans
751
+ %i[bold underline strikethrough italic].each do |prop|
752
+ next unless required_props.include?(prop)
753
+
754
+ if style.key?(prop) && ![true, false].include?(style[prop])
755
+ invalid_values << "#{path}.#{prop} must be true or false, got #{style[prop]}"
756
+ end
757
+ end
758
+ end
759
+
760
+ def validate_visualization_section(vis, missing_keys, _extra_keys,
761
+ _invalid_values)
762
+ required = %i[space tab newline nbsp]
763
+
764
+ required.each do |key|
765
+ unless vis.key?(key)
766
+ missing_keys << "visualization.#{key}"
767
+ end
768
+ end
769
+ end
770
+ end
771
+
772
+ # =====================================================================
773
+ # Theme Resolver - Resolves theme from Config + ENV
774
+ # =====================================================================
775
+
776
+ # Resolves the actual theme hash from configuration.
777
+ # Priority:
778
+ # 1. ENV['CANON_DIFF_THEME'] (highest)
779
+ # 2. config.xml.diff.theme
780
+ # 3. :dark default
781
+ #
782
+ # Also supports:
783
+ # - Theme inheritance via config.xml.diff.theme_inheritance
784
+ # - Custom theme via config.xml.diff.custom_theme
785
+ class Resolver
786
+ # Initialize with a config object (optional)
787
+ # @param config [Canon::Config, nil]
788
+ def initialize(config = nil)
789
+ @config = config
790
+ end
791
+
792
+ # Resolve the theme hash to use for rendering
793
+ # @return [Hash] Complete theme hash
794
+ def resolve
795
+ # Check ENV first
796
+ env_theme = resolve_from_env
797
+ return env_theme if env_theme
798
+
799
+ # Check config theme_inheritance (custom theme with base + overrides)
800
+ if @config.respond_to?(:xml) && @config.xml.diff.respond_to?(:theme_inheritance)
801
+ inheritance = @config.xml.diff.theme_inheritance
802
+ return resolve_inheritance_theme(inheritance) if inheritance
803
+ end
804
+
805
+ # Check config custom_theme (full custom theme hash)
806
+ if @config.respond_to?(:xml) && @config.xml.diff.respond_to?(:custom_theme)
807
+ custom = @config.xml.diff.custom_theme
808
+ return custom if custom.is_a?(Hash) && !custom.empty?
809
+ end
810
+
811
+ # Check config theme name
812
+ if @config.respond_to?(:xml) && @config.xml.diff.respond_to?(:theme)
813
+ theme_name = @config.xml.diff.theme
814
+ return Theme[theme_name] if Theme.include?(theme_name)
815
+ end
816
+
817
+ # Default to :dark
818
+ Theme[:dark]
819
+ end
820
+
821
+ # Get theme by name from ENV or config
822
+ # @return [Symbol] Theme name
823
+ def theme_name
824
+ # ENV takes precedence
825
+ env_name = ENV["CANON_DIFF_THEME"]&.to_sym
826
+ return env_name if env_name && Theme.include?(env_name)
827
+
828
+ # Check config
829
+ if @config.respond_to?(:xml) && @config.xml.diff.respond_to?(:theme)
830
+ theme_name = @config.xml.diff.theme
831
+ return theme_name if Theme.include?(theme_name)
832
+ end
833
+
834
+ # Default
835
+ :dark
836
+ end
837
+
838
+ private
839
+
840
+ def resolve_from_env
841
+ env_theme_name = ENV["CANON_DIFF_THEME"]&.to_sym
842
+ return nil unless env_theme_name
843
+ return nil unless Theme.include?(env_theme_name)
844
+
845
+ Theme[env_theme_name]
846
+ end
847
+
848
+ def resolve_inheritance_theme(inheritance)
849
+ base_name = inheritance[:base]
850
+ overrides = inheritance[:overrides] || {}
851
+
852
+ return Theme[base_name] if overrides.empty?
853
+
854
+ Theme.inherit_from(base_name).merge(overrides).build
855
+ end
856
+ end
857
+
858
+ # Singleton instance for convenience
859
+ def self.resolver(config = nil)
860
+ Resolver.new(config)
861
+ end
862
+ end
863
+ end
864
+ end