lutaml-model 0.8.0 → 0.8.1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +9 -0
  3. data/.github/workflows/downstream-performance.yml +0 -3
  4. data/.rubocop_todo.yml +21 -187
  5. data/README.adoc +212 -15
  6. data/bench/bench_xmi.rb +6 -6
  7. data/bench/gate_config.rb +2 -9
  8. data/docs/_pages/configuration.adoc +155 -41
  9. data/docs/_pages/serialization_adapters.adoc +65 -14
  10. data/docs/index.adoc +3 -1
  11. data/docs/yamls_sequence.adoc +335 -0
  12. data/lib/lutaml/hash_format.rb +4 -0
  13. data/lib/lutaml/json.rb +4 -0
  14. data/lib/lutaml/model/adapter_resolver.rb +410 -0
  15. data/lib/lutaml/model/adapter_scope.rb +64 -0
  16. data/lib/lutaml/model/config.rb +84 -21
  17. data/lib/lutaml/model/configuration.rb +17 -249
  18. data/lib/lutaml/model/format_registry.rb +44 -117
  19. data/lib/lutaml/model/serialize/format_conversion.rb +42 -3
  20. data/lib/lutaml/model/serialize.rb +4 -2
  21. data/lib/lutaml/model/version.rb +1 -1
  22. data/lib/lutaml/model.rb +2 -0
  23. data/lib/lutaml/toml.rb +10 -3
  24. data/lib/lutaml/xml/serialization/instance_methods.rb +6 -0
  25. data/lib/lutaml/xml.rb +3 -4
  26. data/lib/lutaml/yaml.rb +4 -0
  27. data/lib/lutaml/yamls/adapter/mapping.rb +7 -0
  28. data/lib/lutaml/yamls/adapter/standard_adapter.rb +23 -2
  29. data/lib/lutaml/yamls/adapter/transform.rb +105 -7
  30. data/lib/lutaml/yamls/adapter/yamls_sequence.rb +20 -0
  31. data/lib/lutaml/yamls/adapter/yamls_sequence_rule.rb +48 -0
  32. data/lib/lutaml/yamls/adapter.rb +2 -0
  33. data/spec/fixtures/geolexica_v2_concept.rb +136 -0
  34. data/spec/fixtures/geolexica_v2_sample.yaml +36 -0
  35. data/spec/fixtures/geolexica_v2_sample2.yaml +38 -0
  36. data/spec/fixtures/yamls_range_concept.rb +139 -0
  37. data/spec/lutaml/model/xml_decoupling_spec.rb +5 -4
  38. data/spec/lutaml/model/yamls_range_spec.rb +393 -0
  39. data/spec/lutaml/model/yamls_sequence_spec.rb +245 -0
  40. data/spec/spec_helper.rb +5 -0
  41. metadata +13 -3
  42. data/bench/bench_uniword.rb +0 -69
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ # Single authority for adapter resolution: (format, type_name) → adapter class.
6
+ #
7
+ # Consolidates adapter metadata, loading, validation, and auto-detection
8
+ # that was previously spread across Config, Configuration, and FormatRegistry.
9
+ #
10
+ # Resolution chain (in order):
11
+ # 1. Thread-local AdapterScope override
12
+ # 2. Explicitly configured type (from Config.xml_adapter_type =)
13
+ # 3. Lazy auto-detected type (cached after first probe)
14
+ # 4. Default from format metadata
15
+ # 5. Raise FormatAdapterNotSpecifiedError
16
+ #
17
+ # @example Configure an adapter
18
+ # AdapterResolver.set_adapter_type(:xml, :nokogiri)
19
+ #
20
+ # @example Resolve an adapter
21
+ # adapter_class = AdapterResolver.adapter_for(:xml)
22
+ #
23
+ # @example Per-operation override
24
+ # adapter_class = AdapterResolver.resolved_adapter_class(:xml, :ox)
25
+ #
26
+ class AdapterResolver
27
+ class << self
28
+ # --- Resolution ---
29
+
30
+ # Resolve the adapter class for a format using the full resolution chain.
31
+ #
32
+ # @param format [Symbol] the format name (:xml, :json, etc.)
33
+ # @return [Class, nil] the adapter class or nil
34
+ def adapter_for(format)
35
+ # 1. Thread-local scope override
36
+ scope_override = AdapterScope.override_for(format)
37
+ if scope_override
38
+ return resolved_adapter_class(format, scope_override)
39
+ end
40
+
41
+ # 2. Explicitly configured type
42
+ configured = configured_type(format)
43
+ if configured
44
+ adapter = resolved[format]
45
+ return adapter if adapter
46
+ end
47
+
48
+ # 3. Lazy auto-detection (cached)
49
+ detected = detected_types[format]
50
+ if detected.nil? && !detected_formats.key?(format)
51
+ result = detect_adapter_for(format)
52
+ if result
53
+ detected_formats[format] = true
54
+ detected_types[format] = result
55
+ return load_and_cache(format, result)
56
+ end
57
+ detected_formats[format] = true
58
+ elsif detected
59
+ return load_and_cache(format, detected)
60
+ end
61
+
62
+ # 4. Default from metadata
63
+ default = metadata.dig(format, :default)
64
+ if default
65
+ return load_and_cache(format, default)
66
+ end
67
+
68
+ # 5. No adapter available
69
+ nil
70
+ end
71
+
72
+ # Set the adapter type for a format (validates and loads).
73
+ #
74
+ # @param format [Symbol] the format name
75
+ # @param type_name [Symbol] the adapter type name (:nokogiri, :ox, etc.)
76
+ # @return [Class] the loaded adapter class
77
+ def set_adapter_type(format, type_name)
78
+ type_name = type_name.to_sym
79
+ validate!(format, type_name)
80
+ configured_types[format] = type_name
81
+ resolved[format] = load_adapter(format, type_name)
82
+ end
83
+
84
+ # Store a pre-resolved adapter class for a format.
85
+ #
86
+ # Used by FormatRegistry.register for key-value formats that have
87
+ # a fixed adapter class (not selectable by type name).
88
+ #
89
+ # @param format [Symbol] the format name
90
+ # @param adapter_class [Class] the adapter class
91
+ # @return [void]
92
+ def set_adapter_class(format, adapter_class)
93
+ resolved[format] = adapter_class
94
+ configured_types[format] = :__fixed__
95
+ end
96
+
97
+ # Load and resolve an adapter class by type name.
98
+ # Used for per-operation overrides (from_xml adapter: :ox).
99
+ #
100
+ # @param format [Symbol] the format name
101
+ # @param type_name [Symbol, String] the adapter type name
102
+ # @return [Class] the adapter class
103
+ def resolved_adapter_class(format, type_name)
104
+ type_name = type_name.to_sym
105
+ validate!(format, type_name)
106
+ load_adapter(format, type_name)
107
+ end
108
+
109
+ # --- Metadata Registration ---
110
+
111
+ # Register adapter metadata for a format (available adapters, default).
112
+ #
113
+ # Called by FormatRegistry.register for formats with selectable adapters.
114
+ #
115
+ # @param format [Symbol] the format name
116
+ # @param options [Hash] { available: [...], default: :name }
117
+ # @return [void]
118
+ def register_metadata(format, options)
119
+ metadata[format] = options
120
+ end
121
+
122
+ # Register a fixed adapter class for a format with a single adapter.
123
+ #
124
+ # Used for key-value formats (json, yaml, hash, etc.) that have
125
+ # exactly one adapter. Creates metadata so validation works.
126
+ #
127
+ # @param format [Symbol] the format name
128
+ # @param adapter_class [Class] the adapter class
129
+ # @param adapter_name [Symbol] the adapter type name (e.g., :standard)
130
+ # @return [void]
131
+ def register_fixed(format, adapter_class, adapter_name)
132
+ metadata[format] = {
133
+ available: [adapter_name],
134
+ default: adapter_name,
135
+ }
136
+ resolved[format] = adapter_class
137
+ configured_types[format] = adapter_name
138
+ end
139
+
140
+ # Get the configured type name for a format.
141
+ #
142
+ # @param format [Symbol] the format name
143
+ # @return [Symbol, nil] the type name or nil
144
+ def configured_type(format)
145
+ configured_types[format]
146
+ end
147
+
148
+ # --- Reset ---
149
+
150
+ # Clear all state (for testing reset).
151
+ #
152
+ # @return [void]
153
+ def reset!
154
+ @metadata = nil
155
+ @configured_types = nil
156
+ @resolved = nil
157
+ @detected_types = nil
158
+ @detected_formats = nil
159
+ end
160
+
161
+ private
162
+
163
+ # Internal state accessors
164
+
165
+ def metadata
166
+ @metadata ||= {}
167
+ end
168
+
169
+ def configured_types
170
+ @configured_types ||= {}
171
+ end
172
+
173
+ def resolved
174
+ @resolved ||= {}
175
+ end
176
+
177
+ def detected_types
178
+ @detected_types ||= {}
179
+ end
180
+
181
+ def detected_formats
182
+ @detected_formats ||= {}
183
+ end
184
+
185
+ # Load adapter and cache the result.
186
+ #
187
+ # @param format [Symbol] the format name
188
+ # @param type_name [Symbol] the adapter type name
189
+ # @return [Class] the adapter class
190
+ def load_and_cache(format, type_name)
191
+ resolved[format] ||= load_adapter(format, type_name)
192
+ end
193
+
194
+ # Load an adapter file and resolve to its class.
195
+ #
196
+ # @param format [Symbol] the format name
197
+ # @param type_name [Symbol] the adapter type name
198
+ # @return [Class] the adapter class
199
+ def load_adapter(format, type_name)
200
+ adapter = format.to_s
201
+ type = normalize_type_name(type_name, format)
202
+
203
+ load_adapter_file(adapter, type)
204
+ load_moxml_adapter(type_name, format)
205
+
206
+ class_for(adapter, type)
207
+ end
208
+
209
+ # Normalize type name to file/class name pattern.
210
+ #
211
+ # @param type_name [Symbol] raw type name
212
+ # @param format [Symbol] format name
213
+ # @return [String] normalized type name (e.g., "nokogiri_adapter")
214
+ def normalize_type_name(type_name, format)
215
+ if type_name.to_s.start_with?("multi_json")
216
+ "multi_json_adapter"
217
+ else
218
+ "#{type_name.to_s.gsub("_#{format}", '')}_adapter"
219
+ end
220
+ end
221
+
222
+ # Load the adapter file.
223
+ #
224
+ # @param adapter [String] format name as string
225
+ # @param type [String] normalized type name
226
+ def load_adapter_file(adapter, type)
227
+ loader = FormatRegistry.adapter_loader_for(adapter.to_sym)
228
+ if loader.respond_to?(:load_adapter_file)
229
+ loader.load_adapter_file(adapter, type)
230
+ return
231
+ end
232
+
233
+ # Default key-value adapter loading
234
+ adapter_path = if RuntimeCompatibility.opal?
235
+ "lutaml/key_value/adapter/#{adapter}/#{type}"
236
+ else
237
+ File.join(File.dirname(__FILE__), "../key_value/adapter",
238
+ adapter, type)
239
+ end
240
+ require adapter_path
241
+ rescue LoadError
242
+ raise UnknownAdapterTypeError.new(adapter, type), cause: nil
243
+ end
244
+
245
+ # Load the Moxml adapter for XML and similar formats.
246
+ #
247
+ # @param type_name [Symbol] raw type name
248
+ # @param format [Symbol] format name
249
+ def load_moxml_adapter(type_name, format)
250
+ loader = FormatRegistry.adapter_loader_for(format)
251
+ if loader.respond_to?(:load_moxml_adapter)
252
+ loader.load_moxml_adapter(type_name,
253
+ format)
254
+ end
255
+ end
256
+
257
+ # Resolve the adapter class from the type name.
258
+ #
259
+ # @param adapter [String] format name as string
260
+ # @param type [String] normalized type name
261
+ # @return [Class] the adapter class
262
+ def class_for(adapter, type)
263
+ loader = FormatRegistry.adapter_loader_for(adapter.to_sym)
264
+ if loader.respond_to?(:class_for)
265
+ return loader.class_for(adapter, type)
266
+ end
267
+
268
+ # Default key-value adapter class resolution
269
+ KeyValue::Adapter.const_get(to_class_name(adapter))
270
+ .const_get(to_class_name(type))
271
+ end
272
+
273
+ # Validate that the adapter type is available for the format.
274
+ #
275
+ # @param format [Symbol] the format name
276
+ # @param type_name [Symbol] the adapter type name
277
+ # @raise [ArgumentError] if format or adapter is unknown
278
+ def validate!(format, type_name)
279
+ adapter_config = metadata[format]
280
+
281
+ unless adapter_config
282
+ all_formats = metadata.keys
283
+ available_formats = all_formats.map { |f| "`:#{f}`" }.join(", ")
284
+ raise ArgumentError,
285
+ "Unknown format: `:#{format}`. Available formats: #{available_formats}."
286
+ end
287
+
288
+ if format == :toml && type_name == :tomlib && RuntimeCompatibility.windows?
289
+ raise ArgumentError,
290
+ "The `:tomlib` adapter is not supported on Windows due to " \
291
+ "segmentation fault issues. Please use `:toml_rb` instead."
292
+ end
293
+
294
+ available = adapter_config[:available]
295
+ return unless available
296
+ return if available.include?(type_name)
297
+
298
+ available_list = available.map { |a| "`:#{a}`" }.join(", ")
299
+ closest = find_suggestion(type_name.to_s, available.map(&:to_s))
300
+
301
+ msg = "Unknown adapter: `:#{type_name}` for `:#{format}` format. " \
302
+ "Available adapters: #{available_list}."
303
+ msg += " Did you mean `:#{closest}`?" if closest
304
+
305
+ raise ArgumentError, msg
306
+ end
307
+
308
+ # Find closest string match for suggestions.
309
+ #
310
+ # @param input [String]
311
+ # @param candidates [Array<String>]
312
+ # @return [String, nil]
313
+ def find_suggestion(input, candidates)
314
+ return nil if input.nil? || input.empty?
315
+
316
+ candidates.min_by do |candidate|
317
+ levenshtein_distance(input.downcase, candidate.downcase)
318
+ end.tap do |closest|
319
+ max_dist = ([input.length, closest.length].max / 2) + 1
320
+ return nil if closest && levenshtein_distance(input.downcase,
321
+ closest.downcase) > max_dist
322
+ end
323
+ end
324
+
325
+ # Calculate Levenshtein distance.
326
+ #
327
+ # @param lhs [String]
328
+ # @param rhs [String]
329
+ # @return [Integer]
330
+ def levenshtein_distance(lhs, rhs)
331
+ return lhs.length if rhs.empty?
332
+ return rhs.length if lhs.empty?
333
+
334
+ matrix = Array.new(lhs.length + 1) do |i|
335
+ Array.new(rhs.length + 1) do |j|
336
+ if i.zero?
337
+ j
338
+ else
339
+ (j.zero? ? i : 0)
340
+ end
341
+ end
342
+ end
343
+
344
+ (1..lhs.length).each do |i|
345
+ (1..rhs.length).each do |j|
346
+ cost = lhs[i - 1] == rhs[j - 1] ? 0 : 1
347
+ matrix[i][j] = [
348
+ matrix[i - 1][j] + 1,
349
+ matrix[i][j - 1] + 1,
350
+ matrix[i - 1][j - 1] + cost,
351
+ ].min
352
+ end
353
+ end
354
+
355
+ matrix[lhs.length][rhs.length]
356
+ end
357
+
358
+ # Auto-detect available adapter for a format.
359
+ #
360
+ # @param format [Symbol] the format name
361
+ # @return [Symbol, nil] the detected adapter type name
362
+ def detect_adapter_for(format)
363
+ case format
364
+ when :xml
365
+ detect_xml_adapter
366
+ when :toml
367
+ detect_toml_adapter
368
+ else
369
+ metadata.dig(format, :default)
370
+ end
371
+ end
372
+
373
+ # Detect available XML adapter.
374
+ #
375
+ # @return [Symbol, nil] :nokogiri, :ox, :oga, :rexml, or nil
376
+ def detect_xml_adapter
377
+ return :oga if RuntimeCompatibility.opal?
378
+ return :nokogiri if Utils.safe_load("nokogiri", :Nokogiri)
379
+ return :ox if Utils.safe_load("ox", :Ox)
380
+ return :oga if Utils.safe_load("oga", :Oga)
381
+ return :rexml if Utils.safe_load("rexml", :REXML)
382
+
383
+ nil
384
+ end
385
+
386
+ # Detect available TOML adapter.
387
+ #
388
+ # @return [Symbol, nil] :tomlib, :toml_rb, or nil
389
+ def detect_toml_adapter
390
+ return nil if RuntimeCompatibility.opal?
391
+
392
+ if RuntimeCompatibility.windows?
393
+ return :toml_rb if Utils.safe_load("toml-rb", :TomlRb)
394
+
395
+ return nil
396
+ end
397
+
398
+ return :tomlib if Utils.safe_load("tomlib", :Tomlib)
399
+ return :toml_rb if Utils.safe_load("toml-rb", :TomlRb)
400
+
401
+ nil
402
+ end
403
+
404
+ def to_class_name(str)
405
+ str.to_s.split("_").map(&:capitalize).join
406
+ end
407
+ end
408
+ end
409
+ end
410
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ # Thread-local scoped adapter override stack.
6
+ #
7
+ # Provides block-scoped adapter overrides for testing and library stacking.
8
+ # Each thread has its own stack — no mutex needed.
9
+ #
10
+ # @example Testing with a specific adapter
11
+ # Lutaml::Model::Config.with_adapter(xml: :ox) do
12
+ # MyClass.from_xml(xml) # Uses Ox
13
+ # end
14
+ # # Outside the block, reverts to configured default
15
+ #
16
+ # @example Library stacking
17
+ # # Library A guarantees Ox internally
18
+ # def self.parse(data)
19
+ # Config.with_adapter(xml: :nokogiri) { MyModel.from_xml(data) }
20
+ # end
21
+ #
22
+ class AdapterScope
23
+ STACK_KEY = :__lutaml_adapter_scope_stack
24
+ EMPTY = {}.freeze
25
+
26
+ # Push adapter overrides and yield. Restores previous scope on exit.
27
+ #
28
+ # @param overrides [Hash{Symbol => Symbol}] format => adapter type name
29
+ # e.g., { xml: :ox, json: :oj }
30
+ # @yield block within which overrides are active
31
+ # @return [Object] the block's return value
32
+ def self.with(overrides)
33
+ stack = Thread.current[STACK_KEY] ||= []
34
+ stack.push(overrides)
35
+ yield
36
+ ensure
37
+ stack.pop
38
+ Thread.current[STACK_KEY] = nil if stack.empty?
39
+ end
40
+
41
+ # Return the current scope's overrides hash.
42
+ #
43
+ # @return [Hash{Symbol => Symbol}] current overrides or empty hash
44
+ def self.current
45
+ Thread.current[STACK_KEY]&.last || EMPTY
46
+ end
47
+
48
+ # Return the override for a specific format from the current scope.
49
+ #
50
+ # @param format [Symbol] the format name (:xml, :json, etc.)
51
+ # @return [Symbol, nil] the adapter type name or nil
52
+ def self.override_for(format)
53
+ current[format]
54
+ end
55
+
56
+ # Clear all scope state (for testing reset).
57
+ #
58
+ # @return [void]
59
+ def self.reset!
60
+ Thread.current[STACK_KEY] = nil
61
+ end
62
+ end
63
+ end
64
+ end
@@ -2,17 +2,13 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- # Configuration module - single entry point for all configuration
5
+ # Configuration module - single entry point for all configuration.
6
+ #
7
+ # Delegates adapter resolution to AdapterResolver and scoped overrides
8
+ # to AdapterScope. Keeps the configure block API for backward compatibility.
6
9
  module Config
