Class: Leptris::XML::Document

Inherits:
Object
  • Object
show all
Includes:
Searchable
Defined in:
lib/leptris/xml/document.rb

Defined Under Namespace

Classes: Freed

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Searchable

#at, #at_css, #at_xpath, #css, #search, wrap_xpath_first_result, wrap_xpath_result, #xpath

Constructor Details

#initialize(c_ptr = nil, freed = Freed.new(:alive)) ⇒ Document

Returns a new instance of Document.



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/leptris/xml/document.rb', line 33

def initialize(c_ptr = nil, freed = Freed.new(:alive))
  @c_ptr = c_ptr
  @freed = freed
  @readonly = false
  @version = 0
  # Per-document STRONG cache for Node wrappers, keyed on c_ptr
  # address. Every wrapper is created through Node.wrap, which is the
  # single construction path, so the same C node always yields the
  # same Ruby object. Cleared when the Document is freed — no stale
  # entries.
  #
  # Deliberately NOT ObjectSpace::WeakMap: a weak cache makes wrapper
  # identity a GC race. `doc.root.equal?(doc.root)` failed on the
  # Windows CI matrix (188 examples, the 4 identity specs) because
  # between the two calls the first wrapper was referenced only by
  # the weak map — any GC sweep evicted it and the second call built
  # a fresh object. A strong cache costs at most one wrapper per node
  # actually visited, held until the document dies.
  #
  # Allocated lazily: parse-heavy loops stop paying one Hash per
  # document for trees that are freed before any wrap.
end

Instance Attribute Details

#c_ptrObject (readonly)

Returns the value of attribute c_ptr.



6
7
8
# File 'lib/leptris/xml/document.rb', line 6

def c_ptr
  @c_ptr
end

Class Method Details

.createObject

Create an empty document (no root element) backed by its own memory pool. Elements for the tree are created against it via #create_element and friends, then attached with #root=.



115
116
117
118
119
120
# File 'lib/leptris/xml/document.rb', line 115

def self.create
  raw = Leptris::XML::FFI.leptris_document_create
  raise Leptris::XML::Error,
    "leptris_document_create failed" if raw.null?
  wrap(raw)
end

.parse(xml_or_io, options: nil, readonly: false, recover: false) ⇒ Object



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
# File 'lib/leptris/xml/document.rb', line 60

def self.parse(xml_or_io, options: nil, readonly: false, recover: false)
  xml = xml_or_io.respond_to?(:read) ? xml_or_io.read : xml_or_io.to_s
  if xml.empty?
    raise Leptris::XML::ParseError, "empty input"
  end
  if options.nil?
    options = Leptris::XML::ParseOptions.new(recover: recover)
  elsif recover && !options.recover?
    options = options | Leptris::XML::ParseOptions.new(recover: true)
  elsif !options.is_a?(Leptris::XML::ParseOptions)
    raise ArgumentError, "options must be a Leptris::XML::ParseOptions"
  end
  # The status out-param is nullable; the thread-local last error
  # carries failure detail, and skipping the per-parse MemoryPointer
  # is measurable on small documents.
  raw =
    if options.struct_required?
      # Recover is a struct field, not a parse flag — the options
      # struct path (leptris_parse_string_ex) is the only carrier.
      options_struct = options.to_c_struct
      Leptris::XML::FFI.leptris_parse_string_ex(
        xml, xml.bytesize, options_struct.pointer, nil)
    elsif options.flags.zero?
      Leptris::XML::FFI.leptris_parse_string(xml, xml.bytesize, nil)
    else
      Leptris::XML::FFI.leptris_parse_string_flags(
        xml, xml.bytesize, options.flags, nil)
    end
  if raw.null?
    if options.recover?
      # Unreachable in practice: recover returns an empty document
      # rather than NULL; kept so a contract change fails loudly.
      raise Leptris::XML::Error,
        "leptris_parse_string_ex returned NULL under recover"
    end
    raise Leptris::XML::ParseError,
      "leptris_parse_string failed: " +
      Leptris::XML::FFI.leptris_last_error.to_s
  end
  wrap(raw).tap { |doc| doc.readonly! if readonly }
end

.parse_file(path, readonly: false) ⇒ Object



102
103
104
105
106
107
108
109
110
# File 'lib/leptris/xml/document.rb', line 102

