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,650 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Leptris
6
+ # Phase 5 slice: compile a model's XML mapping into a
7
+ # Leptris::XML::Descriptor plan and materialize whole documents in
8
+ # one native pass (leptris_plan_walk) — no moxml wrapper tree, no
9
+ # per-element Ruby dispatch. Measured on the 200-item probe:
10
+ # walk+hydrate 8.7x faster, 82% fewer allocations than the
11
+ # interpretive path, hydration-equal output.
12
+ #
13
+ # A model compiles when EVERY rule is plan-shaped; richer rows
14
+ # defer their subtree verbatim and interpret post-walk rather
15
+ # than opting the whole model out:
16
+ # - map_attribute / map_element / content / raw rows
17
+ # - Value-scalar types or nested Serializables
18
+ # - custom methods, polymorphism, unions, and ordered/mixed
19
+ # children capture :raw and interpret post-walk
20
+ # - ordered/mixed root mappings compile; the entry points
21
+ # reconstruct element_order from the node surface
22
+ # - attributes not derived/union/polymorphic
23
+ #
24
+ # The fast path is opt-in (Config.xml_plan_fast_path) while the
25
+ # full semantics audit (parent links, consolidation, ordering
26
+ # metadata) completes.
27
+ module PlanCompiler
28
+ # TODO.perf/15: env-gated histogram of why models opt out of the
29
+ # plan path. Set PLAN_COMPILE_STATS=1 and read
30
+ # PlanCompiler.plan_stats after a parse. Zero cost when disabled.
31
+ def self.plan_stats
32
+ @plan_stats ||= Hash.new(0)
33
+ end
34
+
35
+ def self.opt_out!(clause)
36
+ @plan_stats ||= Hash.new(0)
37
+ @plan_stats[clause] += 1 if ENV["PLAN_COMPILE_STATS"]
38
+ nil
39
+ end
40
+
41
+ # Isolated holder: suites freeze model classes; a cache on a
42
+ # frozen constant would be immutable (TypeProbeCache precedent).
43
+ PLAN_CACHE = ::Class.new do
44
+ class << self
45
+ def cache
46
+ @cache ||= {}
47
+ end
48
+ end
49
+ end.cache
50
+
51
+ # Name-matched element row kinds eligible for the :unqualified
52
+ # ns form (leptris#1560). Text rows (content) match no element
53
+ # name and wildcard rows carry their own :any.
54
+ ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
55
+ raw].freeze
56
+ # Row kinds whose hydration can dispatch per source node —
57
+ # the only safe claimants of a contested (multi-row) name.
58
+ PER_NODE_ROW_KINDS = %i[nested ordered_deferred
59
+ child_deferred].freeze
60
+
61
+ class << self
62
+ def compile(model_class, register)
63
+ # Version gate: below MIN_LEPTRIS_VERSION (the single
64
+ # capability floor) gems take the interpretive path via
65
+ # the nil opt-out.
66
+ return nil unless Leptris.plan_path_compatible?
67
+
68
+ # A child model's declared lutaml_default_register takes
69
+ # precedence over the ambient (parent) register — the same
70
+ # contract the interpretive path applies through
71
+ # Register.resolve_for_child. Without this, plan compilation
72
+ # resolves the child's symbol attribute types in the parent
73
+ # context and raises UnknownTypeError for ids registered only
74
+ # in the child's own register (#876).
75
+ register = Lutaml::Model::Register.resolve_for_child(
76
+ model_class, register
77
+ )
78
+
79
+ key = [model_class, register]
80
+ return PLAN_CACHE[key] if PLAN_CACHE.key?(key)
81
+
82
+ # Cycle guard: a self-referential model (JATS sec-in-sec) must
83
+ # resolve to nil — the interpretive pipeline owns it. The guard
84
+ # is THREAD-LOCAL (a shared in-progress set races: a concurrent
85
+ # same-key compile would cache false permanently — the #828
86
+ # lesson); the nil at the cycle point is NOT cached — the
87
+ # outermost build completes and caches the real verdict. The
88
+ # :cycle stat makes the guard visible in plan_stats — without
89
+ # it a cyclic graph (uniword pict↔shape, sdt→sdt) reads as
90
+ # "nil with an empty histogram", indistinguishable from a
91
+ # silent build failure.
92
+ stack = (Thread.current[:plan_compiler_stack] ||= [])
93
+ return opt_out!(:cycle) if stack.include?(key)
94
+
95
+ stack.push(key)
96
+ begin
97
+ PLAN_CACHE[key] = build(model_class, register)
98
+ ensure
99
+ stack.pop
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def build(model_class, register)
106
+ return nil unless model_class.is_a?(Class) &&
107
+ model_class.include?(::Lutaml::Model::Serialize)
108
+
109
+ mapping = model_class.mappings_for(:xml, register)
110
+ return nil unless compilable_mapping?(mapping)
111
+ # Ordered/mixed models ride the plan path only when opted
112
+ # in: the per-node ordered hydration beats the interpretive
113
+ # lane on time but allocates above the downstream budget
114
+ # gates (niso pnas ratio 1.25) — it ships dark until the
115
+ # allocation diet lands.
116
+ if (mapping.ordered? || mapping.mixed_content?) &&
117
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
118
+ return opt_out!(:ordered_opt_in)
119
+ end
120
+ # Processing-instruction mappings (rfcxml's <?rfc toc?>)
121
+ # have no plan-row spelling — the interpretive path owns
122
+ # them (compiling here silently dropped pi_settings).
123
+ return opt_out!(:processing_instruction) unless
124
+ mapping.processing_instruction_mappings.empty?
125
+
126
+ rows = []
127
+ attr_rows = [] # [[rule, attr, row_name]] hydration metadata
128
+ plan_attrs = [] # [{name:, kind:}] rows for the engine plan
129
+ compiled = [] # [rule, attr, kind, spelling, delegate_target]
130
+ ns_attr_names = [] # exact-URI attribute rows (ambiguity guard)
131
+ # Local-name claim tallies for attribute rows: a name claimed
132
+ # by both an exact-URI row and a plain (any-namespace) row
133
+ # cannot be dispatched by the walk — the ns form :none
134
+ # matches any qualification, so the plain row captures the
135
+ # qualified spelling too (#758). The interpretive matcher
136
+ # owns shared names.
137
+ attr_local_claims = Hash.new { |h, k| h[k] = { ns: 0, plain: 0 } }
138
+ cdata = false
139
+ mixed_content = false
140
+ needs_nodes = false
141
+ collection_defaults = [] # collection attrs with element rows
142
+ tag = 100 # type_tag echo space for callback-routed rows
143
+ model_ns = plan_namespace(model_class, mapping, register)
144
+
145
+ # Collection rows are NATIVE since leptris 1.9.178 —
146
+ # collection values echo name and type_tag (the #220 gap is
147
+ # closed), so any number routes without callbacks.
148
+ true
149
+
150
+ mapping.mappings(register).each do |rule|
151
+ delegate_target = nil
152
+ attr = if rule.delegate
153
+ delegate_target = model_class.attributes(register)[rule.delegate]
154
+ t = delegate_target&.type(register)
155
+ if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
156
+ t.attributes(register)[rule.to]
157
+ end
158
+ else
159
+ model_class.attributes(register)[rule.to]
160
+ end
161
+ return opt_out!(:attr_nil) if attr.nil?
162
+ return opt_out!(:derived) if attr.derived?
163
+
164
+ # Partition rows (TODO 34 step 2) ride native predicates
165
+ # only in the plain-capture shape; any other when_attribute
166
+ # shape stays interpretive (the suite pins nested-target
167
+ # partitions to the interpretive path).
168
+ unless rule.when_attribute.empty?
169
+ t = attr.type(register)
170
+ plain_partition = !rule.delegate &&
171
+ !rule.has_custom_method_for_deserialization? &&
172
+ !rule.polymorphic_mapping? &&
173
+ !attr.polymorphic? && !attr.union? &&
174
+ !(t.is_a?(Class) &&
175
+ t.include?(::Lutaml::Model::Serialize))
176
+ return opt_out!(:non_plain_partition) unless plain_partition
177
+ end
178
+
179
+ # Interpretive hydration materializes every mapped
180
+ # collection, present or not; the fast path mirrors with
181
+ # constructor-time empty arrays.
182
+ unless rule.attribute? || !attr.collection?
183
+ collection_defaults << attr.name.to_sym
184
+ end
185
+
186
+ # Fragment deferrals lose ancestor namespace context —
187
+ # ns-qualified models keep those rules interpretive.
188
+ fragment_needed = rule.has_custom_method_for_deserialization? ||
189
+ rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
190
+ return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
191
+
192
+ if rule.attribute?
193
+ opt = compile_attribute_row(rule, attr, register, mapping,
194
+ attr_rows, plan_attrs,
195
+ ns_attr_names, attr_local_claims)
196
+ return opt_out!(opt) if opt
197
+ elsif rule.content_mapping?
198
+ return opt_out!(:multi_content) if content_rows(rows) >= 1
199
+
200
+ mixed_content = true
201
+ compiled << [rule, attr,
202
+ attr.collection? ? :content : :content_deferred,
203
+ nil, delegate_target]
204
+ rows << { name: "__content__#{compiled.size}", kind: :content }
205
+ elsif rule.raw_mapping? || rule.raw == :element
206
+ compiled << [rule, attr, :raw, nil, delegate_target]
207
+ rows << { name: rule.name.to_s, kind: :raw }
208
+ elsif rule.has_custom_method_for_deserialization?
209
+ needs_nodes = true
210
+ compiled << [rule, attr, :custom_method, nil, delegate_target]
211
+ rows << { name: rule.name.to_s, kind: :raw }
212
+ elsif rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
213
+ type = attr.type(register)
214
+ return opt_out!(:non_serializable_poly) unless serializable_type?(type) || attr.union?
215
+
216
+ needs_nodes = true
217
+ compiled << [rule, attr, :polymorphic, nil, delegate_target]
218
+ rows << { name: rule.name.to_s, kind: :raw }
219
+ else
220
+ type = attr.type(register)
221
+ if serializable_type?(type)
222
+ child = compile(type, register)
223
+ if child.nil? &&
224
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
225
+ child_mapping = type.mappings_for(:xml, register)
226
+ if child_mapping&.ordered? ||
227
+ child_mapping&.mixed_content?
228
+ # A child refused ONLY by the ordered opt-in gate
229
+ # cascades: the bridged-island deferral drops its
230
+ # mixed children (customxml schemaRefs), so the
231
+ # parent compiles to nil — main's whole-model
232
+ # interpretive shape.
233
+ return opt_out!(:ordered_opt_in_cascade)
234
+ end
235
+ end
236
+ needs_nodes = true if child.nil? || child[:ordered]
237
+ compiled_entry, row = child_row_for(rule, attr, type, child,
238
+ delegate_target,
239
+ model_ns)
240
+ compiled << compiled_entry
241
+ needs_nodes ||= child && child[:needs_nodes]
242
+ rows << row
243
+ elsif rule.multiple_mappings?
244
+ rule.name.each do |spelling|
245
+ compiled << [rule, attr, :spelling, spelling.to_s,
246
+ delegate_target]
247
+ rows << { name: spelling.to_s, kind: :callback,
248
+ type_tag: (tag += 1) }
249
+ end
250
+ else
251
+ return opt_out!(:non_scalar) unless scalar_type?(attr, register)
252
+
253
+ row = { name: rule.name.to_s }
254
+ if rule.namespace_set?
255
+ row[:ns] = child_ns(rule, model_ns)
256
+ else
257
+ # The interpretive writer qualifies an element row
258
+ # by its attribute's type namespace when the rule
259
+ # declares none (resolve step 4). Rows without the
260
+ # type form inherit the tree namespace and land
261
+ # under the wrong default xmlns (uniword
262
+ # CoreProperties: <dc:title> spelled <title>).
263
+ type_ns_form = element_type_ns_form(rule, attr,
264
+ register, model_ns)
265
+ return opt_out!(:elem_type_ns_symbol) if type_ns_form == :bail_symbol
266
+ return opt_out!(:elem_type_ns_aliases) if type_ns_form == :bail_aliases
267
+
268
+ row[:ns] = type_ns_form if type_ns_form
269
+ end
270
+ # lutaml-model#88: same-name rows partitioned by the
271
+ # rule's discriminator ride the engine's exclusive
272
+ # predicate match (leptris 1.9.221+, #1272).
273
+ row[:when] = rule.when_attribute unless rule.when_attribute.empty?
274
+
275
+ if attr.collection?
276
+ compiled << [rule, attr, :collection_native, nil,
277
+ delegate_target]
278
+ rows << row.merge(kind: :collection)
279
+ else
280
+ cdata ||= rule.cdata
281
+ compiled << [rule, attr, :scalar, nil, delegate_target]
282
+ rows << row.merge(kind: :scalar)
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ row_tags = partition_row_tags!(compiled, rows, tag)
289
+
290
+ # Same-name rows cross-echo in the engine walk: every source
291
+ # element of a name lands in every row of that name (two
292
+ # w14:/w15: docId rows each capture both elements). Names
293
+ # claimed by more than one element row are contested — they
294
+ # hydrate per source node (the ordered_deferred pattern)
295
+ # when every claimant is per-node-dispatchable, and opt the
296
+ # model out otherwise (the interpretive matcher owns the
297
+ # (URI, local) dispatch there).
298
+ contested = contested_element_names(rows, compiled)
299
+ return opt_out!(:contested_names) if contested == :unsafe
300
+
301
+ needs_nodes = true unless contested.empty?
302
+
303
+ # The plan serializer does not emit namespace declarations —
304
+ # namespaced models serialize through the interpretive
305
+ # writer (lutaml-model#847: standalone to_xml under leptris
306
+ # dropped the element xmlns entirely).
307
+ # Exact-URI attribute rows include their namespaced spelling
308
+ # here: the plan serializer writes attributes by local name
309
+ # (no prefixed wire spelling, no declaration emission —
310
+ # leptris#1551), so these models serialize interpretively.
311
+ namespaced = !model_ns.nil? || rows.any? { |r| r[:ns] } ||
312
+ plan_attrs.any? { |a| a[:ns] }
313
+
314
+ flags = []
315
+ flags << :cdata if cdata
316
+ flags << :mixed_content if mixed_content
317
+ # Unconditional: a namespace-less model's children match by
318
+ # local name even when the document declares a default or
319
+ # prefixed namespace (#932) — the interpretive matcher's
320
+ # unprefixed-any-URI behavior; elements (unlike attributes)
321
+ # inherit the default xmlns, so the strict no-URI default
322
+ # dropped every child of a namespaced document.
323
+ flags << :ns_lenient
324
+
325
+ # Namespace-less model child rows bind the strict unwritten
326
+ # spelling (bare, or inherited default xmlns) — the
327
+ # interpretive matcher's exact no-namespace semantic,
328
+ # refusing prefixed spellings where the ns_lenient
329
+ # superset bound them. Rows with an explicit form (rule ns,
330
+ # type ns, :none, :any) are untouched; name-matched element
331
+ # row kinds only.
332
+ if model_ns.nil?
333
+ rows.each do |r|
334
+ next if r[:ns]
335
+
336
+ r[:ns] = :unqualified if ROW_KINDS_NS_UNQUALIFIED.include?(r[:kind])
337
+ end
338
+ end
339
+
340
+ tree = { name: mapping.root_element.to_s,
341
+ attributes: plan_attrs, children: rows }
342
+ tree[:ns] = model_ns if model_ns
343
+ tree[:flags] = flags unless flags.empty?
344
+ begin
345
+ # Lazy: the Opal boot loads this file, and leptris is a
346
+ # native gem there — the require only belongs on the
347
+ # engines-enabled path.
348
+ require "leptris/xml/descriptor"
349
+ descriptor = ::Leptris::XML::Descriptor.build(**tree)
350
+ rescue StandardError
351
+ return nil
352
+ end
353
+
354
+ return opt_out!(:attr_ns_claim_conflict) if attr_local_claims
355
+ .any? { |_, c| c[:ns].positive? && c[:plain].positive? }
356
+
357
+ # Exact-URI attribute rows rely on the sole-claimant lenient
358
+ # recovery for out-of-namespace spellings (the interpretive
359
+ # exact-first-then-any-qualification precedence), and that
360
+ # recovery reads the source node — models carrying such rows
361
+ # must receive one through the buckets chain.
362
+ needs_nodes = true unless ns_attr_names.empty?
363
+
364
+ # The catch-all row: kind :wildcard with an explicit :any ns
365
+ # form (pad0) — the two-pass walk routes every child no
366
+ # named row claimed here, tagged for hydrator routing.
367
+ # Document order recovery and bridging happen in the
368
+ # hydrator against the source node.
369
+ if (any_rule = mapping.any_element_rule)
370
+ any_attr = model_class.attributes(register)[any_rule.to]
371
+ return opt_out!(:attr_nil) if any_attr.nil?
372
+
373
+ needs_nodes = true
374
+ wildcard_tag = tag += 1
375
+ compiled << [any_rule, any_attr, :wildcard_any, nil, nil]
376
+ rows << { name: "__lutaml_any__", kind: :wildcard, ns: :any,
377
+ type_tag: wildcard_tag }
378
+ end
379
+
380
+ { descriptor: descriptor, tree: tree, rows: compiled,
381
+ attr_rows: attr_rows, mapping: mapping,
382
+ row_tags: row_tags, namespaced: namespaced,
383
+ ordered: mapping.ordered? || mapping.mixed_content?,
384
+ needs_nodes: needs_nodes,
385
+ collection_defaults: collection_defaults,
386
+ contested_names: contested.empty? ? nil : contested,
387
+ ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names,
388
+ wildcard_tag: (wildcard_tag if defined?(wildcard_tag)) }
389
+ end
390
+
391
+ # Attribute rows: identity is (URI, local) (lutaml-model#744).
392
+ # A type-level namespace compiles into an exact-URI AttrPlan ns
393
+ # row (libleptris 1.9.289): the walk captures the (URI, local)
394
+ # match and the sole-claimant lenient recovery in PlanHydrator
395
+ # keeps the interpretive exact-first-then-any-qualification
396
+ # precedence for out-of-namespace spellings. Returns the
397
+ # opt-out clause when the row must stay interpretive, nil
398
+ # otherwise. Types declaring alias URI families stay
399
+ # interpretive (a plan row holds a single exact URI and
400
+ # non-sole-claimant alias families have no plan-side
401
+ # resolution order); :blank / :inherit arrive as Symbols —
402
+ # no URI to express in a row.
403
+ def compile_attribute_row(rule, attr, register, mapping,
404
+ attr_rows, plan_attrs, ns_attr_names,
405
+ attr_local_claims)
406
+ return :non_scalar unless scalar_type?(attr, register)
407
+
408
+ raw_name = rule.name.to_s
409
+ # W3C-reserved xml:* attributes bind by raw name — the xml
410
+ # prefix is implicit (never a model namespace), so form
411
+ # defaults must not qualify them under the model's URI
412
+ # (w:t's xml:space under a :qualified WML mapping).
413
+ if raw_name.start_with?("xml:")
414
+ attr_local_claims[raw_name][:plain] += 1
415
+ plan_attrs << { name: raw_name }
416
+ attr_rows << [rule, attr, raw_name]
417
+ return nil
418
+ end
419
+ type_ns = attr.type_namespace_class(register)
420
+ raw_name
421
+ if type_ns.nil?
422
+ # attribute_form_default :qualified on the model's
423
+ # namespace qualifies locally-declared attributes under
424
+ # the parent URI — the interpretive
425
+ # resolve_attribute_namespace contract (WML: updateFields
426
+ # w:val under xmlns="..."). The W3C default is
427
+ # :unqualified, so == :qualified means explicitly set.
428
+ # Rules with an explicit :blank stay unprefixed.
429
+ ns_class = mapping.namespace_class
430
+ if rule.namespace_param != :blank && ns_class &&
431
+ ns_class.attribute_form_default == :qualified
432
+ row_name = local_attr_name(raw_name)
433
+ attr_local_claims[row_name][:ns] += 1
434
+ plan_attrs << { name: row_name,
435
+ ns: { exact: ns_class.uri.to_s,
436
+ prefix: ns_class.prefix_default&.to_s } }
437
+ ns_attr_names << row_name
438
+ attr_rows << [rule, attr, row_name]
439
+ return nil
440
+ end
441
+
442
+ attr_local_claims[raw_name][:plain] += 1
443
+ plan_attrs << { name: raw_name }
444
+ attr_rows << [rule, attr, raw_name]
445
+ return nil
446
+ end
447
+ return :attr_type_ns_symbol if type_ns.is_a?(Symbol)
448
+ return :attr_type_ns_aliases unless type_ns.all_uris.size == 1
449
+
450
+ # Exact-URI rows match the engine's (URI, local) capture by
451
+ # the LOCAL name — a prefixed rule spelling (xlink:href)
452
+ # binds its local part under the row's URI. Plain rows keep
453
+ # the raw name (matches-any-qualification capture). The
454
+ # claims tally keys by the effective match key so an exact
455
+ # row and a plain row claiming the same local name still
456
+ # opt the model out (#758).
457
+ row_name = local_attr_name(raw_name)
458
+ attr_local_claims[row_name][:ns] += 1
459
+ plan_attrs << { name: row_name,
460
+ ns: { exact: type_ns.uri.to_s,
461
+ prefix: type_ns.prefix_default&.to_s } }
462
+ ns_attr_names << row_name
463
+ attr_rows << [rule, attr, row_name]
464
+ nil
465
+ end
466
+
467
+ def local_attr_name(name)
468
+ name.include?(":") ? name.split(":", 2).last : name
469
+ end
470
+
471
+ # Names claimed by more than one element row (engine-row
472
+ # view; content and wildcard pseudo-rows are exempt). Returns
473
+ # the contested name list, or :unsafe when any claimant row
474
+ # cannot dispatch per node — the model then compiles to nil
475
+ # and the interpretive matcher owns the dispatch.
476
+ def contested_element_names(rows, compiled)
477
+ contested = []
478
+ pseudo_row_kinds = %i[content wildcard].freeze
479
+ rows.group_by { |r| r[:name] }.each do |name, group|
480
+ next if group.length < 2 || name.nil?
481
+ next if group.any? { |r| pseudo_row_kinds.include?(r[:kind]) }
482
+ # when_attribute partitions route exclusively by engine
483
+ # predicate (leptris 1.9.221+ #1272) — same-name partition
484
+ # groups are engine-managed, not cross-echoed.
485
+ next if group.any? { |r| r[:when] }
486
+
487
+ kinds = compiled.filter_map do |rule, _attr, kind, _spelling, _del|
488
+ next if rule.nil?
489
+
490
+ names = rule.name.is_a?(Array) ? rule.name.map(&:to_s) : [rule.name.to_s]
491
+ kind if names.include?(name)
492
+ end
493
+ return :unsafe unless kinds.all? do |k|
494
+ PER_NODE_ROW_KINDS.include?(k)
495
+ end
496
+
497
+ contested << name
498
+ end
499
+ contested
500
+ end
501
+
502
+ # Row entries for a serializable child. Three shapes: # - nil child (a cyclic graph, or a shape that opted out):
503
+ # :child_deferred — the ROW defers per-node instead of
504
+ # opting the whole model out; the buckets chain delivers
505
+ # source nodes and the interpretive cast runs the full
506
+ # machinery on each island. (The whole-model bail here
507
+ # cascaded child_uncompilable up entire trees — uniword's
508
+ # Run → pict cycle nilled DocumentRoot.)
509
+ # - ordered child: :ordered_deferred — the per-node plan
510
+ # walk + element_order rebuild (PlanOrder) carry the
511
+ # namespace context in the child plan's own exact-URI
512
+ # rows; nothing is lost under a namespaced parent.
513
+ # - otherwise: :nested with the child's tree.
514
+ # Returns [[compiled tuple], tree row].
515
+ def child_row_for(rule, attr, type, child, delegate_target,
516
+ model_ns)
517
+ if child.nil?
518
+ [[rule, attr, :child_deferred, nil, delegate_target, type],
519
+ { name: rule.name.to_s, kind: :raw }]
520
+ elsif child[:ordered]
521
+ [[rule, attr, :ordered_deferred, nil, delegate_target, type],
522
+ { name: rule.name.to_s, kind: :raw }]
523
+ else
524
+ row = { name: rule.name.to_s, kind: :nested,
525
+ plan: child[:tree] }
526
+ row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
527
+ [[rule, attr, :nested, nil, delegate_target, type], row]
528
+ end
529
+ end
530
+
531
+ def compilable_mapping?(mapping)
532
+ # Mapping's uniform interface: root_mappings defaults to
533
+ # false on the base (KeyValue overrides with its own).
534
+ mapping.root_element && !mapping.root_mappings?
535
+ end
536
+
537
+ # Partition bookkeeping for when_attribute rows (#88, TODO
538
+ # 34 step 2): the engine matches same-name rows exclusively —
539
+ # first matching row wins — so predicate rows must PRECEDE any
540
+ # plain sibling on the same name (a plain-first order
541
+ # double-captures: the plain row takes everything and the
542
+ # predicates still claim their matches). Every row of a
543
+ # partitioned name gets a distinct type_tag; plan values echo
544
+ # it back, giving the hydrator row-exact routing with no
545
+ # per-occurrence re-derivation. Returns {compiled_index =>
546
+ # tag} (nil when the model has no partitions). compiled and
547
+ # rows are parallel and stay in lockstep through the reorder.
548
+ def partition_row_tags!(compiled, rows, tag)
549
+ by_name = rows.each_with_index
550
+ .group_by { |(row, _)| row[:name] }
551
+ .select { |_name, pairs| pairs.any? { |(row, _)| row[:when] } }
552
+ return nil if by_name.empty?
553
+
554
+ permutation = by_name.each_value.flat_map do |pairs|
555
+ pairs.sort_by { |(row, i)| [row[:when] ? 0 : 1, i] }
556
+ .map(&:last)
557
+ end
558
+ remaining = (0...rows.length).to_a - permutation
559
+ permutation.concat(remaining)
560
+
561
+ row_tags = {}
562
+ permutation.each_with_index do |old_index, new_index|
563
+ row = rows[old_index]
564
+ next unless by_name.key?(row[:name])
565
+
566
+ tag += 1
567
+ row[:type_tag] = tag
568
+ row_tags[new_index] = tag
569
+ end
570
+ rows.replace(permutation.map { |i| rows[i] })
571
+ compiled.replace(permutation.map { |i| compiled[i] })
572
+ row_tags
573
+ end
574
+
575
+ # Attribute for a rule — delegate rules resolve against their
576
+ # target model's attributes.
577
+ def attr_of(model_class, rule, register)
578
+ attr = model_class.attributes(register)[rule.to]
579
+ return attr if attr || !rule.delegate
580
+
581
+ target = model_class.attributes(register)[rule.delegate]
582
+ t = target&.type(register)
583
+ if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
584
+ t.attributes(register)[rule.to]
585
+ end
586
+ end
587
+
588
+ # Rule-level namespace → ChildPlan ns form (leptris 1.9.178):
589
+ # the child binds by local name under the rule's URI with any
590
+ # prefix. Blank-namespace rules (xmlns="") match :none.
591
+ def child_ns(rule, _model_ns)
592
+ uri = rule.namespace
593
+ return :none if uri.nil? || uri.to_s.empty?
594
+
595
+ { exact: uri.to_s, prefix: rule.prefix&.to_s }
596
+ end
597
+
598
+ # Type-namespace ns form for a scalar element row, mirroring
599
+ # the interpretive resolve_element_namespace step 4: the
600
+ # attribute's type namespace qualifies the row when the rule
601
+ # declares none. Rows inheriting the tree namespace (type ns
602
+ # nil, :inherit, or equal to the model's own URI) get no form.
603
+ # :blank and alias families have no row-expressible form —
604
+ # the model stays interpretive.
605
+ def element_type_ns_form(rule, attr, register, model_ns)
606
+ return nil if rule.namespace_set?
607
+
608
+ type_ns = attr.type_namespace_class(register)
609
+ return nil if type_ns.nil? || type_ns == :inherit
610
+ return :bail_symbol if type_ns.is_a?(Symbol)
611
+ return :bail_aliases unless type_ns.all_uris.size == 1
612
+
613
+ form = { exact: type_ns.uri.to_s,
614
+ prefix: type_ns.prefix_default&.to_s }
615
+ return nil if form[:exact] == model_ns&.[](:exact)
616
+
617
+ form
618
+ end
619
+
620
+ def content_rows(rows)
621
+ rows.count { |r| r[:kind] == :content }
622
+ end
623
+
624
+ # Model-level namespace: exact URI match with lenient prefixes
625
+ # — children bind by local name under any prefix the document
626
+ # bound to the URI (#754 adoption semantics on the engine).
627
+ def plan_namespace(_model_class, mapping, _register)
628
+ # Mapping's uniform interface: namespace_class defaults
629
+ # to nil on the base (Xml overrides).
630
+ ns_class = mapping.namespace_class
631
+ return nil unless ns_class&.uri
632
+
633
+ { exact: ns_class.uri.to_s,
634
+ prefix: ns_class.prefix_default&.to_s }
635
+ end
636
+
637
+ def scalar_type?(attr, register)
638
+ type = attr.type(register)
639
+ type.is_a?(Class) && type < ::Lutaml::Model::Type::Value &&
640
+ !attr.custom_collection?
641
+ end
642
+
643
+ def serializable_type?(type)
644
+ type.is_a?(Class) && type.include?(::Lutaml::Model::Serialize)
645
+ end
646
+ end
647
+ end
648
+ end
649
+ end
650
+ end