canon 0.1.3 → 0.1.5
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.yml +9 -1
- data/.rubocop_todo.yml +276 -7
- data/README.adoc +203 -138
- data/_config.yml +116 -0
- data/docs/ADVANCED_TOPICS.adoc +20 -0
- data/docs/BASIC_USAGE.adoc +16 -0
- data/docs/CHARACTER_VISUALIZATION.adoc +567 -0
- data/docs/CLI.adoc +493 -0
- data/docs/CUSTOMIZING_BEHAVIOR.adoc +19 -0
- data/docs/DIFF_ARCHITECTURE.adoc +435 -0
- data/docs/DIFF_FORMATTING.adoc +540 -0
- data/docs/FORMATS.adoc +447 -0
- data/docs/INDEX.adoc +222 -0
- data/docs/INPUT_VALIDATION.adoc +477 -0
- data/docs/MATCH_ARCHITECTURE.adoc +463 -0
- data/docs/MATCH_OPTIONS.adoc +719 -0
- data/docs/MODES.adoc +432 -0
- data/docs/NORMATIVE_INFORMATIVE_DIFFS.adoc +219 -0
- data/docs/OPTIONS.adoc +1387 -0
- data/docs/PREPROCESSING.adoc +491 -0
- data/docs/RSPEC.adoc +605 -0
- data/docs/RUBY_API.adoc +478 -0
- data/docs/SEMANTIC_DIFF_REPORT.adoc +528 -0
- data/docs/UNDERSTANDING_CANON.adoc +17 -0
- data/docs/VERBOSE.adoc +482 -0
- data/exe/canon +7 -0
- data/lib/canon/cli.rb +179 -0
- data/lib/canon/commands/diff_command.rb +195 -0
- data/lib/canon/commands/format_command.rb +113 -0
- data/lib/canon/comparison/base_comparator.rb +39 -0
- data/lib/canon/comparison/comparison_result.rb +79 -0
- data/lib/canon/comparison/html_comparator.rb +410 -0
- data/lib/canon/comparison/json_comparator.rb +212 -0
- data/lib/canon/comparison/match_options.rb +616 -0
- data/lib/canon/comparison/xml_comparator.rb +566 -0
- data/lib/canon/comparison/yaml_comparator.rb +93 -0
- data/lib/canon/comparison.rb +239 -0
- data/lib/canon/config.rb +172 -0
- data/lib/canon/diff/diff_block.rb +71 -0
- data/lib/canon/diff/diff_block_builder.rb +105 -0
- data/lib/canon/diff/diff_classifier.rb +46 -0
- data/lib/canon/diff/diff_context.rb +85 -0
- data/lib/canon/diff/diff_context_builder.rb +107 -0
- data/lib/canon/diff/diff_line.rb +77 -0
- data/lib/canon/diff/diff_node.rb +56 -0
- data/lib/canon/diff/diff_node_mapper.rb +148 -0
- data/lib/canon/diff/diff_report.rb +133 -0
- data/lib/canon/diff/diff_report_builder.rb +62 -0
- data/lib/canon/diff_formatter/by_line/base_formatter.rb +407 -0
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +672 -0
- data/lib/canon/diff_formatter/by_line/json_formatter.rb +284 -0
- data/lib/canon/diff_formatter/by_line/simple_formatter.rb +190 -0
- data/lib/canon/diff_formatter/by_line/xml_formatter.rb +860 -0
- data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +292 -0
- data/lib/canon/diff_formatter/by_object/base_formatter.rb +199 -0
- data/lib/canon/diff_formatter/by_object/json_formatter.rb +305 -0
- data/lib/canon/diff_formatter/by_object/xml_formatter.rb +248 -0
- data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +17 -0
- data/lib/canon/diff_formatter/character_map.yml +197 -0
- data/lib/canon/diff_formatter/debug_output.rb +431 -0
- data/lib/canon/diff_formatter/diff_detail_formatter.rb +551 -0
- data/lib/canon/diff_formatter/legend.rb +141 -0
- data/lib/canon/diff_formatter.rb +520 -0
- data/lib/canon/errors.rb +56 -0
- data/lib/canon/formatters/html4_formatter.rb +17 -0
- data/lib/canon/formatters/html5_formatter.rb +17 -0
- data/lib/canon/formatters/html_formatter.rb +37 -0
- data/lib/canon/formatters/html_formatter_base.rb +163 -0
- data/lib/canon/formatters/json_formatter.rb +3 -0
- data/lib/canon/formatters/xml_formatter.rb +20 -55
- data/lib/canon/formatters/yaml_formatter.rb +4 -1
- data/lib/canon/pretty_printer/html.rb +57 -0
- data/lib/canon/pretty_printer/json.rb +25 -0
- data/lib/canon/pretty_printer/xml.rb +29 -0
- data/lib/canon/rspec_matchers.rb +222 -80
- data/lib/canon/validators/base_validator.rb +49 -0
- data/lib/canon/validators/html_validator.rb +138 -0
- data/lib/canon/validators/json_validator.rb +89 -0
- data/lib/canon/validators/xml_validator.rb +53 -0
- data/lib/canon/validators/yaml_validator.rb +73 -0
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/attribute_handler.rb +80 -0
- data/lib/canon/xml/c14n.rb +36 -0
- data/lib/canon/xml/character_encoder.rb +38 -0
- data/lib/canon/xml/data_model.rb +225 -0
- data/lib/canon/xml/element_matcher.rb +196 -0
- data/lib/canon/xml/line_range_mapper.rb +158 -0
- data/lib/canon/xml/namespace_handler.rb +86 -0
- data/lib/canon/xml/node.rb +32 -0
- data/lib/canon/xml/nodes/attribute_node.rb +54 -0
- data/lib/canon/xml/nodes/comment_node.rb +23 -0
- data/lib/canon/xml/nodes/element_node.rb +56 -0
- data/lib/canon/xml/nodes/namespace_node.rb +38 -0
- data/lib/canon/xml/nodes/processing_instruction_node.rb +24 -0
- data/lib/canon/xml/nodes/root_node.rb +16 -0
- data/lib/canon/xml/nodes/text_node.rb +23 -0
- data/lib/canon/xml/processor.rb +151 -0
- data/lib/canon/xml/whitespace_normalizer.rb +72 -0
- data/lib/canon/xml/xml_base_handler.rb +188 -0
- data/lib/canon.rb +14 -3
- metadata +116 -21
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canon
|
|
4
|
+
module Comparison
|
|
5
|
+
# Matching Options for Canon Comparison
|
|
6
|
+
#
|
|
7
|
+
# Provides a two-phase architecture for controlling comparison behavior:
|
|
8
|
+
# 1. Preprocessing Phase: What to compare (none/c14n/normalize/format)
|
|
9
|
+
# 2. Matching Phase: How to compare (dimensions × behaviors)
|
|
10
|
+
#
|
|
11
|
+
# Format-specific modules define appropriate dimensions for each format:
|
|
12
|
+
# - Xml/Html: text_content, structural_whitespace, attribute_whitespace, comments
|
|
13
|
+
# - Json/Yaml: text_content, structural_whitespace, key_order, comments
|
|
14
|
+
|
|
15
|
+
# Wrapper class for resolved match options
|
|
16
|
+
# Provides convenient methods for accessing behaviors by dimension
|
|
17
|
+
class ResolvedMatchOptions
|
|
18
|
+
attr_reader :options, :format
|
|
19
|
+
|
|
20
|
+
def initialize(options, format:)
|
|
21
|
+
@options = options
|
|
22
|
+
@format = format
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Get the behavior for a specific dimension
|
|
26
|
+
# @param dimension [Symbol] The match dimension
|
|
27
|
+
# @return [Symbol] The behavior (:strict, :normalize, :ignore)
|
|
28
|
+
def behavior_for(dimension)
|
|
29
|
+
@options[dimension]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get the preprocessing option
|
|
33
|
+
# @return [Symbol] The preprocessing option
|
|
34
|
+
def preprocessing
|
|
35
|
+
@options[:preprocessing]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
@options.dup
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Module containing match option utilities and format-specific modules
|
|
44
|
+
module MatchOptions
|
|
45
|
+
# Preprocessing options - what to do before comparison
|
|
46
|
+
PREPROCESSING_OPTIONS = %i[none c14n normalize format rendered].freeze
|
|
47
|
+
|
|
48
|
+
# Matching behaviors (mutually exclusive)
|
|
49
|
+
MATCH_BEHAVIORS = %i[strict strip compact normalize ignore].freeze
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
# Apply match behavior to text comparison
|
|
53
|
+
#
|
|
54
|
+
# @param text1 [String] First text
|
|
55
|
+
# @param text2 [String] Second text
|
|
56
|
+
# @param behavior [Symbol] Match behavior (:strict, :normalize, :ignore)
|
|
57
|
+
# @return [Boolean] true if texts match according to behavior
|
|
58
|
+
def match_text?(text1, text2, behavior)
|
|
59
|
+
case behavior
|
|
60
|
+
when :strict
|
|
61
|
+
text1 == text2
|
|
62
|
+
when :normalize
|
|
63
|
+
normalize_text(text1) == normalize_text(text2)
|
|
64
|
+
when :ignore
|
|
65
|
+
true
|
|
66
|
+
else
|
|
67
|
+
raise Canon::Error, "Unknown match behavior: #{behavior}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Normalize text by collapsing whitespace and trimming
|
|
72
|
+
# Mimics HTML whitespace collapsing
|
|
73
|
+
#
|
|
74
|
+
# Handles both ASCII and Unicode whitespace characters including:
|
|
75
|
+
# - Regular space (U+0020)
|
|
76
|
+
# - Non-breaking space (U+00A0)
|
|
77
|
+
# - Other Unicode whitespace per \p{Space}
|
|
78
|
+
#
|
|
79
|
+
# @param text [String] Text to normalize
|
|
80
|
+
# @return [String] Normalized text
|
|
81
|
+
def normalize_text(text)
|
|
82
|
+
return "" if text.nil?
|
|
83
|
+
|
|
84
|
+
text.to_s
|
|
85
|
+
.gsub(/[\p{Space}\u00a0]+/, " ") # Collapse all whitespace to single space
|
|
86
|
+
.strip # Remove leading/trailing whitespace
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Process attribute value according to match behavior
|
|
90
|
+
#
|
|
91
|
+
# @param value [String] Attribute value to process
|
|
92
|
+
# @param behavior [Symbol] Match behavior (:strict, :strip, :compact, :normalize, :ignore)
|
|
93
|
+
# @return [String] Processed value
|
|
94
|
+
def process_attribute_value(value, behavior)
|
|
95
|
+
case behavior
|
|
96
|
+
when :strict
|
|
97
|
+
value.to_s
|
|
98
|
+
when :strip
|
|
99
|
+
value.to_s.strip
|
|
100
|
+
when :compact
|
|
101
|
+
value.to_s.gsub(/[\p{Space}\u00a0]+/, " ")
|
|
102
|
+
when :normalize
|
|
103
|
+
normalize_text(value)
|
|
104
|
+
when :ignore
|
|
105
|
+
""
|
|
106
|
+
else
|
|
107
|
+
raise Canon::Error, "Unknown attribute value behavior: #{behavior}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# XML/HTML-specific matching options
|
|
113
|
+
module Xml
|
|
114
|
+
# Matching dimensions for XML/HTML (collectively exhaustive)
|
|
115
|
+
MATCH_DIMENSIONS = %i[
|
|
116
|
+
text_content
|
|
117
|
+
structural_whitespace
|
|
118
|
+
attribute_presence
|
|
119
|
+
attribute_values
|
|
120
|
+
comments
|
|
121
|
+
].freeze
|
|
122
|
+
|
|
123
|
+
# Format-specific defaults
|
|
124
|
+
FORMAT_DEFAULTS = {
|
|
125
|
+
html: {
|
|
126
|
+
preprocessing: :rendered,
|
|
127
|
+
text_content: :normalize,
|
|
128
|
+
structural_whitespace: :normalize,
|
|
129
|
+
attribute_presence: :strict,
|
|
130
|
+
attribute_values: :strict,
|
|
131
|
+
comments: :ignore,
|
|
132
|
+
},
|
|
133
|
+
xml: {
|
|
134
|
+
preprocessing: :none,
|
|
135
|
+
text_content: :strict,
|
|
136
|
+
structural_whitespace: :strict,
|
|
137
|
+
attribute_presence: :strict,
|
|
138
|
+
attribute_values: :strict,
|
|
139
|
+
comments: :strict,
|
|
140
|
+
},
|
|
141
|
+
}.freeze
|
|
142
|
+
|
|
143
|
+
# Predefined match profiles for XML/HTML
|
|
144
|
+
MATCH_PROFILES = {
|
|
145
|
+
# Strict: Match exactly as written in source (XML default)
|
|
146
|
+
strict: {
|
|
147
|
+
preprocessing: :none,
|
|
148
|
+
text_content: :strict,
|
|
149
|
+
structural_whitespace: :strict,
|
|
150
|
+
attribute_presence: :strict,
|
|
151
|
+
attribute_values: :strict,
|
|
152
|
+
comments: :strict,
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
# Rendered: Match rendered output (HTML default)
|
|
156
|
+
# Mimics CSS whitespace collapsing
|
|
157
|
+
rendered: {
|
|
158
|
+
preprocessing: :none,
|
|
159
|
+
text_content: :normalize,
|
|
160
|
+
structural_whitespace: :normalize,
|
|
161
|
+
attribute_presence: :strict,
|
|
162
|
+
attribute_values: :strict,
|
|
163
|
+
comments: :ignore,
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
# HTML4: Match HTML4 rendered output
|
|
167
|
+
# HTML4 rendering normalizes attribute whitespace
|
|
168
|
+
html4: {
|
|
169
|
+
preprocessing: :rendered,
|
|
170
|
+
text_content: :normalize,
|
|
171
|
+
structural_whitespace: :normalize,
|
|
172
|
+
attribute_presence: :strict,
|
|
173
|
+
attribute_values: :normalize,
|
|
174
|
+
comments: :ignore,
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
# HTML5: Match HTML5 rendered output (same as rendered)
|
|
178
|
+
html5: {
|
|
179
|
+
preprocessing: :rendered,
|
|
180
|
+
text_content: :normalize,
|
|
181
|
+
structural_whitespace: :normalize,
|
|
182
|
+
attribute_presence: :strict,
|
|
183
|
+
attribute_values: :strict,
|
|
184
|
+
comments: :ignore,
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
# Spec-friendly: Formatting doesn't matter
|
|
188
|
+
# Uses :rendered preprocessing for HTML which normalizes via to_html
|
|
189
|
+
spec_friendly: {
|
|
190
|
+
preprocessing: :rendered,
|
|
191
|
+
text_content: :normalize,
|
|
192
|
+
structural_whitespace: :ignore,
|
|
193
|
+
attribute_presence: :strict,
|
|
194
|
+
attribute_values: :normalize,
|
|
195
|
+
comments: :ignore,
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
# Content-only: Only content matters
|
|
199
|
+
content_only: {
|
|
200
|
+
preprocessing: :c14n,
|
|
201
|
+
text_content: :normalize,
|
|
202
|
+
structural_whitespace: :ignore,
|
|
203
|
+
attribute_presence: :strict,
|
|
204
|
+
attribute_values: :normalize,
|
|
205
|
+
comments: :ignore,
|
|
206
|
+
},
|
|
207
|
+
}.freeze
|
|
208
|
+
|
|
209
|
+
class << self
|
|
210
|
+
# Resolve match options with precedence handling
|
|
211
|
+
#
|
|
212
|
+
# Precedence order (highest to lowest):
|
|
213
|
+
# 1. Explicit match parameter
|
|
214
|
+
# 2. Profile from match_profile parameter
|
|
215
|
+
# 3. Global configuration
|
|
216
|
+
# 4. Format-specific defaults
|
|
217
|
+
#
|
|
218
|
+
# @param format [Symbol] Format type (:xml or :html)
|
|
219
|
+
# @param match_profile [Symbol, nil] Profile name
|
|
220
|
+
# @param match [Hash, nil] Explicit options per dimension
|
|
221
|
+
# @param preprocessing [Symbol, nil] Preprocessing option
|
|
222
|
+
# @param global_profile [Symbol, nil] Global configured profile
|
|
223
|
+
# @param global_options [Hash, nil] Global configured options
|
|
224
|
+
# @return [Hash] Resolved options for all dimensions
|
|
225
|
+
def resolve(
|
|
226
|
+
format:,
|
|
227
|
+
match_profile: nil,
|
|
228
|
+
match: nil,
|
|
229
|
+
preprocessing: nil,
|
|
230
|
+
global_profile: nil,
|
|
231
|
+
global_options: nil
|
|
232
|
+
)
|
|
233
|
+
# Start with format-specific defaults
|
|
234
|
+
options = FORMAT_DEFAULTS[format]&.dup || FORMAT_DEFAULTS[:xml].dup
|
|
235
|
+
|
|
236
|
+
# Apply global profile if specified
|
|
237
|
+
if global_profile
|
|
238
|
+
profile_opts = get_profile_options(global_profile)
|
|
239
|
+
options.merge!(profile_opts)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Apply global options if specified
|
|
243
|
+
if global_options
|
|
244
|
+
validate_match_options!(global_options)
|
|
245
|
+
options.merge!(global_options)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Apply per-call profile if specified (overrides global)
|
|
249
|
+
if match_profile
|
|
250
|
+
profile_opts = get_profile_options(match_profile)
|
|
251
|
+
options.merge!(profile_opts)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Apply per-call preprocessing if specified (overrides profile)
|
|
255
|
+
if preprocessing
|
|
256
|
+
validate_preprocessing!(preprocessing)
|
|
257
|
+
options[:preprocessing] = preprocessing
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Apply per-call explicit options if specified (highest priority)
|
|
261
|
+
if match
|
|
262
|
+
validate_match_options!(match)
|
|
263
|
+
options.merge!(match)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
options
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Get options for a named profile
|
|
270
|
+
#
|
|
271
|
+
# @param profile [Symbol] Profile name
|
|
272
|
+
# @return [Hash] Profile options
|
|
273
|
+
# @raise [Canon::Error] If profile is unknown
|
|
274
|
+
def get_profile_options(profile)
|
|
275
|
+
unless MATCH_PROFILES.key?(profile)
|
|
276
|
+
raise Canon::Error,
|
|
277
|
+
"Unknown match profile: #{profile}. " \
|
|
278
|
+
"Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
|
|
279
|
+
end
|
|
280
|
+
MATCH_PROFILES[profile].dup
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
# Validate preprocessing option
|
|
286
|
+
def validate_preprocessing!(preprocessing)
|
|
287
|
+
unless MatchOptions::PREPROCESSING_OPTIONS.include?(preprocessing)
|
|
288
|
+
raise Canon::Error,
|
|
289
|
+
"Unknown preprocessing option: #{preprocessing}. " \
|
|
290
|
+
"Valid options: #{MatchOptions::PREPROCESSING_OPTIONS.join(', ')}"
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Validate match options
|
|
295
|
+
def validate_match_options!(match_options)
|
|
296
|
+
match_options.each do |dimension, behavior|
|
|
297
|
+
# Skip preprocessing as it's validated separately
|
|
298
|
+
next if dimension == :preprocessing
|
|
299
|
+
|
|
300
|
+
unless MATCH_DIMENSIONS.include?(dimension)
|
|
301
|
+
raise Canon::Error,
|
|
302
|
+
"Unknown match dimension: #{dimension}. " \
|
|
303
|
+
"Valid dimensions: #{MATCH_DIMENSIONS.join(', ')}"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
unless MatchOptions::MATCH_BEHAVIORS.include?(behavior)
|
|
307
|
+
raise Canon::Error,
|
|
308
|
+
"Unknown match behavior: #{behavior} for #{dimension}. " \
|
|
309
|
+
"Valid behaviors: #{MatchOptions::MATCH_BEHAVIORS.join(', ')}"
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# JSON-specific matching options
|
|
317
|
+
module Json
|
|
318
|
+
# Matching dimensions for JSON (collectively exhaustive)
|
|
319
|
+
MATCH_DIMENSIONS = %i[
|
|
320
|
+
text_content
|
|
321
|
+
structural_whitespace
|
|
322
|
+
key_order
|
|
323
|
+
].freeze
|
|
324
|
+
|
|
325
|
+
# Format defaults for JSON
|
|
326
|
+
FORMAT_DEFAULTS = {
|
|
327
|
+
json: {
|
|
328
|
+
preprocessing: :none,
|
|
329
|
+
text_content: :strict,
|
|
330
|
+
structural_whitespace: :ignore,
|
|
331
|
+
key_order: :strict,
|
|
332
|
+
},
|
|
333
|
+
}.freeze
|
|
334
|
+
|
|
335
|
+
# Predefined match profiles for JSON
|
|
336
|
+
MATCH_PROFILES = {
|
|
337
|
+
# Strict: Match exactly
|
|
338
|
+
strict: {
|
|
339
|
+
preprocessing: :none,
|
|
340
|
+
text_content: :strict,
|
|
341
|
+
structural_whitespace: :strict,
|
|
342
|
+
key_order: :strict,
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
# Spec-friendly: Formatting and order don't matter
|
|
346
|
+
spec_friendly: {
|
|
347
|
+
preprocessing: :normalize,
|
|
348
|
+
text_content: :strict,
|
|
349
|
+
structural_whitespace: :ignore,
|
|
350
|
+
key_order: :ignore,
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
# Content-only: Only values matter
|
|
354
|
+
content_only: {
|
|
355
|
+
preprocessing: :normalize,
|
|
356
|
+
text_content: :normalize,
|
|
357
|
+
structural_whitespace: :ignore,
|
|
358
|
+
key_order: :ignore,
|
|
359
|
+
},
|
|
360
|
+
}.freeze
|
|
361
|
+
|
|
362
|
+
class << self
|
|
363
|
+
# Resolve match options with precedence handling
|
|
364
|
+
#
|
|
365
|
+
# @param format [Symbol] Format type (:json)
|
|
366
|
+
# @param match_profile [Symbol, nil] Profile name
|
|
367
|
+
# @param match [Hash, nil] Explicit options per dimension
|
|
368
|
+
# @param preprocessing [Symbol, nil] Preprocessing option
|
|
369
|
+
# @param global_profile [Symbol, nil] Global configured profile
|
|
370
|
+
# @param global_options [Hash, nil] Global configured options
|
|
371
|
+
# @return [Hash] Resolved options for all dimensions
|
|
372
|
+
def resolve(
|
|
373
|
+
format:,
|
|
374
|
+
match_profile: nil,
|
|
375
|
+
match: nil,
|
|
376
|
+
preprocessing: nil,
|
|
377
|
+
global_profile: nil,
|
|
378
|
+
global_options: nil
|
|
379
|
+
)
|
|
380
|
+
# Start with format-specific defaults
|
|
381
|
+
options = FORMAT_DEFAULTS[:json].dup
|
|
382
|
+
|
|
383
|
+
# Apply global profile if specified
|
|
384
|
+
if global_profile
|
|
385
|
+
profile_opts = get_profile_options(global_profile)
|
|
386
|
+
options.merge!(profile_opts)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Apply global options if specified
|
|
390
|
+
if global_options
|
|
391
|
+
validate_match_options!(global_options)
|
|
392
|
+
options.merge!(global_options)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Apply per-call profile if specified (overrides global)
|
|
396
|
+
if match_profile
|
|
397
|
+
profile_opts = get_profile_options(match_profile)
|
|
398
|
+
options.merge!(profile_opts)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Apply per-call preprocessing if specified (overrides profile)
|
|
402
|
+
if preprocessing
|
|
403
|
+
validate_preprocessing!(preprocessing)
|
|
404
|
+
options[:preprocessing] = preprocessing
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Apply per-call explicit options if specified (highest priority)
|
|
408
|
+
if match
|
|
409
|
+
validate_match_options!(match)
|
|
410
|
+
options.merge!(match)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
options
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Get options for a named profile
|
|
417
|
+
#
|
|
418
|
+
# @param profile [Symbol] Profile name
|
|
419
|
+
# @return [Hash] Profile options
|
|
420
|
+
# @raise [Canon::Error] If profile is unknown
|
|
421
|
+
def get_profile_options(profile)
|
|
422
|
+
unless MATCH_PROFILES.key?(profile)
|
|
423
|
+
raise Canon::Error,
|
|
424
|
+
"Unknown match profile: #{profile}. " \
|
|
425
|
+
"Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
|
|
426
|
+
end
|
|
427
|
+
MATCH_PROFILES[profile].dup
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
private
|
|
431
|
+
|
|
432
|
+
# Validate preprocessing option
|
|
433
|
+
def validate_preprocessing!(preprocessing)
|
|
434
|
+
unless MatchOptions::PREPROCESSING_OPTIONS.include?(preprocessing)
|
|
435
|
+
raise Canon::Error,
|
|
436
|
+
"Unknown preprocessing option: #{preprocessing}. " \
|
|
437
|
+
"Valid options: #{MatchOptions::PREPROCESSING_OPTIONS.join(', ')}"
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Validate match options
|
|
442
|
+
def validate_match_options!(match_options)
|
|
443
|
+
match_options.each do |dimension, behavior|
|
|
444
|
+
# Skip preprocessing as it's validated separately
|
|
445
|
+
next if dimension == :preprocessing
|
|
446
|
+
|
|
447
|
+
unless MATCH_DIMENSIONS.include?(dimension)
|
|
448
|
+
raise Canon::Error,
|
|
449
|
+
"Unknown match dimension: #{dimension}. " \
|
|
450
|
+
"Valid dimensions: #{MATCH_DIMENSIONS.join(', ')}"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
unless MatchOptions::MATCH_BEHAVIORS.include?(behavior)
|
|
454
|
+
raise Canon::Error,
|
|
455
|
+
"Unknown match behavior: #{behavior} for #{dimension}. " \
|
|
456
|
+
"Valid behaviors: #{MatchOptions::MATCH_BEHAVIORS.join(', ')}"
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# YAML-specific matching options
|
|
464
|
+
module Yaml
|
|
465
|
+
# Matching dimensions for YAML (collectively exhaustive)
|
|
466
|
+
MATCH_DIMENSIONS = %i[
|
|
467
|
+
text_content
|
|
468
|
+
structural_whitespace
|
|
469
|
+
key_order
|
|
470
|
+
comments
|
|
471
|
+
].freeze
|
|
472
|
+
|
|
473
|
+
# Format defaults for YAML
|
|
474
|
+
FORMAT_DEFAULTS = {
|
|
475
|
+
yaml: {
|
|
476
|
+
preprocessing: :none,
|
|
477
|
+
text_content: :strict,
|
|
478
|
+
structural_whitespace: :ignore,
|
|
479
|
+
key_order: :strict,
|
|
480
|
+
comments: :ignore,
|
|
481
|
+
},
|
|
482
|
+
}.freeze
|
|
483
|
+
|
|
484
|
+
# Predefined match profiles for YAML
|
|
485
|
+
MATCH_PROFILES = {
|
|
486
|
+
# Strict: Match exactly
|
|
487
|
+
strict: {
|
|
488
|
+
preprocessing: :none,
|
|
489
|
+
text_content: :strict,
|
|
490
|
+
structural_whitespace: :strict,
|
|
491
|
+
key_order: :strict,
|
|
492
|
+
comments: :strict,
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
# Spec-friendly: Formatting and order don't matter
|
|
496
|
+
spec_friendly: {
|
|
497
|
+
preprocessing: :normalize,
|
|
498
|
+
text_content: :strict,
|
|
499
|
+
structural_whitespace: :ignore,
|
|
500
|
+
key_order: :ignore,
|
|
501
|
+
comments: :ignore,
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
# Content-only: Only values matter
|
|
505
|
+
content_only: {
|
|
506
|
+
preprocessing: :normalize,
|
|
507
|
+
text_content: :normalize,
|
|
508
|
+
structural_whitespace: :ignore,
|
|
509
|
+
key_order: :ignore,
|
|
510
|
+
comments: :ignore,
|
|
511
|
+
},
|
|
512
|
+
}.freeze
|
|
513
|
+
|
|
514
|
+
class << self
|
|
515
|
+
# Resolve match options with precedence handling
|
|
516
|
+
#
|
|
517
|
+
# @param format [Symbol] Format type (:yaml)
|
|
518
|
+
# @param match_profile [Symbol, nil] Profile name
|
|
519
|
+
# @param match [Hash, nil] Explicit options per dimension
|
|
520
|
+
# @param preprocessing [Symbol, nil] Preprocessing option
|
|
521
|
+
# @param global_profile [Symbol, nil] Global configured profile
|
|
522
|
+
# @param global_options [Hash, nil] Global configured options
|
|
523
|
+
# @return [Hash] Resolved options for all dimensions
|
|
524
|
+
def resolve(
|
|
525
|
+
format:,
|
|
526
|
+
match_profile: nil,
|
|
527
|
+
match: nil,
|
|
528
|
+
preprocessing: nil,
|
|
529
|
+
global_profile: nil,
|
|
530
|
+
global_options: nil
|
|
531
|
+
)
|
|
532
|
+
# Start with format-specific defaults
|
|
533
|
+
options = FORMAT_DEFAULTS[:yaml].dup
|
|
534
|
+
|
|
535
|
+
# Apply global profile if specified
|
|
536
|
+
if global_profile
|
|
537
|
+
profile_opts = get_profile_options(global_profile)
|
|
538
|
+
options.merge!(profile_opts)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Apply global options if specified
|
|
542
|
+
if global_options
|
|
543
|
+
validate_match_options!(global_options)
|
|
544
|
+
options.merge!(global_options)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Apply per-call profile if specified (overrides global)
|
|
548
|
+
if match_profile
|
|
549
|
+
profile_opts = get_profile_options(match_profile)
|
|
550
|
+
options.merge!(profile_opts)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Apply per-call preprocessing if specified (overrides profile)
|
|
554
|
+
if preprocessing
|
|
555
|
+
validate_preprocessing!(preprocessing)
|
|
556
|
+
options[:preprocessing] = preprocessing
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Apply per-call explicit options if specified (highest priority)
|
|
560
|
+
if match
|
|
561
|
+
validate_match_options!(match)
|
|
562
|
+
options.merge!(match)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
options
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Get options for a named profile
|
|
569
|
+
#
|
|
570
|
+
# @param profile [Symbol] Profile name
|
|
571
|
+
# @return [Hash] Profile options
|
|
572
|
+
# @raise [Canon::Error] If profile is unknown
|
|
573
|
+
def get_profile_options(profile)
|
|
574
|
+
unless MATCH_PROFILES.key?(profile)
|
|
575
|
+
raise Canon::Error,
|
|
576
|
+
"Unknown match profile: #{profile}. " \
|
|
577
|
+
"Valid profiles: #{MATCH_PROFILES.keys.join(', ')}"
|
|
578
|
+
end
|
|
579
|
+
MATCH_PROFILES[profile].dup
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
private
|
|
583
|
+
|
|
584
|
+
# Validate preprocessing option
|
|
585
|
+
def validate_preprocessing!(preprocessing)
|
|
586
|
+
unless MatchOptions::PREPROCESSING_OPTIONS.include?(preprocessing)
|
|
587
|
+
raise Canon::Error,
|
|
588
|
+
"Unknown preprocessing option: #{preprocessing}. " \
|
|
589
|
+
"Valid options: #{MatchOptions::PREPROCESSING_OPTIONS.join(', ')}"
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Validate match options
|
|
594
|
+
def validate_match_options!(match_options)
|
|
595
|
+
match_options.each do |dimension, behavior|
|
|
596
|
+
# Skip preprocessing as it's validated separately
|
|
597
|
+
next if dimension == :preprocessing
|
|
598
|
+
|
|
599
|
+
unless MATCH_DIMENSIONS.include?(dimension)
|
|
600
|
+
raise Canon::Error,
|
|
601
|
+
"Unknown match dimension: #{dimension}. " \
|
|
602
|
+
"Valid dimensions: #{MATCH_DIMENSIONS.join(', ')}"
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
unless MatchOptions::MATCH_BEHAVIORS.include?(behavior)
|
|
606
|
+
raise Canon::Error,
|
|
607
|
+
"Unknown match behavior: #{behavior} for #{dimension}. " \
|
|
608
|
+
"Valid behaviors: #{MatchOptions::MATCH_BEHAVIORS.join(', ')}"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|