leptris 1.9.121.0 → 1.9.144.1

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: 85000791341954ae6ac1ad4b81236ad657e0c9d3dad1914ce182de881e8631ef
4
- data.tar.gz: 55657d439219c20317b1a0c66916817cb131d502a25120cbc9566bd4d17e7c0e
3
+ metadata.gz: '01429a81e019ba9e4ecf9d2a7c23ada3b4d2c6dc3849c5a0be469bbab52b5c92'
4
+ data.tar.gz: 21bc4d37174f592fe72821f9fd7074eb7521c7ff4eacad42141720f2e235c97b
5
5
  SHA512:
6
- metadata.gz: fe1948ea43444c5057909c2a53e9cc2e3f0682db71a46ead7be72cccc65051036c568bd560615ed6426c8da3b5a074f33bd6a222eb3dc0734d4a8da72e9ec33b
7
- data.tar.gz: '088636076490098a9e58a964f45d06a0bc21cd78d5164df2fa04a69f194a0540cfeb9309af95ea9fd67e518533c7c006eb018b84b5de14a9d4266153dfba4a27'
6
+ metadata.gz: 181136f9e8da6a067bba876bbbfe5e841c062470fce5c6a521de413f5abd4a33d824eb387155021b486ff5063e70d96077156b79656440ee418d4b388bc5297c
7
+ data.tar.gz: 3e088d8220ec7c3c40f4e9869caf8cc73e08606ac4f7035a9f8a277454fafc1d7921c11fe17aa64f4c328254362103962bf05218385e1855c27fad3663b5b915
data/CHANGELOG.md CHANGED
@@ -5,6 +5,93 @@ 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.144.1] - 2026-09-12
9
+
10
+ ### Fixed
11
+
12
+ - **#161 (DOM parity for engine porters)**:
13
+ - `Node#dup` works for every node kind — text, comment, CDATA,
14
+ and PI rebuild by value through the document factories behind
15
+ the same `Document.copy_of` seam (elements keep the C deep
16
+ copy); previously `dup` raised "only supported for element
17
+ nodes".
18
+ - `Element#[]` and `#key?` read back qualified attribute names
19
+ the namespace-aware engine lookup misses (a value written as
20
+ `el["n:x"] = "9"` with an undeclared prefix now answers
21
+ `el["n:x"]` — the written-name face `#attributes` exposes);
22
+ declared-prefix reads are unchanged.
23
+ - `Element#qualified_name` — the written spelling ("p:c" /
24
+ "c") behind the `#name`/`#prefix` split.
25
+ - `to_xml(indent:)` was already documented in the README
26
+ serialization section — no change needed.
27
+
28
+ ### Added
29
+
30
+ - **XPATH_FUNCTION result mirror** (PR #127, libleptris TODO 07
31
+ lane): the type-4 constant is attached and `wrap_xpath_result`
32
+ raises an explicit, actionable error when an XPath 3.0 function
33
+ item would cross the FFI boundary (`concat#2`) instead of the
34
+ generic "unknown xpath result type 4".
35
+
36
+ ### Documentation
37
+
38
+ - **#160**: README "Alternative engines" section — the plain
39
+ `ruby`-platform gem ships with every release, so TruffleRuby and
40
+ JRuby activate leptris via FFI; the section documents the
41
+ system-library / `LEPTRIS_LIB_PATH` path those engines need.
42
+
43
+ ## [1.9.144.0] - 2026-09-12
44
+
45
+ ### Changed
46
+
47
+ - **Lockstep with libleptris 1.9.144** (1.9.121 → 1.9.144; audit
48
+ 270 → 289 after attaching 19 new symbols).
49
+ - **`Leptris::XML::Schematron`** — ISO Schematron validation
50
+ (libleptris 1.9.127/1.9.128; schematron-conformance corpus
51
+ 50/50): `Schematron.parse(schema_xml, phase: nil)` / `.parse_file`
52
+ compile once, then `#valid?(doc)` answers the boolean and
53
+ `#validate(doc)` returns the SVRL report as a queryable
54
+ `Document` (failed-assert / successful-report with @location).
55
+ - **`Leptris::XML::Diff`** — native tree diff (libleptris
56
+ 1.9.127, namespace-aware 1.9.129): `Leptris::XML.diff(a, b,
57
+ ignore_ws: false)` → `#ops` (`{type:, name:, path:, before:,
58
+ after:}`; insert/delete/update_text/update_attr with
59
+ positional `/name[i]` paths) and `#to_s` (the serialized op
60
+ list); `Diff.identical?(a, b, ignore_ws:)` answers the boolean
61
+ fast path.
62
+ - **XQuery external variables** (libleptris 1.9.133) —
63
+ `XQuery#eval(doc, params)` binds `declare variable $x external`
64
+ through `leptris_xquery_eval_params` with QT3 `<param select>`
65
+ semantics (each value is an XPath expression in an empty
66
+ context); a binding overrides a default initializer, and an
67
+ empty params hash takes the same lane — the engine decides
68
+ between the default and failure.
69
+ - **`Element#expanded_name`** (libleptris 1.9.144) —
70
+ `{local:, prefix:, namespace_uri:}` in one C call.
71
+ - **`#xpath_versioned(expression, version)`** — version-pinned
72
+ evaluation via `leptris_xpath_eval_versioned`: `:xpath10`
73
+ keeps the strict 1.0 surface (3.x syntax raises), `:xpath31`
74
+ evaluates the full grammar. A separate method by design —
75
+ `#xpath`'s trailing-hash argument is the namespace-binding
76
+ channel.
77
+ - **`Node#digest(drop_ws:)`** — the #869 content-defined Merkle
78
+ digest behind diff pruning.
79
+ - **Serialization: `to_xml(..., expand_empty:)`** — the
80
+ `<e></e>` vs `<e/>` switch at the C seam.
81
+ - Engine arc: the W3C QT3 suite adoption (fn/substring 45/45,
82
+ fn/contains 41/41, fn/starts-with 43/43, fn/ends-with 34/34,
83
+ fn/concat 80/80 — int64 integer fidelity keeps
84
+ `xs:integer('999999999999999999')` exact) and the WHATWG HTML
85
+ completion arc (frameset, in-head-noscript, character
86
+ references, comment close forms, **full adoption agency**
87
+ 1.9.137 — `"<h1>a<h2>b</h1>c</h2>"` now yields
88
+ `<h1>a</h1><h2>b</h2>c` per the spec — initial-mode comment
89
+ placement, in-head comments, foreign-content integration
90
+ scopes, in-table clear-stack, script-data escapes, RCDATA/
91
+ rawtext, numeric-reference end states; html5lib floor
92
+ 928 → 933). #965: relational comparisons no longer swap
93
+ operands (`10 >= @n` compares as written).
94
+
8
95
  ## [1.9.115.0] - 2026-09-09
