lutaml-model 0.8.56 → 0.8.106

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/test_installed_gem.rb +58 -0
  3. data/.github/workflows/downstream-performance.yml +28 -3
  4. data/.github/workflows/js-pr-check.yml +4 -0
  5. data/.github/workflows/js-sync.yml +4 -0
  6. data/.github/workflows/opal.yml +9 -0
  7. data/.github/workflows/rake.yml +6 -41
  8. data/.github/workflows/release.yml +41 -0
  9. data/Gemfile +17 -3
  10. data/bench/bench_alloc_audit.rb +56 -0
  11. data/bench/bench_common.rb +11 -0
  12. data/bench/bench_merge_attempts.rb +49 -0
  13. data/docs/_guides/gem-integration.adoc +85 -0
  14. data/docs/_guides/grammar-backed-formats.adoc +69 -0
  15. data/docs/_guides/index.adoc +8 -1
  16. data/docs/_guides/native-engines.adoc +23 -5
  17. data/docs/_guides/schema-generation.adoc +25 -0
  18. data/docs/_guides/type-registers-contexts.adoc +126 -0
  19. data/lib/compat/opal/lutaml_model_boot.rb +7 -5
  20. data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
  21. data/lib/lutaml/key_value/data_model/element.rb +5 -1
  22. data/lib/lutaml/key_value/mapping_rule.rb +7 -0
  23. data/lib/lutaml/key_value/transform.rb +29 -24
  24. data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
  25. data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
  26. data/lib/lutaml/key_value/transformation.rb +64 -116
  27. data/lib/lutaml/model/adapter_resolver.rb +7 -3
  28. data/lib/lutaml/model/attribute.rb +74 -15
  29. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  30. data/lib/lutaml/model/configuration.rb +13 -0
  31. data/lib/lutaml/model/mapping/mapping.rb +13 -0
  32. data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
  33. data/lib/lutaml/model/render_policy.rb +26 -8
  34. data/lib/lutaml/model/schema/base_schema.rb +15 -0
  35. data/lib/lutaml/model/schema/generator/definition.rb +52 -8
  36. data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
  37. data/lib/lutaml/model/schema.rb +4 -2
  38. data/lib/lutaml/model/serializable.rb +33 -0
  39. data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
  40. data/lib/lutaml/model/serialize/builder.rb +22 -0
  41. data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
  42. data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
  43. data/lib/lutaml/model/serialize/initialization.rb +54 -7
  44. data/lib/lutaml/model/serialize/model_import.rb +60 -0
  45. data/lib/lutaml/model/serialize.rb +14 -6
  46. data/lib/lutaml/model/type/time.rb +6 -0
  47. data/lib/lutaml/model/type/value.rb +9 -7
  48. data/lib/lutaml/model/type_resolver.rb +7 -5
  49. data/lib/lutaml/model/utils.rb +18 -5
  50. data/lib/lutaml/model/version.rb +1 -1
  51. data/lib/lutaml/model.rb +7 -0
  52. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
  53. data/lib/lutaml/xml/adapter_loader.rb +5 -2
  54. data/lib/lutaml/xml/format.rb +18 -16
  55. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  56. data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
  57. data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
  58. data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
  59. data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
  60. data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
  61. data/lib/lutaml/xml/leptris.rb +46 -0
  62. data/lib/lutaml/xml/mapping.rb +71 -18
  63. data/lib/lutaml/xml/model_transform.rb +231 -8
  64. data/lib/lutaml/xml/namespace.rb +18 -8
  65. data/lib/lutaml/xml/namespace_collector.rb +17 -1
  66. data/lib/lutaml/xml/namespace_needs.rb +77 -49
  67. data/lib/lutaml/xml/parse_session.rb +7 -19
  68. data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
  69. data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
  70. data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
  71. data/lib/lutaml/xml/transformation.rb +15 -1
  72. data/lib/lutaml/xml/type/serializers.rb +8 -1
  73. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
  74. data/lib/tasks/performance_probe_plan_walk.rb +1 -1
  75. data/lutaml-model.gemspec +7 -4
  76. data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
  77. data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
  78. data/spec/lutaml/key_value/transformation_spec.rb +87 -21
  79. data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
  80. data/spec/lutaml/model/collection_validation_spec.rb +265 -0
  81. data/spec/lutaml/model/desc_spec.rb +50 -0
  82. data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
  83. data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
  84. data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
  85. data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
  86. data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
  87. data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
  88. data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
  89. data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
  90. data/spec/lutaml/model/store_spec.rb +6 -2
  91. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  92. data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
  93. data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
  94. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  95. data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
  96. data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
  97. data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
  98. data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
  99. data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
  100. data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
  101. data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
  102. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
  103. data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
  104. data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
  105. data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
  106. data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
  107. data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
  108. data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
  109. data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
  110. data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
  111. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
  112. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  113. data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
  114. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
  115. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
  116. data/spec/person_spec.rb +8 -0
  117. metadata +48 -17
  118. data/lib/lutaml/xml/plan_compiler.rb +0 -332
  119. data/lib/lutaml/xml/plan_hydrator.rb +0 -367
  120. data/lib/lutaml/xml/plan_order.rb +0 -53
  121. data/lib/lutaml/xml/plan_serializer.rb +0 -252
  122. data/lib/lutaml/xml/plan_walk.rb +0 -30
