lutaml-model 0.8.23 → 0.8.24

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/performance.yml +1 -1
  3. data/Gemfile +6 -0
  4. data/README.adoc +16 -7
  5. data/docs/_guides/index.adoc +4 -0
  6. data/docs/_guides/native-engines.adoc +61 -0
  7. data/docs/_pages/configuration.adoc +11 -7
  8. data/docs/_pages/serialization_adapters.adoc +14 -5
  9. data/lib/compat/opal/lutaml_model_boot.rb +2 -0
  10. data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
  11. data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
  12. data/lib/lutaml/json/adapter.rb +1 -0
  13. data/lib/lutaml/json/format.rb +1 -1
  14. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
  15. data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
  16. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
  17. data/lib/lutaml/key_value/adapter/toml.rb +1 -0
  18. data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
  19. data/lib/lutaml/key_value/transform.rb +82 -0
  20. data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
  21. data/lib/lutaml/key_value/transformation.rb +25 -1
  22. data/lib/lutaml/model/adapter_resolver.rb +23 -6
  23. data/lib/lutaml/model/attribute.rb +31 -6
  24. data/lib/lutaml/model/configuration.rb +5 -0
  25. data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
  26. data/lib/lutaml/model/serialize/format_conversion.rb +37 -0
  27. data/lib/lutaml/model/toml.rb +10 -6
  28. data/lib/lutaml/model/type/date_time.rb +2 -2
  29. data/lib/lutaml/model/type/value.rb +24 -2
  30. data/lib/lutaml/model/utils.rb +6 -5
  31. data/lib/lutaml/model/version.rb +1 -1
  32. data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
  33. data/lib/lutaml/toml/adapter.rb +1 -0
  34. data/lib/lutaml/toml/format.rb +1 -1
  35. data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
  36. data/lib/lutaml/xml/adapter_element.rb +111 -3
  37. data/lib/lutaml/xml/builder/base.rb +3 -1
  38. data/lib/lutaml/xml/builder/leptris.rb +13 -0
  39. data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
  40. data/lib/lutaml/xml/builder.rb +1 -0
  41. data/lib/lutaml/xml/format.rb +4 -1
  42. data/lib/lutaml/xml/leptris/element.rb +34 -0
  43. data/lib/lutaml/xml/plan_compiler.rb +139 -0
  44. data/lib/lutaml/xml/plan_hydrator.rb +80 -0
  45. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
  46. data/lib/lutaml/xml/xml_element.rb +6 -2
  47. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +40 -0
  48. data/lib/lutaml/yaml/adapter.rb +5 -0
  49. data/lib/lutaml/yaml/format.rb +1 -1
  50. data/lib/tasks/performance.rake +5 -0
  51. data/lib/tasks/performance_comparator.rb +19 -3
  52. data/lib/tasks/performance_probe.rake +33 -0
  53. data/lib/tasks/performance_probe.rb +106 -0
  54. data/lib/tasks/performance_probe_xml.rb +54 -0
  55. data/lutaml-model.gemspec +1 -1
  56. data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
  57. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
  58. data/spec/lutaml/model/json_adapter_spec.rb +6 -0
  59. data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
  60. data/spec/lutaml/model/toml_spec.rb +36 -1
  61. data/spec/lutaml/xml/plan_fast_path_spec.rb +96 -0
  62. data/spec/lutaml/xml/xml_spec.rb +13 -0
  63. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
  64. data/spec/spec_helper.rb +7 -1
  65. metadata +23 -4
@@ -286,7 +286,7 @@ module Lutaml
286
286
  if format == :toml && type_name == :tomlib && RuntimeCompatibility.windows?
287
287
  raise ArgumentError,
288
288
  "The `:tomlib` adapter is not supported on Windows due to " \
289
- "segmentation fault issues. Please use `:toml_rb` instead."
289
+ "segmentation fault issues. Please use `:teptris` or `:toml_rb` instead."
290
290
  end
291
291
 
292
292
  available = adapter_config[:available]
@@ -363,11 +363,23 @@ module Lutaml
363
363
  detect_xml_adapter