def self.parse_file(path, readonly: false)
  raw = Leptris::XML::FFI.leptris_parse_file(path, nil)
  if raw.null?
    raise Leptris::XML::ParseError,
      "leptris_parse_file failed: " +
      Leptris::XML::FFI.leptris_last_error.to_s
  end
  wrap(raw).tap { |doc| doc.readonly! if readonly }
end

.wrap(raw_address) ⇒ Object

Convert a raw LeptrisDocument pointer into a Ruby Document with safe GC lifetime management. The finalizer captures the raw address integer (not the Document or Pointer object — those would prevent GC) and shares a one-shot flag with the instance so explicit #free and the GC finalizer can never both call leptris_document_free on the same address.



128
129
130
131
132
133
134
135
# File 'lib/leptris/xml/document.rb', line 128

def self.wrap(raw_address)
  addr = raw_address.is_a?(::FFI::Pointer) ? raw_address.address : raw_address
  ptr = ::FFI::Pointer.new(addr)
  freed = Freed.new(:alive)
  doc = new(ptr, freed)
  ObjectSpace.define_finalizer(doc, finalizer(addr, freed))
  doc
end

Instance Method Details

#add_pi(target, data = "") ⇒ Object

Append a document-level processing instruction. Returns self.



306
307
308
309
310
311
312
# File 'lib/leptris/xml/document.rb', line 306

def add_pi(target, data = "")
  witness = Leptris::XML::FFI.leptris_document_add_pi(
    @c_ptr, target.to_s, data.to_s)
  raise Leptris::XML::Error, "leptris_document_add_pi failed" if witness.null?
  @version += 1
  self
end

#advance_versionObject