@@ -95,17 +95,28 @@ module Lutaml
95
95
  options.key?(:mappings) || options.key?(:adapter) ||
96
96
  options.key?(:_adapter_override) ||
97
97
  options.key?(:indent) || options.key?(:xml_declaration) ||
98
- options.key?(:declaration) || options.key?(:doctype)
98
+ options.key?(:declaration) || options.key?(:doctype) ||
99
+ # prefix requests ride the interpretive writer's use_prefix
100
+ # option (planner Tier-1) — the plan emission has no
101
+ # option-aware spelling. The instance-level option prep
102
+ # rewrites :prefix to :use_prefix before this gate.
103
+ options.key?(:prefix) || options.key?(:use_prefix)
99
104
 
100
105
  adapter_name = Lutaml::Model::Config.adapter_for(:xml)
101
106
  adapter_name &&= adapter_name.name
102
107
  return nil unless adapter_name.to_s.end_with?("LeptrisAdapter")
103
108
 
104
109
  register = Lutaml::Model::Config.default_register
105
- plan = Lutaml::Xml::PlanCompiler.compile(self, register)
106
- return nil unless plan && Lutaml::Xml::PlanSerializer.serializable?(plan)
107
-
108
- Lutaml::Xml::PlanSerializer.call(instance, plan)
110
+ plan = Lutaml::Xml::Leptris::PlanCompiler.compile(self, register)
111
+ return nil unless plan &&
112
+ Lutaml::Xml::Leptris::PlanSerializer.serializable?(plan)
113
+
114
+ # Namespaced plans ride the plan serializer too: the DOM
115
+ # emission spells prefixed names and hoists declarations
116
+ # (the #847 xmlns drop is fixed in PlanSerializer); any
117
+ # unspellable shape answers nil there and falls back
118
+ # interpretively.
119
+ Lutaml::Xml::Leptris::PlanSerializer.call(instance, plan)
109
120
  end
110
121
 
111
122
  # Whole-document native materialization (Phase 5): compile the
@@ -125,16 +136,20 @@ module Lutaml
125
136
  return nil unless adapter_name.to_s.end_with?("LeptrisAdapter")
126
137
 
127
138
  register = Lutaml::Model::Config.default_register
128
- plan = Lutaml::Xml::PlanCompiler.compile(self, register)
139
+ plan = Lutaml::Xml::Leptris::PlanCompiler.compile(self, register)
129
140
  return nil unless plan
130
141
 
131
142
  root = ::Leptris::XML.parse(data.to_s).root
132
143
  return nil if root.nil?
133
144
  return nil unless root.name == plan[:tree][:name]
134
145
 
135
- Lutaml::Xml::PlanHydrator.call(self, plan,
146
+ Lutaml::Xml::Leptris::PlanHydrator.call(self, plan,
136
147
  plan[:descriptor].walk(root),
137
- node: root)
148
+ node: root,
149
+ declaration_plan_mode:
150
+ options.fetch(
151
+ :import_declaration_plan, :eager
152
+ ))
138
153
  rescue ::Leptris::XML::ParseError => e
139
154
  raise Lutaml::Model::InvalidFormatError.new(:xml, e.message)
140
155
  end
@@ -149,48 +164,41 @@ module Lutaml
149
164
  #
150
165
  # @return [Array<Class>] List of error classes
151
166
  def format_error_types