364
364
  when :toml
365
365
  detect_toml_adapter
366
+ when :yaml, :json
367
+ detect_yeptris_kv_adapter(format)
366
368
  else
367
369
  metadata.dig(format, :default)
368
370
  end
369
371
  end
370
372
 
373
+ # Native key-value engine preference: yeptris (libyeptris C11)
374
+ # when its gem is in the bundle, else the format's standard
375
+ # adapter. Opt-in by bundle contents — nothing is required of
376
+ # consumers that do not add yeptris.
377
+ def detect_yeptris_kv_adapter(format)
378
+ return metadata.dig(format, :default) if Lutaml::Model.opal?
379
+
380
+ Utils.safe_load("yeptris", :Yeptris) ? :yeptris : metadata.dig(format, :default)
381
+ end
382
+
371
383
  # Detect available XML adapter.
372
384
  #
373
385
  # Delegates to moxml which is the authority on XML adapter
@@ -380,17 +392,22 @@ module Lutaml
380
392
 
381
393
  # Detect available TOML adapter.
382
394
  #
383
- # @return [Symbol, nil] :tomlib, :toml_rb, or nil
395
+ # @return [Symbol, nil] :teptris, :tomlib, :toml_rb, or nil
384
396
  def detect_toml_adapter
385
397
  return nil if Lutaml::Model.opal?
386
398
 
387
- if RuntimeCompatibility.windows?
388
- return :toml_rb if Utils.safe_load("toml-rb", :TomlRb)
399
+ # teptris 0.2.12+ parses ~3x and dumps ~8x faster than
400
+ # tomlib and ships prebuilts for every platform (mingw-ucrt
401
+ # restored in 0.2.4+) — native TOML on Windows retires the
402
+ # tomlib segfault workaround (previously pure-Ruby toml-rb
403
+ # only).
404
+ return :teptris if Utils.safe_load("teptris", :Teptris)
389
405
 
390
- return nil
406
+ if !RuntimeCompatibility.windows? &&
407
+ Utils.safe_load("tomlib", :Tomlib)
408
+ return :tomlib
391
409
  end
392
410
 
393
- return :tomlib if Utils.safe_load("tomlib", :Tomlib)
394
411
  return :toml_rb if Utils.safe_load("toml-rb", :TomlRb)
395
412
 
396
413
  nil
@@ -31,6 +31,29 @@ module Lutaml
31
31
  union_member_types
32
32
  ].freeze
33
33
 
34
+ # Per-type-class memo of the custom from_xml/from_json probe used by #cast.
35
+ # Isolated cache holder: enclosing-class freezes (some suites freeze
36
+ # Lutaml::Model::Attribute) would freeze a constant Hash with it.
37
+ TypeProbeCache = ::Class.new do
38
+ class << self
39
+ def cache
40
+ @cache ||= {}
41
+ end
42
+ end
43
+ end
44
+
45
+ class << self
46
+ # Public reader for compiled rule plans: does this type class carry a
47
+ # custom from_xml/from_json (answer static, probed once)?
48
+ def custom_from_probe?(type)
49
+ TypeProbeCache.cache[type] ||= begin
50
+ base = Lutaml::Model::Type::Value.singleton_class
51
+ type.method(:from_xml).owner != base ||
52
+ type.method(:from_json).owner != base
53
+ end
54
+ end
55
+ end
56
+
34
57
  MODEL_STRINGS = [
35
58
  Lutaml::Model::Type::String,
36
59
  "String",
@@ -252,7 +275,9 @@ module Lutaml
252
275
  end
253
276
 
254
277
  def union?
255
- unresolved_type == Lutaml::Model::Type::Union
278
+ return @union unless @union.nil?
279
+
280
+ @union = unresolved_type == Lutaml::Model::Type::Union
256
281
  end
257
282
 
258
283
  def union_member_types
@@ -687,11 +712,11 @@ instance_object = nil)
687
712
 
688
713
  # Fast path for Type::Value subclasses (String, Integer, Boolean, etc.)
