Module: Moxml::Adapter::Leptris::Serialize

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

Overview

Wire-format knowledge: the C serializer entry, the canonicalization pass (apostrophes stay literal, optional empty-element expansion), and the literal-region scanner that keeps both from touching CDATA/comment/PI content.

Constant Summary collapse

LITERAL_REGIONS =

moxml canonical serialization: apostrophes stay literal (only & < > " are escaped) and empty elements expand when the caller asked for it — matching the other adapters' contract. Runs segment-aware: CDATA content is literal and must not be touched. Regions whose content is literal and must never be rewritten. Scanned positionally (String#index), not with a regex: the scan is linear in the input length, so pathological document content cannot blow up the serializer.

{
  "<!--" => "-->",
  "<![CDATA[" => "]]>",
  "<?" => "?>",
}.freeze
EMPTY_ELEMENT_RE =

All quantifiers are possessive and the bare-part class excludes "/" and ">": the possessive groups can never consume the closing delimiter, so no backtracking is possible and the match is linear even on malformed tags.

%r{<([A-Za-z_][\w.:-]*+)((?:"[^"]*+"|'[^']*+'|[^<>"'/]++)*+)/>}
TRAILING_NL_STRIP_ACTIVE =

Element-face trailing-newline strip: engine fix landed in 1.9.42; armed only on older floor bindings.

Gem::Version.new(::Leptris::VERSION) < Gem::Version.new("1.9.42")
EXPAND_EMPTY_NATIVE =

The binding's element face with all-default options — the frozen splat keeps the hot argless path allocation-free. Native expand-empty (engine #882, libleptris 1.9.95, bindings 1.9.144): empty elements emit through a C-side ext entry — the Ruby full-output regex rewrite and its "/>" probe scan drop out of the element path. The document face does not expose the option yet; documents keep the Ruby pass.

Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.144")
ELEMENT_DEFAULT_KWARGS =
{ indent: 0, no_decl: true, encoding: nil }.freeze
ELEMENT_EXPAND_KWARGS =
if EXPAND_EMPTY_NATIVE
  { indent: 0, no_decl: true, encoding: nil, expand_empty: true }.freeze
else
  ELEMENT_DEFAULT_KWARGS
end.freeze
RAW_AMP_RE =

A bare ampersand — not starting a named or numeric entity reference. Some engine builds (observed: the Linux 1.9.50 platform gem) emit text-content ampersands unescaped; the detection below is a no-op on correct builds.

/&(?!#{Entity::NAME_PATTERN};|#\d+;|#x[0-9A-Fa-f]+;)/
RAW_AMP_GUARD_ACTIVE =

The unescaped-ampersand emission was observed on the Linux 1.9.50 platform gem; correct builds make the probe a no-op that still pays its scan (~2µs per document serialize). Stand it down on modern engines — the consistency round-trips fail loudly on any regression.

Gem::Version.new(::Leptris::VERSION) < Gem::Version.new("1.9.60")
RAW_LT_RE =

A raw "<" in text position — the engine intermittently lost the escape when parsing under allocation pressure (leptris-ruby#131), so a decoded < serialized bare and reparsing truncated at it. Fixed in leptris 1.9.80 (verified 0/200 on the churn repro); the guard stays armed below that version and for any future build that regresses — the parse-under-pressure spec in leptris_spec fails loudly if a fixed-version build corrupts while the guard is stood down.

/<(?![A-Za-z_:\/?!])/
RAW_LT_TRIGGER_RE =
/<[^A-Za-z_:\/?!]/
RAW_LT_GUARD_ACTIVE =
Gem::Version.new(::Leptris::VERSION) < Gem::Version.new("1.9.80")

Instance Method Summary collapse

Instance Method Details

#native_expand?(node, options) ⇒ Boolean

Elements (not documents) with expand_empty and NO indent-unit string: the C ext entry handles expansion. The element unit serializer does not take the flag — those keep the Ruby pass.

Returns:

  • (Boolean)


78
79
80
81
82
# File 'lib/moxml/adapter/leptris/serialize.rb', line 78

def native_expand?(node, options)
  EXPAND_EMPTY_NATIVE && options[:expand_empty] &&
    !node.is_a?(::Leptris::XML::Document) &&
    !options[:indent_text].is_a?(String)
end

#next_literal_region(xml, from) ⇒ Object

Nearest literal region at/after from: [position, terminator].



196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/moxml/adapter/leptris/serialize.rb', line 196

def next_literal_region(xml, from)
  best = nil
  best_terminator = nil
  LITERAL_REGIONS.each do |opener, terminator|
    idx = xml.index(opener, from)
    next if idx.nil?

    if best.nil? || idx < best
      best = idx
      best_terminator = terminator
    end
  end
  best.nil? ? nil : [best, best_terminator]
end

#normalize_markup(markup, needs_apos, needs_expand, needs_amp: false, needs_lt: false) ⇒ Object



217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/moxml/adapter/leptris/serialize.rb', line 217

def normalize_markup(markup, needs_apos, needs_expand, needs_amp: false, needs_lt: false)
  markup = markup.gsub("&apos;", "'") if needs_apos
  if needs_expand
    markup = markup.gsub(EMPTY_ELEMENT_RE) do
      "<#{Regexp.last_match(1)}#{Regexp.last_match(2)}></#{Regexp.last_match(1)}>"
    end
  end
  # Segment-aware: this never sees CDATA/comment/PI content,
  # where bare & and < are literal data.
  markup = markup.gsub(RAW_AMP_RE, "&amp;") if needs_amp
  markup = markup.gsub(RAW_LT_RE, "&lt;") if needs_lt
  markup
end

#normalize_serialization(xml, options) ⇒ Object



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

def normalize_serialization(xml, options)
  # The libxml2-layout serializer (>= 1.9.42) keeps attribute
  # apostrophes literal; older engines escaped them.
  needs_apos = !LIBXML2_LAYOUT_PARITY && xml.include?("&apos;")
  needs_expand = options[:expand_empty] &&
    !options[:__expand_handled_natively] &&
    xml.include?("/>")
  # Corruption guards: the 1-char ampersand probe is ~1µs
  # (memchr-class); the raw-< scan runs only on builds that
  # still carry the parse race (leptris-ruby#131).
  needs_amp = RAW_AMP_GUARD_ACTIVE &&
    xml.include?("&") && xml.match?(RAW_AMP_RE)
  needs_lt = RAW_LT_GUARD_ACTIVE && xml.match?(RAW_LT_TRIGGER_RE)
  return xml unless needs_apos || needs_expand || needs_amp || needs_lt

  out = +""
  pos = 0
  while pos < xml.length
    opener_at, terminator = next_literal_region(xml, pos)
    if opener_at.nil?
      out << normalize_markup(xml[pos..], needs_apos, needs_expand,
                              needs_amp: needs_amp, needs_lt: needs_lt)
      break
    end

    out << normalize_markup(xml[pos...opener_at], needs_apos, needs_expand,
                            needs_amp: needs_amp, needs_lt: needs_lt)
    search_from = opener_at + opener_at_offset(terminator)
    close = xml.index(terminator, search_from)
    close_end = close.nil? ? xml.length : close + terminator.length
    out << xml[opener_at...close_end]
    pos = close_end
  end
  out
end

#opener_at_offset(terminator) ⇒ Object

Search for a terminator past its opener's overlap-safe offset ("-->" cannot start inside "<!--").



213
214
215
# File 'lib/moxml/adapter/leptris/serialize.rb', line 213

def opener_at_offset(terminator)
  terminator == "-->" ? 4 : 0
end

#raw_serialize(node, options) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
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
130
131
# File 'lib/moxml/adapter/leptris/serialize.rb', line 84

def raw_serialize(node, options)
  # CDATA must precede Text in this chain: CDATA < Text in the
  # binding, so a Text branch first would swallow CDATA nodes.
  case node
  when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
       CustomizedLeptris::EntityReference, CustomizedLeptris::DocumentPI
    return node.to_xml
  when ::Leptris::XML::CDATA
    return XmlEmitter.cdata(node.content)
  when ::Leptris::XML::Comment
    return "<!--#{node.content}-->"
  when ::Leptris::XML::ProcessingInstruction
    content = node.content.to_s
    return content.empty? ? "<?#{node.target}?>" : "<?#{node.target} #{content}?>"
  when ::Leptris::XML::Text, CustomizedLeptris::TextSegment
    return XmlEmitter.escape_text(node.content.to_s)
  when ::Leptris::XML::Document
    return serialize_document(node, options)
  end

  # Element serialization never emits a declaration — the C
  # element serializer ignores the flag (verified
  # byte-identical); skipping the declaration fetch avoids a
  # document attachment walk per element serialize. Likewise
  # an unset encoding serializes UTF-8 — byte-identical to an
  # explicit "UTF-8" — and the binding then reuses its shared
  # DEFAULT_OPTIONS instead of rebuilding an options struct
  # per call; the wrapper force-tags the string either way.
  indent = options.fetch(:indent, 0)
  encoding = options[:encoding] == "UTF-8" ? nil : options[:encoding]
  native_expand = native_expand?(node, options)
  xml = if indent.zero? && encoding.nil?
          node.to_xml(**(native_expand ? ELEMENT_EXPAND_KWARGS : ELEMENT_DEFAULT_KWARGS))
        else
          kwargs = { indent: indent, no_decl: true, encoding: encoding }
          kwargs[:expand_empty] = true if native_expand
          if INDENT_UNIT_SUPPORTED && options[:indent_text].is_a?(String)
            kwargs[:indent_text] = options[:indent_text]
          end
          node.to_xml(**kwargs)
        end
  # Element output always ends with the close tag — but older
  # engines append a stray trailing newline when the element's
  # last text child is non-ASCII (fixed engine side in
  # 1.9.42). The strip stays armed only below that version —
  # a regex sub per element serialize is measurable in bulk.
  TRAILING_NL_STRIP_ACTIVE ? xml.sub(/\n+\z/, "") : xml
end

#serialize(node, options = {}) ⇒ Object



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/moxml/adapter/leptris/serialize.rb', line 32

def serialize(node, options = {})
  node = to_binding(node) if NATIVE_READ_LAYER
  # Entity restoration belongs to the wrapper layer
  # (Node#to_xml runs adapter.restore_entities for every
  # adapter); doing it here scanned the output a second time.
  xml = if native_expand?(node, options)
          opts = options.dup
          opts[:__expand_handled_natively] = true
          normalize_serialization(raw_serialize(node, opts), opts)
        else
          normalize_serialization(raw_serialize(node, options), options)
        end
  # The binding's FFI strings come back binary-tagged; the
  # engine encoded the bytes per this option, so tag them.
  xml.force_encoding(options[:encoding]) if options[:encoding]
  xml
end