152
- @format_error_types_base ||= begin
153
- errors = [
154
- Lutaml::Model::RuntimeCompatibility.safe_constantize("Psych::SyntaxError"),
155
- Lutaml::Model::RuntimeCompatibility.safe_constantize("JSON::ParserError"),
156
- NoMethodError,
157
- TypeError,
158
- ArgumentError,
159
- ]
160
-
161
- # Collect format-specific error types from FormatRegistry
162
- compatibility = Lutaml::Model::RuntimeCompatibility
163
- FormatRegistry.all.each_value do |info|
164
- next unless info[:error_types]
165
-
166
- info[:error_types].each do |error_class|
167
- cls = if error_class.is_a?(String)
168
- compatibility.safe_constantize(error_class)
169
- else
170
- error_class
171
- end
172
- errors << cls
173
- end
167
+ # Resolved per call, not memoized: adapter engines load lazily,
168
+ # and a cache built before an engine loads permanently drops
169
+ # that engine's parse errors from the rescue list (an
170
+ # REXML::ParseException leaked un-wrapped when any earlier
171
+ # parse ran before rexml was required). The constantize pass
172
+ # is a handful of lookups — negligible against a parse.
173
+ compatibility = Lutaml::Model::RuntimeCompatibility
174
+ errors = [
175
+ compatibility.safe_constantize("Psych::SyntaxError"),
176
+ compatibility.safe_constantize("JSON::ParserError"),
177
+ NoMethodError,
178
+ TypeError,
179
+ ArgumentError,
180
+ ]
181
+
182
+ FormatRegistry.all.each_value do |info|
183
+ next unless info[:error_types]
184
+
185
+ info[:error_types].each do |error_class|
186
+ errors << if error_class.is_a?(String)
187
+ compatibility.safe_constantize(error_class)
188
+ else
189
+ error_class
190
+ end
174
191
  end
175
-
176
- errors.compact.freeze
177
192
  end
178
193
 
179
194
  # Legacy TOML error types are lazy, so check them on each call.
180
- compatibility = Lutaml::Model::RuntimeCompatibility
181
- toml_errors = compatibility.safe_constantize("TomlRB::ParseError")
182
- toml_errors = Array(toml_errors)
195
+ toml_errors = Array(compatibility.safe_constantize("TomlRB::ParseError"))
183
196
  tomllib_err = compatibility.safe_constantize("Tomlib::ParseError")
184
197
  toml_errors << tomllib_err if tomllib_err
185
198
  teptris_err = compatibility.safe_constantize("Teptris::ParseError")
186
199
  toml_errors << teptris_err if teptris_err
187
200
 
188
- @format_error_types_base + toml_errors
189
- end
190
-
191
- # Reset cached error types (for test isolation)
192
- def reset_format_error_types_cache!
193
- @format_error_types_base = nil
201
+ (errors + toml_errors).compact
194
202
  end
195
203
 
196
204
  # Create a model instance from a parsed document
@@ -172,12 +172,30 @@ module Lutaml
172
172
  #
173
173
  # @param register [Symbol, nil] The register context
174
174
  def ensure_imports!(register = nil)
175
- ensure_model_imports!(register)
176
- ensure_choice_imports!(register)
177
- ensure_restrict_attributes!(register)
178
- # Hook for format-specific mapping import resolution.
179
- # XML overrides this to call mappings[:xml]&.ensure_mappings_imported!(register)
180
- ensure_format_mapping_imports!(register)
175
+ # Bounded re-entry for the resolution chain. Each leg's
176
+ # record_import_attempt tail calls attributes(), which calls
177
+ # back into ensure_imports!; nested passes converge imports
178
+ # whose targets only become resolvable after an earlier leg
179
+ # (so they must not be dropped), but with a permanently
180
+ # pending import the tail re-entered unboundedly and crashed
181
+ # with SystemStackError (#938). Two levels allow one nested
182
+ # convergence pass; deeper re-entry is suppressed per
183
+ # register and the counter unwinds on error.
184
+ register_id = extract_register_id(register)
185
+ (@ensure_imports_depth ||= {})[register_id] ||= 0
186
+ return if @ensure_imports_depth[register_id] >= 2
187
+
188
+ @ensure_imports_depth[register_id] += 1
189
+ begin
190
+ ensure_model_imports!(register)
191
+ ensure_choice_imports!(register)
192
+ ensure_restrict_attributes!(register)
193
+ # Hook for format-specific mapping import resolution.
194
+ # XML overrides this to call mappings[:xml]&.ensure_mappings_imported!(register)
195
+ ensure_format_mapping_imports!(register)
196
+ ensure
197
+ @ensure_imports_depth[register_id] -= 1
198
+ end
181
199
  end