689
714
  # These are never Serializable, so skip expensive can_serialize? and needs_conversion? checks
690
- # Skip if type has custom from_xml/from_json methods (defined on the class itself, not inherited)
691
- if resolved_type.is_a?(Class) && resolved_type < Lutaml::Model::Type::Value
692
- has_custom_from_xml = resolved_type.method(:from_xml).owner != Lutaml::Model::Type::Value
693
- has_custom_from_json = resolved_type.method(:from_json).owner != Lutaml::Model::Type::Value
694
- return resolved_type.cast(value) unless has_custom_from_xml || has_custom_from_json
715
+ # Skip if type has custom from_xml/from_json methods (defined on the class itself, not inherited).
716
+ # The probe allocated two Method objects per cast call; the answer is
717
+ # static per type class, so it is resolved once.
718
+ if resolved_type.is_a?(Class) && resolved_type < Lutaml::Model::Type::Value && !self.class.custom_from_probe?(resolved_type)
719
+ return resolved_type.cast(value)
695
720
  end
696
721
 
697
722
  klass = resolve_polymorphic_class(resolved_type, value, options)
@@ -15,10 +15,15 @@ module Lutaml
15
15
  #
16
16
  class Configuration
17
17
  attr_reader :default_register
18
+ # Experimental: whole-document native materialization for plan-
19
+ # compilable XML models (Leptris::XML::Descriptor walk). Opt-in
20
+ # while the full semantics audit completes.
21
+ attr_accessor :xml_plan_fast_path
18
22
 
19
23
  def initialize
20
24
  @default_register = :default
21
25
  @configured = false
26
+ @xml_plan_fast_path = false
22
27
  end
23
28
 
24
29
  def configure
@@ -3,10 +3,10 @@ module Lutaml
3
3
  class UnknownAdapterTypeError < Error
4
4
  # Available adapters by format
