leptris 1.0.0 → 1.1.0

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: 59ba9b4cfe2de60b9ef9a20dc7bd607cc8f5bbeabb8ad3ff8f1df7f8a2b8dfb4
4
- data.tar.gz: 18e0b232c34140548b4908f6532ad193a7e3b3ec7255c918ad4ed624f75935bb
3
+ metadata.gz: ec7aed2502125d89ff790ecd754cab242aec563ee58c45ceaf7e86cac4e96f85
4
+ data.tar.gz: 6e9a72b02fc76907143756992afa7866415912bbe5fa9282d1800974cfde8b74
5
5
  SHA512:
6
- metadata.gz: e57a3704d91f60a43d5b7d1b071ac34359c6be05eb5ac324c81b36871e8bcf310cbf650bc6f814cd4724ed4c00f07d22b44cf190456ec9852fda265edef16c75
7
- data.tar.gz: 2127edf5b84e7ea8322d00b030bb5a60b98feca626bd10863a2a255882673bf1615c3408724fb486d338f9316c5a745e04e8a63538717d3668a4b96217a76f23
6
+ metadata.gz: 1e2a587372714a54f60e560bd175885f09fa3dd5ec3c0d620715c58e152f36651606e63770000f1a7c5162590299328967942093a96a45f9cac923db2c86d369
7
+ data.tar.gz: 60aee00fff31eeba0e89a4b5d4affdf93dff22e5080db1093d17b05e6b029d94608f7934f5ec9c1c4eed3b7bfb3d905adb47f8e51ecc0645f8d52cb3e51da453
data/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ All notable changes to Leptris will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-08-22
9
+
10
+ Lockstep with libleptris 1.1.0.
11
+
12
+ ### Added
13
+
14
+ - `Leptris::XML.parse(xml, options:)` and
15
+ `Leptris::XML::ParseOptions.noblanks` — whitespace-only text nodes
16
+ can now be dropped at parse time (libxml2 `XML_PARSE_NOBLANKS` /
17
+ Nokogiri `noblanks` parity), via the new
18
+ `leptris_parse_string_flags` FFI entry point.
19
+ - Wrapper identity is now a guarantee: every path to a C node
20
+ (`root`, factories, navigation, xpath results) goes through
21
+ `Node.wrap` and its per-document cache, so the same node always
22
+ yields the same Ruby object.
23
+
24
+ ### Changed
25
+
26
+ - Architecture deepening: one status seam (`FFI.check_status`)
27
+ replaces 21 copy-pasted raises; one owned-string read
28
+ (`FFI.read_owned_string`) replaces 6 hand-rolled read-then-free
29
+ dances; a `Serialization` module owns the serialize/canonicalize
30
+ options lifecycle for both `Document` and `Element`; a
31
+ `CStringArray` adapter owns the NULL-terminated `char**` wire
32
+ format in both directions (c14n.rb folded in; the eager require is
33
+ gone and the autoload convention is uniform again).
34
+
35
+ ### Removed
36
+
37
+ - `ParseOptions` constants that mapped to nothing (`RECOVER`,
38
+ `STRICT`, `NOCDATA`, …). They were never honored by any code path;
39
+ the class now exposes only the real flag surface.
40
+
41
+ ### Changed (libleptris 1.1.0 lockstep)
42
+
43
+ - `Element#key?` now calls native `leptris_element_has_attribute`
44
+ (the v0.4.4-era export gap is closed) instead of emulating via
45
+ attribute lookup. The two stale "not exported" comments in ffi.rb
46
+ are gone; `leptris_xinclude_get_encoding` is bound.
47
+ - CI builds libleptris v1.1.0, whose SAX `LEPTRIS_API` annotations
48
+ (#430 fix) unblock Windows: all 9 matrix jobs expected green.
49
+
8
50
  ## [1.0.0] - 2026-08-21
9
51
 
10
52
  The leptris rebrand, in lockstep with libleptris 1.0.0. Every
data/README.adoc CHANGED
@@ -397,7 +397,7 @@ bundle exec rspec spec/xml/xpath_spec.rb:42 # one example by line
397
397
  bundle exec rubocop # lint
398
398
  ----
399
399
 
400
- CI pins libleptris to a released tag (currently v0.26.7) and builds it
400
+ CI pins libleptris to a released tag (currently v1.1.0) and builds it
401
401
  from source on each runner; see `.github/workflows/build.yml`.
402
402
 
403
403
  == License
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapter for libleptris's NULL-terminated char** wire format. Both
4
+ # directions of the conversion live here so the pointer arithmetic has
5
+ # one home (previously c14n built the array by hand and SAX walked it
6
+ # by hand).
7
+ module Leptris::XML::CStringArray
8
+ # Ruby strings -> NULL-terminated char**. Returns [buffer, anchors];
9
+ # the caller must keep both referenced for the duration of the FFI
10
+ # call (e.g. via local variables).
11
+ def self.to_c(strings)
12
+ strs = Array(strings).map(&:to_s)
13
+ return [nil, nil] if strs.empty?
14
+
15
+ ptr_size = ::FFI::type_size(:pointer)
16
+ buffer = ::FFI::MemoryPointer.new(:pointer, strs.size + 1)
17
+ anchors = strs.map { |s| ::FFI::MemoryPointer.from_string(s) }
18
+ anchors.each_with_index { |p, i| buffer.put_pointer(i * ptr_size, p) }
19
+ buffer.put_pointer(strs.size * ptr_size, nil)
20
+ [buffer, anchors]
21
+ end
22
+
23
+ # NULL-terminated char** -> Ruby strings. Stops at the first NULL
24
+ # entry. Returns [] for a NULL pointer.
25
+ def self.to_ruby(ptr)
26
+ return [] if ptr.nil? || ptr.null?
27
+
28
+ ptr_size = ::FFI::type_size(:pointer)
29
+ result = []
30
+ offset = 0
31
+ while (entry = ptr.get_pointer(offset)) && !entry.null?
32
+ result << entry.read_string
33
+ offset += ptr_size
34
+ end
35
+ result
36
+ end
37
+ end
@@ -8,9 +8,8 @@ class Leptris::XML::CDATA < Leptris::XML::Text
8
8
  end
9
9
 
10
10
  def content=(new_content)
11
- status = Leptris::XML::FFI.leptris_cdata_node_set_content(@c_ptr, new_content.to_s)
12
- raise Leptris::XML::Error,
13
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
11
+ Leptris::XML::FFI.check_status(
12
+ Leptris::XML::FFI.leptris_cdata_node_set_content(@c_ptr, new_content.to_s))
14
13
  new_content
15
14
  end
16
15
  end
@@ -8,9 +8,8 @@ class Leptris::XML::Comment < Leptris::XML::Node
8
8
  end
9
9
 
10
10
  def content=(new_content)
11
- status = Leptris::XML::FFI.leptris_comment_node_set_content(@c_ptr, new_content.to_s)
12
- raise Leptris::XML::Error,
13
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
11
+ Leptris::XML::FFI.check_status(
12
+ Leptris::XML::FFI.leptris_comment_node_set_content(@c_ptr, new_content.to_s))
14
13
  new_content
15
14
  end
16
15
  end
@@ -17,20 +17,36 @@ class Leptris::XML::Document
17
17
  def initialize(c_ptr = nil, freed = Freed.new(:alive))
18
18
  @c_ptr = c_ptr
19
19
  @freed = freed
20
- # Per-document weak-ref cache for Node wrappers, keyed on c_ptr
21
- # address. Eliminates re-allocation when the same node is accessed
22
- # repeatedly (e.g. via children, siblings, multiple xpath calls).
23
- # Dies with the Document — no stale entries pointing at freed memory.
24
- @wrapper_cache = ObjectSpace::WeakMap.new
25
- end
26
-
27
- def self.parse(xml_or_io)
20
+ # Per-document STRONG cache for Node wrappers, keyed on c_ptr
21
+ # address. Every wrapper is created through Node.wrap, which is the
22
+ # single construction path, so the same C node always yields the
23
+ # same Ruby object. Cleared when the Document is freed — no stale
24
+ # entries.
25
+ #
26
+ # Deliberately NOT ObjectSpace::WeakMap: a weak cache makes wrapper
27
+ # identity a GC race. `doc.root.equal?(doc.root)` failed on the
28
+ # Windows CI matrix (188 examples, the 4 identity specs) because
29
+ # between the two calls the first wrapper was referenced only by
30
+ # the weak map — any GC sweep evicted it and the second call built
31
+ # a fresh object. A strong cache costs at most one wrapper per node
32
+ # actually visited, held until the document dies.
33
+ @wrapper_cache = {}
34
+ end
35
+
36
+ def self.parse(xml_or_io, options: nil)
28
37
  xml = xml_or_io.respond_to?(:read) ? xml_or_io.read : xml_or_io.to_s
29
38
  if xml.empty?
30
39
  raise Leptris::XML::ParseError, "empty input"
31
40
  end
41
+ flags = resolve_flags(options)
32
42
  status_ptr = ::FFI::MemoryPointer.new(:int)
33
- raw = Leptris::XML::FFI.leptris_parse_string(xml, xml.bytesize, status_ptr)
43
+ raw =
44
+ if flags.zero?
45
+ Leptris::XML::FFI.leptris_parse_string(xml, xml.bytesize, status_ptr)
46
+ else
47
+ Leptris::XML::FFI.leptris_parse_string_flags(
48
+ xml, xml.bytesize, flags, status_ptr)
49
+ end
34
50
  if raw.null?
35
51
  status = status_ptr.read_int
36
52
  raise Leptris::XML::ParseError,
@@ -74,42 +90,51 @@ class Leptris::XML::Document
74
90
  end
75
91
  private_class_method :finalizer
76
92
 
93
+ def self.resolve_flags(options)
94
+ return Leptris::XML::FFI::LEPTRIS_PARSE_DEFAULT if options.nil?
95
+ unless options.is_a?(Leptris::XML::ParseOptions)
96
+ raise ArgumentError, "options must be a Leptris::XML::ParseOptions"
97
+ end
98
+ options.flags
99
+ end
100
+ private_class_method :resolve_flags
101
+
77
102
  def root
78
103
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
79
104
  return nil if @c_ptr.nil?
80
105
  ptr = Leptris::XML::FFI.leptris_document_root(@c_ptr)
81
106
  return nil if ptr.null?
82
- Leptris::XML::Element.new(ptr, self)
107
+ Leptris::XML::Node.wrap(ptr, self)
83
108
  end
84
109
 
85
110
  def create_element(name)
86
111
  ptr = Leptris::XML::FFI.leptris_element_create(@c_ptr, name)
87
112
  raise Leptris::XML::Error, "leptris_element_create failed" if ptr.null?
88
- Leptris::XML::Element.new(ptr, self)
113
+ Leptris::XML::Node.wrap(ptr, self)
89
114
  end
90
115
 
91
116
  def create_text_node(content)
92
117
  ptr = Leptris::XML::FFI.leptris_text_node_create(@c_ptr, content.to_s)
93
118
  raise Leptris::XML::Error, "leptris_text_node_create failed" if ptr.null?
94
- Leptris::XML::Text.new(ptr, self)
119
+ Leptris::XML::Node.wrap(ptr, self)
95
120
  end
96
121
 
97
122
  def create_comment(content)
98
123
  ptr = Leptris::XML::FFI.leptris_comment_node_create(@c_ptr, content.to_s)
99
124
  raise Leptris::XML::Error, "leptris_comment_node_create failed" if ptr.null?
100
- Leptris::XML::Comment.new(ptr, self)
125
+ Leptris::XML::Node.wrap(ptr, self)
101
126
  end
102
127
 
103
128
  def create_cdata(content)
104
129
  ptr = Leptris::XML::FFI.leptris_cdata_node_create(@c_ptr, content.to_s)
105
130
  raise Leptris::XML::Error, "leptris_cdata_node_create failed" if ptr.null?
106
- Leptris::XML::CDATA.new(ptr, self)
131
+ Leptris::XML::Node.wrap(ptr, self)
107
132
  end
108
133
 
109
134
  def create_processing_instruction(target, data = "")
110
135
  ptr = Leptris::XML::FFI.leptris_pi_node_create(@c_ptr, target.to_s, data.to_s)
111
136
  raise Leptris::XML::Error, "leptris_pi_node_create failed" if ptr.null?
112
- Leptris::XML::ProcessingInstruction.new(ptr, self)
137
+ Leptris::XML::Node.wrap(ptr, self)
113
138
  end
114
139
 
115
140
  def fragment(markup)
@@ -133,22 +158,21 @@ class Leptris::XML::Document
133
158
  def to_xml(indent: 0, no_decl: false, encoding: nil)
134
159
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
135
160
  return "" if @c_ptr.nil?
136
- opts, enc_ptr = build_serialize_options(indent: indent, no_decl: no_decl, encoding: encoding)
137
- str_ptr = Leptris::XML::FFI.leptris_document_serialize(@c_ptr, opts.pointer)
138
- return "" if str_ptr.null?
139
- str_ptr.read_string.tap { |s| Leptris::XML::FFI.leptris_free_string(str_ptr) }
161
+ Leptris::XML::Serialization.to_xml(
162
+ Leptris::XML::FFI.method(:leptris_document_serialize), @c_ptr,
163
+ indent: indent, no_decl: no_decl, encoding: encoding)
140
164
  end
141
165
  alias_method :to_s, :to_xml
142
166
  alias_method :serialize, :to_xml
143
167
 
144
168
  def save(path, **opts)
145
- opts_struct, enc_ptr = build_serialize_options(
169
+ opts_struct, _encoding_anchor = Leptris::XML::Serialization.build_options(
146
170
  indent: opts.fetch(:indent, 0),
147
171
  no_decl: opts.fetch(:no_decl, false),
148
172
  encoding: opts[:encoding])
149
- status = Leptris::XML::FFI.leptris_document_save_file(@c_ptr, path, opts_struct.pointer)
150
- raise Leptris::XML::Error,
151
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
173
+ status = Leptris::XML::FFI.leptris_document_save_file(
174
+ @c_ptr, path, opts_struct.pointer)
175
+ Leptris::XML::FFI.check_status(status)
152
176
  self
153
177
  end
154
178
 
@@ -161,12 +185,11 @@ class Leptris::XML::Document
161
185
  return "" if @c_ptr.nil?
162
186
  resolved_mode = mode || (exclusive ? Leptris::XML::FFI::C14N_MODE_EXCLUSIVE
163
187
  : Leptris::XML::FFI::C14N_MODE_CANONICAL)
164
- ns_ptr, _anchor = Leptris::XML.c14n_build_ns_pointer(inclusive_namespaces)
165
- flags = with_comments ? 1 : 0
166
- str_ptr = Leptris::XML::FFI.leptris_c14n_canonicalize_ex(
167
- @c_ptr, version, resolved_mode, ns_ptr, flags)
168
- return "" if str_ptr.null?
169
- str_ptr.read_string.tap { Leptris::XML::FFI.leptris_free_string(str_ptr) }
188
+ Leptris::XML::Serialization.canonicalize(
189
+ Leptris::XML::FFI.method(:leptris_c14n_canonicalize_ex), @c_ptr,
190
+ version: version, mode: resolved_mode,
191
+ inclusive_namespaces: inclusive_namespaces,
192
+ with_comments: with_comments)
170
193
  end
171
194
  alias_method :c14n, :canonicalize
172
195
 
@@ -175,6 +198,7 @@ class Leptris::XML::Document
175
198
  @freed.state = :freed
176
199
  Leptris::XML::FFI.leptris_document_free(@c_ptr) unless @c_ptr.nil?
177
200
  @c_ptr = nil
201
+ @wrapper_cache.clear
178
202
  end
179
203
 
180
204
  def name; "document"; end
@@ -184,19 +208,5 @@ class Leptris::XML::Document
184
208
  Leptris::XML::FFI.leptris_document_encoding(@c_ptr)
185
209
  end
186
210
 
187
- private
188
-
189
- def build_serialize_options(indent:, no_decl:, encoding:)
190
- opts = Leptris::XML::FFI::SerializeOptions.new
191
- opts[:indent] = indent.to_i
192
- opts[:xml_declaration] = no_decl ? 0 : 1
193
- enc_ptr = nil
194
- if encoding
195
- enc_ptr = ::FFI::MemoryPointer.from_string(encoding.to_s)
196
- opts[:encoding] = enc_ptr
197
- end
198
- [opts, enc_ptr]
199
- end
200
-
201
211
  include Leptris::XML::Searchable
202
212
  end
@@ -7,9 +7,8 @@ class Leptris::XML::Element < Leptris::XML::Node
7
7
  alias_method :node_name, :name
8
8
 
9
9
  def name=(new_name)
10
- status = Leptris::XML::FFI.leptris_element_set_name(@c_ptr, new_name)
11
- raise Leptris::XML::Error,
12
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
10
+ Leptris::XML::FFI.check_status(
11
+ Leptris::XML::FFI.leptris_element_set_name(@c_ptr, new_name))
13
12
  new_name
14
13
  end
15
14
  alias_method :node_name=, :name=
@@ -19,9 +18,8 @@ class Leptris::XML::Element < Leptris::XML::Node
19
18
  end
20
19
 
21
20
  def content=(new_content)
22
- status = Leptris::XML::FFI.leptris_element_set_text(@c_ptr, new_content.to_s)
23
- raise Leptris::XML::Error,
24
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
21
+ Leptris::XML::FFI.check_status(
22
+ Leptris::XML::FFI.leptris_element_set_text(@c_ptr, new_content.to_s))
25
23
  new_content
26
24
  end
27
25
 
@@ -32,22 +30,20 @@ class Leptris::XML::Element < Leptris::XML::Node
32
30
  alias_method :get_attribute, :[]
33
31
 
34
32
  def []=(key, value)
35
- status = Leptris::XML::FFI.leptris_element_set_attribute(@c_ptr, key.to_s, value.to_s)
36
- raise Leptris::XML::Error,
37
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
33
+ Leptris::XML::FFI.check_status(
34
+ Leptris::XML::FFI.leptris_element_set_attribute(@c_ptr, key.to_s, value.to_s))
38
35
  value
39
36
  end
40
37
  alias_method :set_attribute, :[]=
41
38
 
42
39
  def key?(name)
43
- !Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name.to_s).nil?
40
+ Leptris::XML::FFI.leptris_element_has_attribute(@c_ptr, name.to_s) != 0
44
41
  end
45
42
  alias_method :has_attribute?, :key?
46
43
 
47
44
  def remove_attribute(name)
48
- status = Leptris::XML::FFI.leptris_element_remove_attribute(@c_ptr, name.to_s)
49
- raise Leptris::XML::Error,
50
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
45
+ Leptris::XML::FFI.check_status(
46
+ Leptris::XML::FFI.leptris_element_remove_attribute(@c_ptr, name.to_s))
51
47
  self
52
48
  end
53
49
  alias_method :delete, :remove_attribute
@@ -82,45 +78,34 @@ class Leptris::XML::Element < Leptris::XML::Node
82
78
  end
83
79
  end
84
80
 
85
- def add_child(node)
86
- status = Leptris::XML::FFI.leptris_element_append_child(@c_ptr, node.c_ptr)
87
- raise Leptris::XML::Error,
88
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
89
- node
90
- end
91
- alias_method :<<, :add_child
92
-
93
81
  def prepend_child(node)
94
- status = Leptris::XML::FFI.leptris_element_prepend_child(@c_ptr, node.c_ptr)
95
- raise Leptris::XML::Error,
96
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
82
+ Leptris::XML::FFI.check_status(
83
+ Leptris::XML::FFI.leptris_element_prepend_child(@c_ptr, node.c_ptr))
97
84
  node
98
85
  end
99
86
 
100
87
  def add_next_sibling(node)
101
- status = Leptris::XML::FFI.leptris_element_insert_after(@c_ptr, node.c_ptr)
102
- raise Leptris::XML::Error,
103
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
88
+ Leptris::XML::FFI.check_status(
89
+ Leptris::XML::FFI.leptris_element_insert_after(@c_ptr, node.c_ptr))
104
90
  node
105
91
  end
106
92
 
107
93
  def add_previous_sibling(node)
108
- status = Leptris::XML::FFI.leptris_element_insert_before(@c_ptr, node.c_ptr)
109
- raise Leptris::XML::Error,
110
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
94
+ Leptris::XML::FFI.check_status(
95
+ Leptris::XML::FFI.leptris_element_insert_before(@c_ptr, node.c_ptr))
111
96
  node
112
97
  end
113
98
 
114
99
  def remove_child(node)
115
- status = Leptris::XML::FFI.leptris_element_remove_child(@c_ptr, node.c_ptr)
116
- raise Leptris::XML::Error,
117
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
100
+ Leptris::XML::FFI.check_status(
101
+ Leptris::XML::FFI.leptris_element_remove_child(@c_ptr, node.c_ptr))
118
102
  node
119
103
  end
120
104
 
121
105
  def children=(node_or_nodes)
122
106
  # Remove existing children, then attach the new ones in source order.
123
- Leptris::XML::FFI.leptris_element_remove_children(@c_ptr)
107
+ Leptris::XML::FFI.check_status(
108
+ Leptris::XML::FFI.leptris_element_remove_children(@c_ptr))
124
109
  Array(node_or_nodes).each { |n| add_child(n) }
125
110
  end
126
111
 
@@ -169,24 +154,22 @@ class Leptris::XML::Element < Leptris::XML::Node
169
154
  def dup
170
155
  copy_ptr = Leptris::XML::FFI.leptris_element_copy(@c_ptr, @document.c_ptr)
171
156
  raise Leptris::XML::Error, "leptris_element_copy failed" if copy_ptr.null?
172
- Leptris::XML::Element.new(copy_ptr, @document)
157
+ Leptris::XML::Node.wrap(copy_ptr, @document)
173
158
  end
174
159
  alias_method :clone, :dup
175
160
 
176
161
  def add_child(node_or_markup)
177
162
  case node_or_markup
178
163
  when Leptris::XML::Node
179
- status = Leptris::XML::FFI.leptris_element_append_child(@c_ptr, node_or_markup.c_ptr)
180
- raise Leptris::XML::Error,
181
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
164
+ Leptris::XML::FFI.check_status(
165
+ Leptris::XML::FFI.leptris_element_append_child(@c_ptr, node_or_markup.c_ptr))
182
166
  node_or_markup
183
167
  when String
184
168
  frag = Leptris::XML::DocumentFragment.parse(node_or_markup, @document)
185
169
  added = []
186
170
  frag.children.each do |n|
187
- status = Leptris::XML::FFI.leptris_element_append_child(@c_ptr, n.c_ptr)
188
- raise Leptris::XML::Error,
189
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
171
+ Leptris::XML::FFI.check_status(
172
+ Leptris::XML::FFI.leptris_element_append_child(@c_ptr, n.c_ptr))
190
173
  added << n
191
174
  end
192
175
  Leptris::XML::NodeSet.new(@document, added)
@@ -194,6 +177,7 @@ class Leptris::XML::Element < Leptris::XML::Node
194
177
  raise ArgumentError, "add_child expects a Node or String, got #{node_or_markup.class}"
195
178
  end
196
179
  end
180
+ alias_method :<<, :add_child
197
181
 
198
182
  def namespace
199
183
  uri = Leptris::XML::FFI.leptris_element_namespace(@c_ptr)
@@ -224,17 +208,9 @@ class Leptris::XML::Element < Leptris::XML::Node
224
208
  end
225
209
 
226
210
  def to_xml(indent: 0, no_decl: false, encoding: nil)
227
- opts = Leptris::XML::FFI::SerializeOptions.new
228
- opts[:indent] = indent.to_i
229
- opts[:xml_declaration] = no_decl ? 0 : 1
230
- enc_ptr = nil
231
- if encoding
232
- enc_ptr = ::FFI::MemoryPointer.from_string(encoding.to_s)
233
- opts[:encoding] = enc_ptr
234
- end
235
- str_ptr = Leptris::XML::FFI.leptris_element_serialize(@c_ptr, opts.pointer)
236
- return "" if str_ptr.null?
237
- str_ptr.read_string.tap { Leptris::XML::FFI.leptris_free_string(str_ptr) }
211
+ Leptris::XML::Serialization.to_xml(
212
+ Leptris::XML::FFI.method(:leptris_element_serialize), @c_ptr,
213
+ indent: indent, no_decl: no_decl, encoding: encoding)
238
214
  end
239
215
 
240
216
  def canonicalize(version = Leptris::XML::FFI::C14N_1_0,
@@ -244,35 +220,31 @@ class Leptris::XML::Element < Leptris::XML::Node
244
220
  mode: nil)
245
221
  resolved_mode = mode || (exclusive ? Leptris::XML::FFI::C14N_MODE_EXCLUSIVE
246
222
  : Leptris::XML::FFI::C14N_MODE_CANONICAL)
247
- ns_ptr, _anchor = Leptris::XML.c14n_build_ns_pointer(inclusive_namespaces)
248
- flags = with_comments ? 1 : 0
249
- str_ptr = Leptris::XML::FFI.leptris_c14n_canonicalize_subtree_ex(
250
- @c_ptr, version, resolved_mode, ns_ptr, flags)
251
- return "" if str_ptr.null?
252
- str_ptr.read_string.tap { Leptris::XML::FFI.leptris_free_string(str_ptr) }
223
+ Leptris::XML::Serialization.canonicalize(
224
+ Leptris::XML::FFI.method(:leptris_c14n_canonicalize_subtree_ex), @c_ptr,
225
+ version: version, mode: resolved_mode,
226
+ inclusive_namespaces: inclusive_namespaces,
227
+ with_comments: with_comments)
253
228
  end
254
229
  alias_method :c14n, :canonicalize
255
230
 
256
231
  def add_namespace_definition(prefix, href)
257
- status = Leptris::XML::FFI.leptris_element_add_namespace_definition(
258
- @c_ptr, prefix.to_s, href.to_s)
259
- raise Leptris::XML::Error,
260
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
232
+ Leptris::XML::FFI.check_status(
233
+ Leptris::XML::FFI.leptris_element_add_namespace_definition(
234
+ @c_ptr, prefix.to_s, href.to_s))
261
235
  Leptris::XML::Namespace.new(self, href.to_s, prefix: prefix.nil? ? nil : prefix.to_s)
262
236
  end
263
237
  alias_method :add_namespace, :add_namespace_definition
264
238
 
265
239
  def default_namespace=(href)
266
- status = Leptris::XML::FFI.leptris_element_set_default_namespace(@c_ptr, href.to_s)
267
- raise Leptris::XML::Error,
268
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
240
+ Leptris::XML::FFI.check_status(
241
+ Leptris::XML::FFI.leptris_element_set_default_namespace(@c_ptr, href.to_s))
269
242
  href
270
243
  end
271
244
 
272
245
  def remove_namespace_definition(prefix)
273
- status = Leptris::XML::FFI.leptris_element_remove_namespace_definition(@c_ptr, prefix.to_s)
274
- raise Leptris::XML::Error,
275
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
246
+ Leptris::XML::FFI.check_status(
247
+ Leptris::XML::FFI.leptris_element_remove_namespace_definition(@c_ptr, prefix.to_s))
276
248
  self
277
249
  end
278
250
  end
@@ -53,6 +53,8 @@ module Leptris
53
53
 
54
54
  attach_function :leptris_parse_string,
55
55
  [:string, :size_t, :pointer], :leptris_document
56
+ attach_function :leptris_parse_string_flags,
57
+ [:string, :size_t, :uint, :pointer], :leptris_document
56
58
  attach_function :leptris_parse_string_inplace,
57
59
  [:pointer, :size_t, :pointer], :leptris_document
58
60
  attach_function :leptris_parse_string_with_encoding,
@@ -92,9 +94,7 @@ module Leptris
92
94
  attach_function :leptris_xinclude_get_href, [:leptris_element], :string
93
95
  attach_function :leptris_xinclude_get_parse, [:leptris_element], :string
94
96
  attach_function :leptris_xinclude_get_xpointer, [:leptris_element], :string
95
- # leptris_xinclude_get_encoding is declared in leptris.h but not exported
96
- # from the v0.4.4 dylib (likely same visibility bug as the serialize
97
- # functions; see upstream issue #166). Re-add once #173 / v0.4.5 ships.
97
+ attach_function :leptris_xinclude_get_encoding, [:leptris_element], :string
98
98
 
99
99
  attach_function :leptris_node_get_type,
100
100
  [:leptris_node_ref], :int
@@ -184,9 +184,8 @@ module Leptris
184
184
  [:leptris_element, :string, :float], :float
185
185
  attach_function :leptris_element_attribute_bool,
186
186
  [:leptris_element, :string, :int], :int
187
- # leptris_element_has_attribute is declared in leptris.h but not exported
188
- # from the v0.4.4 dylib (likely visibility bug; see upstream #166).
189
- # Ruby-level Element#key? will emulate via attribute lookup.
187
+ attach_function :leptris_element_has_attribute,
188
+ [:leptris_element, :string], :int
190
189
  attach_function :leptris_element_attribute_count,
191
190
  [:leptris_element], :size_t
192
191
  attach_function :leptris_element_attribute_name_at,
@@ -415,6 +414,24 @@ module Leptris
415
414
 
416
415
  TRAVERSE_PRE_ORDER = 0
417
416
  TRAVERSE_POST_ORDER = 1
417
+
418
+ LEPTRIS_PARSE_DEFAULT = 0
419
+ LEPTRIS_PARSE_DROP_WS_TEXT = 1
420
+
421
+ # Reads an libleptris-owned char* result and frees it as one unit,
422
+ # so a call site can neither leak nor double-free.
423
+ def self.read_owned_string(ptr)
424
+ return "" if ptr.nil? || ptr.null?
425
+ ptr.read_string.tap { leptris_free_string(ptr) }
426
+ end
427
+
428
+ # Single status seam: turns a C status code into a Ruby error.
429
+ def self.check_status(status)
430
+ unless status == LEPTRIS_OK
431
+ raise Leptris::XML::Error, leptris_status_string(status)
432
+ end
433
+ status
434
+ end
418
435
  end
419
436
  end
420
437
  end
@@ -66,7 +66,7 @@ class Leptris::XML::Node
66
66
  return @parent if @parent
67
67
  ptr = Leptris::XML::FFI.leptris_node_parent(@c_ptr)
68
68
  return nil if ptr.null?
69
- Leptris::XML::Element.new(ptr, @document)
69
+ Leptris::XML::Node.wrap(ptr, @document)
70
70
  end
71
71
 
72
72
  def line
@@ -141,9 +141,8 @@ class Leptris::XML::Node
141
141
  end
142
142
 
143
143
  def unlink
144
- status = Leptris::XML::FFI.leptris_node_unlink(@c_ptr)
145
- raise Leptris::XML::Error,
146
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
144
+ Leptris::XML::FFI.check_status(
145
+ Leptris::XML::FFI.leptris_node_unlink(@c_ptr))
147
146
  @parent = nil
148
147
  self
149
148
  end
@@ -173,7 +172,7 @@ class Leptris::XML::Node
173
172
  def path
174
173
  str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(@c_ptr)
175
174
  return nil if str_ptr.null?
176
- str_ptr.read_string.tap { Leptris::XML::FFI.leptris_free_string(str_ptr) }
175
+ Leptris::XML::FFI.read_owned_string(str_ptr)
177
176
  end
178
177
 
179
178
  def css_path
@@ -189,7 +188,7 @@ class Leptris::XML::Node
189
188
  raise Leptris::XML::Error, "dup is only supported for element nodes" if elem_ptr.null?
190
189
  copy_ptr = Leptris::XML::FFI.leptris_element_copy(elem_ptr, @document.c_ptr)
191
190
  raise Leptris::XML::Error, "leptris_element_copy failed" if copy_ptr.null?
192
- Leptris::XML::Element.new(copy_ptr, @document)
191
+ Leptris::XML::Node.wrap(copy_ptr, @document)
193
192
  end
194
193
  alias_method :clone, :dup
195
194
 
@@ -208,14 +207,5 @@ class Leptris::XML::Node
208
207
  is_a?(Leptris::XML::Element) ? self : nil
209
208
  end
210
209
 
211
- private
212
-
213
- # Walks the subtree in post-order DFS (matches Nokogiri's semantics).
214
- #
215
- # Specialized hot path for #traverse: skips the intermediate NodeSet
216
- # allocation that Element#children would create, walking via raw FFI
217
- # calls and wrapping nodes directly. Saves one Array + one NodeSet
218
- # allocation per parent node.
219
- #
220
210
  include Leptris::XML::Searchable
221
211
  end
@@ -1,19 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Parser flags, mirroring LeptrisParseFlags in libleptris's public
4
+ # headers. Pass an instance as `Leptris::XML.parse(xml, options:)`.
3
5
  class Leptris::XML::ParseOptions
4
- DEFAULT_XML = 0
5
- RECOVER = 1 << 0
6
- NOERROR = 1 << 5
7
- NOWARNING = 1 << 6
8
- NOCDATA = 1 << 8
9
- STRICT = 1 << 18
6
+ # Discard whitespace-only text nodes (runs between tags that contain
7
+ # nothing but spaces/tabs/newlines). Matches libxml2's
8
+ # XML_PARSE_NOBLANKS and Nokogiri's noblanks. Documents parsed this
9
+ # way do not round-trip pretty-printed formatting byte-for-byte.
10
+ NOBLANKS = Leptris::XML::FFI::LEPTRIS_PARSE_DROP_WS_TEXT
10
11
 
11
- attr_reader :options
12
+ attr_reader :flags
12
13
 
13
- def initialize(options = DEFAULT_XML)
14
- @options = options.to_i
14
+ def initialize(flags = Leptris::XML::FFI::LEPTRIS_PARSE_DEFAULT)
15
+ @flags = flags.to_i
15
16
  end
16
17
 
17
- def strict?; !@options.zero?; end
18
- def recover?; @options & RECOVER != 0; end
18
+ def self.noblanks
19
+ new(NOBLANKS)
20
+ end
21
+
22
+ def noblanks?
23
+ @flags & NOBLANKS != 0
24
+ end
25
+
26
+ def |(other)
27
+ self.class.new(@flags | other.flags)
28
+ end
19
29
  end
@@ -11,16 +11,14 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
11
11
  end
12
12
 
13
13
  def target=(new_target)
14
- status = Leptris::XML::FFI.leptris_pi_node_set_target(@c_ptr, new_target.to_s)
15
- raise Leptris::XML::Error,
16
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
14
+ Leptris::XML::FFI.check_status(
15
+ Leptris::XML::FFI.leptris_pi_node_set_target(@c_ptr, new_target.to_s))
17
16
  new_target
18
17
  end
19
18
 
20
19
  def data=(new_data)
21
- status = Leptris::XML::FFI.leptris_pi_node_set_data(@c_ptr, new_data.to_s)
22
- raise Leptris::XML::Error,
23
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
20
+ Leptris::XML::FFI.check_status(
21
+ Leptris::XML::FFI.leptris_pi_node_set_data(@c_ptr, new_data.to_s))
24
22
  new_data
25
23
  end
26
24
  end
@@ -128,21 +128,9 @@ class Leptris::XML::SAX::Parser
128
128
  ::FFI::Function.new(return_type, params, blocking: blocking, &block)
129
129
  end
130
130
 
131
- # The C `const char** attrs` is a NULL-terminated flat array of name/value
132
- # pairs: [name1, value1, name2, value2, ..., NULL]. Walk into pairs.
131
+ # The C `const char** attrs` is a NULL-terminated flat array of
132
+ # name/value pairs; CStringArray owns the format, we slice into pairs.
133
133
  def walk_attr_array(attrs_ptr)
134
- return [] if attrs_ptr.null?
135
- ptr_size = ::FFI.type_size(:pointer)
136
- result = []
137
- offset = 0
138
- loop do
139
- name_ptr = attrs_ptr.get_pointer(offset)
140
- break if name_ptr.null?
141
- value_ptr = attrs_ptr.get_pointer(offset + ptr_size)
142
- break if value_ptr.null?
143
- result << [name_ptr.read_string, value_ptr.read_string]
144
- offset += 2 * ptr_size
145
- end
146
- result
134
+ Leptris::XML::CStringArray.to_ruby(attrs_ptr).each_slice(2).to_a
147
135
  end
148
136
  end
@@ -81,8 +81,7 @@ module Leptris::XML::Searchable
81
81
  v
82
82
  when Leptris::XML::FFI::XPATH_STRING
83
83
  str_ptr = Leptris::XML::FFI.leptris_xpath_result_string(result_ptr)
84
- v = str_ptr.null? ? "" : str_ptr.read_string
85
- Leptris::XML::FFI.leptris_free_string(str_ptr) unless str_ptr.null?
84
+ v = Leptris::XML::FFI.read_owned_string(str_ptr)
86
85
  Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
87
86
  v
88
87
  else
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Owns the SerializeOptions lifecycle and the owned-string read for
4
+ # document and subtree serialization/canonicalization. Document and
5
+ # Element expose one-line methods over this module — the options
6
+ # struct, the encoding anchor, the C call, and the read-and-free all
7
+ # live here.
8
+ module Leptris::XML::Serialization
9
+ # +ffi_function+ is a bound FFI function taking (c_ptr, opts_ptr):
10
+ # leptris_document_serialize or leptris_element_serialize.
11
+ def self.to_xml(ffi_function, c_ptr, indent: 0, no_decl: false, encoding: nil)
12
+ opts, _encoding_anchor = build_options(
13
+ indent: indent, no_decl: no_decl, encoding: encoding)
14
+ str_ptr = ffi_function.call(c_ptr, opts.pointer)
15
+ Leptris::XML::FFI.read_owned_string(str_ptr)
16
+ end
17
+
18
+ # +ffi_function+ is a bound FFI function taking
19
+ # (c_ptr, version, mode, ns_ptr, flags): leptris_c14n_canonicalize_ex
20
+ # or leptris_c14n_canonicalize_subtree_ex.
21
+ def self.canonicalize(ffi_function, c_ptr, version:, mode:,
22
+ inclusive_namespaces: nil, with_comments: false)
23
+ ns_ptr, _ns_anchor = Leptris::XML::CStringArray.to_c(inclusive_namespaces)
24
+ str_ptr = ffi_function.call(c_ptr, version, mode, ns_ptr, with_comments ? 1 : 0)
25
+ Leptris::XML::FFI.read_owned_string(str_ptr)
26
+ end
27
+
28
+ # Builds a SerializeOptions struct. Returns [opts, encoding_anchor];
29
+ # the anchor must stay referenced while opts is in an FFI call.
30
+ def self.build_options(indent: 0, no_decl: false, encoding: nil)
31
+ opts = Leptris::XML::FFI::SerializeOptions.new
32
+ opts[:indent] = indent.to_i
33
+ opts[:xml_declaration] = no_decl ? 0 : 1
34
+ encoding_anchor = nil
35
+ if encoding
36
+ encoding_anchor = ::FFI::MemoryPointer.from_string(encoding.to_s)
37
+ opts[:encoding] = encoding_anchor
38
+ end
39
+ [opts, encoding_anchor]
40
+ end
41
+ end
@@ -8,9 +8,8 @@ class Leptris::XML::Text < Leptris::XML::Node
8
8
  end
9
9
 
10
10
  def content=(new_content)
11
- status = Leptris::XML::FFI.leptris_text_node_set_content(@c_ptr, new_content.to_s)
12
- raise Leptris::XML::Error,
13
- Leptris::XML::FFI.leptris_status_string(status) unless status == Leptris::XML::FFI::LEPTRIS_OK
11
+ Leptris::XML::FFI.check_status(
12
+ Leptris::XML::FFI.leptris_text_node_set_content(@c_ptr, new_content.to_s))
14
13
  new_content
15
14
  end
16
15
  end
data/lib/leptris/xml.rb CHANGED
@@ -18,13 +18,14 @@ module Leptris
18
18
  autoload :Searchable, "leptris/xml/searchable"
19
19
  autoload :ParseOptions, "leptris/xml/parse_options"
20
20
  autoload :CssToXPath, "leptris/xml/css_to_xpath"
21
+ autoload :CStringArray, "leptris/xml/c_string_array"
22
+ autoload :Serialization, "leptris/xml/serialization"
21
23
  autoload :SAX, "leptris/xml/sax"
22
- require "leptris/xml/c14n" # module-level helper, eager load
23
24
 
24
25
  # Nokogiri-style top-level entry points (Nokogiri::XML(...) /
25
26
  # Nokogiri::XML.parse). Delegates to Document.parse.
26
- def self.parse(xml_or_io)
27
- Document.parse(xml_or_io)
27
+ def self.parse(xml_or_io, options: nil)
28
+ Document.parse(xml_or_io, options: options)
28
29
  end
29
30
 
30
31
  def self.parse_file(path)
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: ffi
@@ -112,6 +113,7 @@ files:
112
113
  - lib/leptris/xml.rb
113
114
  - lib/leptris/xml/attr.rb
114
115
  - lib/leptris/xml/c14n.rb
116
+ - lib/leptris/xml/c_string_array.rb
115
117
  - lib/leptris/xml/cdata.rb
116
118
  - lib/leptris/xml/comment.rb
117
119
  - lib/leptris/xml/css_to_xpath.rb
@@ -129,6 +131,7 @@ files:
129
131
  - lib/leptris/xml/sax/document.rb
130
132
  - lib/leptris/xml/sax/parser.rb
131
133
  - lib/leptris/xml/searchable.rb
134
+ - lib/leptris/xml/serialization.rb
132
135
  - lib/leptris/xml/text.rb
133
136
  homepage: https://github.com/leptris/leptris-ruby
134
137
  licenses:
@@ -137,6 +140,7 @@ metadata:
137
140
  homepage_uri: https://github.com/leptris/leptris-ruby
138
141
  source_code_uri: https://github.com/leptris/leptris-ruby
139
142
  changelog_uri: https://github.com/leptris/leptris-ruby/blob/main/CHANGELOG.md
143
+ post_install_message:
140
144
  rdoc_options: []
141
145
  require_paths:
142
146
  - lib
@@ -151,7 +155,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
151
155
  - !ruby/object:Gem::Version
152
156
  version: '0'
153
157
  requirements: []
154
- rubygems_version: 4.0.16
158
+ rubygems_version: 3.5.22
159
+ signing_key:
155
160
  specification_version: 4
156
161
  summary: Nokogiri-compatible Ruby binding for libleptris (XML 1.0, XPath 1.0, SAX)
157
162
  test_files: []