Called by the mutation gates (Node#ensure_writable!, root=, add_pi) — every memo stamped with an older version discards.



29
30
31
# File 'lib/leptris/xml/document.rb', line 29

def advance_version
  @version += 1
end

#canonicalize(version = Leptris::XML::FFI::C14N_1_0, inclusive_namespaces = nil, with_comments: false, exclusive: false, mode: nil) ⇒ Object Also known as: c14n



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/leptris/xml/document.rb', line 255

def canonicalize(version = Leptris::XML::FFI::C14N_1_0,
                 inclusive_namespaces = nil,
                 with_comments: false,
                 exclusive: false,
                 mode: nil)
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
  return "" if @c_ptr.nil?
  resolved_mode = mode || (exclusive ? Leptris::XML::FFI::C14N_MODE_EXCLUSIVE
                                     : Leptris::XML::FFI::C14N_MODE_CANONICAL)
  Leptris::XML::Serialization.canonicalize(
    Leptris::XML::FFI.method(:leptris_c14n_canonicalize_ex), @c_ptr,
    version: version, mode: resolved_mode,
    inclusive_namespaces: inclusive_namespaces,
    with_comments: with_comments)
end

#childrenObject

The document's children, via the document node: prolog comments/PIs, the root element, epilog comments/PIs, in document order (Nokogiri-parity shape).



169
170
171
172
# File 'lib/leptris/xml/document.rb', line 169

def children
  doc_node = node
  doc_node ? doc_node.children : Leptris::XML::NodeSet.new(self, [])
end

#commentsObject

Document-level comments — parsed outside the root element, prolog then epilog, in document order (the companion reader to #processing_instructions; libleptris 1.9.3, upstream #578). Version-memoized like the PI list.



338
339
340
341
342
343
344
345
346
347
# File 'lib/leptris/xml/document.rb', line 338

def comments
  return @comments if @comments_version == @version
  count = Leptris::XML::FFI.leptris_document_comment_count(@c_ptr)
  result = Array.new(count) do |i|
    Leptris::XML::FFI.leptris_document_comment_content(@c_ptr, i)
  end
  @comments = result
  @comments_version = @version
  result
end

#create_cdata(content) ⇒ Object



204
205
206
207
208
# File 'lib/leptris/xml/document.rb', line 204

def create_cdata(content)
  ptr = Leptris::XML::FFI.leptris_cdata_node_create(@c_ptr, content.to_s)
  raise Leptris::XML::Error, "leptris_cdata_node_create failed" if ptr.null?
  Leptris::XML::Node.wrap(ptr, self)
end

#create_comment(content) ⇒ Object



198
199
200
201
202
# File 'lib/leptris/xml/document.rb', line 198

def create_comment(content)
  ptr = Leptris::XML::FFI.leptris_comment_node_create(@c_ptr, content.to_s)
  raise Leptris::XML::Error, "leptris_comment_node_create failed" if ptr.null?
  Leptris::XML::Node.wrap(ptr, self)
end

#create_element(name) ⇒ Object



186
187
188
189
190
# File 'lib/leptris/xml/document.rb', line 186

def create_element(name)
  ptr = Leptris::XML::FFI.leptris_element_create(@c_ptr, name)
  raise Leptris::XML::Error, "leptris_element_create failed" if ptr.null?
  Leptris::XML::Node.wrap(ptr, self)
end

#create_processing_instruction(target, data = "") ⇒ Object



210
211
212
213
214
# File 'lib/leptris/xml/document.rb', line 210

def create_processing_instruction(target, data = "")
  ptr = Leptris::XML::FFI.leptris_pi_node_create(@c_ptr, target.to_s, data.to_s)
  raise Leptris::XML::Error, "leptris_pi_node_create failed" if ptr.null?
  Leptris::XML::Node.wrap(ptr, self)
end

#create_text_node(content) ⇒ Object



192
193
194
195
196
# File 'lib/leptris/xml/document.rb', line 192

def create_text_node(content)
  ptr = Leptris::XML::FFI.leptris_text_node_create(@c_ptr, content.to_s)
  raise Leptris::XML::Error, "leptris_text_node_create failed" if ptr.null?
  Leptris::XML::Node.wrap(ptr, self)
end

#doctypeObject Also known as: internal_subset



227
228
229
230
231
# File 'lib/leptris/xml/document.rb', line 227

def doctype
  ptr = Leptris::XML::FFI.leptris_document_internal_subset(@c_ptr)
  return nil if ptr.null?
  Leptris::XML::DocType.new(ptr, self)
end

#documentObject



372
# File 'lib/leptris/xml/document.rb', line 372

def document; self; end

#dupObject Also known as: clone



220
221
222
223
224
# File 'lib/leptris/xml/document.rb', line 220

def dup
  raw = Leptris::XML::FFI.leptris_document_copy(@c_ptr)
  raise Leptris::XML::Error, "leptris_document_copy failed" if raw.null?
  self.class.wrap(raw)
end

#encodingObject



373
374
375
376
# File 'lib/leptris/xml/document.rb', line 373

def encoding
  return nil if @c_ptr.nil?
  Leptris::XML::FFI.leptris_document_encoding(@c_ptr)
end

#exsltObject

Enable the first-party EXSLT-style extension pack on this document: str:/set:/math: prefixed functions (replace, tokenize, split, concat, padding; distinct, intersection, difference, leading, trailing; max, min, abs, sqrt, power) as native C handlers. Returns self for chaining.



285
286
287
288
289
# File 'lib/leptris/xml/document.rb', line 285

def exslt
  Leptris::XML::FFI.check_status(
    Leptris::XML::FFI.leptris_exslt_enable(@c_ptr))
  self
end

#fragment(markup) ⇒ Object



216
217
218
# File 'lib/leptris/xml/document.rb', line 216

def fragment(markup)
  Leptris::XML::DocumentFragment.parse(markup, self)
end

#freeObject



272
273
274
275
276
277
278
# File 'lib/leptris/xml/document.rb', line 272

def free
  return if @freed.state == :freed
  @freed.state = :freed
  Leptris::XML::FFI.leptris_document_free(@c_ptr) unless @c_ptr.nil?
  @c_ptr = nil
  @wrapper_cache&.clear
end

#freed?Boolean

True once #free has run (or the GC finalizer fired) — borrowed handles check this before dereferencing their c_ptr.

Returns:

  • (Boolean)


330
331
332
# File 'lib/leptris/xml/document.rb', line 330

def freed?
  @freed.state == :freed || @c_ptr.nil?
end

#last_errorObject

The most recent error recorded against this document, or nil.



366
367
368
369
# File 'lib/leptris/xml/document.rb', line 366

def last_error
  msg = Leptris::XML::FFI.leptris_document_last_error(@c_ptr)
  msg.nil? || msg.empty? ? nil : msg
end

#last_error_positionObject

The thread-global last-failure [line, column] (1-based), or nil when no error is recorded — the position companion to Document#last_error; populated by recover parses.



352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/leptris/xml/document.rb', line 352

def last_error_position
  line = ::FFI::MemoryPointer.new(:int)
  column = ::FFI::MemoryPointer.new(:int)
  begin
    Leptris::XML::FFI.leptris_last_error_position(line, column)
    line.read_int.zero? && column.read_int.zero? ? nil :
      [line.read_int, column.read_int]
  ensure
    line.free
    column.free
  end
end

#nameObject



371
# File 'lib/leptris/xml/document.rb', line 371

def name; "document"; end

#nodeObject

The document node — navigation head over the whole tree chain [prolog comments/PIs, root element, epilog comments/PIs] in document order (libleptris 1.9.7, upstream #580: the libxml2 model; XPath /comment() and //processing-instruction() see the document-level nodes). A stable, document-owned singleton — Node.wrap's cache keeps the returned wrapper identical.



160
161
162
163
164
# File 'lib/leptris/xml/document.rb', line 160

def node
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
  @node ||= Leptris::XML::Node.wrap(
    Leptris::XML::FFI.leptris_document_node(@c_ptr), self)
end

#processing_instructionsObject

Document-level processing instructions (not tree nodes): an array of [target, data] pairs in document order.



293
294
295
296
297
298
299
300
301
302
303
# File 'lib/leptris/xml/document.rb', line 293

def processing_instructions
  return @processing_instructions if @pi_version == @version
  count = Leptris::XML::FFI.leptris_document_pi_count(@c_ptr)
  result = count.times.map do |i|
    [Leptris::XML::FFI.leptris_document_pi_target(@c_ptr, i),
     Leptris::XML::FFI.leptris_document_pi_data(@c_ptr, i)]
  end
  @processing_instructions = result
  @pi_version = @version
  result
end

#readonly!Object

Marks the document read-only: tree mutations raise Leptris::XML::ReadOnlyError, and read paths memoize aggressively (names, content, children, attributes) since they can never go stale. The C document is also frozen (advisory upstream). One-way.



318
319
320
321
322
# File 'lib/leptris/xml/document.rb', line 318

def readonly!
  Leptris::XML::FFI.leptris_document_freeze(@c_ptr)
  @readonly = true
  self
end

#readonly?Boolean

Returns:

  • (Boolean)


324
325
326
# File 'lib/leptris/xml/document.rb', line 324

def readonly?
  @readonly == true
end

#rootObject



146
147
148
149
150
151
152
# File 'lib/leptris/xml/document.rb', line 146

def root
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
  return nil if @c_ptr.nil?
  ptr = Leptris::XML::FFI.leptris_document_root(@c_ptr)
  return nil if ptr.null?
  Leptris::XML::Node.wrap(ptr, self)
end

#root=(element) ⇒ Object

Attach element as the document's root element. The element must have been created against this document and must not already have a parent. Any previous root is left detached (still owned by the document's pool until #free).



178
179
180
181
182
183
184
# File 'lib/leptris/xml/document.rb', line 178

def root=(element)
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
  Leptris::XML::FFI.check_status(
    Leptris::XML::FFI.leptris_document_set_root(@c_ptr, element.c_ptr))
  @version += 1
  element
end

#save(path, **opts) ⇒ Object



244
245
246
247
248
249
250
251
252
253
# File 'lib/leptris/xml/document.rb', line 244

def save(path, **opts)
  opts_struct, _encoding_anchor = Leptris::XML::Serialization.build_options(
    indent: opts.fetch(:indent, 0),
    no_decl: opts.fetch(:no_decl, false),
    encoding: opts[:encoding])
  status = Leptris::XML::FFI.leptris_document_save_file(
    @c_ptr, path, opts_struct.pointer)
  Leptris::XML::FFI.check_status(status)
  self
end

#to_xml(indent: 0, no_decl: false, encoding: nil) ⇒ Object Also known as: to_s, serialize



234
235
236
237
238
239
240
# File 'lib/leptris/xml/document.rb', line 234

def to_xml(indent: 0, no_decl: false, encoding: nil)
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
  return "" if @c_ptr.nil?
  Leptris::XML::Serialization.to_xml(
    Leptris::XML::FFI.method(:leptris_document_serialize_into), @c_ptr,
    indent: indent, no_decl: no_decl, encoding: encoding)
end

#versionObject

Mutation version: advanced by every data mutation (via Node#ensure_writable!, root=, add_pi). Node memos stamp the version they were computed under and recompute after any bump — the invalidation that makes WRITABLE-document memoization sound. Readonly documents never advance it, so their memos are forever valid (ADR 0003 semantics, unchanged).



23
24
25
# File 'lib/leptris/xml/document.rb', line 23

def version
  @version
end

#wrapper_cacheObject



56
57
58
# File 'lib/leptris/xml/document.rb', line 56

def wrapper_cache
  @wrapper_cache ||= {}
end