leptris 1.9.380.1-arm-linux → 1.9.381.0-arm-linux

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: c9d57ba010010d0eafdafc080938bc02ebf7d508799cdd01fbde67176d5f1e5f
4
- data.tar.gz: 8d560c49128668a65a82a098e0918b7378957f5a07aa9e1f76b939709acd5283
3
+ metadata.gz: a14d1d324ee4925bbeb41aed36f0efaf3ccaed6ce6a89c1c643460eccfbd56a1
4
+ data.tar.gz: 359ee93d64dca7b0f6de0d9e6028da147fa19aef034c9009663f23260fbcc34e
5
5
  SHA512:
6
- metadata.gz: c7c175ea55f978f206ca9b8505350478c0693c6caa3381d29e3355ee995d25faf69d908a41f4af379e58ec603157c35fb03e91a308b610f887e63095927818bd
7
- data.tar.gz: 57b50326e6a4979410884d2610468e2e52d0de4e99eaf084be3f84a9a026864456105529b9c9cccd1e0d63406919d68f71f62eff894ca4208c5c8278d3c49a77
6
+ metadata.gz: b65285e8a95fd881703df532d3c5196043c56d20c6f7576cb481ab2097a3014b79429b484f30f3ce46846237f34d22dbf59838414b2c36c0e7367991d6385469
7
+ data.tar.gz: 3b866259b7b0e2e5c82196bbdf89dc8d3dffa43e603d32f96f55e24c71614deb7637af0c7463d7ed8ff2b94665ac0674fc937b56b4cbe4e0fdb57dcf729d5b51
data/CHANGELOG.md CHANGED
@@ -10,6 +10,21 @@
10
10
  model. Released 1.9.380.0 with the pin still at 1.9.379 (the
11
11
  vendored dylib missed the fix); this pin bump is the real ride.
12
12
 
13
+ ### Fixed — engine ride (1.9.380.2)
14
+
15
+ - **Bulk XPath priming face rides (leptris/leptris-ruby#537)**:
16
+ `xpath_result_rows_bulk` gains the mx_context/mx_adapter birth-prime
17
+ parameters and marks all-native results; 1.9.380.1 shipped before
18
+ the PR merged, so the face rides in 1.9.380.2.
19
+
20
+ ## [1.9.381.0] - 2026-10-22
21
+
22
+ Ride: libleptris 1.9.381 — the XSLT identity/copy-of quadratic fix
23
+ (#1839): identity transforms 13.0 s -> 57 ms on a 20k-book catalog;
24
+ every CLI row now beats xsltproc end-to-end. The Rakefile pin moves
25
+ 1.9.380 -> 1.9.381 in the same commit so the platform gems vendor
26
+ this core.
27
+
13
28
  ## [1.9.379.3] - 2026-10-22
14
29
 
15
30
  ### Fixed
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.380"
11
+ LIBLEPTRIS_VERSION = "1.9.381"
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
@@ -203,6 +203,10 @@ static VALUE binding_klass_for(int kind);
203
203
  static VALUE c_iteration_scope;
204
204
  static VALUE c_leptris_error;
205
205
  static VALUE c_b_document, c_b_freed, c_ffi_pointer;
206
+ /* BulkIdentityRows resolves lazily like the binding classes: the
207
+ * module lives in native_layer.rb, which optional consumers load. */
208
+ static VALUE c_bulk_rows_mark = Qundef;
209
+ static ID id_bulk_rows_mark;
206
210
  static ID id_ptr_new, id_freed_new, id_alive;
207
211
  static ID id_iv_c_address, id_iv_document, id_iv_parent;
208
212
  static ID id_iv_mx_context, id_iv_mx_adapter, id_iv_mx_native;
@@ -221,8 +225,23 @@ struct native_node {
221
225
  * Slot beats the hash memo on repeat reads of one name. */
222
226
  VALUE attr_key, attr_val, attr_ver;
223
227
  VALUE content_val, content_ver;
228
+ /* Walk-read memos (leptris-ruby#530): the element name and the
229
+ * children list, the two reads a tree walk pays per node. The
230
+ * children memo is what the subtree prime (#527 machinery)
231
+ * fills; the version stamp retires both on any mutation. The
232
+ * children list is SHARED with callers until the version moves
233
+ * (the mutation that would invalidate it also retires the
234
+ * memo) — treat it as read-only, like an interned String. */
235
+ VALUE name_val, name_ver;
236
+ VALUE children_val, children_ver;
224
237
  };
225
238
 
239
+ static VALUE columnar_node_for(VALUE document, VALUE owner_klass,
240
+ void *ptr, int kind);
241
+ static VALUE doc_klass_for_kind(VALUE document, int kind);
242
+ static VALUE nn_prime_children_of(VALUE document, VALUE owner_klass,
243
+ void *node, VALUE ver);
244
+
226
245
  static void nn_mark(void *p)
227
246
  {
228
247
  struct native_node *n = p;
@@ -234,6 +253,10 @@ static void nn_mark(void *p)
234
253
  rb_gc_mark(n->attr_val);
235
254
  if (n->content_val != Qnil)
236
255
  rb_gc_mark(n->content_val);
256
+ if (n->name_val != Qnil)
257
+ rb_gc_mark(n->name_val);
258
+ if (n->children_val != Qnil)
259
+ rb_gc_mark(n->children_val);
237
260
  }
238
261
 
239
262
  static size_t nn_size(const void *p) { (void)p; return sizeof(struct native_node); }
@@ -367,7 +390,19 @@ static VALUE nn_name(VALUE self)
367
390
  {
368
391
  struct native_node *n;
369
392
  const char *s;
393
+ VALUE doc, ver;
394
+
370
395
  TypedData_Get_Struct(self, struct native_node, &nn_type, n);
396
+ /* Walk-memo hit (leptris-ruby#530): a repeat name read is two
397
+ * struct reads and a Fixnum compare — the subtree prime fills
398
+ * this slot ahead of the walk, so walked names never recompute
399
+ * (or re-intern) until the next mutation. */
400
+ doc = n->document;
401
+ if (doc != Qnil) {
402
+ ver = rb_ivar_get(doc, id_iv_version);
403
+ if (n->name_ver == ver)
404
+ return n->name_val;
405
+ }
371
406
  s = f_elem_name(n->ptr);
372
407
  /* Interned (shared, frozen) fstring: element names repeat
373
408
  * massively across a document — one RString per distinct name
@@ -375,7 +410,13 @@ static VALUE nn_name(VALUE self)
375
410
  * ~18% of consumer materialize allocations were fresh name
376
411
  * strings). Names are identifiers; nothing downstream mutates
377
412
  * them in place (renames mint new strings). */
378
- return s ? rb_enc_interned_str(s, strlen(s), rb_utf8_encoding()) : Qnil;
413
+ VALUE name = s ? rb_enc_interned_str(s, strlen(s), rb_utf8_encoding())
414
+ : Qnil;
415
+ if (doc != Qnil) {
416
+ n->name_val = name;
417
+ n->name_ver = ver;
418
+ }
419
+ return name;
379
420
  }
380
421
 
381
422
  static VALUE nn_content(VALUE self)
@@ -563,14 +604,43 @@ static VALUE nn_children(VALUE self)
563
604
  void **buf;
564
605
  int *kinds;
565
606
  int count, i;
566
- VALUE cache, out, owner_klass;
607
+ VALUE cache, out, owner_klass, doc, ver;
567
608
 
568
609
  TypedData_Get_Struct(self, struct native_node, &nn_type, n);
610
+ /* Walk-memo hit (leptris-ruby#530): the subtree prime (or an
611
+ * earlier read) filled this node's list against the current
612
+ * mutation version — serve it, engine walk skipped. */
613
+ doc = n->document;
614
+ if (doc != Qnil) {
615
+ ver = rb_ivar_get(doc, id_iv_version);
616
+ if (n->children_ver == ver)
617
+ return n->children_val;
618
+ /* Registered-consumer documents (#340 doc-klass map = the
619
+ * moxml mint registration) walk whole subtrees top-down:
620
+ * the FIRST cold children_read trades itself for ONE
621
+ * subtree crossing that fills every node's name + children
622
+ * memo (walk_columnar machinery, native-cache identity —
623
+ * the primed wrappers ARE the answers later reads give).
624
+ * Unregistered documents keep the per-node fetch; both
625
+ * paths memo-store below. */
626
+ if (!NIL_P(doc_klass_for_kind(doc, NT_ELEMENT))) {
627
+ out = nn_prime_children_of(doc, rb_obj_class(self), n->ptr,
628
+ ver);
629
+ n->children_val = out;
630
+ n->children_ver = ver;
631
+ return out;
632
+ }
633
+ }
569
634
  count = fetch_children_two_call(n->ptr, &buf, &kinds);
570
635
  if (count <= 0) {
571
636
  ruby_xfree(buf);
572
637
  ruby_xfree(kinds);
573
- return rb_ary_new2(0);
638
+ out = rb_ary_new2(0);
639
+ if (doc != Qnil) {
640
+ n->children_val = out;
641
+ n->children_ver = ver;
642
+ }
643
+ return out;
574
644
  }
575
645
 
576
646
  cache = native_cache_of(n->document);
@@ -592,6 +662,10 @@ static VALUE nn_children(VALUE self)
592
662
  }
593
663
  ruby_xfree(buf);
594
664
  ruby_xfree(kinds);
665
+ if (doc != Qnil) {
666
+ n->children_val = out;
667
+ n->children_ver = ver;
668
+ }
595
669
  return out;
596
670
  }