182
200
 
183
201
  # Hook for format-specific mapping import resolution.
@@ -345,8 +363,20 @@ module Lutaml
345
363
  # @param register [Symbol, nil] The register context
346
364
  # @return [Object] The allocated instance
347
365
  def allocate_for_deserialization(register = nil)
348
- instance = allocate
349
366
  register_id = extract_register_id(register)
367
+ # Deferred imports (symbol-form import_model_attributes) must
368
+ # resolve before state defaults compile: the compiled seeding
369
+ # and the raw readers cover the post-import attribute set, and
370
+ # the transform's records already carry the mapping's rules
371
+ # for imported names — CTR's <m:br> parsed before resolution
372
+ # had no reader (#933). The constructor path ensures the same
373
+ # way (initialize_attributes). Guarded: deep object graphs
374
+ # (XSD schema trees) allocate recursively, and the ensure
375
+ # chain's frames per level pushed that recursion past the
376
+ # stack limit — models without pending deferred imports skip
377
+ # it entirely.
378
+ ensure_imports!(register_id) if finalized? && deferred_imports_pending?(register_id)
379
+ instance = allocate
350
380
  instance.finalize_deserialization(register_id)
351
381
  instance
352
382
  end
@@ -392,6 +422,22 @@ module Lutaml
392
422
  # set (nil when no default), never left as the
393
423
  # uninitialized sentinel — readers and formatters touch
394
424
  # absent attributes freely.
425
+ #
426
+ # lutaml-model#922: unless a custom writer already
427
+ # populated it. An earlier kwarg's writer may have
428
+ # assigned this attribute as a side effect (relaton's ICS
429
+ # populates <text> from the Isoics dataset inside code=);
430
+ # the raw state is the truth — still at its initialized
431
+ # marker (sentinel / lazy collection) means unset, and
432
+ # only then is the default seeded. The writer's own
433
+ # value_set_for (inside the compiled writer) already
434
+ # marked it set, so it renders.
435
+ raw = instance.public_send(:"__lutaml_raw_#{name}")
436
+ unset = raw.nil? ||
437
+ raw.equal?(Lutaml::Model::UninitializedClass.instance) ||
438
+ raw.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
439
+ next unless unset
440
+
395
441
  default = attr.default_value(register_id, instance)
396
442
  value = if Lutaml::Model::Utils.uninitialized?(default)
397
443
  nil
@@ -409,6 +455,7 @@ module Lutaml
409
455
  unknown = attrs.keys.find { |k| !known.include?(k.to_sym) }
410
456
  raise Error, "unknown attribute '#{unknown}' for #{self}"
411
457
  end
458
+ instance.clear_mutated_after_parse!
412
459
  instance
413
460
  end
414
461
 
@@ -60,6 +60,10 @@ module Lutaml
60
60
  model.attributes.each_value { |attr| define_attribute_methods(attr) }
61
61
  @attributes.merge!(Utils.deep_dup(model.attributes))
62
62
  @choice_attributes.concat(deep_duplicate_choice_attributes(model))
63
+ # Imported attributes grow the state-defaults surface (the
64
+ # raw readers live there) — a first use may already have
65
+ # compiled it for the pre-import attribute set (#933).
66
+ invalidate_state_defaults!
63
67
  # Ensure @models_imported is a hash; migrate from legacy nil/false state
64
68
  @models_imported ||= {}
65
69
  @models_imported[reg] = true
@@ -122,6 +126,9 @@ module Lutaml
122
126
  deep_duplicate_choice_attributes(model, register_id),
123
127
  )
124
128
  @merged_attributes_cache = nil
129
+ # See import_model_attributes: late-registered attributes
130
+ # must recompile the register's state defaults (#933).
131
+ invalidate_state_defaults!
125
132
  end
126
133
  private :register_only_import_model_attributes
127
134
 
@@ -153,6 +160,43 @@ module Lutaml
153
160
  # Ensure all model imports are resolved for a specific register
154
161
  #
155
162
  # @param register_id [Symbol, nil] The register context
