leptris 1.9.21 → 1.9.23

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01fef4edf0eca47c087e7c3e111952207c69c10ed75c367858f284f78b5c6753
4
- data.tar.gz: 4b094de8b8b535e081fee40f7fd1117330ed0b28ad909043293c34071ae47335
3
+ metadata.gz: ec6d834f30c917e77000bf9784e85ecc59b8752c246f28bb6932bd003f0d45f9
4
+ data.tar.gz: a0cd90f99e074b719d9538dc3c1f245e85fc3185fbe693c5b4dab3bee405c77b
5
5
  SHA512:
6
- metadata.gz: 92c89619228205696fe95555b1e2cb55032237ff82066651140824219994ae0e9a60829a6de0262e7a21de39fcb1655177575de18c9191bb52e50f7a1b772a21
7
- data.tar.gz: 12862b063ed8f1c903c53f17de0e1d30f84b059c042a9f3ca1c786901af4189362f311f67fe51a1df0905a4b7e5e30d7638061293a9c5d3cfc238bd1856ba888
6
+ metadata.gz: d4de54e8c4cb0b5d3951a7c7ac63fc4ce4d97335597d923eb424f445cbaaa790fb27220563d6bbd65ca7490522f2a42423da5e28b199b9e16fcfeb638b148dfb
7
+ data.tar.gz: 797164d946ad51fde25139e6cbdcc45d9c86a18f5062b09a93c63e098f443ad4b48c1c1f8cce6d26b7f2583a7decd774954dbe4dbe77e4839ad3f5c9d9e3eab9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to Leptris will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.22] - 2026-08-27
9
+
10
+ ### Added
11
+
12
+ - **libleptris 1.9.4 lockstep** (leptris/leptris#585, #586): Rakefile
13
+ pin bumped; symbol audit 242/242 attached-vs-exported with the ten
14
+ new functions.
15
+ - **`SAX::Recorder`**: public chunked-event API over the C recorder —
16
+ `Recorder.parse(xml_or_io)` / `Recorder#feed` / `#each_event`
17
+ yield `kind, name, text, attrs, line, column` with UTF-8 strings,
18
+ drained as one bulk read per chunk (records via `read_bytes` +
19
+ `unpack` against the packed string arena — no per-event FFI
20
+ struct). NOT wired into `SAX::Parser#parse_memory`: measured
21
+ parity-to-−30% vs the callback path (per-parse recorder setup
22
+ scales with input size; findings and measurements filed upstream
23
+ as leptris/leptris#594). It remains the right shape for hosts
24
+ that drain bulk events per chunk.
25
+ - **Iterparse v2** (#586): `Iterparse.parse` / `.parse_file` take
26
+ `mode:` — `:top_level` (v1: the root's completed children) or
27
+ `:full_document` (every element, post-order, child before parent).
28
+ `#namespace_uri(prefix)` / `#namespace_count` resolve prefixes on
29
+ the last yielded element's in-scope snapshot (call inside the run
30
+ block); `#error` reports truncated/malformed input.
31
+
32
+ ### Changed
33
+
34
+ - **Iterparse lifecycle**: `#run` no longer frees in `ensure` — the
35
+ block form of `.parse`/`.parse_file` frees on return (Pull's
36
+ pattern), the no-block form returns a live iterator for explicit
37
+ `#free`. This fixes the `enum_for` path, which previously freed
38
+ the handle before the enumerator drove it, and lets `#error`
39
+ stay readable after the run (`#free` snapshots the terminal
40
+ message). Full-document mode measures +36% CPU over top-level —
41
+ proportional to yielding every element instead of root children.
42
+
43
+ ### Meta
44
+
45
+ - Head-to-head vs published 1.9.21 (engine 1.9.0 vs 1.9.4): SAX
46
+ callback parsing and iterparse at parity across two passes; no
47
+ regression from the engine bump.
48
+
8
49
  ## [1.9.21] - 2026-08-27
9
50
 
10
51
  ### Changed
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  # Pin for `rake compile` and the platform-gem builds. Keep in lockstep
9
9
  # with .github/workflows/build.yml (which calls `rake compile`) and the
10
10
  # CHANGELOG when libleptris releases.
11
- LIBLEPTRIS_VERSION = "1.9.3"
11
+ LIBLEPTRIS_VERSION = "1.9.4"
12
12
 
13
13
  CMAKE_FLAGS = %w[
14
14
  -DCMAKE_BUILD_TYPE=Release
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.21"
4
+ VERSION = "1.9.23"
5
5
  end
@@ -43,6 +43,7 @@ module Leptris
43
43
  typedef :pointer, :leptris_xpath_ns_set
44
44
  typedef :pointer, :leptris_xpath_compiled
45
45
  typedef :pointer, :leptris_xslt
46
+ typedef :pointer, :leptris_sax_recorder
46
47
  typedef :pointer, :leptris_pull_parser
47
48
  typedef :pointer, :leptris_iterparse
48
49
  typedef :pointer, :leptris_xpath_var_set
@@ -81,6 +82,33 @@ module Leptris
81
82
  :recover, :int
82
83
  end
83
84
 
85
+ # LeptrisSaxEventRecord (types.h, issue #585): fixed layout the
86
+ # header explicitly freezes for FFI mirrors. Strings are NOT
87
+ # inline — name/text/attrs slice the recorder's arena.
88
+ class SaxEventRecord < ::FFI::Struct
89
+ layout :kind, :uint8,
90
+ :reserved, [:uint8, 7],
91
+ :name_off, :uint32, :name_len, :uint32,
92
+ :text_off, :uint32, :text_len, :uint32,
93
+ :attrs_off, :uint32, :attr_count, :uint32,
94
+ :line, :uint32, :column, :uint32
95
+ end
96
+
97
+ SAX_EVENT_START_DOCUMENT = 0
98
+ SAX_EVENT_END_DOCUMENT = 1
99
+ SAX_EVENT_START_ELEMENT = 2
100
+ SAX_EVENT_END_ELEMENT = 3
101
+ SAX_EVENT_CHARACTERS = 4
102
+ SAX_EVENT_COMMENT = 5
103
+ SAX_EVENT_CDATA = 6
104
+ SAX_EVENT_PI = 7
105
+ SAX_EVENT_START_PREFIX = 8
106
+ SAX_EVENT_END_PREFIX = 9
107
+ SAX_EVENT_ERROR = 10
108
+
109
+ ITERPARSE_TOP_LEVEL = 0
110
+ ITERPARSE_FULL_DOCUMENT = 1
111
+
84
112
  # struct LeptrisPullEvent (leptris/sax.h): FFI::Struct derives
85
113
  # the field offsets from the layout, so ABI changes are a
86
114
  # one-line edit here instead of silent offset drift in callers.
@@ -499,6 +527,19 @@ module Leptris
499
527
  [:pointer, :pointer], :leptris_sax_parser
500
528
  attach_function :leptris_sax_parser_feed,
501
529
  [:leptris_sax_parser, :string, :size_t, :int], :int
530
+ # Chunked event recorder (libleptris 1.9.4, #585): buffers
531
+ # events C-side (fixed records + packed arena); the host
532
+ # drains in bulk — callbacks become O(chunks), not O(events).
533
+ attach_function :leptris_sax_recorder_new,
534
+ [], :leptris_sax_recorder
535
+ attach_function :leptris_sax_recorder_feed,
536
+ [:leptris_sax_recorder, :string, :size_t, :int], :int
537
+ attach_function :leptris_sax_recorder_records,
538
+ [:leptris_sax_recorder, :pointer], :pointer
539
+ attach_function :leptris_sax_recorder_arena,
540
+ [:leptris_sax_recorder, :pointer], :pointer
541
+ attach_function :leptris_sax_recorder_free,
542
+ [:leptris_sax_recorder], :void
502
543
  attach_function :leptris_sax_parser_free,
503
544
  [:leptris_sax_parser], :void
504
545
  attach_function :leptris_sax_parser_set_streaming,
@@ -528,6 +569,19 @@ module Leptris
528
569
  [:string], :leptris_iterparse
529
570
  attach_function :leptris_iterparse_next,
530
571
  [:leptris_iterparse], :leptris_element
572
+ # iterparse v2 (libleptris 1.9.4, #586): explicit yield mode
573
+ # (top-level children or full-document post-order), namespace
574
+ # resolution on the last yielded element, error channel.
575
+ attach_function :leptris_iterparse_new_ex,
576
+ [:string, :size_t, :int], :leptris_iterparse
577
+ attach_function :leptris_iterparse_new_file_ex,
578
+ [:string, :int], :leptris_iterparse
579
+ attach_function :leptris_iterparse_ns_count,
580
+ [:leptris_iterparse], :size_t
581
+ attach_function :leptris_iterparse_ns_uri,
582
+ [:leptris_iterparse, :string], :string
583
+ attach_function :leptris_iterparse_error,
584
+ [:leptris_iterparse], :string
531
585
  attach_function :leptris_iterparse_free,
532
586
  [:leptris_iterparse], :void
533
587
 
@@ -12,34 +12,78 @@ require "ffi"
12
12
  # process(element) # subtree valid until the next iteration
13
13
  # end
14
14
  #
15
- # v1 limitation (upstream): element names are the QName as written;
16
- # namespace prefixes are not re-resolved use the DOM path when
17
- # namespace URIs matter. The yielded elements have no parent
18
- # Document; #document returns nil and they must not outlive the
19
- # iteration.
15
+ # v2 (libleptris 1.9.4, #586): an explicit yield mode
16
+ # :top_level (v1: the root's completed children) or
17
+ # :full_document (every element in document-order completion
18
+ # post-order, child before parent, subtree attached) plus
19
+ # namespace resolution on the last yielded element and an error
20
+ # channel for truncated input:
21
+ #
22
+ # it = Leptris::XML::Iterparse.parse(xml, mode: :full_document)
23
+ # it.run do |el|
24
+ # it.namespace_uri("p") # in scope on el, the last yielded
25
+ # end
26
+ # it.free
27
+ #
28
+ # Leptris::XML::Iterparse.parse(xml) { |el| ... }.error
29
+ # # => nil, or the parse failure (readable after the run: the
30
+ # # block form frees the iterator on return, snapshotting it)
31
+ #
32
+ # The yielded elements have no parent Document; #document returns
33
+ # nil and they must not outlive the iteration.
20
34
  class Leptris::XML::Iterparse
35
+ MODES = {
36
+ top_level: Leptris::XML::FFI::ITERPARSE_TOP_LEVEL,
37
+ full_document: Leptris::XML::FFI::ITERPARSE_FULL_DOCUMENT,
38
+ }.freeze
39
+
21
40
  # NOTE: an IO argument is read fully into memory before the C
22
41
  # iterator starts — the bounded-memory path is parse_file (C-side
23
42
  # file streaming). The C API takes one (xml, len) buffer.
24
- def self.parse(xml_or_io, &block)
43
+ def self.parse(xml_or_io, mode: :top_level, &block)
25
44
  xml = xml_or_io.respond_to?(:read) ? xml_or_io.read : xml_or_io.to_s
26
- iterator = new(Leptris::XML::FFI.leptris_iterparse_new(xml, xml.bytesize))
27
- iterator.run(&block)
45
+ iterator = new(Leptris::XML::FFI.leptris_iterparse_new_ex(
46
+ xml, xml.bytesize, mode_code(mode)))
47
+ return iterator unless block
48
+ begin
49
+ iterator.run(&block)
50
+ ensure
51
+ iterator.free
52
+ end
53
+ iterator
28
54
  end
29
55
 
30
- def self.parse_file(path, &block)
31
- iterator = new(Leptris::XML::FFI.leptris_iterparse_new_file(path))
32
- iterator.run(&block)
56
+ def self.parse_file(path, mode: :top_level, &block)
57
+ iterator = new(Leptris::XML::FFI.leptris_iterparse_new_file_ex(
58
+ path, mode_code(mode)))
59
+ return iterator unless block
60
+ begin
61
+ iterator.run(&block)
62
+ ensure
63
+ iterator.free
64
+ end
65
+ iterator
66
+ end
67
+
68
+ def self.mode_code(mode)
69
+ MODES.fetch(mode) do
70
+ raise ArgumentError,
71
+ "mode must be :top_level or :full_document, got #{mode.inspect}"
72
+ end
33
73
  end
74
+ private_class_method :mode_code
34
75
 
35
76
  def initialize(handle)
36
77
  raise Leptris::XML::ParseError, "leptris_iterparse_new failed" if handle.null?
37
78
  @handle = handle
38
79
  end
39
80
 
81
+ # Yields completed elements until the document is exhausted or the
82
+ # parse fails (see #error). Does not free the iterator — the block
83
+ # form of .parse/.parse_file does, or call #free explicitly.
40
84
  def run
41
85
  return enum_for(:run) unless block_given?
42
- loop do
86
+ while @handle
43
87
  ptr = Leptris::XML::FFI.leptris_iterparse_next(@handle)
44
88
  break if ptr.null?
45
89
  # document: nil — the subtree belongs to the iterator's pool,
@@ -48,13 +92,44 @@ class Leptris::XML::Iterparse
48
92
  yield Leptris::XML::Node.wrap(ptr, nil)
49
93
  end
50
94
  self
51
- ensure
52
- free
53
95
  end
54
96
 
97
+ # The URI bound to +prefix+ ("" for the default namespace) in the
98
+ # last yielded element's in-scope namespace snapshot, or nil when
99
+ # unbound (v2, #586). Valid only while the iterator is live —
100
+ # call it inside the #run block; nil after #free.
101
+ def namespace_uri(prefix)
102
+ return nil if @handle.nil?
103
+ Leptris::XML::FFI.leptris_iterparse_ns_uri(@handle, prefix.to_s)
104
+ end
105
+
106
+ # Number of namespace bindings in the last yielded element's
107
+ # scope. Valid only while the iterator is live; 0 after #free.
108
+ def namespace_count
109
+ return 0 if @handle.nil?
110
+ Leptris::XML::FFI.leptris_iterparse_ns_count(@handle)
111
+ end
112
+
113
+ # The parse error for malformed/truncated input, or nil when the
114
+ # document is well-formed and exhausted. Yielded elements stop at
115
+ # the failure point; #free snapshots the message so this stays
116
+ # readable after the block form of .parse returns.
117
+ def error
118
+ @handle ? live_error : @last_error
119
+ end
120
+
121
+ # Releases the C iterator. Safe to call twice.
55
122
  def free
56
123
  return if @handle.nil?
124
+ @last_error = live_error
57
125
  Leptris::XML::FFI.leptris_iterparse_free(@handle)
58
126
  @handle = nil
59
127
  end
128
+
129
+ private
130
+
131
+ def live_error
132
+ msg = Leptris::XML::FFI.leptris_iterparse_error(@handle)
133
+ msg.nil? || msg.empty? ? nil : msg
134
+ end
60
135
  end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ # Chunked event recorder (libleptris 1.9.4, issue #585): a SAX
6
+ # transport that buffers events C-side — fixed LeptrisSaxEventRecord
7
+ # entries plus a packed string arena — and drains them in one bulk
8
+ # read per fed chunk. The handler receives the identical calls the
9
+ # callback API makes; only the transport changes, and the callback
10
+ # count becomes O(chunks), not O(events): through FFI, per-event
11
+ # C-to-Ruby dispatch cost more than the parse itself.
12
+ #
13
+ # Leptris::XML::SAX::Recorder.parse(xml) do |kind, name, text, attrs|
14
+ # # kind: :start_element, :characters, ... (see KINDS)
15
+ # end
16
+ #
17
+ class Leptris::XML::SAX::Recorder
18
+ KINDS = {
19
+ Leptris::XML::FFI::SAX_EVENT_START_DOCUMENT => :start_document,
20
+ Leptris::XML::FFI::SAX_EVENT_END_DOCUMENT => :end_document,
21
+ Leptris::XML::FFI::SAX_EVENT_START_ELEMENT => :start_element,
22
+ Leptris::XML::FFI::SAX_EVENT_END_ELEMENT => :end_element,
23
+ Leptris::XML::FFI::SAX_EVENT_CHARACTERS => :characters,
24
+ Leptris::XML::FFI::SAX_EVENT_COMMENT => :comment,
25
+ Leptris::XML::FFI::SAX_EVENT_CDATA => :cdata,
26
+ Leptris::XML::FFI::SAX_EVENT_PI => :pi,
27
+ Leptris::XML::FFI::SAX_EVENT_START_PREFIX => :start_prefix,
28
+ Leptris::XML::FFI::SAX_EVENT_END_PREFIX => :end_prefix,
29
+ Leptris::XML::FFI::SAX_EVENT_ERROR => :error,
30
+ }.freeze
31
+
32
+ def self.open
33
+ raw = Leptris::XML::FFI.leptris_sax_recorder_new
34
+ if raw.null?
35
+ raise Leptris::XML::Error, "leptris_sax_recorder_new failed"
36
+ end
37
+ new(raw)
38
+ end
39
+
40
+ # One-shot parse over a complete document, yielding Record structs.
41
+ def self.parse(xml_or_io)
42
+ recorder = open
43
+ begin
44
+ if xml_or_io.respond_to?(:read)
45
+ recorder.feed_stream(xml_or_io) { |*args| yield(*args) }
46
+ else
47
+ recorder.feed(xml_or_io.to_s, final: true)
48
+ recorder.each_event { |*args| yield(*args) }
49
+ end
50
+ ensure
51
+ recorder.free
52
+ end
53
+ end
54
+
55
+ def initialize(handle)
56
+ @handle = handle
57
+ end
58
+
59
+ def free
60
+ return if @handle.nil?
61
+ Leptris::XML::FFI.leptris_sax_recorder_free(@handle)
62
+ @handle = nil
63
+ end
64
+
65
+ # Feed one chunk; records/arena reset at feed entry, so drain
66
+ # after every feed. Returns self.
67
+ def feed(chunk, final: false)
68
+ chunk = chunk.dup.force_encoding(Encoding::UTF_8)
69
+ rc = Leptris::XML::FFI.leptris_sax_recorder_feed(
70
+ @handle, chunk, chunk.bytesize, final ? 1 : 0)
71
+ rc
72
+ end
73
+
74
+ # Feed an IO in chunks, draining events after each chunk.
75
+ def feed_stream(io, chunk_size = 65_536, &block)
76
+ while (chunk = io.read(chunk_size))
77
+ feed(chunk)
78
+ each_event(&block)
79
+ end
80
+ feed("", final: true)
81
+ each_event(&block)
82
+ end
83
+
84
+ # Drains the current chunk's records: bulk-reads the record array
85
+ # and the packed arena (two FFI calls), then slices every string
86
+ # from the arena in Ruby — no per-string FFI.
87
+ def each_event
88
+ count_ptr = ::FFI::MemoryPointer.new(:size_t)
89
+ len_ptr = ::FFI::MemoryPointer.new(:size_t)
90
+ begin
91
+ records_ptr = Leptris::XML::FFI.leptris_sax_recorder_records(
92
+ @handle, count_ptr)
93
+ return self if records_ptr.null? || count_ptr.read_uint64.zero?
94
+
95
+ arena_ptr = Leptris::XML::FFI.leptris_sax_recorder_arena(
96
+ @handle, len_ptr)
97
+ arena = arena_ptr.read_bytes(len_ptr.read_uint64)
98
+ count = count_ptr.read_uint64
99
+
100
+ # The whole record block in ONE read: read_bytes + String#unpack
101
+ # (C-fast) — two FFI calls per chunk total (records + arena),
102
+ # the true bulk discipline. Template per record: kind byte,
103
+ # 7 pad, then the eight uint32 fields (little-endian hosts —
104
+ # the layout the header freezes for FFI mirrors).
105
+ stride = Leptris::XML::FFI::SaxEventRecord.size
106
+ fields = records_ptr.read_bytes(count * stride)
107
+ .unpack("C x7 V8" * count)
108
+
109
+ i = 0
110
+ while i < count
111
+ kind = KINDS[fields[i * 9]]
112
+ if kind
113
+ base = i * 9 + 1
114
+ yield kind,
115
+ slice(arena, fields[base], fields[base + 1]),
116
+ slice(arena, fields[base + 2], fields[base + 3]),
117
+ attrs_from(arena, fields[base + 4], fields[base + 5]),
118
+ fields[base + 6], fields[base + 7]
119
+ end
120
+ i += 1
121
+ end
122
+ ensure
123
+ count_ptr.free
124
+ len_ptr.free
125
+ end
126
+ self
127
+ end
128
+
129
+ private
130
+
131
+ # Zero-length slices are empty strings, not nil — the default
132
+ # namespace prefix is legitimately "" (START_PREFIX: name may be
133
+ # ""), matching the callback transport's delivery.
134
+ def slice(arena, off, len)
135
+ arena.byteslice(off, len).force_encoding(Encoding::UTF_8)
136
+ end
137
+
138
+ # attrs_off addresses name\0value\0... pairs, attr_count pairs;
139
+ # nil when the element carries none.
140
+ def attrs_from(arena, off, count)
141
+ return nil if count.zero?
142
+ hash = {}
143
+ pos = off
144
+ count.times do
145
+ name_end = arena.index("\0", pos)
146
+ name = arena.byteslice(pos, name_end - pos)
147
+ pos = name_end + 1
148
+ value_end = arena.index("\0", pos)
149
+ value = arena.byteslice(pos, value_end - pos)
150
+ pos = value_end + 1
151
+ hash[name.force_encoding(Encoding::UTF_8)] =
152
+ value.force_encoding(Encoding::UTF_8)
153
+ end
154
+ hash
155
+ end
156
+ end
@@ -7,6 +7,7 @@ module Leptris
7
7
  module SAX
8
8
  autoload :Document, "leptris/xml/sax/document"
9
9
  autoload :Parser, "leptris/xml/sax/parser"
10
+ autoload :Recorder, "leptris/xml/sax/recorder"
10
11
  end
11
12
  end
12
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.21
4
+ version: 1.9.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -99,6 +99,7 @@ files:
99
99
  - lib/leptris/xml/sax.rb
100
100
  - lib/leptris/xml/sax/document.rb
101
101
  - lib/leptris/xml/sax/parser.rb
102
+ - lib/leptris/xml/sax/recorder.rb
102
103
  - lib/leptris/xml/searchable.rb
103
104
  - lib/leptris/xml/serialization.rb
104
105
  - lib/leptris/xml/text.rb