7
10
  extend self
8
11
 
9
- # Bootstrap format lists for key-value adapter method definition.
10
- # At runtime, prefer FormatRegistry.formats for dynamic format discovery.
11
- # NOTE: XML is NOT listed here — it registers dynamically via FormatRegistry
12
- # when `require "lutaml/xml"` is called.
13
- AVAILABLE_FORMATS = %i[json jsonl yaml toml hash].freeze
14
- KEY_VALUE_FORMATS = AVAILABLE_FORMATS
15
-
16
12
  # Dynamic format discovery from FormatRegistry
17
13
  def available_formats
18
14
  FormatRegistry.formats
@@ -28,9 +24,26 @@ module Lutaml
28
24
  @instance ||= Configuration.new
29
25
  end
30
26
 
31
- # Adapter storage - used by FormatRegistry for dynamic format registration
32
- def adapters
33
- @adapters ||= {}
27
+ # Block-scoped adapter override (thread-safe).
28
+ #
29
+ # Pushes adapter overrides onto a thread-local stack for the duration
30
+ # of the block. Restores previous state on exit.
31
+ #
32
+ # @param overrides [Hash{Symbol => Symbol}] format => adapter type name
33
+ # @yield block within which overrides are active
34
+ # @return [Object] the block's return value
35
+ #
36
+ # @example Testing with a specific adapter
37
+ # Config.with_adapter(xml: :ox) do
38
+ # MyClass.from_xml(xml) # Uses Ox
39
+ # end
40
+ #
41
+ # @example Library stacking
42
+ # Config.with_adapter(xml: :nokogiri, toml: :tomlib) do
43
+ # MyModel.from_xml(data)
44
+ # end
45
+ def with_adapter(**overrides, &)
46
+ AdapterScope.with(overrides, &)
34
47
  end