163
+ # Whether deferred (symbol-form) imports are still pending —
164
+ # gates the allocation-time ensure chain (#933).
165
+ # The attribute-set fingerprint at the moment an import
166
+ # resolution attempt ran — deferred_imports_pending? compares
167
+ # against it to skip re-resolution on stable sets.
168
+ def record_import_attempt(register_id)
169
+ # Raw stores only — the lazy attributes() re-enters
170
+ # ensure_imports! and, when @models_imported was just set to
171
+ # false (unresolved imports), cycles through this very
172
+ # method forever (the uniword register_in SystemStackError).
173
+ register_attrs = (@register_records ||= {})[register_id]
174
+ (@import_attempt_fingerprints ||= {})[register_id] =
175
+ @attributes.size + @choice_attributes.to_a.size +
176
+ (register_attrs ? register_attrs[:attributes].to_a.size : 0)
177
+ end
178
+
179
+ def deferred_imports_pending?(register_id = nil)
180
+ register_id ||= Lutaml::Model::Config.default_register
181
+ has_importables = importable_models.any? { |_m, models| models.any? } ||
182
+ importable_choices.any? do |_c, imports|
183
+ imports.any? { |_m, models| models.any? }
184
+ end || restrict_attributes.any?
185
+ return false unless has_importables
186
+
187
+ # Unresolvable imports stay pending forever; re-running the
188
+ # ensure chain (and its state-defaults invalidation) on every
189
+ # allocation thrashed import-heavy models quadratically.
190
+ # Resolution re-arms only when the attribute set CHANGED
191
+ # since the last attempt — a late declaration after a failed
192
+ # resolution re-attempts; a stable set skips.
193
+ attempted = (@import_attempt_fingerprints ||= {})[register_id]
194
+ return true if attempted.nil?
195
+
196
+ current = attributes(register_id).size + @choice_attributes.to_a.size
197
+ current != attempted
198
+ end
199
+
156
200
  def ensure_model_imports!(register_id = nil)
157
201
  register_id ||= Lutaml::Model::Config.default_register
158
202
  @models_imported = {} if @models_imported.nil? || @models_imported == false
@@ -184,6 +228,7 @@ module Lutaml
184
228
  end
185
229
 
186
230
  @models_imported[register_id] = all_resolved
231
+ record_import_attempt(register_id)
187
232
  end
188
233
 
189
234
  # Ensure all choice imports are resolved for a specific register
@@ -197,6 +242,7 @@ module Lutaml
197
242
  @choices_imported[register_id] = true
198
243
  all_resolved = true
199
244
 
245
+ resolved_any = false
200
246
  importable_choices.each do |choice, choice_imports|
201
247
  choice_imports.each do |method, models|
202
248
  models.uniq.each do |model|
@@ -216,11 +262,20 @@ module Lutaml
216
262
  end
217
263
 
218
264
  choice.public_send(method, model_class, register_id)
265
+ resolved_any = true
219
266
  end
220
267
  end
221
268
  end
222
269
 
223
270
  @choices_imported[register_id] = true if all_resolved
271
+ record_import_attempt(register_id)
272
+ # Choice imports can add attributes without passing through
273
+ # define_attribute_methods — recompile state defaults so the
274
+ # seeding and raw readers cover them (#933). Only when an
275
+ # import actually resolved: ensure_choice_imports! runs on
276
+ # every ensure_imports! (allocation-time), and a recompile
277
+ # per allocation would churn.
278
+ invalidate_state_defaults! if resolved_any
224
279
  end
225
280
 
226
281
  # Ensure all restrict attributes are applied
@@ -243,6 +298,11 @@ module Lutaml
243
298
  # Uses TracePoint to detect when a class definition is complete,
244
299
  # allowing deferred import resolution.
245
300
  def setup_trace_point
301
+ # Opal has no TracePoint. Symbol imports still resolve lazily
302
+ # on first use (ensure_imports!), so there the class is marked
303
+ # finalized at once instead of at the end of its body.
304
+ return @finalized = true if Lutaml::Model.opal?
305
+
246
306
  @trace ||= TracePoint.new(:end) do |_tp|
247
307
  if include?(Lutaml::Model::Serialize)
248
308
  @finalized = true
@@ -197,8 +197,11 @@ module Lutaml
197
197
  end
198
198
 
199
199
  def attr_value(attrs, name, attribute)
