Module: Moxml::Adapter::Leptris::DocumentParts

Included in:
Moxml::Adapter::Leptris
Defined in:
lib/moxml/adapter/leptris/document_parts.rb

Constant Summary collapse

DOCUMENT_ATTACHMENT_KEYS =

Issue #134: deterministic release of the C tree. The binding clears its wrapper cache and raises UseAfterFreeError on later access; moxml-side attachments for the document are swept too (the context wrapper identity map self-cleans via its size valve).

%i[
  entity_markers declaration doctype
  had_source_declaration document_text
].freeze
DOCUMENT_CHILD_PTRS =

Issue #158: the engine's whole-document serializer is one C call; the composed path serializes the root through the element face, which copies the subtree into a fresh document on every call (~4.5x slower end to end). The engine's output is byte-identical to the composed one EXCEPT epilog parts glue directly to the root — so the fast path declines whenever anything follows the root, any attachment overrides a part, or the engine's declaration line differs from the facade's canonical form (checked post-hoc: a source declaration carrying standalone or another version would diverge). 1.9.174 attached leptris_document_first_child (libleptris 1.9.174): pointer probe for the single-child document shape without wrapping the child chain.

::Leptris::XML::FFI.respond_to?(:leptris_document_first_child)

Instance Method Summary collapse

Instance Method Details

#add_document_child(doc, child) ⇒ Object



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 55

def add_document_child(doc, child)
  case child
  when CustomizedLeptris::Declaration
    child.parent_doc = doc
    attachments.set(doc, :declaration, child)
    mirror_declaration_native(doc, child) if NATIVE_DOC_PARTS
  when CustomizedLeptris::Doctype
    if NATIVE_DOC_PARTS
      dt = doc.set_doctype(child.name,
                           public_id: child.external_id,
                           system_id: child.system_id)
      attachments.set(doc, :doctype, dt)
      return dt
    end
    child.parent_doc = doc
    attachments.set(doc, :doctype, child)
  when ::Leptris::XML::DocType
    if NATIVE_DOC_PARTS
      dt = doc.set_doctype(child.root_name,
                           public_id: child.public_id,
                           system_id: child.system_id)
      attachments.set(doc, :doctype, dt)
      return dt
    end
    raise Moxml::DocumentStructureError.new(
      "libleptris does not support attaching a native DocType to a document",
    )
  when ::Leptris::XML::Element
    doc.root = child
  when ::Leptris::XML::ProcessingInstruction
    doc.add_pi(child.target, child.content.to_s)
  when CustomizedLeptris::DocumentPI
    doc.add_pi(child.target, child.data)
  when ::Leptris::XML::Text
    texts = attachments.get(doc, :document_text) || []
    texts << child
    attachments.set(doc, :document_text, texts)
    child
  when ::Leptris::XML::Comment
    # The tree model supports document comments (they parse
    # and serialize, libleptris 1.9.3 #578) but the engine
    # has no add entry yet (leptris/leptris#1032).
    raise Moxml::NotImplementedError.new(
      "Adding document-level comments requires an engine entry (leptris/leptris#1032)",
      feature: "add_document_child", adapter: "Leptris",
    )
  else
    raise Moxml::DocumentStructureError.new(
      "Unsupported document child: #{child.class}",
    )
  end
  child
end

#assemble_document_children(doc) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 23

def assemble_document_children(doc)
  children = []

  # Attached DOCTYPEs (NATIVE_DOC_PARTS) list the STORED
  # native — the same object the attaching wrapper was
  # refreshed onto — so wrapper identity holds; doc.doctype
  # mints a fresh DocType per call and would fork wrappers.
  doctype_native = attachments.get(doc, :doctype)
  doctype_native = doc.doctype unless doctype_native.is_a?(::Leptris::XML::DocType)
  children << doctype_native if doctype_native

  # The libxml2-model document node lists prolog PIs/comments,
  # the root, and epilog PIs/comments in document order — the
  # Nokogiri-shaped contract, epilog anchoring included (issue
  # #130). Built documents reflect their parts immediately
  # since leptris-ruby 1.9.32 (leptris-ruby#91).
  children.concat(doc.children.to_a)

  texts = attachments.get(doc, :document_text)
  children.concat(texts) if texts

  # Canonicalize through the doc's address-keyed native
  # cache: #root mints a NativeNode for the document
  # element, and an uncanonicalized list would hand the
  # binding twin instead — two wrappers over one node, and
  # equal?-based exclusion (canon's document-element skip)
  # silently breaks (issue #219). Mint-on-miss converges
  # both accessors on the same native object.
  children.map! { |child| canonical_native(doc, child) } if NATIVE_READ_LAYER
  children
