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
|
@@ -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
|