5
5
  AVAILABLE_ADAPTERS = {
6
- xml: %w[nokogiri ox oga rexml],
7
- json: %w[standard multi_json oj],
8
- yaml: %w[standard],
9
- toml: %w[tomlib toml_rb],
6
+ xml: %w[nokogiri ox oga rexml leptris],
7
+ json: %w[standard multi_json oj yeptris],
8
+ yaml: %w[standard yeptris],
9
+ toml: %w[teptris tomlib toml_rb],
10
10
  hash: %w[standard],
11
11
  jsonl: %w[standard],
12
12
  yamls: %w[standard],
@@ -45,6 +45,11 @@ module Lutaml
45
45
  # @param options [Hash] Additional options
46
46
  # @return [Object] The deserialized model instance
47
47
  def from(format, data, options = {})
48
+ if format == :xml && Lutaml::Model::Config.instance.xml_plan_fast_path
49
+ fast = xml_plan_fast_path(data, options)
50
+ return fast if fast
51
+ end
52
+
48
53
  Instrumentation.instrument(:from, model: name, format: format) do
49
54
  adapter = resolve_adapter(format, options.delete(:adapter))
50
55
 
@@ -76,6 +81,36 @@ module Lutaml
76
81
  # No-op by default; XML overrides via prepend
77
82
  end
78
83
 
84
+ # Whole-document native materialization (Phase 5): compile the
85
+ # mapping into a Leptris descriptor plan and hydrate from one
86
+ # plan walk. Only when the resolved XML adapter is leptris-
87
+ # backed, the model fully compiles, and no path-affecting
88
+ # options are present; anything else falls back to the
89
+ # interpretive pipeline.
90
+ def xml_plan_fast_path(data, options)
91
+ return nil unless defined?(::Leptris::XML::Descriptor)
92
+ return nil if options.key?(:adapter) || options.key?(:only) ||
93
+ options.key?(:except) || options.key?(:mappings) ||
94
+ options.key?(:register)
95
+
96
+ adapter_name = Lutaml::Model::Config.adapter_for(:xml)
97
+ adapter_name = adapter_name&.name
98
+ return nil unless adapter_name.to_s.end_with?("LeptrisAdapter")
99
+
100
+ register = Lutaml::Model::Config.default_register
101
+ plan = Lutaml::Xml::PlanCompiler.compile(self, register)
102
+ return nil unless plan
103
+
104
+ root = ::Leptris::XML.parse(data.to_s).root
105
+ return nil if root.nil?
106
+ return nil unless root.name == plan[:tree][:name]
107
+
108
+ Lutaml::Xml::PlanHydrator.call(self, plan,
109
+ plan[:descriptor].walk(root))
110
+ rescue ::Leptris::XML::ParseError => e
111
+ raise Lutaml::Model::InvalidFormatError.new(:xml, e.message)
112
+ end
113
+
79
114
  # Get list of error types that can be raised during format parsing.
80
115
  # Core errors are always included; format-specific errors come from
81
116
  # FormatRegistry registrations.
@@ -119,6 +154,8 @@ module Lutaml
119
154
  toml_errors = Array(toml_errors)
120
155
  tomllib_err = compatibility.safe_constantize("Tomlib::ParseError")
121
156
  toml_errors << tomllib_err if tomllib_err
157
+ teptris_err = compatibility.safe_constantize("Teptris::ParseError")
158
+ toml_errors << teptris_err if teptris_err
122
159
 
123
160
  @format_error_types_base + toml_errors
124
161
  end
@@ -7,6 +7,7 @@ module Lutaml
7
7
  module Toml
8
8
  Lutaml::Model::RuntimeCompatibility.define_native_aliases(
9
9
  self,
10
+ TeptrisAdapter: "::Lutaml::Toml::Adapter::TeptrisAdapter",
10
11
  TomlibAdapter: "::Lutaml::Toml::Adapter::TomlibAdapter",
11
12
  TomlRbAdapter: "::Lutaml::Toml::Adapter::TomlRbAdapter",
12
13
  )
@@ -18,15 +19,18 @@ module Lutaml
18
19
  def self.detect_toml_adapter
19
20
  return nil if Lutaml::Model.opal?
20
21
 
21
- # Skip tomlib on Windows entirely due to segfault issues
22
- if Lutaml::Model::RuntimeCompatibility.windows?
23
- return :toml_rb if Lutaml::Model::Utils.safe_load("toml-rb", :TomlRb)
22
+ # teptris 0.2.12+ parses ~3x and dumps ~8x faster than tomlib
23
+ # and ships prebuilts for every platform (mingw-ucrt restored
24
+ # in 0.2.4+) — native TOML on Windows retires the tomlib
25
+ # segfault workaround (previously pure-Ruby toml-rb only).
26
+ return :teptris if Lutaml::Model::Utils.safe_load("teptris", :Teptris)
24
27
 
25
- return nil
28
+ # tomlib segfaults on Windows when parsing invalid TOML
29
+ if !Lutaml::Model::RuntimeCompatibility.windows? &&
30
+ Lutaml::Model::Utils.safe_load("tomlib", :Tomlib)
31
+ return :tomlib
26
32
  end
27
33
 
28
- # On non-Windows, prefer tomlib
29
- return :tomlib if Lutaml::Model::Utils.safe_load("tomlib", :Tomlib)
30
34
  return :toml_rb if Lutaml::Model::Utils.safe_load("toml-rb", :TomlRb)
31
35
 
32
36
  nil
@@ -1,7 +1,7 @@
1
- require "date"
2
-
3
1
  # frozen_string_literal: true
4
2
 
3
+ require "date"
4
+
5
5
  module Lutaml
6
6
  module Model
7
7
  module Type
@@ -2,6 +2,16 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
+ # Isolated holder for format_type_serializer_for's memo: Type::Value
6
+ # can be frozen by suites, which would freeze an ivar Hash with it.
7
+ SERIALIZER_LOOKUP_CACHE = ::Class.new do
8
+ class << self
9
+ def cache
10
+ @cache ||= {}
11
+ end
12
+ end
13
+ end.cache
14
+
5
15
  module Type
6
16
  # Base class for all value types
7
17
  class Value
@@ -35,14 +45,26 @@ from: nil)
35
45
  # @param type_class [Class] the type class to look up
