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,702 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Leptris
6
+ # Serialize-side mirror of the plan fast path: builds the output
7
+ # tree directly through leptris's fused create_child chain — no
8
+ # XmlElement tree, no DeclarationPlanner pass, no builder context
9
+ # stack, no moxml wrapper minting. Serialized shapes must be the
10
+ # plain ones (attributes, scalars, collections, nested models,
11
+ # raw fragments, content runs); custom-method, polymorphic,
12
+ # union, and multi-spelling rows keep the interpretive serializer
13
+ # (their output flows through hash-shaped custom APIs).
14
+ #
15
+ # Ordered/mixed models serialize from element_order when the
16
+ # instance carries one (parsed models): text runs, comments, and
17
+ # element entries in document order, mirroring the interpretive
18
+ # order applier — including its content-attr-first text reads
19
+ # (mutations to the model show up) and CDATA flattening. Models
20
+ # without element_order fall back interpretively (return nil).
21
+ # Writer-added values the order snapshot does not cover ride the
22
+ # OrderReconciler: the walk consumes the reconciled order and
23
+ # unplaceable rules emit appended (the interpretive
24
+ # apply_remaining_rules contract).
25
+ module PlanSerializer
26
+ SERIALIZABLE_KINDS = %i[scalar collection_native collection_cb
27
+ nested ordered_deferred raw content
28
+ content_deferred].freeze
29
+ PLAN_SERIALIZABLE_KINDS = SERIALIZABLE_KINDS
30
+ CONTENT_KINDS = %i[content content_deferred].freeze
31
+
32
+ # Reconciler host: the interpretive support modules are written
33
+ # against the Transformation's combined surface (the reconciler
34
+ # consumes applier helpers such as find_rule_for_element and
35
+ # rule_discriminator), so the plan lane drives them through a
36
+ # dedicated host holding both. The helpers are pure functions
37
+ # of their arguments — the frozen host carries no state.
38
+ class ReconcilerHost
39
+ include(TransformationSupport::OrderedApplier)
40
+ include(TransformationSupport::OrderReconciler)
41
+ include(TransformationSupport::SkipLogic)
42
+ end
43
+ RECONCILER = ReconcilerHost.new.freeze
44
+
45
+ class << self
46
+ # plan: the compiler's entry for instance's class
47
+ def call(instance, plan, register = nil)
48
+ # The child model's declared lutaml_default_register takes
49
+ # precedence over the ambient register — the resolve_for_child
50
+ # contract (#876); without it, child plans and attribute
51
+ # serialization resolve in the parent context.
52
+ register = Lutaml::Model::Register.resolve_for_child(
53
+ instance.class,
54
+ register || Lutaml::Model::Config.default_register,
55
+ )
56
+ return nil if plan[:ordered] && instance.element_order.nil?
57
+
58
+ # The native lane first: purely namespace-less trees emit
59
+ # through the engine's result builder and writers, all-C
60
+ # after the ops list — 2.4x the DOM-mint loop on the
61
+ # audit corpus. Everything else keeps the DOM lane below
62
+ # (namespaced spellings are instance-dependent; ordered,
63
+ # wildcard, raw, and callback shapes are not
64
+ # ops-expressible).
65
+ return NativeEmission.emit(instance, plan, register) if NativeEmission.compatible?(plan)
66
+
67
+ doc = ::Leptris::XML::Document.create
68
+ ns_ctx = Namespaces.new(instance, plan, register)
69
+ return nil if ns_ctx.unspellable?
70
+
71
+ root = doc.create_element(
72
+ ns_ctx.spell(plan[:tree][:name], plan[:tree][:ns]),
73
+ )
74
+ doc.root = root
75
+ ns_ctx.declare_on(root)
76
+ # The interpretive writer's raw schemaLocation re-emission
77
+ # (handle_schema_location): the parse-captured string rides
78
+ # back as xsi:schemaLocation with its declaration.
79
+ if instance.is_a?(::Lutaml::Model::Serialize) &&
80
+ instance.raw_schema_location &&
81
+ !instance.raw_schema_location.empty?
82
+ root.set_attribute(
83
+ "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"
84
+ )
85
+ root.set_attribute("xsi:schemaLocation",
86
+ instance.raw_schema_location)
87
+ end
88
+ built = if plan[:ordered]
89
+ build_ordered(root, instance, plan, doc, register, ns_ctx)
90
+ else
91
+ build(root, instance, plan, doc, register, ns_ctx)
92
+ end
93
+ return nil unless built
94
+
95
+ doc.to_xml(indent: 2, no_decl: true)
96
+ end
97
+
98
+ # Namespace spelling for namespaced plans (#847): every ns
99
+ # form resolves to a wire spelling (prefix:local, or local
100
+ # under a default xmlns), and every distinct (prefix, uri)
101
+ # pair is declared exactly once on the output root in
102
+ # first-encounter order — the #1558 contract on the DOM
103
+ # path. A nested plan's own tree ns overrides the inherited
104
+ # one; rows without an ns form inherit.
105
+ # Namespace spelling sourced from the interpretive writer's
106
+ # own machinery: NamespaceCollector + DeclarationPlanner
107
+ # decide prefix-vs-default per namespace exactly as the
108
+ # interpretive path does — no heuristic divergence (the
109
+ # uniword FontScheme/Image regressions). Option-bearing
110
+ # calls (prefix: true) never reach here; the serializer's
111
+ # plain spelling is the interpretive writer's plain
112
+ # spelling.
113
+ class Namespaces
114
+ attr_reader :unspellable
115
+
116
+ def initialize(instance, plan, register)
117
+ @forms = {}
118
+ @hoisted = {}
119
+ @declared = {}
120
+ @unspellable = false
121
+ mapping = plan[:mapping]
122
+ # The interpretive writer plans from the BUILT element
123
+ # tree: absent attributes (nil values) and absent
124
+ # children contribute no namespace needs, so an element
125
+ # whose own namespace would otherwise be prefix-hoisted
126
+ # (W3C: prefixed attributes need a prefix declaration)
127
+ # falls back to default-ns format. Collecting from the
128
+ # instance reproduces that — collecting from nil (type
129
+ # analysis) hoists every declared need and diverges
130
+ # (`<w:b xmlns:w>` vs the interpretive `<b xmlns>` when
131
+ # val=true omits the only prefixed attribute).
132
+ ns_prefix = if instance.is_a?(::Lutaml::Model::Serialize)
133
+ instance.xml_namespace_prefix
134
+ end
135
+ collect_options = { mapper_class: instance.class }
136
+ if ns_prefix && !ns_prefix.empty?
137
+ collect_options[:__xml_namespace_prefix] = ns_prefix
138
+ end
139
+ needs = Lutaml::Xml::NamespaceCollector.new(register).collect(
140
+ instance, mapping, **collect_options
141
+ )
142
+ planner = Lutaml::Xml::DeclarationPlanner.new(register)
143
+ declared_plan = planner.plan(
144
+ instance.class, mapping, needs, options: {}
145
+ )
146
+ declared_plan.root_node&.hoisted_declarations&.each do |prefix, uri|
147
+ @hoisted[uri] = prefix
148
+ @forms[uri] = { prefix: prefix, uri: uri }
149
+ end
150
+ # Format preservation (the interpretive writer's
151
+ # rule): an instance parsed from a PREFIXED source keeps
152
+ # its prefixes — the model's own namespace hoists with
153
+ # the instance's recorded prefix even when the fresh
154
+ # planner preferred the default-ns format.
155
+ model_uri = plan[:tree][:ns] && plan[:tree][:ns][:exact]
156
+ if ns_prefix && !ns_prefix.empty? && model_uri &&
157
+ @hoisted.key?(model_uri)
158
+ @hoisted[model_uri] = ns_prefix
159
+ @forms[model_uri] = { prefix: ns_prefix, uri: model_uri }
160
+ end
161
+ # Source declaration preservation (the interpretive
162
+ # writer's stored-plan merge): prefixes the parsed source
163
+ # declared are kept for URIs the document emits. The
164
+ # planner's class-analysis path has no stored-plan
165
+ # access, so the merge reads the instance's recorded plan
166
+ # directly.
167
+ stored = if instance.is_a?(::Lutaml::Model::Serialize)
168
+ instance.import_declaration_plan
169
+ end
170
+ if stored.is_a?(Lutaml::Xml::DeclarationPlan) &&
171
+ stored.root_node.is_a?(
172
+ Lutaml::Xml::DeclarationPlan::ElementNode,
173
+ ) && (ns_prefix.nil? || ns_prefix.empty?)
174
+ original_uris = stored.original_namespace_uris || {}
175
+ stored.root_node.hoisted_declarations.each do |prefix, uri|
176
+ effective_uri = original_uris[uri] || uri
177
+ already = @hoisted.value?(effective_uri) ||
178
+ @hoisted.value?(uri)
179
+ @hoisted[prefix] = uri if already && !@hoisted.key?(prefix)
180
+ end
181
+ end
182
+ collect(plan[:tree])
183
+ end
184
+
185
+ def unspellable?
186
+ @unspellable
187
+ end
188
+
189
+ def collect(tree)
190
+ check(tree[:ns])
191
+ (tree[:attributes] || []).each { |a| check(a[:ns]) }
192
+ (tree[:children] || []).each do |row|
193
+ check(row[:ns]) if row[:ns].is_a?(Hash)
194
+ collect(row[:plan]) if row[:kind] == :nested && row[:plan]
195
+ end
196
+ end
197
+
198
+ # An exact form the planner did not declare cannot be
199
+ # spelled — the serializer falls back interpretively.
200
+ def check(ns_form)
201
+ return if ns_form.nil? || ns_form == :none || ns_form == :any
202
+
203
+ uri = ns_form[:exact]
204
+ @unspellable = true if uri && !@forms.key?(uri)
205
+ end
206
+
207
+ # The planner's hoisted prefix for a URI (nil when hoisted
208
+ # as the default namespace).
209
+ def prefix_for(uri)
210
+ @hoisted[uri]
211
+ end
212
+
213
+ # The wire spelling for a local name under this form: the
214
+ # planner's hoisted prefix for the URI, or unprefixed when
215
+ # hoisted as the default namespace.
216
+ def spell(local, ns_form = nil)
217
+ uri = if ns_form.is_a?(Hash)
218
+ ns_form[:exact]
219
+ else
220
+ @forms.key?(ns_form) ? @forms[ns_form][:uri] : nil
221
+ end
222
+ prefix = uri ? @hoisted[uri] : nil
223
+ prefix ? "#{prefix}:#{local}" : local
224
+ end
225
+
226
+ def form_for(ns_form)
227
+ return nil if ns_form.nil? || ns_form == :none
228
+
229
+ ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
230
+ end
231
+
232
+ # Declaration order matches the interpretive writer: the
233
+ # prefixed namespaces sorted by prefix, the default
234
+ # namespace last.
235
+ def declare_on(root, _attr_form_prefix = nil)
236
+ @hoisted.sort_by { |_uri, prefix| [prefix ? 0 : 1, prefix.to_s] }
237
+ .each do |uri, prefix|
238
+ key = uri
239
+ next if key.nil? || @declared[key]
240
+
241
+ @declared[key] = true
242
+ root.add_namespace_definition(prefix, uri)
243
+ end
244
+ end
245
+ end
246
+
247
+ # Whether the compiled plan is serialize-shaped. Ordered/mixed
248
+ # models serialize through build_ordered from element_order;
249
+ # plain models through plan rows.
250
+ def serializable?(plan)
251
+ plan[:rows].all? do |_rule, _attr, kind, _sp, _del|
252
+ PLAN_SERIALIZABLE_KINDS.include?(kind)
253
+ end
254
+ end
255
+
256
+ private
257
+
258
+ def register
259
+ Lutaml::Model::Config.default_register
260
+ end
261
+
262
+ def build(element, instance, plan, doc, reg = nil, ns_ctx = nil,
263
+ inherited_ns = nil)
264
+ reg ||= register
265
+ write_attributes(element, instance, plan, reg, ns_ctx)
266
+ ns_index = ns_index_for(plan)
267
+ nested_plans = nested_plans_for(plan)
268
+ plan[:rows].each do |rule, attr, kind, spelling, delegate|
269
+ value = value_of(instance, attr, delegate)
270
+ next unless rule.render?(value, instance)
271
+
272
+ case kind
273
+ when :scalar
274
+ # Multi-capture parity: an attribute that collected
275
+ # several occurrences serializes one element per item,
276
+ # as the interpretive writer does. A plain scalar skips
277
+ # the Array() wrap — the common case.
278
+ row_ns = ns_index[row_name(rule, spelling)] || inherited_ns
279
+ if value.is_a?(::Array)
280
+ value.each do |item|
281
+ add_leaf(element,
282
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
283
+ attr.serialize(item, :xml, reg), doc,
284
+ attrs: rule.when_attribute)
285
+ end
286
+ else
287
+ add_leaf(element,
288
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
289
+ attr.serialize(value, :xml, reg), doc,
290
+ attrs: rule.when_attribute)
291
+ end
292
+ when :collection_native, :collection_cb
293
+ row_ns = ns_index[rule.name.to_s] || inherited_ns
294
+ Array(value).each do |item|
295
+ add_leaf(element,
296
+ ns_ctx ? ns_ctx.spell(rule.name.to_s, row_ns) : rule.name.to_s,
297
+ attr.serialize(item, :xml, reg), doc,
298
+ attrs: rule.when_attribute)
299
+ end
300
+ when :nested, :ordered_deferred
301
+ Array(value).each do |item|
302
+ # Keyed by [class, row name]: distinct rows (sz, szCs)
303
+ # can share one class with different root names.
304
+ child_plan = nested_plans[[item.class, rule.name.to_s]] ||
305
+ (nested_plans[[item.class, rule.name.to_s]] = PlanCompiler.compile(item.class, reg))
306
+ child_ns = child_plan[:tree][:ns] || inherited_ns
307
+ # The ROW's wire name wins over the child plan's
308
+ # root — distinct rows (sz, szCs) share one class.
309
+ child = element.create_child(
310
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), child_ns) : row_name(rule, spelling),
311
+ )
312
+ ns_ctx&.collect(child_plan[:tree])
313
+ if child_plan[:ordered]
314
+ build_ordered(child, item, child_plan,
315
+ doc, reg, ns_ctx, child_ns) || return
316
+ else
317
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
318
+ end
319
+ end
320
+ when :raw
321
+ Array(value).each { |raw| element.add_child(raw.to_s) }
322
+ when :child_deferred
323
+ # A plan-less child carrying a value (a cycle point
324
+ # with data, e.g. bibliography's Author → author):
325
+ # the interpretive serializer owns the model — the
326
+ # plan lane has no faithful spelling for it. Empty
327
+ # deferred rows ride the fast lane untouched.
328
+ return nil unless Array(value).empty?
329
+ when :content, :content_deferred
330
+ Array(value).each { |run| append_text(element, run.to_s, doc) }
331
+ end
332
+ end
333
+ end
334
+
335
+ # Per-plan lazy indexes: the name→ns lookup and the
336
+ # per-class child-plan resolution are per-instance calls on
337
+ # the serialize hot path — memoized once per compiled plan
338
+ # they cost nothing per row (the old per-row children#find
339
+ # plus PlanCompiler key arrays were ~4 allocs/row).
340
+ def ns_index_for(plan)
341
+ plan[:ns_index] ||= begin
342
+ h = {}
343
+ (plan[:tree][:children] || []).each do |r|
344
+ h[r[:name]] = r[:ns] unless h.key?(r[:name])
345
+ end
346
+ h
347
+ end
348
+ end
349
+
350
+ def nested_plans_for(plan)
351
+ plan[:nested_plans] ||= {}
352
+ end
353
+
354
+ # The row's exact element URI — the child type's namespace,
355
+ # the same source the interpretive matcher dispatches by for
356
+ # (URI, local) element identity. Nil for scalar/raw/content
357
+ # rows (any-qualification binding). Memoized per plan (rules
358
+ # are stable per compiled plan).
359
+ def row_exact_uri(rule, attr, kind, type, reg, plan)
360
+ return nil unless %i[nested ordered_deferred].include?(kind)
361
+
362
+ cache = plan[:row_exact_uris] ||= {}.compare_by_identity
363
+ return cache[rule] if cache.key?(rule)
364
+
365
+ child_type = type || attr.type(reg)
366
+ child_plan = child_type && PlanCompiler.compile(child_type, reg)
367
+ tree_ns = child_plan && child_plan[:tree][:ns]
368
+ cache[rule] = tree_ns.is_a?(Hash) ? tree_ns[:exact] : tree_ns
369
+ end
370
+
371
+ def ordered_layout_for(plan, inherited_ns, ns_index, reg)
372
+ cache = plan[:ordered_layouts] ||= {}.compare_by_identity
373
+ return cache[inherited_ns] if cache.key?(inherited_ns)
374
+
375
+ rows_by_name = {}
376
+ name_keys = ::Hash.new { |h, k| h[k] = [] }
377
+ plan[:rows].each do |rule, attr, kind, spelling, delegate, type|
378
+ next unless rule.name
379
+
380
+ exact = row_exact_uri(rule, attr, kind, type, reg, plan)
381
+ name = rule.name.to_s
382
+ key = exact ? [exact, name] : [nil, name]
383
+ rows_by_name[key] ||= [rule, attr, kind, spelling,
384
+ delegate,
385
+ ns_index ? ns_index[name] || inherited_ns : inherited_ns]
386
+ name_keys[name] << key
387
+ end
388
+ name_keys.default = nil
389
+ cache[inherited_ns] = [rows_by_name, name_keys]
390
+ end
391
+
392
+ # Element_order-driven emission (the interpretive order
393
+ # applier's contract): text runs and comments verbatim, each
394
+ # element entry consuming the next item of its collection
395
+ # (document order). Text reads prefer the content attribute
396
+ # when it lines up with the text-node count, so mutations to
397
+ # the model are reflected; element_order text is the fallback.
398
+ # PIs drop (interpretive parity). A nested ordered instance
399
+ # without element_order (built by Ruby, not parsed) falls
400
+ # back to declaration order — the interpretive behavior.
401
+ def build_ordered(element, instance, plan, doc, reg = nil,
402
+ ns_ctx = nil, inherited_ns = nil)
403
+ if instance.element_order.nil?
404
+ return build(element, instance, plan, doc, reg, ns_ctx,
405
+ inherited_ns)
406
+ end
407
+
408
+ reg ||= register
409
+ ns_ctx&.collect(plan[:tree])
410
+ write_attributes(element, instance, plan, reg, ns_ctx)
411
+ ns_index = ns_ctx ? ns_index_for(plan) : nil
412
+ # (URI, local) row keys — the interpretive matcher's
413
+ # typed-element identity: same-name rows in different
414
+ # namespaces (w14:docId vs w15:docId) must not share one
415
+ # dispatch slot. Name-keyed rows (no exact URI) bind any
416
+ # qualification. The layout is static per (plan, inherited
417
+ # ns) — the reconciliation corpus re-serializes thousands
418
+ # of same-shaped paragraphs, so it is memoized.
419
+ rows_by_name, name_keys =
420
+ ordered_layout_for(plan, inherited_ns, ns_index, reg)
421
+ content_entry = plan[:rows].find do |rule, _attr, kind, _sp, _del|
422
+ CONTENT_KINDS.include?(kind) && rule.name.nil?
423
+ end
424
+ # Writer-added values sit outside the order snapshot — the
425
+ # reconciler inserts their entries at the interpretive
426
+ # anchors and returns the rules it could not place for
427
+ # appended emission (silent drops are data loss). The
428
+ # reconciler speaks CompiledRule — the interpretive
429
+ # transformation's memoized set; the plan's MappingRule
430
+ # rows cannot drive it. Pristine parsed instances skip the
431
+ # scan entirely: the parse-built order covers every set
432
+ # value, and the writer-set flag is the only flow that can
433
+ # produce deficits.
434
+ rules = if instance.mutated_after_parse?
435
+ compiled_rules_for(instance.class, reg)
436
+ end
437
+ if rules
438
+ order, fallback_rules =
439
+ RECONCILER.reconciled_element_order(instance, rules, {})
440
+ else
441
+ order = instance.element_order
442
+ fallback_rules = []
443
+ end
444
+ element_indices = ::Hash.new(0)
445
+ content_attr = content_entry && content_entry[1]
446
+ content_value = content_attr &&
447
+ value_of(instance, content_attr, content_entry[4])
448
+ text_node_count = order.count { |o| o.type == "Text" }
449
+ use_content_index = content_value.is_a?(Array) &&
450
+ content_value.length == text_node_count
451
+ content_cdata = content_entry && content_entry[0].cdata
452
+ text_node_index = 0
453
+
454
+ order.each do |object|
455
+ case object.type
456
+ when "Text"
457
+ text = if content_attr && !content_value.nil?
458
+ if use_content_index
459
+ content_value[text_node_index].to_s
460
+ elsif !content_value.is_a?(Array) && text_node_count <= 1
461
+ content_value.to_s
462
+ else
463
+ object.text_content || object.name
464
+ end
465
+ else
466
+ object.text_content || object.name
467
+ end
468
+ text_node_index += 1
469
+ next if text.nil?
470
+ # Mixed content keeps whitespace; ordered-only skips it
471
+ next if content_attr.nil? && text.strip.empty?
472
+
473
+ element.add_child(content_cdata ? doc.create_cdata(text) : doc.create_text_node(text))
474
+ when "Comment"
475
+ element.add_child(doc.create_comment(object.text_content))
476
+ when "Element"
477
+ # A uniquely-named row absorbs the entry at any
478
+ # qualification (the interpretive matcher's lenient
479
+ # binding — order entries written by consumers carry no
480
+ # namespace). Contested names dispatch strictly by
481
+ # (URI, local), falling back to URI-less rows.
482
+ name = object.name
483
+ keys = name_keys[name]
484
+ if keys.length == 1
485
+ dispatch_key = keys.first
486
+ entry = rows_by_name[dispatch_key]
487
+ else
488
+ dispatch_key = [object.namespace_uri, name]
489
+ entry = rows_by_name[dispatch_key]
490
+ unless entry
491
+ dispatch_key = [nil, name]
492
+ entry = rows_by_name[dispatch_key]
493
+ end
494
+ end
495
+ next unless entry
496
+
497
+ _, attr, kind, _spelling, delegate, row_ns = entry
498
+ value = value_of(instance, attr, delegate)
499
+ case kind
500
+ when :collection_native, :collection_cb
501
+ index = element_indices[dispatch_key]
502
+ items = Array(value)
503
+ next unless index < items.length
504
+
505
+ element_indices[dispatch_key] += 1
506
+ add_leaf(element,
507
+ ns_ctx ? ns_ctx.spell(object.name, row_ns) : object.name,
508
+ attr.serialize(items[index], :xml, reg), doc)
509
+ when :nested, :ordered_deferred
510
+ item = if attr.collection?
511
+ index = element_indices[dispatch_key]
512
+ element_indices[dispatch_key] += 1
513
+ Array(value)[index]
514
+ else
515
+ value
516
+ end
517
+ next unless item
518
+
519
+ child_plan = nested_plans_for(plan)[[item.class, object.name]] ||
520
+ (nested_plans_for(plan)[[item.class, object.name]] = PlanCompiler.compile(item.class, reg))
521
+ child_ns = child_plan[:tree][:ns] || row_ns || inherited_ns
522
+ child = element.create_child(
523
+ ns_ctx ? ns_ctx.spell(object.name, child_ns) : object.name,
524
+ )
525
+ if child_plan[:ordered]
526
+ return nil unless build_ordered(child, item, child_plan,
527
+ doc, reg, ns_ctx, child_ns)
528
+ else
529
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
530
+ end
531
+ when :raw
532
+ element.add_child(value.to_s) unless value.nil?
533
+ when :child_deferred
534
+ # Valued cycle-point children: interpretive lane (see
535
+ # build).
536
+ return nil unless Array(value).empty?
537
+ when :scalar
538
+ add_leaf(element,
539
+ ns_ctx ? ns_ctx.spell(object.name, row_ns) : object.name,
540
+ attr.serialize(value, :xml, reg), doc)
541
+ end
542
+ end
543
+ end
544
+ return nil if !fallback_rules.empty? &&
545
+ !emit_uncovered_values(element, instance, plan, fallback_rules,
546
+ doc, reg, ns_ctx, inherited_ns)
547
+
548
+ true
549
+ end
550
+
551
+ # OrderReconciler fallback: element rules the reconciled order
552
+ # could not place but that still hold a value — emit them
553
+ # appended, the ordinary way (the interpretive
554
+ # apply_remaining_rules contract for the plan row model).
555
+ def emit_uncovered_values(element, instance, plan, fallback, doc,
556
+ reg, ns_ctx, inherited_ns)
557
+ by_attr = {}
558
+ plan[:rows].each { |row| by_attr[row[1].name] ||= row if row[1] }
559
+ fallback.each do |rule|
560
+ entry = by_attr[rule.attribute_name]
561
+ next unless entry
562
+
563
+ _rule, attr, kind, _spelling, delegate, row_ns = entry
564
+ value = value_of(instance, attr, delegate)
565
+ name = rule.name.to_s
566
+ Array(value).each do |item|
567
+ case kind
568
+ when :collection_native, :collection_cb, :scalar
569
+ add_leaf(element,
570
+ ns_ctx ? ns_ctx.spell(name, row_ns) : name,
571
+ attr.serialize(item, :xml, reg), doc)
572
+ when :nested, :ordered_deferred
573
+ next if item.nil?
574
+
575
+ child_plan =
576
+ nested_plans_for(plan)[[item.class, name]] ||
577
+ (nested_plans_for(plan)[[item.class, name]] =
578
+ PlanCompiler.compile(item.class, reg))
579
+ child_ns = child_plan[:tree][:ns] || row_ns || inherited_ns
580
+ child = element.create_child(
581
+ ns_ctx ? ns_ctx.spell(name, child_ns) : name,
582
+ )
583
+ if child_plan[:ordered]
584
+ return nil unless build_ordered(child, item, child_plan,
585
+ doc, reg, ns_ctx, child_ns)
586
+ else
587
+ built = build(child, item, child_plan, doc, reg, ns_ctx,
588
+ child_ns)
589
+ return nil unless built
590
+ end
591
+ end
592
+ end
593
+ # Valued cycle-point rows keep the walk's contract.
594
+ return nil if kind == :child_deferred && !Array(value).empty?
595
+ end
596
+ true
597
+ end
598
+
599
+ def compiled_rules_for(klass, reg)
600
+ klass.transformation_for(:xml, reg)&.compiled_rules
601
+ end
602
+
603
+ # Attributes in document order when the instance recorded it
604
+ # (parsed models), else plan row order.
605
+ def write_attributes(element, instance, plan, reg = nil,
606
+ ns_ctx = nil)
607
+ recorded = instance.attribute_order
608
+ attr_prefixes = nil
609
+ if ns_ctx && !(plan[:tree][:attributes] || []).empty?
610
+ attr_prefixes = {}
611
+ (plan[:tree][:attributes] || []).each do |a|
612
+ form = a[:ns].is_a?(Hash) ? a[:ns] : nil
613
+ # Namespaced attributes always spell prefixed (W3C:
614
+ # attributes cannot use the default namespace) — the
615
+ # interpretive writer emits the type namespace's own
616
+ # prefix even when the element hoists the same URI as
617
+ # the default namespace (updateFields w:val under
618
+ # xmlns="...").
619
+ attr_prefixes[a[:name]] =
620
+ if form
621
+ form[:prefix] || ns_ctx.prefix_for(form[:exact])
622
+ end
623
+ end
624
+ end
625
+ if recorded && !recorded.empty?
626
+ by_name = {}
627
+ plan[:attr_rows].each do |rule, attr, _sp, delegate|
628
+ by_name[rule.name.to_s] = [rule, attr, delegate]
629
+ end
630
+ names = recorded |
631
+ plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
632
+ names.each do |name|
633
+ write_attribute(element, instance, name, by_name[name],
634
+ reg, attr_prefixes && attr_prefixes[name])
635
+ end
636
+ else
637
+ plan[:attr_rows].each do |rule, attr, _sp, delegate|
638
+ write_attribute(element, instance, rule.name.to_s,
639
+ [rule, attr, delegate], reg,
640
+ attr_prefixes && attr_prefixes[rule.name.to_s])
641
+ end
642
+ end
643
+ end
644
+
645
+ def write_attribute(element, instance, name, entry, reg = nil,
646
+ prefix = nil)
647
+ return unless entry
648
+
649
+ rule, attr, delegate = entry
650
+ value = value_of(instance, attr, delegate)
651
+ return if value.nil?
652
+ return unless rule.render?(value, instance)
653
+
654
+ spelled = prefix ? "#{prefix}:#{name}" : name
655
+ element.set_attribute(spelled,
656
+ attr.serialize(value, :xml, reg || register).to_s)
657
+ end
658
+
659
+ # Partition rows (#88) re-emit their discriminator: the wire
660
+ # element carries the when_attribute pairs so the round trip
661
+ # re-routes to the same attribute on reparse.
662
+ def add_leaf(element, name, value, doc, attrs: nil)
663
+ child = element.create_child(name)
664
+ attrs&.each { |k, v| child[k.to_s] = v.to_s }
665
+ append_text(child, value.to_s, doc) unless value.nil?
666
+ child
667
+ end
668
+
669
+ # Text attach: the address face (leptris#408 adoption)
670
+ # passes raw node addresses — the pointer face mints an
671
+ # FFI::Pointer wrapper per fresh node, the last per-row
672
+ # Ruby-side allocation on the DOM build.
673
+ def append_text(parent, text, doc)
674
+ node = doc.create_text_node(text)
675
+ status = ::Leptris::XML::FFI.leptris_element_add_child_addr(
676
+ parent.c_address, node.c_address
677
+ )
678
+ unless status.zero?
679
+ raise ::Leptris::XML::Error,
680
+ "text attach failed (status=#{status})"
681
+ end
682
+
683
+ node
684
+ end
685
+
686
+ def value_of(instance, attr, delegate)
687
+ if delegate
688
+ target = instance.public_send(delegate)
689
+ target&.public_send(attr.name)
690
+ else
691
+ instance.public_send(attr.name)
692
+ end
693
+ end
694
+
695
+ def row_name(rule, spelling)
696
+ (spelling || rule.name).to_s
697
+ end
698
+ end
699
+ end
700
+ end
701
+ end
702
+ end