9
96
 
10
97
  ### Changed
data/README.adoc CHANGED
@@ -60,6 +60,21 @@ export LEPTRIS_LIB_PATH=/usr/local/lib/libleptris.dylib
60
60
  If Leptris can't find the library at startup, every `parse` call raises
61
61
  `LoadError`.
62
62
 
63
+ === Alternative engines: TruffleRuby and JRuby (#160)
64
+
65
+ The gem ships a plain `ruby`-platform variant alongside the precompiled
66
+ platform gems, so Bundler activates `leptris` on TruffleRuby and JRuby
67
+ (the binding is FFI-based — no MRI C extension anywhere). Those engines
68
+ filter out precompiled platform gems at activation and always resolve
69
+ the `ruby` variant, which does not vendor the native library:
70
+
71
+ 1. Install `libleptris` on the host — build from source (above) or set
72
+ `LEPTRIS_LIB_PATH=/path/to/libleptris.{so,dylib}`.
73
+ 2. `gem install leptris` (or `bundle install`) — activation succeeds
74
+ like on MRI, and FFI loads the system library at require time.
75
+
76
+ The startup `LoadError` names this remedy when the library is missing.
77
+
63
78
  == Parsing
64
79
 
65
80
  The top-level entry point is `Leptris::XML`. Parse a string or an IO:
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.121"
11
+ LIBLEPTRIS_VERSION = "1.9.144"
12
12
  # Vendored alongside libleptris for fn:normalize-unicode (TODO
13
13
  # .restructure/20): built per platform with a RELOCATABLE @rpath
14
14
  # install name, loaded by ffi.rb before libleptris so the
@@ -8,6 +8,10 @@ in verification order:
8
8
  - [x] Minimal C ext (one file): a TypedData class holding
9
9
  (pointer, document-ref) — prototype built and measured
10
10
  (ext + bench preserved under /tmp/typedproto, /tmp/bench_typed.rb).
11
+ - [x] NOTE (2026-09-12): /tmp was wiped — the prototype ext and
12
+ bench under /tmp/typedproto, /tmp/bench_typed.rb are GONE.
13
+ The measurements below stand (recorded here); the build
14
+ phase rebuilds the ext properly under ext/ anyway.
11
15
  - [x] Measured (100k holders, GC.settled RSS deltas):
12
16
  FFI::Pointer-backed 154 B/node vs TypedData 40 B/node —
13
17
  **114 B/node saved**, ~40% of the wrapper layer (#147's
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.121.0"
4
+ VERSION = "1.9.144.1"
5
5
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ # Tree diff between two documents (libleptris >= 1.9.126):
6
+ # equal subtrees prune in O(1) via the #869 content-defined Merkle
7
+ # digest; diverging regions align through an LCS on child digests,
8
+ # then same-named elements recurse. Attribute add/remove are
9
+ # UPDATE_ATTR ops with an empty side.
10
+ #
11
+ # diff = Leptris::XML.diff(before_doc, after_doc)
12
+ # diff.ops # => [{type: :update_text, name: "price",
13
+ # # path: "/r/item[2]/price", before: "3", after: "4"}, ...]
14
+ # diff.to_s # => serialized op list
15
+ # Leptris::XML.diff(a, b, ignore_ws: true) # ws-only text absent
16
+ #
17
+ class Leptris::XML::Diff
18
+ class Handle < ::FFI::AutoPointer
19
+ def self.release(ptr)
20
+ Leptris::XML::FFI.leptris_diff_free(ptr)
21
+ end
22
+ end
23
+
24
+ OP_TYPES = {
25
+ 1 => :insert,
26
+ 2 => :delete,
27
+ 3 => :update_text,
28
+ 4 => :update_attr,
29
+ }.freeze
30
+ private_constant :OP_TYPES
31
+
32
+ def self.compute(document_a, document_b, ignore_ws: false)
33
+ flags = ignore_ws ? 1 : 0
34
+ raw = Leptris::XML::FFI.leptris_diff(
35
+ document_a.c_ptr, document_b.c_ptr, flags, nil)
36
+ if raw.null?
37
+ raise Leptris::XML::Error,
38
+ "leptris_diff failed: #{Leptris::XML::FFI.leptris_last_error}"
39
+ end
40
+ new(Handle.new(raw))
41
+ end
42
+
43
+ # The boolean fast path canon-style consumers want: are the two
44
+ # trees structurally identical? Zero ops == identical — the
45
+ # digest pruning makes the common equal case O(1) per subtree.
46
+ def self.identical?(document_a, document_b, ignore_ws: false)
47
+ compute(document_a, document_b, ignore_ws: ignore_ws).ops.empty?
48
+ end
49
+
50
+ def initialize(handle)
51
+ @handle = handle
52
+ end
53
+
54
+ # The op list in document order: {type:, name:, path:, before:,
55
+ # after:} — paths are root-relative ("/r/i[2]"); before/after
56
+ # carry payloads (attribute values / text) where the op has them.
57
+ def ops
58
+ count = Leptris::XML::FFI.leptris_diff_op_count(@handle)
59
+ Array.new(count) do |i|
60
+ {
61
+ type: OP_TYPES[Leptris::XML::FFI.leptris_diff_op_type(@handle, i)],
62
+ name: Leptris::XML::FFI.leptris_diff_op_name(@handle, i),
63
+ path: Leptris::XML::FFI.leptris_diff_op_path(@handle, i),
64
+ before: Leptris::XML::FFI.leptris_diff_op_before(@handle, i),
65
+ after: Leptris::XML::FFI.leptris_diff_op_after(@handle, i),
66
+ }
67
+ end
68
+ end
69
+
70
+ def to_s
71
+ str_ptr = Leptris::XML::FFI.leptris_diff_serialize(@handle)
72
+ Leptris::XML::FFI.read_owned_string(str_ptr)
73
+ end
74
+ end
@@ -119,18 +119,38 @@ class Leptris::XML::Document
119
119
  wrap(raw)
120
120
  end
121
121
 
122
- # Deep copy of +element+ as the root of a NEW document — the one
123
- # authority behind Node#dup / Element#dup and the indent-unit
124
- # path (the C copy keeps every child kind and namespace since
125
- # libleptris 1.9.76 #696/#721/#812). The returned tree is
126
- # detached from the original and fully navigable.
127
- def self.copy_of(element)
122
+ # Deep copy of +node+ in a NEW document — the one authority
123
+ # behind Node#dup / Element#dup and the indent-unit path.
124
+ # Elements deep-copy through the C copier (every child kind and
125
+ # namespace survives, #696/#721/#812); every other node kind
126
+ # rebuilds by value through this document's factories — their
127
+ # entire state is the payload the factory takes (#161). The
128
+ # returned node is detached from the original and fully usable.
129
+ def self.copy_of(node)
128
130
  new_doc = create
129
- copy = Leptris::XML::FFI.leptris_element_copy(element.c_ptr, new_doc.c_ptr)
131
+ return rebuild_node(node, new_doc) unless node.element?
132
+ copy = Leptris::XML::FFI.leptris_element_copy(node.c_ptr, new_doc.c_ptr)
130
133
  raise Leptris::XML::Error, "leptris_element_copy failed" if copy.null?
131
134
  new_doc.root = Leptris::XML::Node.wrap(copy, new_doc)
132
135
  end
133
136
 
137
+ def self.rebuild_node(node, new_doc)
138
+ case node.type
139
+ when Leptris::XML::FFI::NODE_TEXT
140
+ new_doc.create_text_node(node.content)
141
+ when Leptris::XML::FFI::NODE_CDATA
142
+ new_doc.create_cdata(node.content)
143
+ when Leptris::XML::FFI::NODE_COMMENT
144
+ new_doc.create_comment(node.content)
145
+ when Leptris::XML::FFI::NODE_PI
146
+ new_doc.create_processing_instruction(node.name, node.content)
147
+ else
148
+ raise Leptris::XML::Error,
149
+ "dup is not supported for #{node.class} nodes"
150
+ end
151
+ end
152
+ private_class_method :rebuild_node
153
+
134
154
  # Convert a raw LeptrisDocument pointer into a Ruby Document with safe
135
155
  # GC lifetime management. The finalizer captures the raw address
136
156
  # integer (not the Document or Pointer object — those would prevent
@@ -79,7 +79,13 @@ class Leptris::XML::Element < Leptris::XML::Node
79
79
  return v
80
80
  end
81
81
  ensure_alive!
82
- Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
82
+ v = Leptris::XML::FFI.leptris_element_attribute(@c_ptr, name)
83
+ # Namespace-aware misses (an undeclared prefix never resolves
84
+ # through in-scope declarations) fall back to the WRITTEN name
85
+ # — the same flat face #attributes exposes, so a value written
86
+ # as el["n:x"] reads back through el["n:x"] (#161).
87
+ return v unless v.nil?
88
+ attributes[name]&.value
83
89
  end
84
90
  alias_method :attr, :[]
85
91
  alias_method :get_attribute, :[]
@@ -94,7 +100,11 @@ class Leptris::XML::Element < Leptris::XML::Node
94
100
 
95
101
  def key?(name)
96
102
  ensure_alive!
97
- Leptris::XML::FFI.leptris_element_has_attribute(@c_ptr, name.to_s) != 0
103
+ return true if Leptris::XML::FFI.leptris_element_has_attribute(
104
+ @c_ptr, name.to_s) != 0
105
+ # Same written-name fallback as #[] — the engine lookup is
106
+ # namespace-aware and misses undeclared prefixes (#161).
107
+ attributes.key?(name.to_s)
98
108
  end
99
109
  alias_method :has_attribute?, :key?
100
110
 
@@ -196,6 +206,15 @@ class Leptris::XML::Element < Leptris::XML::Node
196
206
  Leptris::XML::FFI.leptris_element_prefix(@c_ptr)
197
207
  end
198
208
 
209
+ # The written qualified name — "foo:child" with a prefix, the
210
+ # bare local name without. The explicit contract behind the
211
+ # #name/#prefix split: #name answers the local part, the
212
+ # serialized form uses this spelling (#161).
213
+ def qualified_name
214
+ p = prefix
215
+ p ? "#{p}:#{name}" : name
216
+ end
217
+
199
218
  def prepend_child(node)
200
219
  ensure_writable!
201
220
  Leptris::XML::FFI.check_status(
@@ -341,6 +360,30 @@ class Leptris::XML::Element < Leptris::XML::Node
341
360
  uri
342
361
  end
343
362
 
363
+ # The local name, prefix, and resolved namespace URI in ONE
364
+ # call (libleptris 1.9.144) — the adapter fan-out read.
365
+ # Equivalent to #name + #prefix + #namespace, single entry.
366
+ def expanded_name
367
+ ensure_alive!
368
+ local = ::FFI::MemoryPointer.new(:pointer)
369
+ prefix = ::FFI::MemoryPointer.new(:pointer)
370
+ uri = ::FFI::MemoryPointer.new(:pointer)
371
+ Leptris::XML::FFI.leptris_element_expanded_name(
372
+ @c_ptr, local, prefix, uri)
373
+ local_ptr = local.read_pointer
374
+ prefix_ptr = prefix.read_pointer
375
+ uri_ptr = uri.read_pointer
376
+ result = {
377
+ local: local_ptr.read_string,
378
+ prefix: prefix_ptr.null? ? nil : prefix_ptr.read_string,
379
+ namespace_uri: uri_ptr.null? ? nil : uri_ptr.read_string,
380
+ }
381
+ local.free
382
+ prefix.free
383
+ uri.free
384
+ result
385
+ end
386
+
344
387
  def namespace_definitions
345
388
  return @namespace_definitions if memo_hit?(@namespace_definitions_version)
346
389
  ensure_alive!
@@ -64,6 +64,8 @@ module Leptris
64
64
  typedef :pointer, :leptris_relaxng
65
65
  typedef :pointer, :leptris_xslt
66
66
  typedef :pointer, :leptris_xquery
67
+ typedef :pointer, :leptris_schematron
68
+ typedef :pointer, :leptris_diff
67
69
  typedef :pointer, :leptris_sax_recorder
68
70
  typedef :pointer, :leptris_pull_parser
69
71
  typedef :pointer, :leptris_iterparse
@@ -744,8 +746,72 @@ attach_function :leptris_parse_string,
744
746
  attach_function :leptris_xquery_eval,
745
747
  [:leptris_xquery, :leptris_document, :leptris_element],
746
748
  :leptris_xpath_result
749
+ # External-variable XQuery evaluation (libleptris 1.9.133):
750
+ # names[i]/selects[i] bind `declare variable $name external`
751
+ # — each select is an XPath expression in an empty context
752
+ # (QT3 <param select> semantics), overriding any default
753
+ # initializer. An external with neither binding nor default
754
+ # fails evaluation.
755
+ attach_function :leptris_xquery_eval_params,
756
+ [:leptris_xquery, :leptris_document, :leptris_element,
757
+ :pointer, :pointer, :size_t], :leptris_xpath_result
747
758
  attach_function :leptris_xquery_free, [:leptris_xquery], :void
748
759
 
760
+ # Schematron validation (libleptris >= 1.9.126 family):
761
+ # parse once (optionally selecting a PHASE), validate any
762
+ # number of documents — #validate returns the SVRL report as
763
+ # a Document; #valid? answers through the SVRL root name.
764
+ attach_function :leptris_schematron_parse,
765
+ [:string, :size_t, :pointer], :leptris_schematron
766
+ attach_function :leptris_schematron_parse_file,
767
+ [:string, :pointer], :leptris_schematron
768
+ attach_function :leptris_schematron_parse_phase,
769
+ [:string, :size_t, :string, :pointer], :leptris_schematron
770
+ attach_function :leptris_schematron_free,
771
+ [:leptris_schematron], :void
772
+ attach_function :leptris_schematron_valid,
773
+ [:leptris_schematron, :leptris_document], :int
774
+ attach_function :leptris_schematron_validate,
775
+ [:leptris_schematron, :leptris_document], :leptris_document
776
+ attach_function :leptris_schematron_error,
777
+ [:leptris_schematron], :string
778
+
779
+ # Tree diff (libleptris >= 1.9.126 family): equal subtrees
780
+ # prune in O(1) by the #869 Merkle digest; diverging regions
781
+ # align via LCS on child digests, then recurse by name.
782
+ attach_function :leptris_diff,
783
+ [:leptris_document, :leptris_document, :int, :pointer],
784
+ :leptris_diff
785
+ attach_function :leptris_diff_free, [:leptris_diff], :void
786
+ attach_function :leptris_diff_op_count, [:leptris_diff], :size_t
787
+ attach_function :leptris_diff_op_type,
788
+ [:leptris_diff, :size_t], :int
789
+ attach_function :leptris_diff_op_name,
790
+ [:leptris_diff, :size_t], :string
791
+ attach_function :leptris_diff_op_path,
792
+ [:leptris_diff, :size_t], :string
793
+ attach_function :leptris_diff_op_before,
794
+ [:leptris_diff, :size_t], :string
795
+ attach_function :leptris_diff_op_after,
796
+ [:leptris_diff, :size_t], :string
797
+ attach_function :leptris_diff_serialize,
798
+ [:leptris_diff], :pointer
799
+
800
+ # One-call expanded name (libleptris 1.9.144): local name +
801
+ # prefix + resolved namespace URI in a single entry — the
802
+ # FFI-adapter fan-out read.
803
+ attach_function :leptris_element_expanded_name,
804
+ [:leptris_element, :pointer, :pointer, :pointer], :void
805
+
806
+ # Versioned XPath evaluation (libleptris lane 15): XPATH_10
807
+ # keeps the strict 1.0 surface (3.x syntax rejected with
808
+ # INVALID_ARG); XPATH_31 evaluates the full grammar.
809
+ attach_function :leptris_xpath_eval_versioned,
810
+ [:leptris_document, :leptris_element, :string, :int, :pointer],
811
+ :leptris_xpath_result
812
+ XPATH_10 = 1 # strict XPath 1.0: the 3.x surface is rejected
813
+ XPATH_31 = 3 # the full grammar (the default entry's surface)
814
+
749
815
  # One-pass entity pre-scan (libleptris 1.9.62, leptris#745):
750
816
  # 1 when the buffer holds a named entity that is not one of
751
817
  # the five predefined XML entities (nor numeric) — the moxml
@@ -843,6 +909,10 @@ attach_function :leptris_parse_string,
843
909
  XPATH_BOOLEAN = 1
844
910
  XPATH_NUMBER = 2
845
911
  XPATH_STRING = 3
912
+ # XPath 3.0 function item (libleptris TODO 07 lane): a closure
913
+ # or named function reference. Not callable across the FFI
914
+ # boundary yet — wrap_xpath_result raises explicitly.
915
+ XPATH_FUNCTION = 4
846
916
 
847
917
  XPATH_NODE_ELEMENT = 0
848
918
  XPATH_NODE_ATTRIBUTE = 1
@@ -430,11 +430,10 @@ class Leptris::XML::Node
430
430
 
431
431
  # Deep copy in a NEW document via Document.copy_of (the single
432
432
  # copy seam — comment/PI children and namespaces both survive,
433
- # #696/#721/#812; the copier is pool-threaded, ~2.3x Nokogiri).
433
+ # #696/#721/#812; the copier is pool-threaded, ~2.3x Nokogiri;
434
+ # non-element kinds rebuild by value — #161).
434
435
  def dup
435
436
  ensure_alive!
436
- elem_ptr = Leptris::XML::FFI.leptris_node_as_element(@c_ptr)
437
- raise Leptris::XML::Error, "dup is only supported for element nodes" if elem_ptr.null?
438
437
  Leptris::XML::Document.copy_of(self)
439
438
  end
440
439
  alias_method :clone, :dup
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module Leptris::XML::Schematron
6
+ module_function
7
+
8
+ def parse(schema_xml, phase: nil)
9
+ Schema.parse(schema_xml, phase: phase)
10
+ end
11
+
12
+ def parse_file(path, phase: nil)
13
+ Schema.parse_file(path, phase: phase)
14
+ end
15
+
16
+ # A compiled Schematron schema (libleptris >= 1.9.126): parse
17
+ # once — optionally selecting a PHASE, which activates only its
18
+ # patterns — then validate any number of documents. #validate
19
+ # returns the SVRL report as a queryable Document; #valid?
20
+ # answers the boolean.
21
+ #
22
+ # sch = Leptris::XML::Schematron.parse(schema_xml)
23
+ # sch.valid?(doc) # => true/false
24
+ # svrl = sch.validate(doc) # => Document (svrl:schema root)
25
+ # sch = Leptris::XML::Schematron.parse(schema_xml, phase: "Basic")
26
+ #
27
+ class Schema
28
+ class Handle < ::FFI::AutoPointer
29
+ def self.release(ptr)
30
+ Leptris::XML::FFI.leptris_schematron_free(ptr)
31
+ end
32
+ end
33
+
34
+ def self.parse(schema_xml, phase: nil)
35
+ xml = schema_xml.to_s
36
+ raw =
37
+ if phase
38
+ Leptris::XML::FFI.leptris_schematron_parse_phase(
39
+ xml, xml.bytesize, phase.to_s, nil)
40
+ else
41
+ Leptris::XML::FFI.leptris_schematron_parse(xml, xml.bytesize, nil)
42
+ end
43
+ if raw.null?
44
+ raise Leptris::XML::Error,
45
+ "schema parse failed: #{Leptris::XML::FFI.leptris_last_error}"
46
+ end
47
+ new(Handle.new(raw))
48
+ end
49
+
50
+ def self.parse_file(path, phase: nil)
51
+ raw =
52
+ if phase
53
+ xml = File.read(path)
54
+ Leptris::XML::FFI.leptris_schematron_parse_phase(
55
+ xml, xml.bytesize, phase.to_s, nil)
56
+ else
57
+ Leptris::XML::FFI.leptris_schematron_parse_file(path.to_s, nil)
58
+ end
59
+ if raw.null?
60
+ raise Leptris::XML::Error,
61
+ "schema parse failed: #{Leptris::XML::FFI.leptris_last_error}"
62
+ end
63
+ new(Handle.new(raw))
64
+ end
65
+
66
+ def initialize(handle)
67
+ @handle = handle
68
+ end
69
+
70
+ # The SVRL report as a Document (svrl:schema root — query
71
+ # failed-assert / successful-report elements like any other
72
+ # tree), or the schema-level error text.
73
+ def validate(document)
74
+ report = Leptris::XML::FFI.leptris_schematron_validate(
75
+ @handle, document.c_ptr)
76
+ if report.null?
77
+ Leptris::XML::FFI.leptris_schematron_error(@handle)
78
+ else
79
+ Leptris::XML::Document.wrap(report)
80
+ end
81
+ end
82
+
83
+ def valid?(document)
84
+ Leptris::XML::FFI.leptris_schematron_valid(
85
+ @handle, document.c_ptr) != 0
86
+ end
87
+ end
88
+ end
@@ -85,6 +85,27 @@ module Leptris::XML::Searchable
85
85
  at_xpath(expr)
86
86
  end
87
87
 
88
+ # Version-pinned evaluation (libleptris lane 15,
89
+ # leptris_xpath_eval_versioned): :xpath10 keeps the strict XPath
90
+ # 1.0 surface — 3.x syntax (arrow, bang, lookup, let, inline
91
+ # functions, string templates) raises; :xpath31 evaluates the
92
+ # full grammar (identical to #xpath's default entry). A separate
93
+ # method by design: #xpath's trailing-hash argument is the
94
+ # namespace-binding channel, and a declared keyword would
95
+ # capture it.
96
+ def xpath_versioned(expression, version)
97
+ doc_ptr = is_a?(Leptris::XML::Document) ? c_ptr : document.c_ptr
98
+ context_ptr = is_a?(Leptris::XML::Document) ? nil : c_ptr
99
+ result_ptr = Leptris::XML::FFI.leptris_xpath_eval_versioned(
100
+ doc_ptr, context_ptr, expression,
101
+ Leptris::XML::Searchable.xpath_version_code(version), nil)
102
+ if result_ptr.null?
103
+ raise Leptris::XML::XPathError,
104
+ Leptris::XML::FFI.leptris_last_error.to_s
105
+ end
106
+ Leptris::XML::Searchable.wrap_xpath_result(document, result_ptr)
107
+ end
108
+
88
109
  protected
89
110
 
90
111
  # Evaluates against a caller-owned namespace binding set; the
@@ -95,6 +116,21 @@ module Leptris::XML::Searchable
95
116
  end
96
117
  end
97
118
 
119
+
120
+ # Version pin for xpath_versioned: :xpath10
121
+ # keeps the strict XPath 1.0 surface (3.x syntax — arrow, bang,
122
+ # lookup, let, inline functions — raises); :xpath31 evaluates the
123
+ # full grammar (identical to the default entry).
124
+ def self.xpath_version_code(version)
125
+ case version
126
+ when :xpath10, "1.0" then Leptris::XML::FFI::XPATH_10
127
+ when :xpath31, "3.1" then Leptris::XML::FFI::XPATH_31
128
+ else
129
+ raise ArgumentError,
130
+ "version must be :xpath10 or :xpath31, got #{version.inspect}"
131
+ end
132
+ end
133
+
98
134
  def parse_search_args(args)
99
135
  handler = args.find { |a| !a.is_a?(String) && !a.is_a?(Hash) && !a.is_a?(Symbol) }
100
136
  args = args - [handler] if handler
@@ -163,6 +199,12 @@ module Leptris::XML::Searchable
163
199
  v = Leptris::XML::FFI.read_owned_string(str_ptr)
164
200
  Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
165
201
  v
202
+ when Leptris::XML::FFI::XPATH_FUNCTION
203
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
204
+ raise Leptris::XML::XPathError,
205
+ 'XPath function items cannot cross the FFI boundary yet ' \
206
+ '(libleptris TODO 07; call them inside the expression, ' \
207
+ 'e.g. for-each((1,2), function($n){$n+1}))'
166
208
  else
167
209
  Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
168
210
  raise Leptris::XML::XPathError, "unknown xpath result type #{type}"
@@ -46,10 +46,28 @@ class Leptris::XML::XQuery
46
46
  # FLWOR results arrive as the sequence channel — read them through
47
47
  # an aggregate until the engine materializes readable sequence
48
48
  # items (same caveat as the XPath for-return subset).
49
- def eval(doc_or_element)
49
+ # +params+ binds external variables (libleptris 1.9.133): each
50
+ # value is an XPath expression evaluated in an empty context
51
+ # (QT3 <param select> semantics) — {"x" => "21 * 2"} binds
52
+ # `declare variable $x external`. A binding overrides the
53
+ # declaration's default initializer; an external with neither
54
+ # fails evaluation. An empty params hash takes the same lane —
55
+ # the engine decides between default initializer and failure.
56
+ def eval(doc_or_element, params = nil)
50
57
  context = Leptris::XML::EvaluationContext.of(doc_or_element)
51
- result_ptr = Leptris::XML::FFI.leptris_xquery_eval(
52
- @handle, context.document.c_ptr, context.context_node_ptr)
58
+ result_ptr =
59
+ if params
60
+ names, name_anchors = Leptris::XML::CStringArray.to_c(
61
+ params.keys.map(&:to_s))
62
+ selects, select_anchors = Leptris::XML::CStringArray.to_c(
63
+ params.values.map(&:to_s))
64
+ Leptris::XML::FFI.leptris_xquery_eval_params(
65
+ @handle, context.document.c_ptr, context.context_node_ptr,
66
+ names, selects, params.size)
67
+ else
68
+ Leptris::XML::FFI.leptris_xquery_eval(
69
+ @handle, context.document.c_ptr, context.context_node_ptr)
70
+ end
53
71
  if result_ptr.null?
54
72
  raise Leptris::XML::XPathError,
55
73
  Leptris::XML::FFI.leptris_last_error.to_s
data/lib/leptris/xml.rb CHANGED
@@ -23,6 +23,8 @@ module Leptris
23
23
  autoload :SAX, "leptris/xml/sax"
24
24
  autoload :XPath, "leptris/xml/xpath"
25
25
  autoload :EvaluationContext, "leptris/xml/evaluation_context"
26
+ autoload :Schematron, "leptris/xml/schematron"
27
+ autoload :Diff, "leptris/xml/diff"
26
28
  autoload :IterationScope, "leptris/xml/iteration_scope"
27
29
  autoload :ResultText, "leptris/xml/result_text"
28
30
  autoload :ResultAttr, "leptris/xml/result_attr"
@@ -39,6 +41,14 @@ module Leptris
39
41
  recover: recover)
40
42
  end
41
43
 
44
+ # Tree diff between two documents: equal subtrees prune in
45
+ # O(1) by content digest; diverging regions align by LCS.
46
+ # +ignore_ws+ treats whitespace-only text nodes as absent.
47
+ def self.diff(document_a, document_b, ignore_ws: false)
48
+ Leptris::XML::Diff.compute(document_a, document_b,
49
+ ignore_ws: ignore_ws)
50
+ end
51
+
42
52
  # Does +string+ contain a named entity reference that is not
43
53
  # one of the five predefined XML entities (nor numeric)? One C
44
54
  # pass (libleptris 1.9.62) — downstream adapters use it to skip
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.121.0
4
+ version: 1.9.144.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-09 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -106,6 +106,7 @@ files:
106
106
  - lib/leptris/xml/cdata.rb
107
107
  - lib/leptris/xml/comment.rb
108
108
  - lib/leptris/xml/css_to_xpath.rb
109
+ - lib/leptris/xml/diff.rb
109
110
  - lib/leptris/xml/doc_type.rb
110
111
  - lib/leptris/xml/document.rb
111
112
  - lib/leptris/xml/document_fragment.rb
@@ -128,6 +129,7 @@ files:
128
129
  - lib/leptris/xml/sax/dom_dispatch.rb
129
130
  - lib/leptris/xml/sax/parser.rb
130
131
  - lib/leptris/xml/sax/recorder.rb
132
+ - lib/leptris/xml/schematron.rb
131
133
  - lib/leptris/xml/searchable.rb
132
134
  - lib/leptris/xml/serialization.rb
133
135
  - lib/leptris/xml/text.rb