36
46
  # @return [Hash, nil] { to: Proc, from: Proc } or nil
37
47
  def format_type_serializer_for(format, type_class)
48
+ # Hot path: memoize per [format, resolved class] — the hierarchy
49
+ # walk below only runs on a miss. Format serializers register at
50
+ # load time, so a hit is stable.
51
+ cache = SERIALIZER_LOOKUP_CACHE
52
+ key = [format, type_class]
53
+ return cache[key] if cache.key?(key)
54
+
38
55
  klass = type_class
56
+ found = nil
39
57
  while klass && klass <= Value
40
58
  s = @format_type_serializers[[format, klass]]
41
- return s if s
59
+ if s
60
+ found = s
61
+ break
62
+ end
42
63
 
43
64
  klass = klass.superclass
44
65
  end
45
- nil
66
+ cache[key] = found
67
+ found
46
68
  end
47
69
  end
48
70
 
@@ -121,11 +121,12 @@ module Lutaml
121
121
  end
122
122
 
123
123
  def blank?(value)
124
- case value
125
- when ::String, ::Array, ::Hash then value.empty?
126
- when ::NilClass then true
127
- else false
128
- end
124
+ # Hot path: the nil test is a pointer compare, and nil dominates
125
+ # in optional-field-heavy documents.
126
+ return true if value.nil?
127
+ return false unless value.is_a?(::String) || value.is_a?(::Array) || value.is_a?(::Hash)
128
+
129
+ value.empty?
129
130
  end
130
131
 
131
132
  def empty_collection?(collection)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.23"
5
+ VERSION = "0.8.24"
6
6
  end
7
7
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../key_value/adapter/toml/teptris_adapter"
4
+
5
+ module Lutaml
6
+ module Toml
7
+ module Adapter
8
+ # The implementation lives in the KeyValue namespace (the live
9
+ # surface for adapter resolution); this alias keeps the legacy
10
+ # Lutaml::Toml::Adapter surface complete.
11
+ TeptrisAdapter = Lutaml::KeyValue::Adapter::Toml::TeptrisAdapter
12
+ end
13
+ end
14
+ end
@@ -11,6 +11,7 @@ module Lutaml
11
11
  autoload :Transform, "#{__dir__}/adapter/transform"
12
12
  Lutaml::Model::RuntimeCompatibility.autoload_native(
13
13
  self,
14
+ TeptrisAdapter: "#{__dir__}/adapter/teptris_adapter",
14
15
  TomlRbAdapter: "#{__dir__}/adapter/toml_rb_adapter",
15
16
  TomlibAdapter: "#{__dir__}/adapter/tomlib_adapter",
16
17
  )