35
48
 
36
49
  # Delegate configure to Configuration
@@ -39,24 +52,43 @@ module Lutaml
39
52
  self
40
53
  end
41
54
 
42
- # Set adapter for a format (used by FormatRegistry)
43
- def set_adapter_for(format, adapter)
44
- adapters[format] = adapter
55
+ # Get adapter class for a format using the full resolution chain.
56
+ #
57
+ # @param format [Symbol] the format name
58
+ # @return [Class, nil] the adapter class or nil
59
+ def adapter_for(format)
60
+ AdapterResolver.adapter_for(format)
45
61
  end
46
62
 
47
- # Get adapter for a format
48
- def adapter_for(format)
49
- adapters[format] || instance.get_adapter(format)
63
+ # Store a pre-resolved adapter class for a format.
64
+ #
65
+ # @param format [Symbol] the format name
66
+ # @param adapter [Class] the adapter class
67
+ def set_adapter_for(format, adapter)
68
+ AdapterResolver.set_adapter_class(format, adapter)
50
69
  end
51
70
 
52
- # Delegate adapter setters to Configuration
71
+ # Dynamic adapter type accessors for boot-time formats.
72
+ # Additional formats (xml, etc.) get their accessors registered
73
+ # via FormatRegistry.register which calls define_adapter_type_methods.
74
+ AVAILABLE_FORMATS = %i[json jsonl yaml toml hash yamls].freeze
75
+ KEY_VALUE_FORMATS = AVAILABLE_FORMATS
76
+
53
77
  AVAILABLE_FORMATS.each do |format|
