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
@@ -0,0 +1,748 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Leptris
6
+ # Hydrates model instances from a Descriptor#walk PlanValue tree,
7
+ # keyed by each value's producing row name (never position — rows
8
+ # for missing elements are simply absent). Native rows build
9
+ # constructor kwargs; deferred rows (custom methods, polymorphism,
10
+ # unions) capture their subtree verbatim and interpret it
11
+ # post-walk — the fragment parse runs the existing interpretive
12
+ # machinery on just that island. Delegate rules hydrate
13
+ # post-instance onto their target object. Collection rows route
14
+ # natively when a single one exists, else through callback rows
15
+ # (their values echo name and type_tag; native collection values
16
+ # echo neither — leptris-ruby#220).
17
+ module PlanHydrator
18
+ # Row kinds whose child type/plan resolve once per plan (see
19
+ # child_row_plans).
20
+ CHILD_ROW_KINDS = %i[nested ordered_deferred].freeze
21
+
22
+ class << self
23
+ # plan: the compiled plan entry for model_class
24
+ # value: the walk root PlanValue (element)
25
+ # node: the parsed source element (leptris); ordered/mixed
26
+ # plans rebuild element_order from it and ordered children
27
+ # hydrate natively against their own nodes
28
+ # declaration_plan_mode: mirrors from_xml's
29
+ # :import_declaration_plan option (:skip records nothing;
30
+ # :lazy is honored as :eager — the lazy variant pins the
31
+ # raw engine DOM, the retention the eager default exists
32
+ # to avoid)
33
+ def call(model_class, plan, value, parent: nil, node: nil,
34
+ register: nil, declaration_plan_mode: :eager)
35
+ # The child model's declared lutaml_default_register takes
36
+ # precedence over the ambient register — the same
37
+ # resolve_for_child contract the interpretive path applies.
38
+ # Without it, hydration resolves the child's symbol attribute
39
+ # types in the parent context and raises UnknownTypeError
40
+ # for ids registered only in the child's register (#876).
41
+ register = Lutaml::Model::Register.resolve_for_child(
42
+ model_class, register || Lutaml::Model::Config.default_register
43
+ )
44
+ buckets = if node && plan[:needs_nodes]
45
+ element_buckets(node, plan[:contested_names])
46
+ end
47
+ attr_kwargs = attributes_kwargs(plan, value, node)
48
+ child_kwargs, children, delegates =
49
+ children_kwargs(model_class, plan, value, buckets, register)
50
+ plan[:collection_defaults].each do |name|
51
+ next if attr_kwargs.key?(name) || child_kwargs.key?(name)
52
+
53
+ child_kwargs[name] = []
54
+ end
55
+ # TODO.max-perf/33: the bulk constructor — allocate +
56
+ # compiled writers, from_hash semantics — measured ~30x on
57
+ # hydration-heavy corpora versus the generic constructor.
58
+ attr_kwargs.update(child_kwargs)
59
+ instance = model_class.instantiate(attr_kwargs, register)
60
+ instance.lutaml_parent = parent if parent
61
+ instance.lutaml_root ||= parent&.lutaml_root || parent
62
+ # Root prefix fidelity (the interpretive transform records
63
+ # it from the source element): prefixed sources keep their
64
+ # prefixes through the serialize lane's format
65
+ # preservation.
66
+ if parent.nil? && node.respond_to?(:prefix) && node.prefix &&
67
+ !node.prefix.empty?
68
+ instance.xml_namespace_prefix = node.prefix
69
+ end
70
+ # #909: order summaries are per-instance pinned records —
71
+ # read-only consumers skip them via the config.
72
+ if node && plan[:ordered] &&
73
+ Lutaml::Model::Config.instance.element_order_tracking
74
+ instance.element_order = PlanOrder.build(node)
75
+ end
76
+ # Root declaration-plan parity (the interpretive
77
+ # transform's eager mode): consumers and the interpretive
78
+ # serialize fallback read import_declaration_plan state —
79
+ # plan-hydrated roots must carry the same in-scope xmlns
80
+ # declarations or round-trips drop them (#872). The root's
81
+ # xsi:schemaLocation rides the same state (the
82
+ # interpretive set_schema_location contract).
83
+ if parent.nil? && node
84
+ record_declaration_plan(instance, node, model_class) if declaration_plan_mode != :skip
85
+ record_schema_location(instance, node)
86
+ end
87
+ children.each do |child|
88
+ child.lutaml_parent = instance
89
+ child.lutaml_root ||= instance.lutaml_root || instance
90
+ end
91
+ interpret_deferred(model_class, plan, value, instance, node, buckets)
92
+ route_delegates(delegates, instance)
93
+ instance
94
+ end
95
+
96
+ private
97
+
98
+ def record_declaration_plan(instance, node, model_class)
99
+ ns_data = collect_native_namespaces(node)
100
+ return if ns_data.empty?
101
+
102
+ plan = Lutaml::Xml::DeclarationPlan.from_input_with_locations(
103
+ ns_data, model_class.mappings_for(:xml)
104
+ )
105
+ instance.import_declaration_plan = plan if plan
106
+ end
107
+
108
+ # The interpretive parse stores the root's xsi:schemaLocation
109
+ # verbatim (set_schema_location); the writer re-emits it.
110
+ def record_schema_location(instance, node)
111
+ schema_loc = node.attributes.each_value.find do |a|
112
+ a.name.to_s.split(":", 2).last == "schemaLocation"
113
+ end
114
+ instance.raw_schema_location = schema_loc.value if schema_loc
115
+ end
116
+
117
+ # Same ns_data shape as ModelTransform.collect_element_namespaces,
118
+ # collected straight off the engine nodes — no wrapper tree.
119
+ # Iterates children with an inline element filter:
120
+ # element_children materializes a filtered array per node,
121
+ # which the niso allocation gate caught at scale.
122
+ def collect_native_namespaces(element, path = [], result = {})
123
+ own = element.namespace_definitions
124
+ unless own.empty?
125
+ namespaces = {}
126
+ own.each do |ns|
127
+ key = ns.prefix || :default
128
+ namespaces[key] = { uri: ns.href, prefix: ns.prefix,
129
+ format: ns.prefix ? :prefix : :default }
130
+ end
131
+ result[path.dup] = namespaces
132
+ end
133
+ element.children.each do |child|
134
+ next unless child.is_a?(::Leptris::XML::Element)
135
+
136
+ path.push(child.name)
137
+ collect_native_namespaces(child, path, result)
138
+ path.pop
139
+ end
140
+ result
141
+ end
142
+
143
+ def register
144
+ Lutaml::Model::Config.default_register
145
+ end
146
+
147
+ def attributes_kwargs(plan, value, node = nil)
148
+ kwargs = {}
149
+ sole_claimants = sole_claimant_names(plan)
150
+ ns_attr_names = plan[:ns_attr_names]
151
+ plan_attr_rows(plan).each do |rule, attr, name|
152
+ v = value.attribute(name)
153
+ if v.nil? && node && sole_claimants.include?(name)
154
+ # #754/#790 parity: a sole-claimant attribute rule binds
155
+ # any qualification (the interpretive matcher's lenient
156
+ # recovery). The walk captures exact (URI, local) matches
157
+ # only, so the qualified spelling is read off the node.
158
+ v = lenient_node_attribute(node, name)
159
+ end
160
+ next if v.nil?
161
+
162
+ # (URI, local) rows mirror the interpretive matcher's
163
+ # ambiguity refusal: a second spelling of the same local
164
+ # name binds nothing (lutaml-model#744).
165
+ if ns_attr_names&.include?(name) &&
166
+ ambiguous_local_spellings?(node, name)
167
+ next
168
+ end
169
+
170
+ v = v.split(rule.delimiter) if rule.delimiter
171
+ if rule.as_list && rule.as_list[:import]
172
+ v = rule.as_list[:import].call(v)
173
+ end
174
+ kwargs[attr.name.to_sym] = v
175
+ end
176
+ kwargs
177
+ end
178
+
179
+ # True when the node carries more than one spelling of the
180
+ # local name (any qualification). Only consulted for exact-URI
181
+ # attribute rows.
182
+ def ambiguous_local_spellings?(node, local_name)
183
+ return false unless node.respond_to?(:attributes)
184
+
185
+ seen = false
186
+ node.attributes.each_value do |attr|
187
+ name = attr.name.to_s
188
+ name = name.split(":").last if name.include?(":")
189
+ next unless name == local_name
190
+
191
+ return true if seen
192
+
193
+ seen = true
194
+ end
195
+ false
196
+ end
197
+
198
+ # A captured-but-empty child marshals as a nil string value.
199
+ # The interpretive pipeline classifies the raw "" through the
200
+ # rule's value map (:empty key) — the :empty directive (the
201
+ # plain-rule default) keeps it as "", :nil binds nil, and any
202
+ # other directive means uninitialized, i.e. no binding.
203
+ def mapped_empty_value(rule)
204
+ case rule.value_map(:from)[:empty]
205
+ when :nil then nil
206
+ when :empty then ""
207
+ else :skip
208
+ end
209
+ end
210
+
211
+ def mapped_capture_values(values, rule)
212
+ return values unless values.include?(nil)
213
+
214
+ mapped = mapped_empty_value(rule)
215
+ out = []
216
+ values.each do |item|
217
+ if item.nil?
218
+ out << mapped unless mapped == :skip
219
+ else
220
+ out << item
221
+ end
222
+ end
223
+ out
224
+ end
225
+
226
+ # Wire-name strings are plan-frozen; materializing them per
227
+ # hydration allocated two or three strings per attribute row
228
+ # per instance.
229
+ def plan_attr_rows(plan)
230
+ plan[:attr_row_names] ||= plan[:attr_rows].map do |rule, attr, row_name|
231
+ [rule, attr, row_name || rule.name.to_s]
232
+ end
233
+ end
234
+
235
+ # Returns [kwargs, hydrated_child_instances, delegate_values]
236
+ # — child instances come back so the caller can decorate
237
+ # parent/root links once the parent exists; delegate values
238
+ # wait for the instance (their target object must exist).
239
+ def children_kwargs(_model_class, plan, value, buckets = nil,
240
+ register = nil)
241
+ register ||= Lutaml::Model::Config.default_register
242
+ grouped, tagged = group_children(value, plan[:row_tags])
243
+ buckets = nil unless buckets && plan[:needs_nodes]
244
+ kwargs = {}
245
+ children = []
246
+ delegates = []
247
+ # Lazy: models without multi-spelling rows never touch it, and
248
+ # the block-backed Hash is a fresh object per hydrated row.
249
+ spellings = nil
250
+ row_tags = plan[:row_tags]
251
+ wire_names = row_wire_names(plan)
252
+ row_memo = child_row_plans(plan, register)
253
+ # rubocop:disable Metrics/BlockLength -- one row-kind
254
+ # dispatch table; splitting it would scatter the contract
255
+ plan[:rows].each_with_index do |(rule, attr, kind, spelling, delegate, _row_type), idx|
256
+ tag = row_tags&.[](idx)
257
+ name = wire_names[idx]
258
+ case kind
259
+ when :scalar
260
+ # Raw passthrough: the model constructor is the single
261
+ # cast authority — pre-casting here doubled every cast.
262
+ # Class transforms still apply before assignment.
263
+ if tag && (vals = tagged[tag]) && vals.size > 1
264
+ # Interpretive parity: several captures into a
265
+ # non-collection attribute arrive as the full array.
266
+ v = vals.map(&:string_value)
267
+ if v.include?(nil)
268
+ v = v.map { |item| item.nil? ? mapped_empty_value(rule) : item }
269
+ v.reject! { |item| item == :skip }
270
+ end
271
+ else
272
+ first = if tag
273
+ tagged[tag]&.first
274
+ else
275
+ grouped.dig(name, 0)
276
+ end
277
+ v = first&.string_value
278
+ v = mapped_empty_value(rule) if v.nil? && first
279
+ end
280
+ unless v.nil? || v == :skip
281
+ v = rule.transform_value(attr, v, :from, :xml) if rule.transform.is_a?(Class)
282
+ assign(kwargs, delegates, delegate, rule, attr, v)
283
+ end
284
+ when :wildcard_any
285
+ # interpreted post-instance (interpret_deferred)
286
+ when :content
287
+ runs = content_runs(value)
288
+ if runs.empty? && !attr.collection?
289
+ # An empty element's content binds through the rule's
290
+ # value map for scalar content attributes: "" for the
291
+ # plain :empty directive, nil for :nil, skipped for
292
+ # anything else (uninitialized).
293
+ bound = mapped_empty_value(rule)
294
+ assign(kwargs, delegates, delegate, rule, attr, bound) unless bound == :skip
295
+ else
296
+ assign(kwargs, delegates, delegate, rule, attr, runs)
297
+ end
298
+ when :ordered_deferred
299
+ # With a source node the child hydrates natively: one
300
+ # walk against its own node + element_order from the
301
+ # node's children. Without one (direct PlanHydrator
302
+ # use), the subtree fragment-parses interpretively.
303
+ if buckets && row_memo[idx]
304
+ child_type, child_plan, child_register, contested = row_memo[idx]
305
+ selected = child_selection(buckets, name, child_plan, contested)
306
+ items = (selected || []).map do |n|
307
+ call(child_type, child_plan,
308
+ child_plan[:descriptor].walk(n), node: n,
309
+ register: child_register)
310
+ end
311
+ unless items.empty?
312
+ children.concat(items)
313
+ assign(kwargs, delegates, delegate, rule, attr,
314
+ attr.collection? ? items : items.first)
315
+ end
316
+ end
317
+ when :child_deferred
318
+ # Uncompilable child (cycles, opted-out shapes): each
319
+ # source node casts through the attribute — the full
320
+ # interpretive machinery on the bridged island,
321
+ # identical semantics to the interpretive path.
322
+ contested_uri = if plan[:contested_names]&.include?(name)
323
+ child_type_uri(attr, register)
324
+ end
325
+ elements = deferred_elements(value, name, buckets,
326
+ contested_uri)
327
+ next if elements.empty?
328
+
329
+ results = elements.map do |element|
330
+ attr.cast(element, :xml, register)
331
+ end
332
+ unless results.empty?
333
+ children.concat(results)
334
+ assign(kwargs, delegates, delegate, rule, attr,
335
+ attr.collection? ? results : results.first)
336
+ end
337
+ when :collection_cb
338
+ values = grouped[name].to_a.map do |item|
339
+ item&.string_value
340
+ end
341
+ values = mapped_capture_values(values, rule)
342
+ if rule.transform.is_a?(Class)
343
+ values = values.map { |v| rule.transform_value(attr, v, :from, :xml) }
344
+ end
345
+ unless values.empty?
346
+ assign(kwargs, delegates, delegate, rule, attr, values)
347
+ end
348
+ when :collection_native
349
+ values = if tag
350
+ tagged[tag].to_a.map(&:string_value)
351
+ else
352
+ native_collection(value, name)
353
+ end
354
+ values = mapped_capture_values(values, rule) unless values.nil?
355
+ unless values.nil?
356
+ assign(kwargs, delegates, delegate, rule, attr, values)
357
+ end
358
+ when :spelling
359
+ spellings ||= Hash.new { |h, k| h[k] = [] }
360
+ spellings[[rule, attr]] << grouped[spelling.to_s].to_a
361
+ when :nested
362
+ child_type, child_plan, child_register, contested = row_memo[idx]
363
+ selected = child_selection(buckets, name, child_plan, contested)
364
+ if selected && contested
365
+ # Contested name: the walk's grouped values cross-echo
366
+ # every same-name element into every row — dispatch
367
+ # per source node instead (the walk is row-scoped).
368
+ items = selected.map do |n|
369
+ call(child_type, child_plan,
370
+ child_plan[:descriptor].walk(n), node: n,
371
+ register: child_register)
372
+ end
373
+ else
374
+ values = grouped.fetch(name, [])
375
+ cursor = if child_plan[:needs_nodes] && buckets
376
+ selected
377
+ end
378
+ items = values.each_with_index
379
+ .map do |v, i|
380
+ call(child_type, child_plan, v,
381
+ node: cursor && cursor[i], register: child_register)
382
+ end
383
+ end
384
+ next if items.empty?
385
+
386
+ children.concat(items)
387
+ assign(kwargs, delegates, delegate, rule, attr,
388
+ attr.collection? ? items : items.first)
389
+ end
390
+ end
391
+ # rubocop:enable Metrics/BlockLength
392
+ unless !spellings || spellings.empty?
393
+ spellings.each do |(rule, attr), groups|
394
+ # Interpretive order for shared-attribute groups is
395
+ # spelling-group order (first spelling's matches, then
396
+ # the next's), not interleaved document order.
397
+ values = groups.compact.flatten.map(&:string_value)
398
+ next if values.empty?
399
+
400
+ delegate = delegate_of(plan, rule)
401
+ assign(kwargs, delegates, delegate, rule, attr,
402
+ attr.collection? ? values : values.first)
403
+ end
404
+ end
405
+ [kwargs, children, delegates]
406
+ end
407
+
408
+ # Deferred islands: raw subtrees become wrapper elements via a
409
+ # fragment parse, then the interpretive machinery runs on just
410
+ # that island — custom method invocation with an
411
+ # element-shaped argument, or the polymorphic/union cast.
412
+ # With the source node available the island wraps the ORIGINAL
413
+ # node (no fragment parse — the reparse dominated deferred
414
+ # hydration). Ordered children only land here without a node.
415
+ def interpret_deferred(model_class, plan, value, instance, node = nil,
416
+ buckets = nil)
417
+ plan[:rows].each_with_index do |row, idx|
418
+ rule = row[0]
419
+ attr = row[1]
420
+ kind = row[2]
421
+ wire_name = row_wire_names(plan)[idx]
422
+ case kind
423
+ when :content_deferred
424
+ runs = content_runs(value)
425
+ if runs.empty?
426
+ # The interpretive pipeline classifies the raw ""
427
+ # through the rule's value map: the :empty directive
428
+ # (the plain-rule default) binds "" — the attribute is
429
+ # still marked set, which is what the #856 mark is
430
+ # about; :nil binds nil; other directives leave the
431
+ # binding unset.
432
+ instance.value_set_for(attr.name)
433
+ case rule.value_map(:from)[:empty]
434
+ when :nil
435
+ instance.public_send(:"#{attr.name}=", nil)
436
+ when :empty
437
+ instance.public_send(:"#{attr.name}=", "")
438
+ end
439
+ next
440
+ end
441
+
442
+ # Non-collection content attrs hold the joined text
443
+ # (the interpretive path assigns element text, the runs
444
+ # concatenated).
445
+ instance.public_send(:"#{attr.name}=", runs.join)
446
+ when :raw
447
+ raws = raw_strings(value, wire_name)
448
+ next if raws.empty?
449
+
450
+ instance.public_send(:"#{attr.name}=",
451
+ attr.collection? ? raws : raws.first)
452
+ when :custom_method
453
+ elements = deferred_elements(value, wire_name, buckets)
454
+ next if elements.empty?
455
+
456
+ args = attr.collection? ? elements : elements.first
457
+ rule.deserialize(instance, args,
458
+ model_class.attributes(register),
459
+ model_class)
460
+ when :ordered_deferred
461
+ next if node # hydrated natively in children_kwargs
462
+
463
+ results = deferred_elements(value, wire_name, buckets).map do |element|
464
+ attr.cast(element, :xml, register,
465
+ lutaml_parent: instance,
466
+ lutaml_root: instance.lutaml_root || instance)
467
+ end
468
+ next if results.empty?
469
+
470
+ instance.public_send(:"#{attr.name}=",
471
+ attr.collection? ? results : results.first)
472
+ when :wildcard_any
473
+ # map_any_element: every child element no named row
474
+ # claims, in document order, bridged as wrappers — the
475
+ # interpretive pass's contract. The wildcard row's
476
+ # type_tag marks its captures in the walk.
477
+ next unless node
478
+
479
+ claimed = plan[:rows].filter_map do |wr, _wa, _wk, spelling, _wd|
480
+ next if wr.equal?(rule) || wr.name.nil?
481
+
482
+ spelling ? spelling.to_s : wr.name.to_s
483
+ end.uniq
484
+ elements = []
485
+ node.children.each do |child|
486
+ next unless child.is_a?(::Leptris::XML::Element)
487
+ next if claimed.include?(child.name)
488
+
489
+ elements << bridge_element(child)
490
+ end
491
+ next if elements.empty?
492
+
493
+ # The interpretive pass casts each collected element
494
+ # through the attribute: String keeps the wrapper,
495
+ # Serializable types build model instances.
496
+ results = elements.map do |element|
497
+ attr.cast(element, :xml, register,
498
+ lutaml_parent: instance,
499
+ lutaml_root: instance.lutaml_root || instance)
500
+ end
501
+ instance.public_send(
502
+ :"#{attr.name}=",
503
+ attr.collection? ? results : results.first,
504
+ )
505
+ when :polymorphic
506
+ results = deferred_elements(value, wire_name, buckets).map do |element|
507
+ attr.cast(element, :xml, register,
508
+ polymorphic: rule.polymorphic,
509
+ lutaml_parent: instance,
510
+ lutaml_root: instance.lutaml_root || instance)
511
+ end
512
+ next if results.empty?
513
+
514
+ instance.public_send(:"#{attr.name}=",
515
+ attr.collection? ? results : results.first)
516
+ end
517
+ end
518
+ end
519
+
520
+ # Wrapper elements for deferred rows: the ORIGINAL source
521
+ # nodes bridged through moxml's wrapper cache (wrapper
522
+ # construction only, no parse), falling back to a fragment
523
+ # parse of the captured subtree when no node is available.
524
+ # Both lists are document-ordered for the row's name.
525
+ def deferred_elements(value, name, buckets, contested_uri = nil)
526
+ return raw_strings(value, name).map { |raw| fragment_element(raw) } unless buckets
527
+
528
+ nodes = buckets[name]
529
+ return [] unless nodes
530
+
531
+ if contested_uri
532
+ # Contested name: bind only the (URI, local) matches —
533
+ # the type's own mapping namespace is the row identity.
534
+ nodes = buckets[[contested_uri, name]] || []
535
+ end
536
+ nodes.map { |n| bridge_element(n) }
537
+ end
538
+
539
+ def child_type_uri(attr, register)
540
+ type = attr.type(register)
541
+ return nil unless type.is_a?(Class) &&
542
+ type.include?(::Lutaml::Model::Serialize)
543
+
544
+ type.mappings_for(:xml, register)&.namespace_uri
545
+ end
546
+
547
+ def contested_name?(plan, rule)
548
+ plan[:contested_names]&.include?(rule.name.to_s) || false
549
+ end
550
+
551
+ def bridge_element(node)
552
+ Lutaml::Xml::LeptrisElement.new(
553
+ Moxml::Node.wrap(node, bridge_context),
554
+ )
555
+ end
556
+
557
+ def bridge_context
558
+ @bridge_context ||= Moxml::Context.new(:leptris)
559
+ end
560
+
561
+ def route_delegates(delegates, instance)
562
+ delegates.each do |rule, delegate_attr, attr, v|
563
+ target = instance.public_send(rule.delegate)
564
+ unless target
565
+ # the interpretive path instantiates absent delegate
566
+ # targets; mirror it
567
+ target = delegate_attr.type(register).new
568
+ instance.public_send(:"#{rule.delegate}=", target)
569
+ end
570
+ target.public_send(:"#{attr.name}=", v)
571
+ end
572
+ end
573
+
574
+ def delegate_of(plan, rule)
575
+ entry = plan[:rows].find { |r, _, _k, _| r.equal?(rule) }
576
+ entry && entry[4]
577
+ end
578
+
579
+ # Delegate rules hold their values for post-instance routing
580
+ # (the target object must exist first); plain rules land in
581
+ # kwargs directly.
582
+ def assign(kwargs, delegates, delegate, rule, attr, value)
583
+ if delegate
584
+ delegates << [rule, delegate, attr, value]
585
+ else
586
+ kwargs[attr.name.to_sym] = value
587
+ end
588
+ end
589
+
590
+ def content_runs(value)
591
+ out = []
592
+ value.count.times do |i|
593
+ c = value.at(i)
594
+ next unless c.name.nil? && c.kind == :collection
595
+
596
+ out.concat(Array.new(c.count) { |j| c.at(j).string_value })
597
+ end
598
+ out
599
+ end
600
+
601
+ # Native collection rows echo their producing row's name
602
+ # (leptris 1.9.178).
603
+ def native_collection(value, name)
604
+ value.count.times do |i|
605
+ c = value.at(i)
606
+ next unless c.kind == :collection && c.name == name
607
+
608
+ return Array.new(c.count) { |j| c.at(j).string_value }
609
+ end
610
+ nil
611
+ end
612
+
613
+ def raw_strings(value, name)
614
+ out = []
615
+ value.count.times do |idx|
616
+ child = value.at(idx)
617
+ if child.name == name && child.kind == :raw
618
+ out << child.string_value
619
+ end
620
+ end
621
+ out
622
+ end
623
+
624
+ # Fragment deferral: parse the captured subtree back into a
625
+ # wrapper element. Ancestor namespace context is absent (the
626
+ # compiler only defers on namespace-free model chains).
627
+ def fragment_element(raw)
628
+ Lutaml::Xml::Adapter::LeptrisAdapter.parse(raw).root
629
+ end
630
+
631
+ # Attribute names claimed by exactly one attr row — the only
632
+ # names eligible for #754/#790 lenient binding (multi-claimant
633
+ # names stay exact; per #841 leniency is sole-claimant-only).
634
+ def sole_claimant_names(plan)
635
+ # Frozen with the plan: recomputing the tally per hydration
636
+ # showed in the #876-era profile as Enumerable#find/tally churn.
637
+ plan[:sole_claimants] ||= plan[:attr_rows]
638
+ .map { |rule, _attr, row_name| row_name || rule.name.to_s }.tally
639
+ .select { |_n, c| c == 1 }.keys
640
+ end
641
+
642
+ # Local-name attribute lookup on the source node, qualified
643
+ # spellings included (w:val matches rule "val").
644
+ def lenient_node_attribute(node, local_name)
645
+ return nil unless node.respond_to?(:attributes)
646
+
647
+ node.attributes.each_value do |attr|
648
+ # Leptris::XML::Attr carries the raw (possibly prefixed)
649
+ # name; strip the prefix for the local-name comparison.
650
+ name = attr.name.to_s
651
+ name = name.split(":").last if name.include?(":")
652
+ return attr.value if name == local_name
653
+ end
654
+ nil
655
+ end
656
+
657
+ def group_children(value, row_tags = nil)
658
+ # One crossing per subtree: names/tags/children come back in
659
+ # parallel arrays from leptris_plan_value_children_snapshot.
660
+ # The plan-path version gate (PlanCompiler.compile) already
661
+ # guarantees leptris >= 1.9.273.0.
662
+ names, tags, children = value.children_snapshot
663
+ grouped = {}
664
+ want_tags = !row_tags.nil?
665
+ tagged = want_tags ? {} : nil
666
+ names.each_with_index do |name, i|
667
+ next if name.nil? # content runs, read separately
668
+
669
+ child = children[i]
670
+ (grouped[name] ||= []) << child
671
+ if want_tags && tags[i] != 0
672
+ (tagged[tags[i]] ||= []) << child
673
+ end
674
+ end
675
+ [grouped, tagged]
676
+ end
677
+
678
+ # Element children bucketed by local name, document order
679
+ # preserved — the node-side mirror of group_children for
680
+ # ordered-child hydration. Only built when the plan's subtree
681
+ # needs source nodes; namespace-qualified models never get
682
+ # here (the compiler keeps them interpretive).
683
+ # Contested names need (URI, local) node buckets; every other
684
+ # plan hydrates from the name view alone — the qualified keys
685
+ # are one array + hash write per child and measured as an
686
+ # allocation regression on element-heavy corpora (niso gate).
687
+ def element_buckets(node, contested_names = nil)
688
+ buckets = {}
689
+ qualified = contested_names && !contested_names.empty?
690
+ node.children.each do |child|
691
+ next unless child.is_a?(::Leptris::XML::Element)
692
+
693
+ name = child.name
694
+ (buckets[name] ||= []) << child
695
+ if qualified
696
+ uri = child.namespace&.href
697
+ (buckets[[uri, name]] ||= []) << child if uri
698
+ end
699
+ end
700
+ buckets
701
+ end
702
+
703
+ # Selected source nodes for a child row: contested names
704
+ # dispatch by (URI, local); every other row binds the whole
705
+ # name bucket leniently (any qualification). The pair form
706
+ # this replaces allocated a fresh array per row per node on
707
+ # the ordered walk (niso gate).
708
+ def child_selection(buckets, name, child_plan, contested)
709
+ all = buckets && buckets[name]
710
+ return all unless buckets && all && contested
711
+
712
+ tree_ns = child_plan[:tree][:ns]
713
+ exact_uri = tree_ns.is_a?(Hash) ? tree_ns[:exact] : tree_ns
714
+ exact_uri ? (buckets[[exact_uri, name]] || []) : all
715
+ end
716
+
717
+ # Per-(plan, register) child-row tuples, computed once and
718
+ # reused across every node the walk hands this plan: the
719
+ # per-node type lookup, compile hit, child register
720
+ # resolution, and contested flag were each a measurable slice
721
+ # of the ordered-lane allocation profile (niso gate).
722
+ def child_row_plans(plan, register)
723
+ plan[:child_row_memo] ||= {}
724
+ plan[:child_row_memo][register] ||= plan[:rows].map do |rule, attr, kind, _spelling, _delegate, row_type|
725
+ next nil unless CHILD_ROW_KINDS.include?(kind)
726
+
727
+ child_type = row_type || attr.type(register)
728
+ child_plan = PlanCompiler.compile(child_type, register)
729
+ child_register = Lutaml::Model::Register.resolve_for_child(
730
+ child_type, register
731
+ )
732
+ [child_type, child_plan, child_register,
733
+ contested_name?(plan, rule)]
734
+ end
735
+ end
736
+
737
+ # Plan-frozen wire names: rule.name.to_s minted a fresh string
738
+ # per row per node in the hot loop.
739
+ def row_wire_names(plan)
740
+ plan[:row_wire_names] ||= plan[:rows].map do |rule, _attr, _kind, _spelling, _delegate, _row_type|
741
+ rule.name.to_s
742
+ end
743
+ end
744
+ end
745
+ end
746
+ end
747
+ end
748
+ end