200
- value = Utils.fetch_str_or_sym(attrs, name,
201
- attribute.default(lutaml_register, self))
200
+ # The default is computed only on absence: it used to be
201
+ # evaluated eagerly as the fetch fallback on EVERY value,
202
+ # paying proc/static default dispatch per present attribute.
203
+ value = Utils.fetch_str_or_sym(attrs, name, Utils::MISSING)
204
+ value = attribute.default(lutaml_register, self) if value.equal?(Utils::MISSING)
202
205
  attribute.cast_value(value, lutaml_register)
203
206
  end
204
207
 
@@ -368,15 +371,20 @@ module Lutaml
368
371
  value = self.class.apply_value_map(value, vmap, attr)
369
372
  # Performance: Only call ensure_utf8 for string values
370
373
  value = self.class.ensure_utf8(value) if value.is_a?(::String)
371
- public_send(:"#{name}=", value)
374
+ # Interpolating the writer symbol allocated a String per
375
+ # attribute per instance; the per-register writer table is
376
+ # interned once per class.
377
+ public_send(self.class.instantiate_writers(lutaml_register)[name], value)
372
378
  using_default_for(name) if default
373
379
  end
374
380
  end
375
381
 
376
382
  def determine_value(attrs, name, attr)
377
- if attrs.key?(name) || attrs.key?(name.to_s)
378
- return attr_value(attrs, name, attr)
379
- end
383
+ # One hash pass: a sentinel fetch distinguishes absent keys
384
+ # from present-nil values, replacing two key probes plus the
385
+ # re-fetch inside attr_value (four hash ops per attribute).
386
+ value = Utils.fetch_str_or_sym(attrs, name, Utils::MISSING)
387
+ return attr.cast_value(value, lutaml_register) unless value.equal?(Utils::MISSING)
380
388
 
381
389
  resolved = attr.default_value(lutaml_register, self)
382
390
  return Lutaml::Model::UninitializedClass.instance if Lutaml::Model::Utils.uninitialized?(resolved)
@@ -13,6 +13,12 @@ module Lutaml
13
13
  case value
14
14
  when ::Time then value
15
15
  when ::DateTime then value.to_time
16
+ when ::Integer
17
+ # YAML 1.1 sexagesimal: psych resolves an unquoted hh:mm:ss
18
+ # scalar to seconds since midnight (25200 for "07:00:00").
19
+ # Feeding that integer through Time.parse yields a garbage
20
+ # date — reconstruct the wall-clock time of the current day.
21
+ ::Date.today.to_time + value
16
22
  else ::Time.parse(value.to_s)
17
23
  end
18
24
  rescue ArgumentError
@@ -67,12 +67,14 @@ from: nil)
67
67
  # @param type_class [Class] the type class to look up
68
68
  # @return [Hash, nil] { to: Proc, from: Proc } or nil
69
69
  def format_type_serializer_for(format, type_class)
70
- # Hot path: memoize per [format, resolved class] — the hierarchy
71
- # walk below only runs on a miss. Format serializers register at
72
- # load time, so a hit is stable.
73
- cache = SERIALIZER_LOOKUP_CACHE
74
- key = [format, type_class]
75
- return cache[key] if cache.key?(key)
70
+ # Hot path: memoized per format -> class with NO key
71
+ # allocation — the previous [format, class] array key
72
+ # allocated on every lookup (2k arrays per 200 serializes in
73
+ # the object-mode profile). Identity-keyed nested maps; the
74
+ # hierarchy walk below only runs on a miss, and format
75
+ # serializers register at load time, so a hit is stable.
76
+ per_format = (SERIALIZER_LOOKUP_CACHE[format] ||= {}.compare_by_identity)
77
+ return per_format[type_class] if per_format.key?(type_class)
76
78
 
77
79
  klass = type_class
78
80
  found = nil
@@ -85,7 +87,7 @@ from: nil)
85
87
 
86
88
  klass = klass.superclass
87
89
  end
88
- cache[key] = found
90
+ per_format[type_class] = found
89
91
  found
90
92
  end
91
93
 
@@ -44,7 +44,9 @@ module Lutaml
44
44
  # 5. Raise UnknownTypeError if not found anywhere
45
45
  #
46
46
  # @param name [Symbol, String, Class] The type name or class to resolve
47
- # @param context [TypeContext] The resolution context
47
+ # @param context [TypeContext, nil] The resolution context (nil means
48
+ # no primary registry and no fallbacks — an id registered in the
49
+ # Register namespace but not as a type context)
48
50
  # @return [Class] The resolved type class