78
+ define_method(:"#{format}_adapter") do
79
+ AdapterResolver.adapter_for(format)
80
+ end
81
+
82
+ define_method(:"#{format}_adapter=") do |adapter_klass|
83
+ AdapterResolver.set_adapter_class(format, adapter_klass)
84
+ end
85
+
54
86
  define_method(:"#{format}_adapter_type=") do |type_name|
55
- instance.set_adapter(format, type_name)
87
+ AdapterResolver.set_adapter_type(format, type_name)
56
88
  end
57
89
 
58
90
  define_method(:"#{format}_adapter_type") do
59
- instance.adapter_for(format)
91
+ AdapterResolver.configured_type(format)
60
92
  end
61
93
  end
62
94
 
@@ -84,7 +116,38 @@ module Lutaml
84
116
  instance.default_context_id = value
85
117
  end
86
118
 
87
- # Utility method
119
+ # Define dynamic adapter type accessor methods for a format.
120
+ # Called by FormatRegistry.register when a new format is registered.
121
+ #
122
+ # Defines two pairs of methods:
123
+ # - #{format}_adapter / #{format}_adapter= — adapter class getter/setter
124
+ # - #{format}_adapter_type / #{format}_adapter_type= — type name getter/setter
125
+ #
126
+ # @param format [Symbol] the format name
127
+ def define_adapter_type_methods(format)
128
+ return if respond_to?(:"#{format}_adapter_type=")
129
+
130
+ # Adapter class getter (returns Class)
131
+ define_method(:"#{format}_adapter") do
132
+ AdapterResolver.adapter_for(format)
133
+ end
134
+
135
+ # Adapter class setter (accepts Class)
136
+ define_method(:"#{format}_adapter=") do |adapter_klass|
137
+ AdapterResolver.set_adapter_class(format, adapter_klass)
138
+ end
139
+
140
+ # Adapter type name setter (accepts Symbol like :nokogiri)
141
+ define_method(:"#{format}_adapter_type=") do |type_name|
142
+ AdapterResolver.set_adapter_type(format, type_name)
143
+ end
144
+
145
+ # Adapter type name getter (returns Symbol or nil)
146
+ define_method(:"#{format}_adapter_type") do
147
+ AdapterResolver.configured_type(format)
148
+ end
149
+ end
150
+
88
151
  def to_class_name(str)
89
152
  str.to_s.split("_").map(&:capitalize).join
90
153
  end