end

#default_declaration_xml(doc, options) ⇒ Object



310
311
312
313
314
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 310

def default_declaration_xml(doc, options)
  encoding = options[:encoding] || doc.encoding
  encoding = "UTF-8" if encoding.to_s.empty?
  XmlEmitter.declaration_xml("1.0", encoding, nil)
end

#document_has_declaration?(native) ⇒ Boolean

Returns:

  • (Boolean)


316
317
318
319
320
321
322
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 316

def document_has_declaration?(native)
  return false unless native.is_a?(::Leptris::XML::Document)

  return true if attachments.get(native, :declaration)

  attachments.get(native, :had_source_declaration) ? true : false
end

#fast_document_output(doc, options) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 165

def fast_document_output(doc, options)
  return nil unless LIBXML2_LAYOUT_PARITY
  return nil unless attachments.none_set?(
    doc, %i[declaration doctype document_text entity_markers]
  )

  include_decl = !options[:no_declaration] && options.fetch(:declaration) do
    document_has_declaration?(doc)
  end

  # Exactly one document child — the root — is the common
  # parsed shape; pointer probes decide it without wrapping
  # the child chain. Anything else (prolog/epilog parts,
  # multi-root) falls back to the scan.
  root = doc.root
  single_child = DOCUMENT_CHILD_PTRS && root &&
    ::Leptris::XML::FFI.leptris_document_first_child(doc.c_ptr)
      .address == root.c_ptr.address &&
    root.next_sibling.nil?
  unless single_child
    root_seen = false
    doc.children.each do |child|
      if child.is_a?(::Leptris::XML::Element)
        return nil if root_seen

        root_seen = true
      elsif root_seen
        # Epilog parts glue directly to the root in the engine's
        # document output — compose those.
        return nil
      end
    end
  end

  # The engine's subset serializer mangles every declaration
  # after the first (leptris/leptris#687) — moxml's own
  # formatter is correct, so those compose until the probe
  # says the engine is fixed.
  dt = doc.doctype
  if !ENGINE_MULTI_DECL_SUBSET_OK && dt&.class&.method_defined?(:internal_subset) &&
      (subset = dt.internal_subset) && subset.scan("<!").size > 1
    return nil
  end

  # Passing encoding when it equals the document's own is a
  # no-op conversion the serializer still pays (~20% of a
  # 31KB document serialize); nil skips it. Byte-equality of
  # both forms verified on 1.9.163.2 — gate to those builds.
  encoding = options[:encoding]
  if NATIVE_STRINGS_UTF8 && encoding.to_s.casecmp?("UTF-8")
    encoding = nil
  end
  kwargs = {
    indent: options.fetch(:indent, 0),
    no_decl: !include_decl,
    encoding: encoding,
  }
  if INDENT_UNIT_SUPPORTED && options[:indent_text].is_a?(String)
    kwargs[:indent_text] = options[:indent_text]
  end
  output = doc.to_xml(**kwargs)
  return nil if output.nil? || output.empty?

  if include_decl && !output.start_with?(default_declaration_xml(doc, options))
    return nil
  end

  output << "\n" unless output.end_with?("\n")
  output
end

#format_internal_subset(subset) ⇒ Object

libxml2's DTD dump layout (leptris/leptris#636): newline after "[", one after every markup declaration, none after comments (they glue to both neighbors); an empty subset drops the brackets. Returns the INNER text for XmlEmitter.doctype_xml, nil when there is nothing to emit. The engine reports internal_subset as raw source text, so the declarations are re-tokenized — quote-aware, since an attribute default can contain ">".



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 253

def format_internal_subset(subset)
  return nil if subset.nil? || subset.empty?

  out = +"\n"
  pos = 0
  length = subset.length
  while pos < length
    start = subset.index("<", pos)
    break if start.nil?

    terminator, skip = if subset[start, 4] == "<!--"
                         ["-->", 4]
                       elsif subset[start, 2] == "<?"
                         ["?>", 2]
                       else
                         [nil, 0]
                       end
    if terminator
      stop = subset.index(terminator, start + skip)
      break if stop.nil?

      item_end = stop + terminator.length
    else
      item_end = markup_decl_end(subset, start)
      break if item_end.nil?
    end
    out << subset[start...item_end]
    # Comments carry no trailing newline; declarations do.
    out << "\n" unless subset[start, 4] == "<!--"
    pos = item_end
  end
  out == "\n" ? nil : out