597
671
 
@@ -1940,18 +2014,28 @@ static VALUE nf_at_xpath_first(VALUE self, VALUE document,
1940
2014
  * Nodesets free the handle here (TODO.perf/22 eager discipline);
1941
2015
  * non-nodeset results return Qnil UNFREED — the caller keeps the
1942
2016
  * scalar path, whose wrap owns the free. */
1943
- static VALUE nf_xpath_result_rows_bulk(VALUE self, VALUE document,
1944
- VALUE result_addr)
2017
+ static VALUE nf_xpath_result_rows_bulk(int argc, VALUE *argv, VALUE self)
1945
2018
  {
1946
- void *result = (void *)(uintptr_t)NUM2ULL(result_addr);
2019
+ VALUE document, result_addr, mx_context, mx_adapter;
2020
+ void *result;
1947
2021
  void **buf;
1948
2022
  int *kinds;
1949
2023
  size_t scount;
1950
2024
  long i, n;
1951
2025
  VALUE cache, out;
2026
+ int all_native;
1952
2027
 
1953
2028
  (void)self;
1954
2029
  resolve_binding_classes();
2030
+ /* Optional (context, adapter): when the consumer passes its
2031
+ * contract state, every registered-klass row is born primed
2032
+ * (@native is the node itself, @context/@adapter written here)
2033
+ * and an all-native result extends with BulkIdentityRows —
2034
+ * the consumer's per-row wrap prime disappears from the
2035
+ * iterate-every-result lane (same triple as nn_create_element). */
2036
+ rb_scan_args(argc, argv, "22", &document, &result_addr,
2037
+ &mx_context, &mx_adapter);
2038
+ result = (void *)(uintptr_t)NUM2ULL(result_addr);
1955
2039
  if (f_xp_type(result) != 0) /* XPATH_NODESET only */
1956
2040
  return Qnil;
1957
2041
 
@@ -1969,6 +2053,7 @@ static VALUE nf_xpath_result_rows_bulk(VALUE self, VALUE document,
1969
2053
  n = (long)scount;
1970
2054
  out = rb_ary_new_capa(n);
1971
2055
  cache = binding_cache_of(document);
2056
+ all_native = !NIL_P(mx_context);
1972
2057
  for (i = 0; i < n; i++) {
1973
2058
  void *ptr = buf[i];
1974
2059
  int tag;
@@ -1988,10 +2073,17 @@ static VALUE nf_xpath_result_rows_bulk(VALUE self, VALUE document,
1988
2073
  node = rb_hash_aref(native_cache_of(document), key);
1989
2074
  if (NIL_P(node))
1990
2075
  node = mint_consumer_node(mk, document, ptr);
2076
+ if (all_native &&
2077
+ !rb_ivar_defined(node, id_iv_mx_context)) {
2078
+ rb_ivar_set(node, id_iv_mx_native, node);
2079
+ rb_ivar_set(node, id_iv_mx_context, mx_context);
2080
+ rb_ivar_set(node, id_iv_mx_adapter, mx_adapter);
2081
+ }
1991
2082
  rb_ary_push(out, node);
1992
2083
  continue;
1993
2084
  }
1994
2085
  }
2086
+ all_native = 0;
1995
2087
  node = materialize_xp_entry(document, cache, result, ptr,
1996
2088
  kinds[i], (int)i);
1997
2089
  if (node == m_native_sentinel) {
@@ -2005,6 +2097,22 @@ static VALUE nf_xpath_result_rows_bulk(VALUE self, VALUE document,
2005
2097
  ruby_xfree(buf);
2006
2098
  ruby_xfree(kinds);
2007
2099
  f_xp_free(result);
2100
+ if (all_native) {
2101
+ /* Certificate for the consumer's fast row loop: every row
2102
+ * is a born-primed, registered-klass identity native. The
2103
+ * module lives in the Ruby layer (native_layer.rb) and may
2104
+ * not be loaded on faces used without it — an unresolved
2105
+ * mark degrades to the unmarked Array, never an error. */
2106
+ if (c_bulk_rows_mark == Qundef) {
2107
+ VALUE m_xml = rb_path2class("Leptris::XML");
2108
+ c_bulk_rows_mark =
2109
+ rb_const_defined_at(m_xml, id_bulk_rows_mark)
2110
+ ? rb_const_get_at(m_xml, id_bulk_rows_mark)
2111
+ : Qnil;
2112
+ }
2113
+ if (c_bulk_rows_mark != Qnil)
2114
+ rb_extend_object(out, c_bulk_rows_mark);
2115
+ }
2008
2116
  return out;
2009
2117
  }
2010
2118
 
@@ -2599,6 +2707,58 @@ static void walk_columnar_impl(VALUE document, VALUE owner_klass,
2599
2707
  }
2600
2708
  }
2601
2709
 
2710
+ /* ---- Subtree memo priming (leptris-ruby#530) ---------------------
2711
+ * Registered-consumer documents (the #340 doc-klass map — moxml's
2712
+ * mint registration) walk whole subtrees top-down, so the FIRST
2713
+ * cold children_read fills every ELEMENT's name + children read
2714
+ * memos in ONE crossing, reusing the walk_columnar machinery and
2715
+ * minting through columnar_node_for (native-cache identity — the
2716
+ * primed wrappers ARE the answers later reads hand back). Memos
2717
+ * are version-stamped like the content memo: any mutation bumps
2718
+ * @version and retires them subtree-wide.
2719
+ *
2720
+ * Conservative by construction: only ELEMENT descendants get their
2721
+ * memos filled here (the one kind whose f_first_child chain IS its
2722
+ * children list, the #527 contract); leaf and shell kinds keep the
2723
+ * per-node fetch, which memo-stores the same way. The receiver's
2724
+ * own name memo is left to nn_name (one node, once). */
2725
+ static VALUE nn_prime_children_of(VALUE document, VALUE owner_klass,
2726
+ void *node, VALUE ver)
2727
+ {
2728
+ VALUE out = rb_ary_new();
2729
+ void *c;
2730
+
2731
+ for (c = f_first_child(node); c; c = f_next_sibling(c)) {
2732
+ int kind = f_node_type(c);
2733
+ VALUE wrapper = columnar_node_for(document, owner_klass, c, kind);
2734
+ struct native_node *nn;
2735
+
2736
+ TypedData_Get_Struct(wrapper, struct native_node, &nn_type, nn);
2737
+ if (kind == WS_NODE_ELEMENT) {
2738
+ const char *name = f_elem_name(c);
2739
+ nn->name_val = name ? rb_enc_interned_str(name, strlen(name),
2740
+ rb_utf8_encoding())
2741
+ : Qnil;
2742
+ nn->name_ver = ver;
2743
+ nn->children_val = nn_prime_children_of(document, owner_klass,
2744
+ c, ver);
2745
+ nn->children_ver = ver;
2746
+ } else if (kind != WS_NODE_DOCTYPE && kind != 9 /* DOCUMENT */) {
2747
+ /* leaf kinds: memo the name exactly as nn_name computes
2748
+ * it (f_elem_name answers "" for nameless kinds); their
2749
+ * (empty) children keep the fetch path, which
2750
+ * memo-stores the same way. */
2751
+ const char *name = f_elem_name(c);
2752
+ nn->name_val = name ? rb_enc_interned_str(name, strlen(name),
2753
+ rb_utf8_encoding())
2754
+ : Qnil;
2755
+ nn->name_ver = ver;
2756
+ }
2757
+ rb_ary_push(out, wrapper);
2758
+ }
2759
+ return out;
2760
+ }
2761
+
2602
2762
  static VALUE nf_walk_columnar(VALUE self, VALUE root)
2603
2763
  {
2604
2764
  struct native_node *n;
@@ -2839,18 +2999,41 @@ static void *create_elem_core(void *doc_ptr, void *parent,
2839
2999
  return elem;
2840
3000
  }
2841
3001
 
3002
+ /* leptris-ruby#539: the ATTACHED construction faces are
3003
+ * wrapper-visible mutations — they splice a built child under a
3004
+ * parent — so they run the same freed/readonly gates and version
3005
+ * bump as every other write face (bump before the call: a failed
3006
+ * mutation merely discards memos). The raw-address face keeps the
3007
+ * legacy Integer-document contract (moxml's pre-#340 callers pass
3008
+ * doc.c_address): a bare engine address cannot reach the Document
3009
+ * object, so the gate runs only when the caller names it. */
2842
3010
  static VALUE nf_create_element_with_attrs(VALUE self, VALUE document,
2843
3011
  VALUE parent_addr, VALUE name,
2844
3012
  VALUE attrs)
2845
3013
  {
2846
- void *doc_ptr = NIL_P(document)
2847
- ? NULL
2848
- : (void *)(uintptr_t)NUM2ULL(document);
2849
- void *parent = NIL_P(parent_addr)
2850
- ? NULL
2851
- : (void *)(uintptr_t)NUM2ULL(parent_addr);
2852
- void *elem = create_elem_core(doc_ptr, parent,
2853
- StringValueCStr(name), attrs);
3014
+ void *doc_ptr;
3015
+ void *parent;
3016
+ void *elem;
3017
+
3018
+ parent = NIL_P(parent_addr)
3019
+ ? NULL
3020
+ : (void *)(uintptr_t)NUM2ULL(parent_addr);
3021
+ if (!NIL_P(document) && !RB_INTEGER_TYPE_P(document)) {
3022
+ if (NIL_P(rb_ivar_get(document, id_iv_c_address)))
3023
+ rb_raise(c_use_after_free_error,
3024
+ "owning document has been freed");
3025
+ /* The bump rides the ATTACH: a free-floating create mutates
3026
+ * no tree (detached-create discipline, no bump). */
3027
+ if (parent)
3028
+ document_advance_version(document);
3029
+ doc_ptr = doc_ptr_of(document);
3030
+ } else {
3031
+ doc_ptr = NIL_P(document)
3032
+ ? NULL
3033
+ : (void *)(uintptr_t)NUM2ULL(document);
3034
+ }
3035
+ elem = create_elem_core(doc_ptr, parent,
3036
+ StringValueCStr(name), attrs);
2854
3037
  return elem ? ULL2NUM((uintptr_t)elem) : Qnil;
2855
3038
  }
2856
3039
 
@@ -2865,15 +3048,29 @@ static VALUE nf_create_element_with_attrs_wrapped(
2865
3048
  VALUE self, VALUE document, VALUE parent_addr, VALUE name,
2866
3049
  VALUE attrs)
2867
3050
  {
2868
- void *doc_ptr = doc_ptr_of(document);
2869
- void *parent = NIL_P(parent_addr)
2870
- ? NULL
2871
- : (void *)(uintptr_t)NUM2ULL(parent_addr);
2872
- void *elem = create_elem_core(doc_ptr, parent,
2873
- StringValueCStr(name), attrs);
3051
+ void *doc_ptr;
3052
+ void *parent;
3053
+ void *elem;
2874
3054
  VALUE ck, key, node, cache;
2875
3055
 
2876
3056
  (void)self;
3057
+ parent = NIL_P(parent_addr)
3058
+ ? NULL
3059
+ : (void *)(uintptr_t)NUM2ULL(parent_addr);
3060
+ if (NIL_P(rb_ivar_get(document, id_iv_c_address)))
3061
+ rb_raise(c_use_after_free_error,
3062
+ "owning document has been freed");
3063
+ /* #539: attached construction bumps the version — the same
3064
+ * discipline as append_binding_child (bump before the call; a
3065
+ * failed mutation merely discards memos) — so version-stamped
3066
+ * memos (C content, walk-reads) retire across the splice.
3067
+ * Free-floating creates (parent_addr 0) mutate no tree and
3068
+ * stay bump-free, like the detached create faces. */
3069
+ if (parent)
3070
+ document_advance_version(document);
3071
+ doc_ptr = doc_ptr_of(document);
3072
+ elem = create_elem_core(doc_ptr, parent,
3073
+ StringValueCStr(name), attrs);
2877
3074
  if (!elem) return Qnil;
2878
3075
  resolve_binding_classes();
2879
3076
  ck = doc_klass_for_kind(document, 0 /* element */);
@@ -3715,6 +3912,7 @@ LEPTRIS_INIT_EXPORT void Init_native(void)
3715
3912
  id_iv_mx_context = rb_intern("@context");
3716
3913
  id_iv_mx_native = rb_intern("@native");
3717
3914
  id_iv_mx_adapter = rb_intern("@adapter");
3915
+ id_bulk_rows_mark = rb_intern("BulkIdentityRows");
3718
3916
  id_iv_parent = rb_intern("@parent");
3719
3917
  id_iv_structure_memoizable = rb_intern("@structure_memoizable");
3720
3918
  id_iv_native_fast = rb_intern("@native_fast");
@@ -3823,7 +4021,7 @@ LEPTRIS_INIT_EXPORT void Init_native(void)
3823
4021
  rb_define_module_function(m_native, "materialize_xpath",
3824
4022
  nf_materialize_xpath, 2);
3825
4023
  rb_define_module_function(m_native, "xpath_result_rows_bulk",
3826
- nf_xpath_result_rows_bulk, 2);
4024
+ nf_xpath_result_rows_bulk, -1);
3827
4025
  rb_define_module_function(m_native, "set_binding_name",
3828
4026
  nf_set_binding_name, 3);
3829
4027
  rb_define_module_function(m_native, "set_binding_text",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.380.1"
4
+ VERSION = "1.9.381.0"
5
5
  end
Binary file
Binary file
@@ -59,6 +59,19 @@ rescue LoadError => e
59
59
  MSG
60
60
  end
61
61
 
62
+ # Certificate module for the bulk XPath rows face: an Array extended
63
+ # with this carries ONLY born-primed registered-klass identity natives
64
+ # (xpath_result_rows_bulk answered with a consumer context+adapter, so
65
+ # @native is the node itself and @context/@adapter are already set).
66
+ # Consumers with a per-row wrap in their result loop can key a fast
67
+ # path on it (moxml's NodeSet#each); one object per native address
68
+ # remains the binding's native-cache contract (#529) either way.
69
+ module Leptris::XML::BulkIdentityRows
70
+ def bulk_identity_rows?
71
+ true
72
+ end
73
+ end
74
+
62
75
  module Leptris::XML::Native
63
76
  # FFI.libleptris_candidates is the single source of truth — the
64
77
  # native layer MUST dlsym the exact image the FFI layer loaded,
@@ -118,10 +131,15 @@ class Leptris::XML::Document
118
131
  # (nodesets free the handle in C); a non-nodeset result answers
119
132
  # nil and leaves the pointer with the caller — route it through
120
133
  # Searchable.wrap_xpath_result for the scalar faces.
121
- def xpath_result_rows_bulk(result_ptr)
134
+ # Optional (context, adapter): when the consumer passes its
135
+ # contract state, every registered-klass row is born primed and an
136
+ # all-native result extends with BulkIdentityRows — the consumer's
137
+ # per-row wrap prime disappears from the iterate-every-result lane.
138
+ def xpath_result_rows_bulk(result_ptr, mx_context = nil, mx_adapter = nil)
122
139
  Leptris::XML::Native.xpath_result_rows_bulk(
123
140
  self, result_ptr.respond_to?(:address) ? result_ptr.address
124
- : result_ptr)
141
+ : result_ptr,
142
+ mx_context, mx_adapter)
125
143
  end
126
144
 
127
145
  # Builder factories that return NativeNodes (#149): one C call
data/lib/libleptris.so CHANGED
Binary file
data/vendor-src/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  Sources vendored in this gem (recompile rights; the ruby
2
2
  variant builds them at install via extconf.rb):
3
3
 
4
- - libleptris v1.9.380
4
+ - libleptris v1.9.381
5
5
  - utf8proc v2.11.0
6
6
 
7
7
  Rebuild by hand:
@@ -1,5 +1,47 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.9.381] - 2026-10-22
4
+
5
+ The XSLT transform engine is no longer slower than libxslt on the
6
+ workload users actually run. The identity transform
7
+ (`match="@*|node()"` + `xsl:copy`) was quadratic: 13.0 s where
8
+ xsltproc takes 65 ms on a 20k-book catalog. Three independent causes
9
+ went in PR #1839, and every measured CLI row now beats xsltproc
10
+ end-to-end:
11
+
12
+ | stylesheet (20k-book catalog) | xsltproc 1.1.35 | 1.9.380 | 1.9.381 |
13
+ | --- | --- | --- | --- |
14
+ | identity `@*|node()` | 64.5 ms | 13,002 ms | **56.9 ms** |
15
+ | `copy-of //book` | 37.2 ms | 58.8 ms | **25.3 ms** |
16
+ | dispatch (apply-templates) | 63.8 ms | 23.5 ms | **23.6 ms** |
17
+ | 1,000-template sheet | 293.8 ms | 18.7 ms | **17.6 ms** |
18
+
19
+ ### Fixed
20
+
21
+ - apply xsi:type at every depth in whole-instance validation: an
22
+ instance document may pin a derived type on any element via
23
+ `xsi:type`; validation previously honored it only at the root, so
24
+ deeper particles validated against the declared type (#1838)
25
+
26
+ ### Performance
27
+
28
+ - **xslt: identity transforms and copy-of were quadratic (#1839).**
29
+ Three fixes: (1) the pattern fast path never compiled node-type
30
+ tests — `node(`/`text(`/`processing-instruction(` parsed as NCName
31
+ and failed on the paren, sending every match through the quadratic
32
+ ancestor ladder (and `processing-instruction(` compared 21 of its
33
+ 23 prefix characters); (2) the document-order rank table hashed
34
+ pointers by low bits, which degenerates on fixed-stride pool
35
+ allocations (872 ms to build for a 500k-node transform); (3) the
36
+ append-tail pair pointed at the deepest node a body built, so wide
37
+ parents re-walked their whole child chain on every append after an
38
+ interleaved child. Full suite 2103/2103, libxslt-205 output parity
39
+ included.
40
+ - dom: lane-18 append/create micro-levers from a fresh profile
41
+ (#1836)
42
+
43
+
44
+
3
45
  ## [1.9.380] - 2026-10-22
4
46
 
5
47
  ### Fixed
@@ -3,7 +3,7 @@
3
3
  cmake_minimum_required(VERSION 3.20)
4
4
 
5
5
  project(leptris
6
- VERSION 1.9.380
6
+ VERSION 1.9.381
7
7
  DESCRIPTION "Fast XML parser and XPath evaluator in pure C"
8
8
  LANGUAGES C CXX
9
9
  )
@@ -106,7 +106,11 @@ Harness `wt/460-harness/build_bench.rb`; A/B pairs alternate within
106
106
  ONE window (drift ±20%). Rejected within noise: fast_attach,
107
107
  native-cache deletion, doc_klass memo, identity-probe deletion,
108
108
  add_child alias. Residual: wrapper allocation (owner-level node
109
- layout).
109
+ layout). **Allocation census 2026-10-22: AT FLOOR** — moxml
110
+ 0.06 minor GC/build vs nokogiri 0.39 (6.5x fewer), 3420 objects vs
111
+ 9907; every named suspect eliminated by the levers; the non-floor
112
+ remainder is 17 objects/build (~0.1% CPU). No lever remains — the
113
+ gap is the wrapper-count/identity-contract decision.
110
114
  - [ ] per-element object budget: one Ruby object per created node
111
115
  (drop the shell entirely post-create-priming; the mint cache
112
116
  insert policy; the context registration)
@@ -138,7 +142,19 @@ three interleaved-median runs (`wt/460-harness/pipeline_bench.rb`,
138
142
  build, serialize medians, fail on >5% regression)
139
143
 
140
144
  ### B5 remaining red rows
141
- - [ ] pugixml lane-18 append/attr-parse (2.0x/5.9x; hash prefilter)
145
+ - [x] pugixml lane-18 append/attr-parse — **re-dated 2026-10-22: the
146
+ 2.0x/5.9x rows no longer exist.** The named levers shipped in
147
+ the prior 22-round campaign (dup-check hash PR #1413, FNV-fused
148
+ lookup PR #1425, append parity PR #1533). Attr-lookup
149
+ ~parity (0.99x; caveat: in-process rigs let leptris's teardown
150
+ depress the adjacent pugixml row — self-warmed rigs are the
151
+ honest numbers). Append: **1.44x on self-warmed rigs; 1.37x
152
+ after PR leptris#1836** (4 behavior-identical levers: childless-
153
+ proof ancestor-walk skip, fused type chains, bounded name scan,
154
+ create_pooled double-copy fix; −3.6%, 9/10 pairs). Residual
155
+ ~1.37x parse gap = conformance features pugixml doesn't do
156
+ (dup detection, error positions, NUL writes) — the measured
157
+ floor; owner decision in #1436.
142
158
  - [ ] zero-copy text lever (#1218) — adopt if it wins any row
143
159
 
144
160
  ## Gate C — Safety: trust is auditable
@@ -58,7 +58,7 @@ from stale build dirs, so the harness protocol is part of the gate.
58
58
  | attr listing | ≥ all | 99x — ahead |
59
59
  | build | ≥ all | 0.87x — ahead |
60
60
  | walk+read (moxml) | ≥ 0.95x nokogiri | **done 2026-10-19** — ~195ns name / ~193ns content, 0.90–0.95x |
61
- | append / attr-parse (pugixml lane 18) | ≥ parity | **RED 2.0x / 5.9x** — hash-prefilter lever named |
61
+ | append / attr-parse (pugixml lane 18) | ≥ parity | lookup ~parity (rig-bias caveat); **append 1.37x after PR leptris#1836** (was 1.44x self-warmed) — residual = conformance floor (#1436) |
62
62
  | DOM mutation rows | ≥ parity | RED — node-layout redesign, owner decision pending |
63
63
  | zero-copy text (#1218 lever) | adopt if it wins any row | lever named, unmeasured |
64
64
 
@@ -59,6 +59,7 @@ LeptrisElement leptris_element_create_with_view(
59
59
  memset(elem, 0, sizeof(struct leptris_element));
60
60
  elem->base.type = LEPTRIS_NODE_TYPE_ELEMENT;
61
61
  char* nm = leptris_sv_to_cstr_pooled(&name_view, pool);
62
+ if (!nm) return NULL; /* pool OOM — do not store a NULL name */
62
63
  elem_set_name(elem, nm);
63
64
  elem->name_hash = leptris_name_hash_compute(nm);
64
65
  elem->name_len = (name_view.length > 254)
@@ -107,13 +108,16 @@ LeptrisElement leptris_element_create_zero_copy(LeptrisMemoryPool* pool) {
107
108
  LeptrisElement leptris_element_create_pooled(const char* name, LeptrisMemoryPool* pool) {
108
109
  if (!name || !pool) return NULL;
109
110
 
110
- /* Pool-owned copy; lives for the document's lifetime. */
111
- char* name_copy = leptris_pool_strdup(pool, name);
112
- if (!name_copy) return NULL;
113
-
114
- /* Hand ownership to create_with_view via the inplace path so the
115
- * name_view points into pool-owned storage, not the caller's buffer. */
116
- return leptris_element_create_pooled_inplace(name_copy, pool);
111
+ /* Lane 18: ONE strlen + ONE pooled copy. The old route strdup'ed
112
+ * the name into the pool and then handed that copy to the inplace
113
+ * path, whose with_view call copies the SAME bytes into the pool
114
+ * again via sv_to_cstr_pooled — the strdup result was orphaned
115
+ * (never stored on the element), so every fallback-path create
116
+ * paid a dead strlen + pool alloc + memcpy. Building the view
117
+ * over the caller's buffer lands on the identical final name
118
+ * pointer (same pooled copy, same interning behavior). */
119
+ return leptris_element_create_with_view(leptris_sv_from_cstr(name),
120
+ pool);
117
121
  }
118
122
 
119
123
  /* Free element - pool allocated, so this is a no-op */
@@ -771,22 +775,16 @@ void leptris_element_append_child_hint_doc(LeptrisElement elem,
771
775
  LeptrisNode* hint) {
772
776
  if (!elem || !child) return;
773
777
 
774
- /* SAFETY: Verify node type field is valid before accessing it
775
- * Small values like 0x4 or 0x1 in the type field suggest the pointer is corrupted */
776
- if (child->type < LEPTRIS_NODE_TYPE_ELEMENT ||
777
- (child->type > LEPTRIS_NODE_TYPE_DOCTYPE &&
778
- child->type != LEPTRIS_NODE_TYPE_ENTITY_REF)) {
779
- /* Invalid node type - corrupted pointer */
780
- return;
781
- }
782
-
783
- if (child->type != LEPTRIS_NODE_TYPE_ELEMENT &&
784
- child->type != LEPTRIS_NODE_TYPE_TEXT &&
785
- child->type != LEPTRIS_NODE_TYPE_CDATA &&
786
- child->type != LEPTRIS_NODE_TYPE_COMMENT &&
787
- child->type != LEPTRIS_NODE_TYPE_PI &&
788
- child->type != LEPTRIS_NODE_TYPE_ENTITY_REF) {
789
- return; /* Only allow these node types as children */
778
+ /* Type gate, fused (lane 18): the appendable set is
779
+ * {ELEMENT..PI} ∪ {ENTITY_REF}; the old two-chain form (a range
780
+ * guard followed by a six-way membership walk) admitted exactly
781
+ * the same set in ~8 compare/branch pairs. One load + two
782
+ * compares decide it. */
783
+ {
784
+ unsigned t = (unsigned)child->type;
785
+ if (!(t <= (unsigned)LEPTRIS_NODE_TYPE_PI ||
786
+ t == (unsigned)LEPTRIS_NODE_TYPE_ENTITY_REF))
787
+ return; /* corrupted or non-appendable node type */
790
788
  }
791
789
 
792
790
  /* Issue #217: if child is already attached to a parent, unlink
@@ -909,22 +907,16 @@ void leptris_element_append_child_hint_doc(LeptrisElement elem,
909
907
  void leptris_element_prepend_child_internal(LeptrisElement elem, LeptrisNode* child) {
910
908
  if (!elem || !child) return;
911
909
 
912
- /* SAFETY: Verify node type field is valid before accessing it
913
- * Small values like 0x4 or 0x1 in the type field suggest the pointer is corrupted */
914
- if (child->type < LEPTRIS_NODE_TYPE_ELEMENT ||
915
- (child->type > LEPTRIS_NODE_TYPE_DOCTYPE &&
916
- child->type != LEPTRIS_NODE_TYPE_ENTITY_REF)) {
917
- /* Invalid node type - corrupted pointer */
918
- return;
919
- }
920
-
921
- if (child->type != LEPTRIS_NODE_TYPE_ELEMENT &&
922
- child->type != LEPTRIS_NODE_TYPE_TEXT &&
923
- child->type != LEPTRIS_NODE_TYPE_CDATA &&
924
- child->type != LEPTRIS_NODE_TYPE_COMMENT &&
925
- child->type != LEPTRIS_NODE_TYPE_PI &&
926
- child->type != LEPTRIS_NODE_TYPE_ENTITY_REF) {
927
- return; /* Only allow these node types as children */
910
+ /* Type gate, fused (lane 18): the appendable set is
911
+ * {ELEMENT..PI} ∪ {ENTITY_REF}; the old two-chain form (a range
912
+ * guard followed by a six-way membership walk) admitted exactly
913
+ * the same set in ~8 compare/branch pairs. One load + two
914
+ * compares decide it. */
915
+ {
916
+ unsigned t = (unsigned)child->type;
917
+ if (!(t <= (unsigned)LEPTRIS_NODE_TYPE_PI ||
918
+ t == (unsigned)LEPTRIS_NODE_TYPE_ENTITY_REF))
919
+ return; /* corrupted or non-appendable node type */
928
920
  }
929
921
 
930
922
  /* Issue #217: if child is already attached to a parent, unlink
@@ -183,7 +183,12 @@ LeptrisElement leptris_element_create(LeptrisDocument doc, const char* name) {
183
183
  * Same init contract as leptris_element_create_with_view
184
184
  * (memset-zero + type/name/name_hash/name_len). Falls back
185
185
  * to the plain pool path when a block can't be allocated. */
186
- size_t name_len = strlen(name);
186
+ /* Lane 18: element names are almost always tiny — a bounded
187
+ * byte scan resolves the length without the strlen call;
188
+ * anything 8+ bytes pays the same strlen as before. */
189
+ size_t name_len = 0;
190
+ while (name_len < 8 && name[name_len]) name_len++;
191
+ if (name_len == 8 && name[8]) name_len = strlen(name);
187
192
  elem = mut_elem_carve(doc);
188
193
  if (elem) {
189
194
  char* name_copy = mut_name_carve(doc, name, name_len);
@@ -469,6 +474,11 @@ LeptrisStatus leptris_element_add_child_addr_installed(
469
474
  LeptrisStatus leptris_element_append_child(LeptrisElement parent, LeptrisElement child) {
470
475
  if (!parent || !child) return LEPTRIS_ERROR_NULL_ARG;
471
476
 
477
+ /* Single document resolution per mutation (TODO 195c): the
478
+ * root walk + map lookup was paid three times per append
479
+ * (twice here, once inside the internal for the tail cache). */
480
+ struct leptris_document* doc = leptris_element_get_document(parent);
481
+
472
482
  /* #1636: appending a node into its own descendant subtree
473
483
  * builds a parent cycle — every tree walker (serializer
474
484
  * first) then recurses without bound and the process dies
@@ -477,9 +487,24 @@ LeptrisStatus leptris_element_append_child(LeptrisElement parent, LeptrisElement
477
487
  * ancestor edge: walk child's parent chain for parent. The
478
488
  * same-document fast path below never sees foreign copies as
479
489
  * ancestors, so the walk runs on the caller's node — O(depth),
480
- * depth bounded by the parse cap. */
481
- {
482
- if (parent == child) return LEPTRIS_ERROR_INVALID_ARG;
490
+ * depth bounded by the parse cap.
491
+ *
492
+ * Lane 18: the freshness probe below runs FIRST. A child is
493
+ * provably childless — and therefore cannot be an ancestor of
494
+ * parent (nor equal any node that has descendants) — only when
495
+ * BOTH hold: it was carved from the current mut block (recent
496
+ * create) AND its child list is still empty. The extent alone is
497
+ * NOT enough: a block-extent element may have been given
498
+ * children since its create, and appending an ancestor under its
499
+ * own descendant must keep rejecting. parent == child stays
500
+ * unconditional and the walk still sees the ORIGINAL child
501
+ * before any #1528 adoption, so accept/reject decisions are
502
+ * identical to the unconditioned guard. */
503
+ int child_fresh = node_in_current_mut_block(doc, child) &&
504
+ (leptris_elem_first_child(child) == NULL);
505
+
506
+ if (parent == child) return LEPTRIS_ERROR_INVALID_ARG;
507
+ if (!child_fresh) {
483
508
  LeptrisElement p = leptris_element_parent(parent);
484
509
  int guard = 0;
485
510
  while (p && guard++ < 8192) {
@@ -488,11 +513,6 @@ LeptrisStatus leptris_element_append_child(LeptrisElement parent, LeptrisElement
488
513
  }
489
514
  }
490
515
 
491
- /* Single document resolution per mutation (TODO 195c): the
492
- * root walk + map lookup was paid three times per append
493
- * (twice here, once inside the internal for the tail cache). */
494
- struct leptris_document* doc = leptris_element_get_document(parent);
495
-
496
516
  /* #1528: a foreign document's node is adopted by copy, never
497
517
  * spliced raw. The freshly-created-then-appended shape proves
498
518
  * same-doc in two hot loads: a child inside the CURRENT mut
@@ -500,7 +520,7 @@ LeptrisStatus leptris_element_append_child(LeptrisElement parent, LeptrisElement
500
520
  * THIS document's block by the create call just executed — no
501
521
  * foreign allocation can live inside it. Everything else takes
502
522
  * the full check (Lane 18 append bar). */
503
- if (!(node_in_current_mut_block(doc, child))) {
523
+ if (!child_fresh) {
504
524
  child = (LeptrisElement)adopt_for_splice((LeptrisNode*)child,
505
525
  doc);
506
526
  if (!child) return LEPTRIS_ERROR_MEMORY;
@@ -508,6 +508,21 @@ static void doc_rank_table_free(DocOrderRankTable* t) {
508
508
  free(t->ranks);
509
509
  }
510
510
 
511
+ /* Mix the pointer before masking. Pool nodes are allocated at a
512
+ * fixed stride, so the old (ptr >> 4) & mask sent every key to the
513
+ * same residue class: with a power-of-two capacity and 64/96-byte
514
+ * strides, only a fraction of the slots were ever reachable and
515
+ * linear probing degenerated. A 500k-node identity transform spent
516
+ * 872 ms in the rank build; mixing makes the same build O(1) per
517
+ * insert. */
518
+ static size_t doc_rank_slot(const void* key, size_t mask) {
519
+ uint64_t x = (uint64_t)(uintptr_t)key;
520
+ x ^= x >> 33;
521
+ x *= 0xff51afd7ed558ccdULL;
522
+ x ^= x >> 33;
523
+ return (size_t)x & mask;
524
+ }
525
+
511
526
  /* Grow at 70% load so probe chains stay short. */
512
527
  static int doc_rank_table_grow(DocOrderRankTable* t) {
513
528
  size_t new_cap = (t->mask + 1) << 1;
@@ -521,7 +536,7 @@ static int doc_rank_table_grow(DocOrderRankTable* t) {
521
536
  size_t new_mask = new_cap - 1;
522
537
  for (size_t i = 0; i <= t->mask; i++) {
523
538
  if (!t->keys[i]) continue;
524
- size_t j = (size_t)(((uintptr_t)t->keys[i] >> 4) & new_mask);
539
+ size_t j = doc_rank_slot(t->keys[i], new_mask);
525
540
  while (new_keys[j]) j = (j + 1) & new_mask;
526
541
  new_keys[j] = t->keys[i];
527
542
  new_ranks[j] = t->ranks[i];
@@ -538,7 +553,7 @@ static int doc_rank_table_put(DocOrderRankTable* t, void* key, int64_t rank) {
538
553
  if ((t->count + 1) * 10 >= (t->mask + 1) * 7) {
539
554
  if (!doc_rank_table_grow(t)) return 0;
540
555
  }
541
- size_t i = (size_t)(((uintptr_t)key >> 4) & t->mask);
556
+ size_t i = doc_rank_slot(key, t->mask);
542
557
  while (t->keys[i]) {
543
558
  if (t->keys[i] == key) return 1; /* first visit wins */
544
559
  i = (i + 1) & t->mask;
@@ -550,7 +565,7 @@ static int doc_rank_table_put(DocOrderRankTable* t, void* key, int64_t rank) {
550
565
  }
551
566
 
552
567
  static int64_t doc_rank_table_get(const DocOrderRankTable* t, void* key) {
553
- size_t i = (size_t)(((uintptr_t)key >> 4) & t->mask);
568
+ size_t i = doc_rank_slot(key, t->mask);
554
569
  while (t->keys[i]) {
555
570
  if (t->keys[i] == key) return t->ranks[i];
556
571
  i = (i + 1) & t->mask;
@@ -423,8 +423,25 @@ static void validate_children(struct xsd_validator* v,
423
423
  }
424
424
  }
425
425
  }
426
- if (ptype)
426
+ /* leptris-ruby#535 (XSD 1.0 2.6.2): xsi:type applies at every
427
+ * assessed depth -- a child typed by the parent particle is
428
+ * substituted exactly like a globally declared one (which
429
+ * validate_element above already handles). */
430
+ const char* cxsi = leptris_element_attribute_ns(
431
+ ce, "http://www.w3.org/2001/XMLSchema-instance", "type");
432
+ if (cxsi && cxsi[0] && ptype) {
433
+ char xuri[512], xlocal[256];
434
+ xsd_resolve_qname_inst(ce, cxsi, xuri, sizeof(xuri),
435
+ xlocal, sizeof(xlocal));
436
+ if (!xlocal[0]) {
437
+ verrf_at(v, ce, "element", cl,
438
+ "xsi:type prefix is not bound in the instance");
439
+ } else {
440
+ validate_local_uri(v, s, ce, cl, xlocal, xuri, cxsi);
441
+ }
442
+ } else if (ptype) {
427
443
  validate_local(v, s, ce, cl, ptype);
444
+ }
428
445
  }
429
446
  }
430
447
 
@@ -1011,6 +1011,15 @@ static int op_result_elem(XsltExec* ex, const XsltInstr* in,
1011
1011
  }
1012
1012
  }
1013
1013
  ex->pending_parent = parent;
1014
+ /* The content ran only inside e (append-only, strictly nested),
1015
+ * so e is `parent`'s newest child — hand it back as the next
1016
+ * append's tail hint (the single-entry pair otherwise points at
1017
+ * the deepest node the body built, and each append to a wide
1018
+ * parent then walks its whole child chain, #682). */
1019
+ if (parent) {
1020
+ ex->last_append_parent = parent;
1021
+ ex->last_append_child = (LeptrisNodeRef)e;
1022
+ }
1014
1023
  return rc;
1015
1024
  }
1016
1025
 
@@ -2691,6 +2700,16 @@ static int op_copy(XsltExec* ex, const XsltInstr* in, LeptrisElement node) {
2691
2700
  ex->pending_parent = e;
2692
2701
  int rc = xslt_exec_instrs(ex, in->child, node);
2693
2702
  ex->pending_parent = saved;
2703
+ /* Result-tree construction is append-only and strictly nested,
2704
+ * so `e` is `saved`'s newest child when the body returns — hand
2705
+ * it back as the next append's tail hint. The single-entry pair
2706
+ * otherwise points at the deepest node the body built, and each
2707
+ * append to a wide parent then walks that parent's whole child
2708
+ * chain (quadratic identity transforms, #682). */
2709
+ if (saved) {
2710
+ ex->last_append_parent = saved;
2711
+ ex->last_append_child = (LeptrisNodeRef)e;
2712
+ }
2694
2713
  return rc;
2695
2714
  }
2696
2715
 
@@ -2763,21 +2782,27 @@ static int copy_node_deep(XsltExec* ex, LeptrisElement node,
2763
2782
  LeptrisNodeRef cc = (LeptrisNodeRef)leptris_cdata_create(
2764
2783
  t, t ? strlen(t) : 0,
2765
2784
  ((struct leptris_document*)ex->result)->pool);
2766
- if (cc) leptris_element_append_child_internal(e, (LeptrisNode*)cc);
2785
+ if (cc) out_append_child(ex, e, (void*)cc);
2767
2786
  } else if (ty == LEPTRIS_NODE_TYPE_COMMENT) {
2768
2787
  LeptrisNodeRef cm = leptris_comment_node_create(
2769
2788
  ex->result, leptris_comment_node_get_content(c));
2770
- if (cm) leptris_element_append_child_internal(e, (LeptrisNode*)cm);
2789
+ if (cm) out_append_child(ex, e, (void*)cm);
2771
2790
  } else if (ty == LEPTRIS_NODE_TYPE_PI) {
2772
2791
  LeptrisNodeRef pi = leptris_pi_node_create(
2773
2792
  ex->result, leptris_pi_node_get_target(c),
2774
2793
  leptris_pi_node_get_data(c));
2775
- if (pi) leptris_element_append_child_internal(e, (LeptrisNode*)pi);
2794
+ if (pi) out_append_child(ex, e, (void*)pi);
2776
2795
  } else if (ty == LEPTRIS_NODE_TYPE_TEXT) {
2777
2796
  const char* t = leptris_text_get_content((LeptrisTextNode*)c);
2778
2797
  if (t && t[0]) out_append_text(ex, e, t);
2779
2798
  }
2780
2799
  }
2800
+ /* The caller's newest child is e — hand it back as the tail
2801
+ * hint so sibling copies append O(1) (see op_copy). */
2802
+ if (parent) {
2803
+ ex->last_append_parent = parent;
2804
+ ex->last_append_child = (LeptrisNodeRef)e;
2805
+ }
2781
2806
  return 0;
2782
2807
  }
2783
2808
 
@@ -3515,6 +3540,10 @@ static int builtin_no_match(XsltExec* ex, const char* mode,
3515
3540
  &(XsltInstr){ .kind = XSLT_INSTR_APPLY_TEMPLATES, .name = mode },
3516
3541
  node);
3517
3542
  ex->pending_parent = saved_parent;
3543
+ if (saved_parent && e) {
3544
+ ex->last_append_parent = saved_parent;
3545
+ ex->last_append_child = (LeptrisNodeRef)e;
3546
+ }
3518
3547
  return rc;
3519
3548
  }
3520
3549
 
@@ -3876,6 +3905,16 @@ static int op_element(XsltExec* ex, const XsltInstr* in,
3876
3905
  ex->pending_parent = e;
3877
3906
  int rc = xslt_exec_instrs(ex, in->child, node);
3878
3907
  ex->pending_parent = saved;
3908
+ /* Result-tree construction is append-only and strictly nested,
3909
+ * so `e` is `saved`'s newest child when the body returns — hand
3910
+ * it back as the next append's tail hint. The single-entry pair
3911
+ * otherwise points at the deepest node the body built, and each
3912
+ * append to a wide parent then walks that parent's whole child
3913
+ * chain (quadratic identity transforms, #682). */
3914
+ if (saved) {
3915
+ ex->last_append_parent = saved;
3916
+ ex->last_append_child = (LeptrisNodeRef)e;
3917
+ }
3879
3918
  return rc;
3880
3919
  }
3881
3920
 
@@ -59,6 +59,22 @@ static int parse_pat_step(const char** pp, const char* end,
59
59
  if (*p == '@') { s->is_attr = 1; p++; }
60
60
  if (p < end && *p == '*') {
61
61
  p++;
62
+ /* Node-type tests FIRST: "node(" also starts with an NCName, so
63
+ * the name branch below would swallow the keyword and then fail
64
+ * on its '(' — leaving steps_valid=0 and sending every match of
65
+ * node()/text()/comment()/processing-instruction() into the
66
+ * ancestor ladder (O(width) per node: the identity transform's
67
+ * @*|node() went quadratic). The bare names "node"/"text" still
68
+ * parse as name tests: the keyword check requires the '('. */
69
+ } else if (p + 5 <= end && strncmp(p, "node(", 5) == 0) {
70
+ s->kind = 1; p += 5;
71
+ } else if (p + 5 <= end && strncmp(p, "text(", 5) == 0) {
72
+ s->kind = 2; p += 5;
73
+ } else if (p + 8 <= end && strncmp(p, "comment(", 8) == 0) {
74
+ s->kind = 3; p += 8;
75
+ } else if (p + 23 <= end &&
76
+ strncmp(p, "processing-instruction(", 23) == 0) {
77
+ s->kind = 4; p += 23;
62
78
  } else if (p < end && ncname_char(*p) && *p != '-') {
63
79
  const char* st = p;
64
80
  while (p < end && ncname_char(*p)) p++;
@@ -72,15 +88,6 @@ static int parse_pat_step(const char** pp, const char* end,
72
88
  if (!s->name) return 0;
73
89
  memcpy(s->name, st, n);
74
90
  s->name[n] = 0;
75
- } else if (p + 5 <= end && strncmp(p, "node(", 5) == 0) {
76
- s->kind = 1; p += 5;
77
- } else if (p + 5 <= end && strncmp(p, "text(", 5) == 0) {
78
- s->kind = 2; p += 5;
79
- } else if (p + 8 <= end && strncmp(p, "comment(", 8) == 0) {
80
- s->kind = 3; p += 8;
81
- } else if (p + 21 <= end &&
82
- strncmp(p, "processing-instruction(", 23 - 2) == 0) {
83
- s->kind = 4; p += 21;
84
91
  } else {
85
92
  return 0;
86
93
  }
@@ -2966,6 +2966,48 @@ TEST(DomCycleGuard, AncestorAppendRejected) {
2966
2966
  leptris_document_free(d);
2967
2967
  }
2968
2968
 
2969
+ /* Lane 18: the append path skips the #1636 ancestor walk when the
2970
+ * child is provably childless (fresh mut-block carve AND empty
2971
+ * child list). An element still sitting inside the mut-block
2972
+ * extent is NOT automatically childless — it may have been given
2973
+ * children since its create — so the walk must still run for it.
2974
+ * Regression pin for the first cut, which keyed the skip on the
2975
+ * extent alone and ACCEPTED root-under-b for mutation-built
2976
+ * trees (parse-built trees never enter the mut block, which is
2977
+ * why the parse-only specs above could not see it). */
2978
+ TEST(DomCycleGuard, AncestorAppendRejectedInMutBlockExtent) {
2979
+ LeptrisDocument d = leptris_document_create();
2980
+ ASSERT_NE(d, nullptr);
2981
+ LeptrisElement root = leptris_element_create(d, "root");
2982
+ ASSERT_NE(root, nullptr);
2983
+ ASSERT_EQ(leptris_document_set_root(d, root), LEPTRIS_OK);
2984
+ LeptrisElement a = leptris_element_create(d, "a");
2985
+ LeptrisElement b = leptris_element_create(d, "b");
2986
+ ASSERT_NE(a, nullptr);
2987
+ ASSERT_NE(b, nullptr);
2988
+ ASSERT_EQ(leptris_element_append_child(root, a), LEPTRIS_OK);
2989
+ ASSERT_EQ(leptris_element_append_child(a, b), LEPTRIS_OK);
2990
+
2991
+ /* all three still sit in the current mut-block extent here —
2992
+ * the guard must not trust the extent as childlessness */
2993
+ EXPECT_EQ(leptris_element_append_child(b, root),
2994
+ LEPTRIS_ERROR_INVALID_ARG);
2995
+ EXPECT_EQ(leptris_element_append_child(a, a),
2996
+ LEPTRIS_ERROR_INVALID_ARG);
2997
+ EXPECT_EQ(leptris_element_prepend_child(b, root),
2998
+ LEPTRIS_ERROR_INVALID_ARG);
2999
+
3000
+ /* fresh create->append (the fast-path shape) still links */
3001
+ LeptrisElement c = leptris_element_create(d, "c");
3002
+ ASSERT_NE(c, nullptr);
3003
+ EXPECT_EQ(leptris_element_append_child(b, c), LEPTRIS_OK);
3004
+ char* out = leptris_document_serialize(d, nullptr);
3005
+ ASSERT_NE(out, nullptr);
3006
+ EXPECT_NE(strstr(out, "<b><c/></b>"), nullptr);
3007
+ leptris_free_string(out);
3008
+ leptris_document_free(d);
3009
+ }
3010
+
2969
3011
  TEST(DomCycleGuard, InsertIntoOwnSubtreeRejected) {
2970
3012
  LeptrisStatus st = LEPTRIS_OK;
2971
3013
  LeptrisDocument d =
@@ -2855,4 +2855,94 @@ TEST(XsdConformanceClauses, XsiTypeUnknownTypeIsLoud) {
2855
2855
  leptris_xsd_free(s);
2856
2856
  }
2857
2857
 
2858
+ TEST(XsdConformanceClauses, XsiTypeAtDepthGlobalChild) {
2859
+ const char schema[] =
2860
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
2861
+ " targetNamespace=\"urn:t\" xmlns=\"urn:t\""
2862
+ " elementFormDefault=\"qualified\">"
2863
+ " <xs:complexType name=\"Base\">"
2864
+ " <xs:sequence><xs:element name=\"a\" type=\"xs:string\"/></xs:sequence>"
2865
+ " </xs:complexType>"
2866
+ " <xs:complexType name=\"Derived\">"
2867
+ " <xs:complexContent>"
2868
+ " <xs:extension base=\"Base\">"
2869
+ " <xs:sequence><xs:element name=\"b\" type=\"xs:string\"/></xs:sequence>"
2870
+ " </xs:extension>"
2871
+ " </xs:complexContent>"
2872
+ " </xs:complexType>"
2873
+ " <xs:element name=\"doc\" type=\"Base\"/>"
2874
+ " <xs:element name=\"wrap\">"
2875
+ " <xs:complexType>"
2876
+ " <xs:sequence><xs:element ref=\"doc\"/></xs:sequence>"
2877
+ " </xs:complexType>"
2878
+ " </xs:element>"
2879
+ "</xs:schema>";
2880
+ LeptrisXsdSchema s = compile_string(schema);
2881
+ ASSERT_NE(s, (LeptrisXsdSchema)0);
2882
+ /* leptris-ruby#535 depth case, global-declaration child: the
2883
+ * child walk re-enters validate_element, which reads xsi:type.
2884
+ * The prefix resolves through the ROOT's declaration -- in
2885
+ * scope, not just the bearing element's own. */
2886
+ EXPECT_EQ(validate_compiled(
2887
+ s,
2888
+ "<wrap xmlns=\"urn:t\" xmlns:i=\"urn:t\""
2889
+ " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">"
2890
+ "<doc xsi:type=\"i:Derived\"><a>x</a><b>y</b></doc></wrap>"),
2891
+ 1)
2892
+ << "xsi:type must be honored on a global child below the root";
2893
+ /* the declared type governs without xsi:type: b is out of Base. */
2894
+ EXPECT_EQ(validate_compiled(
2895
+ s, "<wrap xmlns=\"urn:t\"><doc><a>x</a><b>y</b></doc></wrap>"),
2896
+ 0);
2897
+ leptris_xsd_free(s);
2898
+ }
2899
+
2900
+ TEST(XsdConformanceClauses, XsiTypeAtDepthParticleTypedChild) {
2901
+ const char schema[] =
2902
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
2903
+ " targetNamespace=\"urn:t\" xmlns=\"urn:t\""
2904
+ " elementFormDefault=\"qualified\">"
2905
+ " <xs:complexType name=\"Base\">"
2906
+ " <xs:sequence><xs:element name=\"a\" type=\"xs:string\"/></xs:sequence>"
2907
+ " </xs:complexType>"
2908
+ " <xs:complexType name=\"Derived\">"
2909
+ " <xs:complexContent>"
2910
+ " <xs:extension base=\"Base\">"
2911
+ " <xs:sequence><xs:element name=\"b\" type=\"xs:string\"/></xs:sequence>"
2912
+ " </xs:extension>"
2913
+ " </xs:complexContent>"
2914
+ " </xs:complexType>"
2915
+ " <xs:element name=\"wrap\">"
2916
+ " <xs:complexType>"
2917
+ " <xs:sequence><xs:element name=\"doc\" type=\"Base\"/></xs:sequence>"
2918
+ " </xs:complexType>"
2919
+ " </xs:element>"
2920
+ "</xs:schema>";
2921
+ LeptrisXsdSchema s = compile_string(schema);
2922
+ ASSERT_NE(s, (LeptrisXsdSchema)0);
2923
+ /* leptris-ruby#535 depth case, local particle: no global `doc`
2924
+ * exists, so the child is typed by wrap's particle -- xsi:type
2925
+ * must still substitute Derived's model at that depth. */
2926
+ EXPECT_EQ(validate_compiled(
2927
+ s,
2928
+ "<wrap xmlns=\"urn:t\" xmlns:i=\"urn:t\""
2929
+ " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">"
2930
+ "<doc xsi:type=\"i:Derived\"><a>x</a><b>y</b></doc></wrap>"),
2931
+ 1)
2932
+ << "xsi:type must substitute on a particle-typed child too";
2933
+ /* the named type's own constraint applies: Derived needs b. */
2934
+ EXPECT_EQ(validate_compiled(
2935
+ s,
2936
+ "<wrap xmlns=\"urn:t\" xmlns:i=\"urn:t\""
2937
+ " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">"
2938
+ "<doc xsi:type=\"i:Derived\"><a>x</a></doc></wrap>"),
2939
+ 0);
2940
+ /* without xsi:type the declared particle type governs: b is out
2941
+ * of Base's model. */
2942
+ EXPECT_EQ(validate_compiled(
2943
+ s, "<wrap xmlns=\"urn:t\"><doc><a>x</a><b>y</b></doc></wrap>"),
2944
+ 0);
2945
+ leptris_xsd_free(s);
2946
+ }
2947
+
2858
2948
  } // namespace
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg/master/scripts/vcpkg.schema.json",
3
3
  "name": "leptris",
4
- "version": "1.9.380",
4
+ "version": "1.9.381",
5
5
  "description": "High-performance XML parser and XPath 1.0 engine in C (libleptris)",
6
6
  "homepage": "https://github.com/leptris/leptris",
7
7
  "license": "MIT",
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.380.1
4
+ version: 1.9.381.0
5
5
  platform: arm-linux
6
6
  authors:
7
7
  - Ribose Inc.