49
51
  # @raise [UnknownTypeError] If type cannot be resolved
50
52
  #
@@ -66,11 +68,11 @@ module Lutaml
66
68
  type_name = name.to_sym
67
69
 
68
70
  # 1. Check primary registry
69
- type = context.lookup_local(type_name)
71
+ type = context&.lookup_local(type_name)
70
72
  return apply_substitutions(type, context) if type
71
73
 
72
74
  # 2. Check fallback contexts in order
73
- if context.has_fallbacks?
75
+ if context&.has_fallbacks?
74
76
  context.fallback_contexts.each do |fallback_context|
75
77
  type = resolve_from_fallback(type_name, fallback_context)
76
78
  return apply_substitutions(type, context) if type
@@ -97,8 +99,8 @@ module Lutaml
97
99
  # 5. Type not found - raise error
98
100
  raise UnknownTypeError.new(
99
101
  type_name,
100
- context_id: context.id,
101
- available_types: available_type_names(context),
102
+ context_id: context&.id,
103
+ available_types: context ? available_type_names(context) : [],
102
104
  )
103
105
  end
104
106
 
@@ -3,6 +3,10 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  module Utils
6
+ # Fetch sentinel distinguishing absent keys from present-nil
7
+ # values; shared by the deserialization fast paths.
8
+ MISSING = Object.new.freeze
9
+
6
10
  class << self
7
11
  UNINITIALIZED = Lutaml::Model::UninitializedClass.instance
8
12
 
@@ -175,19 +179,28 @@ module Lutaml
175
179
  if hash.key?(key)
176
180
  hash[key]
177
181
  else
178
- (hash.key?(key.to_sym) ? hash[key.to_sym] : default)
182
+ sym = key.to_sym
183
+ hash.key?(sym) ? hash[sym] : default
179
184
  end
180
185
  when Symbol
181
186
  if hash.key?(key)
182
187
  hash[key]
183
188
  else
184
- (hash.key?(key.to_s) ? hash[key.to_s] : default)
189
+ # Symbol#name is the frozen string — no allocation per probe.
190
+ # This branch is the hot path of the serialization walk
191
+ # (Symbol attribute names against String-keyed hashes), and
192
+ # the double key.to_s here was a top string allocation site
193
+ # in large-document metanorma compiles.
194
+ str = key.name
195
+ hash.key?(str) ? hash[str] : default
185
196
  end
186
197
  else
187
- if hash.key?(key.to_s)
188
- hash[key.to_s]
198
+ str = key.to_s
199
+ if hash.key?(str)
200
+ hash[str]
189
201
  else
190
- (hash.key?(key.to_sym) ? hash[key.to_sym] : default)
202
+ sym = key.to_sym
203
+ hash.key?(sym) ? hash[sym] : default
191
204
  end
192
205
  end
193
206
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.56"
5
+ VERSION = "0.8.106"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -294,3 +294,10 @@ require "#{__dir__}/xml/format"
294
294
  # Builder must be prepended AFTER XML so its initialize runs first
295
295
  # (Builder -> XML InstanceMethods -> Serialize)
296
296
  Lutaml::Model::Serialize.prepend(Lutaml::Model::Serialize::Builder)
297
+
298
+ # Opal does not propagate a module prepended into an already-included
299
+ # module to classes that included it earlier (see lib/lutaml/xml/format.rb).
300
+ # Serializable included Serialize at boot, so Builder goes onto it directly.
301
+ if Lutaml::Model.opal?
302
+ Lutaml::Model::Serializable.prepend(Lutaml::Model::Serialize::Builder)
303
+ end
@@ -22,7 +22,7 @@ module Lutaml
22
22
  parsed = parse_with_moxml(xml, parse_encoding)
23
23
  root_element = parsed.root
24
24
 
25
- raise_empty_document_error if root_element.nil?
25
+ raise_empty_document_error(parsed.parse_errors) if root_element.nil?
26
26
 
27
27
  root = self::PARSED_ELEMENT_CLASS.new(root_element)
28
28
  doc_pis = extract_document_processing_instructions(parsed)
@@ -69,8 +69,11 @@ module Lutaml
69
69
  }
70
70
  end
71
71
 
72
- def raise_empty_document_error
72
+ def raise_empty_document_error(parse_errors = nil)
73
73
  message = self::EMPTY_DOCUMENT_ERROR_MESSAGE