@@ -35,7 +35,7 @@ Lutaml::Model::FormatRegistry.register(
35
35
  nil
36
36
  else
37
37
  {
38
- available: %i[tomlib toml_rb],
38
+ available: %i[teptris tomlib toml_rb],
39
39
  default: Lutaml::Model::RuntimeCompatibility.windows? ? :toml_rb : :tomlib,
40
40
  }
41
41
  end,
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "moxml/adapter/leptris"
4
+
5
+ module Lutaml
6
+ module Xml
7
+ module Adapter
8
+ class LeptrisAdapter < BaseAdapter
9
+ MOXML_ADAPTER = Moxml::Adapter::Leptris
10
+ BUILDER_CLASS = Builder::Leptris
11
+ PARSED_ELEMENT_CLASS = Lutaml::Xml::LeptrisElement
12
+ end
13
+ end
14
+ end
15
+ end
@@ -22,7 +22,11 @@ module Lutaml
22
22
  text = case node
23
23
  when Moxml::Element
24
24
  namespace_name = node.namespace&.prefix
25
- ns_defs = node.namespaces
25
+ # moxml >= 0.5.33 contract: #namespaces is the full
26
+ # in-scope map (parity with Nokogiri, #198/#201); the
27
+ # own-declarations shape this parse path needs is
28
+ # #namespace_definitions.
29
+ ns_defs = node.namespace_definitions
26
30
 
27
31
  has_empty_xmlns = ns_defs.any? do |ns|
28
32
  ns.prefix.nil? && ns.uri == ""
@@ -172,14 +176,118 @@ module Lutaml
172
176
  def parse_children(node, default_namespace: nil)
173
177
  return [] unless node.children
174
178
 
179
+ # Non-element children (text/cdata/comment/PI) stay RAW moxml
180
+ # nodes in the list — the hot scalar parse path reads text off
181
+ # the element (inner_text) and never touches their wrappers, so
182
+ # wrapping every text node eagerly paid half the parse tree for
183
+ # nothing. #children wraps them on first access; document order
184
+ # is preserved because they sit at their original positions.
175
185
  node.children.filter_map do |child|
176
186
  next if (child.is_a?(Moxml::Text) || child.is_a?(Moxml::Cdata)) && child.content.empty?
177
187
 
178
- self.class.new(child, parent: self,
179
- default_namespace: default_namespace)
188
+ if child.is_a?(Moxml::Element)
189
+ self.class.new(child, parent: self,
190
+ default_namespace: default_namespace)
191
+ else
192
+ child
193
+ end
180
194
  end
181
195
  end
182
196
 
197
+ public
198
+
199
+ def children
200
+ unless @non_element_children_wrapped
201
+ @children.map! do |child|
202
+ if child.is_a?(Moxml::Node) && !child.is_a?(Moxml::Element)
203
+ self.class.new(child, parent: self)
204
+ else
205
+ child
206
+ end
207
+ end
208
+ @non_element_children_wrapped = true
209
+ end
210
+ @children
211
+ end
212
+
213
+ def children=(new_children)
214
+ @non_element_children_wrapped = true
215
+ super
216
+ end
217
+
218
+ def element_children
219
+ return @element_children if defined?(@element_children)
220
+
221
+ @element_children = @children.reject do |child|
222
+ raw_non_element_child?(child) ||
223
+ (child.is_a?(XmlElement) &&
224
+ (child.text? || child.processing_instruction?))
225
+ end
226
+ end
227
+
228
+ def raw_non_element_child?(child)
229
+ child.is_a?(Moxml::Node) && !child.is_a?(Moxml::Element)
230
+ end
231
+
232
+ # Order consumes raw non-element children inline: wrapping them
233
+ # just to read .text/.content would materialize the whole lazy
234
+ # layer on every parse.
235
+ def order
236
+ return @order_cache if @order_cache
237
+
238
+ @order_cache = @children.filter_map do |child|
239
+ case child
240
+ when Moxml::Cdata
241
+ Lutaml::Xml::Element.new("Text", "#cdata-section",
242
+ text_content: child.content,
243
+ node_type: :cdata)
244
+ when Moxml::Text
245
+ next if child.content.nil?
246
+
247
+ Lutaml::Xml::Element.new("Text", "text",
248
+ text_content: child.content,
249
+ node_type: :text)
250
+ when Moxml::Comment
251
+ Lutaml::Xml::Element.new("Comment", "comment",
252
+ text_content: child.content,
253
+ node_type: :comment)
254
+ when Moxml::ProcessingInstruction
255
+ Lutaml::Xml::Element.new("ProcessingInstruction",
256
+ child.target,
257
+ text_content: child.content.to_s.sub(/\A\s+/, ""),
258
+ node_type: :processing_instruction)
259
+ else
260
+ next if child.is_a?(Moxml::Node)
261
+
262
+ if child.cdata?
263
+ Lutaml::Xml::Element.new("Text", "#cdata-section",
264
+ text_content: child.text,
265
+ node_type: :cdata)
266
+ elsif child.text?
267
+ next if child.text.nil?
268
+
269
+ Lutaml::Xml::Element.new("Text", "text",
270
+ text_content: child.text,
271
+ node_type: :text)
272
+ elsif child.comment?
273
+ Lutaml::Xml::Element.new("Comment", "comment",
274
+ text_content: child.text,
275
+ node_type: :comment)
276
+ elsif child.processing_instruction?
277
+ Lutaml::Xml::Element.new("ProcessingInstruction",
278
+ child.unprefixed_name,
279
+ text_content: child.text,
280
+ node_type: :processing_instruction)
281
+ else
282
+ Lutaml::Xml::Element.new("Element", child.unprefixed_name,
283
+ node_type: :element,
284
+ namespace_uri: child.namespace_uri,
285
+ namespace_prefix: child.namespace_prefix)
286
+ end
287
+ end
288
+ end.each(&:freeze).freeze
289
+ end
290
+
183
291
  def add_namespaces_from_defs(ns_defs, is_root: false)
184
292
  has_default_xmlns = is_root || ns_defs.any? { |ns| ns.prefix.nil? }
185
293
 
@@ -223,7 +223,9 @@ module Lutaml
223
223
  ns = new_el.in_scope_namespaces.find { |n| n.prefix == prefix }
224
224
  new_el.namespace = ns if ns
225
225
  elsif !prefix_unset && prefix.nil?
226
- new_el.namespace = nil
226
+ # Skip the no-op clear: namespace-less elements pay the
227
+ # undeclare path on some engines (leptris) for nothing.
228
+ new_el.namespace = nil unless new_el.namespace.nil?
227
229
  end
228
230
  end
229
231
 
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Builder
6
+ class Leptris < Base
7
+ def self.moxml_backend
8
+ :leptris
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -4,6 +4,9 @@ module Lutaml
4
4
  module Xml
5
5
  module Builder
6
6
  class Nokogiri < Base
7
+ def self.moxml_backend
8
+ :nokogiri
9
+ end
7
10
  end
8
11
  end
9
12
  end
@@ -11,6 +11,7 @@ module Lutaml
11
11
  self,
12
12
  Nokogiri: "#{__dir__}/builder/nokogiri",
13
13
  Ox: "#{__dir__}/builder/ox",
14
+ Leptris: "#{__dir__}/builder/leptris",
14
15
  )
