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



53
54
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
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 53

def add_document_child(doc, child)
  case child
  when CustomizedLeptris::Declaration
    child.parent_doc = doc
    attachments.set(doc, :declaration, child)
  when CustomizedLeptris::Doctype
    child.parent_doc = doc
    attachments.set(doc, :doctype, child)
  when ::Leptris::XML::DocType
    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
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 23

def assemble_document_children(doc)
  children = []

  native_doctype = doc.doctype
  children << native_doctype if native_doctype

  doctype_wrapper = attachments.get(doc, :doctype)
  children << doctype_wrapper if doctype_wrapper

  # 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



293
294
295
296
297
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 293

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



299
300
301
302
303
304
305
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 299

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



148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
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
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 148

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 ">".



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 236

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



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

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



275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 275

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

#native_doctype_xml(doc) ⇒ Object



219
220
221
222
223
224
225
226
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 219

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.



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/moxml/adapter/leptris/document_parts.rb', line 95

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

  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