end

#free_document(native) ⇒ Object



17
18
19
20
21
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 17

def free_document(native)
  DOCUMENT_ATTACHMENT_KEYS.each { |key| attachments.delete(native, key) }
  native.free
  nil
end

#marker_text_for(parent, name) ⇒ Object



358
359
360
361
362
363
364
365
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 358

def marker_text_for(parent, name)
  return nil unless parent.is_a?(::Leptris::XML::Element)

  marker = "#{Entity::MARKER}#{name};"
  parent.children.to_a.find do |child|
    child.is_a?(::Leptris::XML::Text) && child.content == marker
  end
end

#markup_decl_end(subset, start) ⇒ Object



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 292

def markup_decl_end(subset, start)
  quote = nil
  i = start
  length = subset.length
  while i < length
    ch = subset[i]
    if quote
      quote = nil if ch == quote
    elsif QUOTE_CHARS.include?(ch)
      quote = ch
    elsif ch == ">"
      return i + 1
    end
    i += 1
  end
  nil
end

#mirror_declaration_native(doc, child) ⇒ Object

Write a created declaration through to the engine's document state (setters, libleptris 1.9.176 / #1094) so native reads and serialization see the same truth the facade does. Removal clears it (clear_declaration).



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 328

def mirror_declaration_native(doc, child)
  # The engine setters reject empty values; the facade's
  # minimal declarations may carry them (serializer formats
  # what it gets). Mirror only non-empty parts — the wrapper
  # remains the record for what the facade shows.
  unless child.version.to_s.empty?
    ::Leptris::XML::FFI.check_status(
      ::Leptris::XML::FFI.leptris_document_set_version(
        doc.c_ptr, child.version.to_s
      ),
    )
  end
  unless child.encoding.to_s.empty?
    ::Leptris::XML::FFI.check_status(
      ::Leptris::XML::FFI.leptris_document_set_encoding(
        doc.c_ptr, child.encoding.to_s
      ),
    )
  end
  case child.standalone.to_s
  when "yes" then sa = 1
  when "no" then sa = 0
  end
  ::Leptris::XML::FFI.check_status(
    ::Leptris::XML::FFI.leptris_document_set_standalone(
      doc.c_ptr, sa || -1
    ),
  )
end

#native_doctype_xml(doc) ⇒ Object



236
237
238
239
240
241
242
243
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 236

def native_doctype_xml(doc)
  dt = doc.doctype
  return nil unless dt

  subset = dt.internal_subset if dt.class.method_defined?(:internal_subset)
  subset = format_internal_subset(subset) if LIBXML2_LAYOUT_PARITY
  XmlEmitter.doctype_xml(dt.root_name, dt.public_id, dt.system_id, subset)
end

#serialize_document(doc, options) ⇒ Object

Documents compose from their parts: the native serializer only walks the root subtree, so declaration, DOCTYPE, PIs and document-level text are assembled around it explicitly.



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 112

def serialize_document(doc, options)
  fast = fast_document_output(doc, options)
  return fast if fast

  # Nokogiri's document shape: every top-level part is
  # newline-terminated, at any indent — declaration, DOCTYPE,
  # document PIs, the root element, trailing newline after it.
  # Document-level text is content, not structure: no added
  # newline.
  parts = []

  include_decl = !options[:no_declaration] && options.fetch(:declaration) do
    document_has_declaration?(doc)
  end
  if include_decl
    declaration = attachments.get(doc, :declaration)
    parts << (declaration ? declaration.to_xml : default_declaration_xml(doc, options)) << "\n"
  end

  doctype = attachments.get(doc, :doctype)
  parts << doctype.to_xml << "\n" if doctype.is_a?(CustomizedLeptris::Doctype)

  native = native_doctype_xml(doc)
  parts << native << "\n" if native

  # The libxml2-model document node: prolog PIs/comments, the
  # root, epilog PIs/comments — in document order, so epilog
  # parts serialize after the root (issue #130).
  doc.children.each { |child| parts << raw_serialize(child, options) << "\n" }

  texts = attachments.get(doc, :document_text)
  texts&.each { |text| parts << XmlEmitter.escape_text(text.content.to_s) }

  parts.join
end