15
16
  end
16
17
  end
@@ -21,6 +21,8 @@ end
21
21
 
22
22
  module Lutaml
23
23
  module Xml
24
+ autoload :PlanCompiler, "#{__dir__}/plan_compiler"
25
+ autoload :PlanHydrator, "#{__dir__}/plan_hydrator"
24
26
  # Error module for XML-specific errors
25
27
  module Error
26
28
  autoload :XmlError, "#{__dir__}/error/xml_error"
@@ -161,6 +163,7 @@ module Lutaml
161
163
  NokogiriElement: "#{__dir__}/nokogiri/element",
162
164
  Nokogiri: "#{__dir__}/nokogiri",
163
165
  Ox: "#{__dir__}/ox",
166
+ LeptrisElement: "#{__dir__}/leptris/element",
164
167
  )
165
168
  end
166
169
  end
@@ -192,7 +195,7 @@ Lutaml::Model::FormatRegistry.register(
192
195
  }
193
196
  else
194
197
  {
195
- available: %i[nokogiri ox oga rexml],
198
+ available: %i[nokogiri ox oga rexml leptris],
196
199
  default: :nokogiri,
197
200
  }
198
201
  end,
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ class LeptrisElement < AdapterElement
6
+ def initialize(node, parent: nil, default_namespace: nil)
7
+ @raw_text = node.raw_content if node.is_a?(Moxml::Text)
8
+ super
9
+ end
10
+
11
+ private
12
+
13
+ def adapter_class
14
+ Lutaml::Xml::Adapter::LeptrisAdapter
15
+ end
16
+
17
+ def build_text_for_xml
18
+ @raw_text || @text
19
+ end
20
+
21
+ def build_element_xml(builder)
22
+ builder.create_and_add_element(name,
23
+ prefix: namespace_prefix,
24
+ attributes: build_attributes(self)) do |xml|
25
+ children.each { |child| child.build_xml(xml) }
26
+ end
27
+ end
28
+
29
+ def attribute_value_for_build(attr)
30
+ attr.raw_value
31
+ end
32
+ end
33
+ end
34
+ end