74
+ if parse_errors && !parse_errors.empty?
75
+ message += " (the XML engine reported: #{parse_errors.join('; ')})"
76
+ end
74
77
 
75
78
  case self::EMPTY_DOCUMENT_ERROR_TYPE
76
79
  when :parse_exception
@@ -19,9 +19,12 @@ module Lutaml
19
19
  File.join(File.dirname(__FILE__), "adapter", type)
20
20
  end
21
21
  require adapter_path
22
- rescue LoadError
22
+ rescue LoadError => e
23
+ # Chain the cause: a known adapter whose underlying engine gem is
24
+ # missing (e.g. no nokogiri in the Gemfile) must not present as a
25
+ # bare unknown-type error — the LoadError names the real gap.
23
26
  raise Lutaml::Model::UnknownAdapterTypeError.new("xml", type),
24
- cause: nil
27
+ cause: e
25
28
  end
26
29
 
27
30
  # Load the Moxml adapter for XML
@@ -22,11 +22,9 @@ end
22
22
  module Lutaml
23
23
  module Xml
24
24
  autoload :HtmlEntities, "#{__dir__}/html_entities"
25
- autoload :PlanCompiler, "#{__dir__}/plan_compiler"
26
- autoload :PlanHydrator, "#{__dir__}/plan_hydrator"
27
- autoload :PlanOrder, "#{__dir__}/plan_order"
28
- autoload :PlanSerializer, "#{__dir__}/plan_serializer"
29
- autoload :PlanWalk, "#{__dir__}/plan_walk"
25
+ # The leptris plan fast path lives under its own namespace; its
26
+ # autoloads and the leptris version gate live in lutaml/xml/leptris.
27
+ autoload :Leptris, "#{__dir__}/leptris"
30
28
  # Error module for XML-specific errors
31
29
  module Error
32
30
  autoload :XmlError, "#{__dir__}/error/xml_error"
@@ -202,7 +200,13 @@ Lutaml::Model::FormatRegistry.register(
202
200
  else
203
201
  {
204
202
  available: %i[nokogiri ox oga rexml leptris],
205
- default: :nokogiri,
203
+ # #908: the metadata default is the LAST resort —
204
+ # explicit configuration and moxml runtime detection
205
+ # answer first. When resolution falls this far, prefer
206
+ # leptris, consistent with moxml's runtime preference
207
+ # (leptris whenever present); bundles without it
208
+ # resolve through auto-detection long before here.
209
+ default: :leptris,
206
210
  }
207
211
  end,
208
212
  )
@@ -239,9 +243,11 @@ Lutaml::Model::Serialize.prepend(
239
243
  #
240
244
  # Anonymous model classes (Class.new { include Serialize }) extend
241
245
  # Serialize::ClassMethods rather than inheriting from Serializable, so the
242
- # ModelImportExt override (root?) must land on ClassMethods too. The other
243
- # two (FormatConversion, InstanceMethods) are already prepended unconditionally
244
- # above, so Opal's no-double-prepend rule means we don't repeat them here.
246
+ # ModelImportExt override (root?) must land on ClassMethods too.
247
+ #
248
+ # On Opal (and TruffleRuby, below) the ClassMethods.prepend(FormatConversion)
249
+ # above does not reach Serializable's singleton chain, which extended
250
+ # ClassMethods before it ran, so it is prepended onto that singleton directly.
245
251
  #
246
252
  # TruffleRuby (34.0.1) has the same propagation gap, on both the include
247
253
  # side and the extend/singleton side — a module prepended into an
@@ -269,13 +275,9 @@ if Lutaml::Model.opal? || RUBY_ENGINE == "truffleruby"
269
275
  Lutaml::Xml::Serialization::ModelImportExt,
270
276
  )
271
277
 
272
- # TruffleRuby only: Opal's no-double-prepend rule makes the unconditional
273
- # ClassMethods.prepend(FormatConversion) above sufficient there.
274
- if RUBY_ENGINE == "truffleruby"
275
- Lutaml::Model::Serializable.singleton_class.prepend(
276
- Lutaml::Xml::Serialization::FormatConversion,
277
- )
278
- end
278
+ Lutaml::Model::Serializable.singleton_class.prepend(
279
+ Lutaml::Xml::Serialization::FormatConversion,
280
+ )
279
281
  end
280
282
 
281
283
  # Register XML-specific attribute override warning names