leptris 1.9.174.5 → 1.9.174.7

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: ec76aed6663e2ae97d677c09e8b05486eb6e0543da0f92640a81a87d49c38565
4
- data.tar.gz: bd881b0e87ff21939925f879abfced736f304a5396f675013a2bc8e2f220a89c
3
+ metadata.gz: 2b55134beeb4963b45dab45f2702b840c8db5e4f1c5e282622f5e4c4b5f55b1e
4
+ data.tar.gz: 92278400c3c9a6ff0450a151583a9edd383677b621d6c95bdbd5286d0ba0b8a1
5
5
  SHA512:
6
- metadata.gz: f9f8f54bca5c16e6f1068a9988374ab2a852febc8f4f8695fa7cf0e5bb21fed700bf5b7a34b0732693d55f520000528ed35719dc2488fdf3afb3b6224d2dce9d
7
- data.tar.gz: a883b80b556feaa34abc6544fe7b4c5f03d6da46597c6dc3170d1219ab66aa4018be3233f824ae6df551c50dc963974a35f72ea715037d596a26259bc8087d8a
6
+ metadata.gz: 3e476a9c2aaecd83069e118a4a7c1aa4ed141df2c853b238f4eb745b916be151ce46b0a5c29686f3d481d2bb816fe4c6f4744df7e76835bfe53ccc82c998134d
7
+ data.tar.gz: af8d33a0aaa87d762405979bcbc08be5af416e8dac2de924027eb900184df6740fcc4a8ef30e533becf131db08c2502a9dfc4a634b9678d9a87b5fa0513af212
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ 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.174.7] - 2026-09-16
9
+
10
+ ### Changed
11
+
12
+ - **Namespace-bound xpath on the compiled path (TODO.perf/29)**:
13
+ the ns branch rides the compiled-expression cache (the handle
14
+ is ns-independent) plus the cached ns set through
15
+ `XPath#eval_ns_ptrs`, then the eager materializer — measured
16
+ (load ~16): repeat ns-xpath 7.9µs → 3.1µs (2.5x).
17
+ - **dup + element-child faces (TODO.perf/30)**: `Element#dup`
18
+ runs engine create + handle + element_copy + rooted wrap in
19
+ one C dispatch (the namespace-lift decision stays in Ruby) —
20
+ ns-bearing dup 37.0µs → 9.6µs (3.9x); `first/last_element_child`
21
+ answer in one C walk each (the FFI paths paid O(N) scans).
22
+
23
+ ## [1.9.174.6] - 2026-09-15
24
+
25
+ ### Changed
26
+
27
+ - **C-yield traversal (TODO.perf/27)**: `traverse` and `visit`
28
+ no longer allocate an FFI::Function closure per call — the
29
+ ext's callbacks rb_yield directly, preserving post-order +
30
+ abort-at-self + stash-abort-raise (traverse) and the
31
+ (node, entering, depth) visit contract. Measured (load 34):
32
+ **traverse over 8k nodes 1663.5µs → 379.9µs (4.4x; 6.81x
33
+ Nokogiri); visit 2288.6µs → 545.4µs (4.2x)**.
34
+ - **Address-based first-touch fills (TODO.perf/28)**:
35
+ text/comment/CDATA/PI content and `path` fill through address
36
+ faces (scope-eligible); the bulk attribute faces accept
37
+ scope-owned elements.
38
+
8
39
  ## [1.9.174.5] - 2026-09-15
9
40
 
10
41
  ### Changed
@@ -0,0 +1,38 @@
1
+ # 27 — C-yield traversal (traverse/visit without the FFI::Function closure)
2
+
3
+ Status: DONE (1.9.174.6)
4
+
5
+ Node#traverse and #visit allocate an FFI::Function closure per
6
+ call (~1µs) and dispatch every visited node back through the
7
+ Ruby closure — the dominant per-node cost of whole-subtree
8
+ iteration, paid even on small subtrees where the walk itself is
9
+ microseconds.
10
+
11
+ Bind both to C callbacks that rb_yield directly: the ext's
12
+ traverse face keeps the abort-at-self post-order contract and
13
+ the exception discipline (rb_protect in the callback stashes,
14
+ aborts the walk, re-raises after — exactly the Ruby version's
15
+ contract); visit keeps its (node, entering, depth) callback
16
+ shape with the same stash-and-raise treatment. Node wrapping per
17
+ visit rides the shared binding construction (identity cache +
18
+ kind dispatch), and the walk state lives on the C stack — no
19
+ globals, nested traversals safe.
20
+
21
+ Gates: post-order sequence identical (receiver last); the
22
+ subtree boundary holds (abort-at-self); block exceptions
23
+ re-raise after the walk stops; enum_for forms unchanged; suite
24
+ both modes.
25
+
26
+ ## Outcome (1.9.174.6)
27
+
28
+ Native.traverse_binding / visit_binding: C callbacks that
29
+ rb_yield directly (block forwarded explicitly — rb_yield needs
30
+ it on the C entry's frame). traverse keeps post-order +
31
+ abort-at-self + stash-abort-raise (rb_protect in the callback,
32
+ walk state on the C stack — nested traversals independent);
33
+ visit keeps (node, entering, depth) with the engine's
34
+ (user, node, ...) callback order. Two bugs found by the suite:
35
+ the visit callback's argument order (user first — the segfault
36
+ spec caught it) and the block forwarding. Measured (load 34):
37
+ traverse over 8k nodes 1663.5µs (FFI closure) -> 379.9µs
38
+ (4.4x; 6.81x Nokogiri); visit 2288.6µs -> 545.4µs (4.2x).
@@ -0,0 +1,25 @@
1
+ # 28 — Address-based first-touch fills for the remaining reads
2
+
3
+ Status: DONE (1.9.174.6)
4
+
5
+ The memoized content readers still fill through FFI marshaling on
6
+ first touch — Text/Comment/CDATA/PI content, and Node#path
7
+ (owned-string read + free) — and the bulk attribute faces exclude
8
+ scope-owned elements though they touch no document state. Add the
9
+ trivial address faces (fast_text/comment/cdata content, pi
10
+ target/data, node path with the free protocol) and switch the
11
+ fills to @addr_reads_fast (scope-eligible); the attributes bulk
12
+ path follows.
13
+
14
+ Gates: identical values for every kind (UTF-8, escapes, nils);
15
+ scope-owned fills answer the same; path memo unchanged; suite
16
+ both modes.
17
+
18
+ ## Outcome (1.9.174.6)
19
+
20
+ fast_text/comment/cdata content, fast_pi_target/data (the
21
+ read_pi_data strip preserved), and fast_path (owned-string copy
22
+ + engine free) — every first-touch content fill now rides the
23
+ address faces via @addr_reads_fast (scope-eligible), and the
24
+ bulk attribute faces accept scope-owned elements (they touch no
25
+ document state; the scope's version stamps the memo).
@@ -0,0 +1,23 @@
1
+ # 29 — Namespace-bound xpath on the compiled + eager path
2
+
3
+ Status: DONE (1.9.174.7)
4
+
5
+ TODO.perf/15 excluded namespace-bound evaluations from the
6
+ compiled-expression cache — they still pay the engine's string
7
+ parse per call (~2-3µs) on top of an already-cached ns set. The
8
+ engine has leptris_xpath_compiled_eval_ns; route the ns branch
9
+ of xpath/at_xpath through the compiled cache (the handle is
10
+ ns-independent — compile once) and the cached ns set, then let
11
+ the eager materializer (TODO.perf/22) take the result.
12
+
13
+ Gates: ns-bound results identical (prefix resolution, missing
14
+ prefixes raising as today); the version-pinned path untouched;
15
+ suite both modes.
16
+
17
+ ## Outcome (1.9.174.7)
18
+
19
+ The ns branch of xpath/at_xpath rides the compiled-expression
20
+ cache (the handle is ns-independent) + the cached ns set via
21
+ XPath#eval_ns_ptrs (leptris_xpath_compiled_eval_ns); results
22
+ flow into the eager materializer. Measured (load ~16): repeat
23
+ ns-xpath 7.9µs -> 3.1µs (2.5x).
@@ -0,0 +1,31 @@
1
+ # 30 — dup one-dispatch + first/last element child faces
2
+
3
+ Status: DONE (1.9.174.7)
4
+
5
+ Node#dup runs create + FFI element_copy + root= (three dispatches
6
+ plus the lift-guard walk) for an engine-copier op that is already
7
+ 2.3x — bind the whole copy: engine create + handle + element_copy
8
+ + root wrap in one face. And the element-child readers pay O(N):
9
+ first_element_child scans siblings with two FFI calls per
10
+ non-element (text-heavy parents), last_element_children batches
11
+ ALL children to keep one — one C walk each answers in a single
12
+ dispatch.
13
+
14
+ Gates: dup semantics (comment/PI children, namespaces — the
15
+ copy_of seam contract #696/#721/#812); first/last on mixed
16
+ content, empty elements, document receivers; suite both modes.
17
+
18
+ ## Outcome (1.9.174.7)
19
+
20
+ Native.copy_binding_element: engine create + lifetime handle +
21
+ document wrapper + element_copy + rooted+memo-seeded wrap in one
22
+ dispatch; the namespace-lift decision stays in Ruby (skip_
23
+ adoption_lift? + lift — the copy_of seam's #696/#721/#812
24
+ semantics), and the return shape matches copy_of's contract (the
25
+ copied root ELEMENT — my first version returned the document and
26
+ the probe caught it). Native.first/last_element_child: one C
27
+ walk each (the FFI scan paid two calls per skipped sibling; the
28
+ batch fetch materialized every child to keep one). Measured
29
+ (load ~16): ns-bearing element dup 37.0µs -> 9.6µs (3.9x);
30
+ first_element_child over 1000 mixed children 98ns (memo-hit
31
+ shape), last 3.2µs cold-walk.
@@ -87,8 +87,20 @@ static node_str_fn f_comment_content, f_cdata_content, f_pi_target,
87
87
  f_pi_data;
88
88
  typedef int (*set_str_fn)(void *, const char *);
89
89
  typedef int (*node_unlink_fn)(void *);
90
+ typedef int (*traverse_cb_fn)(void *, void *);
91
+ typedef int (*traverse_fn)(void *, int, traverse_cb_fn, void *);
92
+ typedef void (*visit_cb_fn)(void *, void *, int, int);
93
+ typedef void (*visit_fn)(void *, visit_cb_fn, void *);
94
+ typedef void (*free_str_fn)(void *);
95
+ typedef const char *(*node_xpath_fn)(void *);
96
+ typedef void *(*elem_copy_fn)(void *, void *);
90
97
  static set_str_fn f_elem_set_name, f_elem_set_text, f_text_set_content;
91
98
  static node_unlink_fn f_node_unlink;
99
+ static traverse_fn f_node_traverse;
100
+ static visit_fn f_node_visit;
101
+ static free_str_fn f_free_str;
102
+ static node_xpath_fn f_node_xpath;
103
+ static elem_copy_fn f_elem_copy;
92
104
  static set_root_fn f_set_root;
93
105
  static doc_free_fn f_doc_free;
94
106
 
@@ -105,6 +117,10 @@ static VALUE c_use_after_free_error;
105
117
  static VALUE native_cache_of(VALUE document);
106
118
  static void resolve_binding_classes(void);
107
119
  static VALUE binding_cache_of(VALUE document);
120
+ static VALUE binding_klass_for(int kind);
121
+ static VALUE c_iteration_scope;
122
+ static VALUE c_b_document, c_b_freed, c_ffi_pointer;
123
+ static ID id_ptr_new, id_freed_new, id_alive;
108
124
 
109
125
  #define NT_ELEMENT 0
110
126
 
@@ -200,6 +216,11 @@ static void resolve_symbols(const char *lib_path)
200
216
  f_elem_set_text = (set_str_fn)lib_sym(h, "leptris_element_set_text");
201
217
  f_text_set_content = (set_str_fn)lib_sym(h, "leptris_text_node_set_content");
202
218
  f_node_unlink = (node_unlink_fn)lib_sym(h, "leptris_node_unlink");
219
+ f_node_traverse = (traverse_fn)lib_sym(h, "leptris_node_traverse");
220
+ f_node_visit = (visit_fn)lib_sym(h, "leptris_node_visit");
221
+ f_free_str = (free_str_fn)lib_sym(h, "leptris_free_string");
222
+ f_node_xpath = (node_xpath_fn)lib_sym(h, "leptris_node_get_xpath");
223
+ f_elem_copy = (elem_copy_fn)lib_sym(h, "leptris_element_copy");
203
224
  f_set_root = (set_root_fn)lib_sym(h, "leptris_document_set_root");
204
225
  f_doc_free = (doc_free_fn)lib_sym(h, "leptris_document_free");
205
226
  if (!f_elem_name || !f_text_content || !f_attr ||
@@ -211,6 +232,8 @@ static void resolve_symbols(const char *lib_path)
211
232
  !f_comment_content || !f_cdata_content || !f_pi_target ||
212
233
  !f_pi_data || !f_elem_set_name || !f_elem_set_text ||
213
234
  !f_text_set_content || !f_node_unlink ||
235
+ !f_node_traverse || !f_node_visit || !f_free_str ||
236
+ !f_node_xpath || !f_elem_copy ||
214
237
  !f_doc_free ||
215
238
  !f_elem_prefix || !f_xp_count || !f_xp_nodes_ex ||
216
239
  !f_xp_node_kind || !f_xp_node_name || !f_xp_node_value ||
@@ -707,6 +730,277 @@ static VALUE nf_unlink_binding_node(VALUE self, VALUE document,
707
730
  return INT2FIX(st);
708
731
  }
709
732
 
733
+ /* ---- C-yield traversal (TODO.perf/27) ----------------------------
734
+ * Same contracts as the Ruby FFI::Function versions without the
735
+ * per-call closure: traverse is post-order with abort-at-self
736
+ * (the receiver is the LAST node of its subtree in post-order,
737
+ * so stopping at self bounds the walk exactly) and
738
+ * stash-abort-raise exception discipline; visit passes (node,
739
+ * entering, depth). Walk state lives on the caller's C stack —
740
+ * nested traversals are independent. */
741
+ struct trav_state {
742
+ VALUE document;
743
+ VALUE err;
744
+ void *self_ptr;
745
+ int for_visit;
746
+ };
747
+
748
+ static VALUE trav_yield_one(VALUE arg)
749
+ {
750
+ return rb_yield(arg);
751
+ }
752
+
753
+ static int trav_cb(void *node_ptr, void *user)
754
+ {
755
+ struct trav_state *st = user;
756
+ VALUE cache, key, node;
757
+ int kind, state;
758
+
759
+ if (st->err != Qnil)
760
+ return 1;
761
+ kind = f_node_type(node_ptr);
762
+ cache = binding_cache_of(st->document);
763
+ key = ULL2NUM((uint64_t)(uintptr_t)node_ptr);
764
+ node = rb_hash_aref(cache, key);
765
+ if (NIL_P(node)) {
766
+ node = rb_obj_alloc(binding_klass_for(kind));
767
+ rb_iv_set(node, "@c_address", key);
768
+ rb_iv_set(node, "@document", st->document);
769
+ rb_iv_set(node, "@parent", Qnil);
770
+ if (rb_obj_class(st->document) == c_iteration_scope) {
771
+ rb_iv_set(node, "@structure_memoizable", Qfalse);
772
+ rb_iv_set(node, "@native_fast", Qfalse);
773
+ rb_iv_set(node, "@pub_document", Qnil);
774
+ } else {
775
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
776
+ rb_iv_set(node, "@native_fast", Qtrue);
777
+ rb_iv_set(node, "@pub_document", st->document);
778
+ }
779
+ rb_iv_set(node, "@addr_reads_fast", Qtrue);
780
+ rb_iv_set(node, "@node_type", INT2FIX(kind));
781
+ rb_hash_aset(cache, key, node);
782
+ }
783
+ rb_protect(trav_yield_one, node, &state);
784
+ if (state) {
785
+ st->err = rb_errinfo();
786
+ return 1; /* abort the walk */
787
+ }
788
+ return node_ptr == st->self_ptr ? 1 : 0; /* abort-at-self */
789
+ }
790
+
791
+ static VALUE nf_traverse_binding(VALUE self, VALUE document,
792
+ VALUE addr)
793
+ {
794
+ struct trav_state st;
795
+
796
+ (void)self;
797
+ resolve_binding_classes();
798
+ st.document = document;
799
+ st.err = Qnil;
800
+ st.self_ptr = (void *)(uintptr_t)NUM2ULL(addr);
801
+ st.for_visit = 0;
802
+ f_node_traverse(st.self_ptr, 1 /* TRAVERSE_POST_ORDER */,
803
+ trav_cb, &st);
804
+ if (st.err != Qnil)
805
+ rb_exc_raise(st.err);
806
+ return Qnil;
807
+ }
808
+
809
+ struct visit_yield_args {
810
+ VALUE node, entering, depth;
811
+ };
812
+
813
+ static VALUE trav_yield_args(VALUE arg)
814
+ {
815
+ struct visit_yield_args *a = (struct visit_yield_args *)arg;
816
+ return rb_yield_values(3, a->node, a->entering, a->depth);
817
+ }
818
+
819
+ /* Engine visit callback order is (user, node, entering, depth) —
820
+ * the Ruby closure's |_, node_ptr, entering, depth| mirrors it. */
821
+ static void visit_cb(void *user, void *node_ptr, int entering,
822
+ int depth)
823
+ {
824
+ /* visit's engine signature cannot abort; exceptions are
825
+ * stashed and every later node is skipped — the raise
826
+ * happens after the walk, same observable outcome. */
827
+ struct trav_state *st = user;
828
+ struct visit_yield_args va;
829
+ VALUE cache, key, node;
830
+ int kind, state;
831
+
832
+ if (st->err != Qnil)
833
+ return;
834
+ kind = f_node_type(node_ptr);
835
+ cache = binding_cache_of(st->document);
836
+ key = ULL2NUM((uint64_t)(uintptr_t)node_ptr);
837
+ node = rb_hash_aref(cache, key);
838
+ if (NIL_P(node)) {
839
+ node = rb_obj_alloc(binding_klass_for(kind));
840
+ rb_iv_set(node, "@c_address", key);
841
+ rb_iv_set(node, "@document", st->document);
842
+ rb_iv_set(node, "@parent", Qnil);
843
+ if (rb_obj_class(st->document) == c_iteration_scope) {
844
+ rb_iv_set(node, "@structure_memoizable", Qfalse);
845
+ rb_iv_set(node, "@native_fast", Qfalse);
846
+ rb_iv_set(node, "@pub_document", Qnil);
847
+ } else {
848
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
849
+ rb_iv_set(node, "@native_fast", Qtrue);
850
+ rb_iv_set(node, "@pub_document", st->document);
851
+ }
852
+ rb_iv_set(node, "@addr_reads_fast", Qtrue);
853
+ rb_iv_set(node, "@node_type", INT2FIX(kind));
854
+ rb_hash_aset(cache, key, node);
855
+ }
856
+ va.node = node;
857
+ va.entering = entering ? Qtrue : Qfalse;
858
+ va.depth = INT2NUM(depth);
859
+ rb_protect(trav_yield_args, (VALUE)&va, &state);
860
+ if (state)
861
+ st->err = rb_errinfo();
862
+ }
863
+
864
+ static VALUE nf_visit_binding(VALUE self, VALUE document, VALUE addr)
865
+ {
866
+ struct trav_state st;
867
+
868
+ (void)self;
869
+ resolve_binding_classes();
870
+ st.document = document;
871
+ st.err = Qnil;
872
+ st.self_ptr = (void *)(uintptr_t)NUM2ULL(addr);
873
+ st.for_visit = 1;
874
+ f_node_visit(st.self_ptr, visit_cb, &st);
875
+ if (st.err != Qnil)
876
+ rb_exc_raise(st.err);
877
+ return Qnil;
878
+ }
879
+
880
+ /* ---- Address-based fills (TODO.perf/28) -------------------------
881
+ * Pure reads of node-local data — no cache, no version — the
882
+ * same shapes the inner_html pass uses. */
883
+ static VALUE nf_fast_text_content(VALUE self, VALUE addr)
884
+ {
885
+ const char *s;
886
+ (void)self;
887
+ s = f_text_content((void *)(uintptr_t)NUM2ULL(addr));
888
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
889
+ }
890
+
891
+ static VALUE nf_fast_comment_content(VALUE self, VALUE addr)
892
+ {
893
+ const char *s;
894
+ (void)self;
895
+ s = f_comment_content((void *)(uintptr_t)NUM2ULL(addr));
896
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
897
+ }
898
+
899
+ static VALUE nf_fast_cdata_content(VALUE self, VALUE addr)
900
+ {
901
+ const char *s;
902
+ (void)self;
903
+ s = f_cdata_content((void *)(uintptr_t)NUM2ULL(addr));
904
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
905
+ }
906
+
907
+ static VALUE nf_fast_pi_target(VALUE self, VALUE addr)
908
+ {
909
+ const char *s;
910
+ (void)self;
911
+ s = f_pi_target((void *)(uintptr_t)NUM2ULL(addr));
912
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
913
+ }
914
+
915
+ static VALUE nf_fast_pi_data(VALUE self, VALUE addr)
916
+ {
917
+ const char *s;
918
+ (void)self;
919
+ s = f_pi_data((void *)(uintptr_t)NUM2ULL(addr));
920
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
921
+ }
922
+
923
+ /* Shared construction for address-walk faces (TODO.perf/30):
924
+ * cache lookup + kind-dispatched binding wrapper. */
925
+ static VALUE wrap_addr_node(VALUE document, void *ptr)
926
+ {
927
+ VALUE cache, key, node;
928
+ int kind;
929
+
930
+ kind = f_node_type(ptr);
931
+ cache = binding_cache_of(document);
932
+ key = ULL2NUM((uint64_t)(uintptr_t)ptr);
933
+ node = rb_hash_aref(cache, key);
934
+ if (NIL_P(node)) {
935
+ node = rb_obj_alloc(binding_klass_for(kind));
936
+ rb_iv_set(node, "@c_address", key);
937
+ rb_iv_set(node, "@document", document);
938
+ rb_iv_set(node, "@parent", Qnil);
939
+ if (rb_obj_class(document) == c_iteration_scope) {
940
+ rb_iv_set(node, "@structure_memoizable", Qfalse);
941
+ rb_iv_set(node, "@native_fast", Qfalse);
942
+ rb_iv_set(node, "@pub_document", Qnil);
943
+ } else {
944
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
945
+ rb_iv_set(node, "@native_fast", Qtrue);
946
+ rb_iv_set(node, "@pub_document", document);
947
+ }
948
+ rb_iv_set(node, "@addr_reads_fast", Qtrue);
949
+ rb_iv_set(node, "@node_type", INT2FIX(kind));
950
+ rb_hash_aset(cache, key, node);
951
+ }
952
+ return node;
953
+ }
954
+
955
+ /* First element child in one C walk (text-heavy parents paid two
956
+ * FFI calls per skipped sibling). */
957
+ static VALUE nf_first_element_child(VALUE self, VALUE document,
958
+ VALUE addr)
959
+ {
960
+ void *p;
961
+
962
+ (void)self;
963
+ resolve_binding_classes();
964
+ for (p = f_first_child((void *)(uintptr_t)NUM2ULL(addr));
965
+ p != NULL; p = f_next_sibling(p)) {
966
+ if (f_node_type(p) == NT_ELEMENT)
967
+ return wrap_addr_node(document, p);
968
+ }
969
+ return Qnil;
970
+ }
971
+
972
+ /* Last element child in one C walk (the batch fetch materialized
973
+ * every child to keep one). */
974
+ static VALUE nf_last_element_child(VALUE self, VALUE document,
975
+ VALUE addr)
976
+ {
977
+ void *p, *last = NULL;
978
+
979
+ (void)self;
980
+ resolve_binding_classes();
981
+ for (p = f_first_child((void *)(uintptr_t)NUM2ULL(addr));
982
+ p != NULL; p = f_next_sibling(p)) {
983
+ if (f_node_type(p) == NT_ELEMENT)
984
+ last = p;
985
+ }
986
+ return last ? wrap_addr_node(document, last) : Qnil;
987
+ }
988
+
989
+ /* Owned string: copy + free through the engine's seam (the
990
+ * read_owned_string protocol). */
991
+ static VALUE nf_fast_path(VALUE self, VALUE addr)
992
+ {
993
+ const char *s;
994
+ VALUE out;
995
+ (void)self;
996
+ s = f_node_xpath((void *)(uintptr_t)NUM2ULL(addr));
997
+ if (!s)
998
+ return Qnil;
999
+ out = rb_utf8_str_new_cstr(s);
1000
+ f_free_str((void *)s);
1001
+ return out;
1002
+ }
1003
+
710
1004
  /* C-bound insert family (TODO.perf/14): prepend / insert-after /
711
1005
  * insert-before share append's gates + predicate + bump shape.
712
1006
  * mode: 1 prepend, 2 after, 3 before (anchor = receiver). */
@@ -1003,6 +1297,8 @@ static VALUE m_native_sentinel; /* module object = fallback marker */
1003
1297
  static VALUE materialize_xp_entry(VALUE document, VALUE cache,
1004
1298
  void *result, void *ptr, int kind,
1005
1299
  int idx);
1300
+ static VALUE binding_klass_for(int kind);
1301
+ static VALUE c_iteration_scope;
1006
1302
 
1007
1303
  static VALUE bulk_xpath_array(VALUE document, void *result);
1008
1304
 
@@ -1512,6 +1808,48 @@ static const rb_data_type_t dh_type = {
1512
1808
  static VALUE c_doc_handle;
1513
1809
  static ID id_iv_doc_handle, id_freed_new, id_alive;
1514
1810
 
1811
+ /* Node#dup in one dispatch (TODO.perf/30): engine create +
1812
+ * lifetime handle + document wrapper + element_copy + the copy
1813
+ * wrapped-and-rooted (memo-seeded). The namespace lift stays a
1814
+ * Ruby decision (skip_adoption_lift? — the copy_of seam's
1815
+ * semantics, #696/#721/#812). */
1816
+ static VALUE nf_copy_binding_element(VALUE self, VALUE document,
1817
+ VALUE addr)
1818
+ {
1819
+ void *doc, *copy;
1820
+ VALUE new_doc, doc_addr, handle, freed, root;
1821
+ struct doc_handle *h;
1822
+
1823
+ (void)self;
1824
+ resolve_binding_classes();
1825
+ doc = f_doc_create();
1826
+ if (!doc)
1827
+ return Qnil;
1828
+ doc_addr = ULL2NUM((uint64_t)(uintptr_t)doc);
1829
+ new_doc = rb_obj_alloc(c_b_document);
1830
+ rb_iv_set(new_doc, "@c_ptr",
1831
+ rb_funcall(c_ffi_pointer, id_ptr_new, 1, doc_addr));
1832
+ rb_iv_set(new_doc, "@c_address", doc_addr);
1833
+ freed = rb_funcall(c_b_freed, id_freed_new, 1, ID2SYM(id_alive));
1834
+ rb_iv_set(new_doc, "@freed", freed);
1835
+ rb_iv_set(new_doc, "@readonly", Qfalse);
1836
+ rb_iv_set(new_doc, "@version", INT2FIX(0));
1837
+ handle = TypedData_Make_Struct(c_doc_handle, struct doc_handle,
1838
+ &dh_type, h);
1839
+ h->doc = doc;
1840
+ rb_ivar_set(new_doc, id_iv_doc_handle, handle);
1841
+
1842
+ copy = f_elem_copy((void *)(uintptr_t)NUM2ULL(addr), doc);
1843
+ if (!copy) {
1844
+ h->doc = NULL; /* the handle releases the empty doc */
1845
+ return Qnil;
1846
+ }
1847
+ root = wrap_addr_node(new_doc, copy);
1848
+ rb_ivar_set(new_doc, rb_intern("@root"), root);
1849
+ rb_ivar_set(new_doc, rb_intern("@root_version"), INT2FIX(0));
1850
+ return new_doc;
1851
+ }
1852
+
1515
1853
  static VALUE nf_doc_handle_attach(VALUE self, VALUE document)
1516
1854
  {
1517
1855
  struct doc_handle *h;
@@ -1711,6 +2049,28 @@ void Init_native(void)
1711
2049
  nf_set_binding_text, 3);
1712
2050
  rb_define_module_function(m_native, "unlink_binding_node",
1713
2051
  nf_unlink_binding_node, 2);
2052
+ rb_define_module_function(m_native, "traverse_binding",
2053
+ nf_traverse_binding, 2);
2054
+ rb_define_module_function(m_native, "visit_binding",
2055
+ nf_visit_binding, 2);
2056
+ rb_define_module_function(m_native, "fast_text_content",
2057
+ nf_fast_text_content, 1);
2058
+ rb_define_module_function(m_native, "fast_comment_content",
2059
+ nf_fast_comment_content, 1);
2060
+ rb_define_module_function(m_native, "fast_cdata_content",
2061
+ nf_fast_cdata_content, 1);
2062
+ rb_define_module_function(m_native, "fast_pi_target",
2063
+ nf_fast_pi_target, 1);
2064
+ rb_define_module_function(m_native, "fast_pi_data",
2065
+ nf_fast_pi_data, 1);
2066
+ rb_define_module_function(m_native, "fast_path",
2067
+ nf_fast_path, 1);
2068
+ rb_define_module_function(m_native, "first_element_child",
2069
+ nf_first_element_child, 2);
2070
+ rb_define_module_function(m_native, "last_element_child",
2071
+ nf_last_element_child, 2);
2072
+ rb_define_module_function(m_native, "copy_binding_element",
2073
+ nf_copy_binding_element, 2);
1714
2074
  rb_define_module_function(m_native, "fast_inner_xml",
1715
2075
  nf_fast_inner_xml, 1);
1716
2076
  rb_define_module_function(m_native, "doc_handle_attach",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.174.5"
4
+ VERSION = "1.9.174.7"
5
5
  end
@@ -6,7 +6,11 @@ class Leptris::XML::CDATA < Leptris::XML::Text
6
6
  def content
7
7
  return @content if memo_hit?(@content_version)
8
8
  ensure_alive!
9
- result = Leptris::XML::FFI.leptris_cdata_node_get_content(c_ptr)
9
+ result = if @addr_reads_fast
10
+ Leptris::XML::Native.fast_cdata_content(@c_address)
11
+ else
12
+ Leptris::XML::FFI.leptris_cdata_node_get_content(c_ptr)
13
+ end
10
14
  if @document
11
15
  @content = result
12
16
  @content_version = @document.version
@@ -6,7 +6,11 @@ class Leptris::XML::Comment < Leptris::XML::Node
6
6
  def content
7
7
  return @content if memo_hit?(@content_version)
8
8
  ensure_alive!
9
- result = Leptris::XML::FFI.leptris_comment_node_get_content(c_ptr)
9
+ result = if @addr_reads_fast
10
+ Leptris::XML::Native.fast_comment_content(@c_address)
11
+ else
12
+ Leptris::XML::FFI.leptris_comment_node_get_content(c_ptr)
13
+ end
10
14
  if @document
11
15
  @content = result
12
16
  @content_version = @document.version
@@ -260,7 +260,9 @@ class Leptris::XML::Element < Leptris::XML::Node
260
260
  # Require the full Attr-hash face — a cold [] may have set the
261
261
  # version with only a partial @attr_values (ruby#150).
262
262
  return @attributes if @attributes && memo_hit?(@attributes_version)
263
- if native_fast?
263
+ # TODO.perf/28: the bulk faces touch no document state —
264
+ # scope-eligible via @addr_reads_fast.
265
+ if @addr_reads_fast
264
266
  # TODO.perf/10: both memo faces in one C walk.
265
267
  result, values = Leptris::XML::Native.bulk_attr_faces(
266
268
  @c_address, self)
@@ -495,9 +497,21 @@ class Leptris::XML::Element < Leptris::XML::Node
495
497
 
496
498
  # Deep copy in a NEW document via Document.copy_of (the single
497
499
  # copy seam — every child kind and namespace survives,
498
- # #696/#721/#812).
500
+ # #696/#721/#812). TODO.perf/30: one C dispatch when the ext
501
+ # is loaded — the namespace-lift decision stays here.
499
502
  def dup
500
503
  ensure_alive!
504
+ if @addr_reads_fast
505
+ doc = Leptris::XML::Native.copy_binding_element(
506
+ @document, @c_address)
507
+ unless doc.nil?
508
+ copied = doc.root
509
+ unless Leptris::XML::Element.skip_adoption_lift?(copied)
510
+ Leptris::XML::Element.lift_namespaces_for_adoption(copied, {})
511
+ end
512
+ return copied
513
+ end
514
+ end
501
515
  Leptris::XML::Document.copy_of(self)
502
516
  end
503
517
  alias_method :clone, :dup
@@ -390,6 +390,17 @@ class Leptris::XML::Node
390
390
  def first_element_child
391
391
  return @first_element_child if memo_hit?(@first_element_child_version)
392
392
  ensure_alive!
393
+ # TODO.perf/30: one C walk (the FFI scan paid two calls per
394
+ # skipped non-element sibling).
395
+ if @addr_reads_fast
396
+ result = Leptris::XML::Native.first_element_child(
397
+ @document, @c_address)
398
+ if @document
399
+ @first_element_child = result
400
+ @first_element_child_version = @document.version
401
+ end
402
+ return result
403
+ end
393
404
  # Raw pointer scan: non-element siblings are typed with one C
394
405
  # call each — never wrapped, never cached — and the found
395
406
  # element carries the ELEMENT hint into the wrap.
@@ -413,8 +424,12 @@ class Leptris::XML::Node
413
424
  end
414
425
 
415
426
  def last_element_child
416
- # Element receivers: the element-only batch fetches pointers
417
- # without wrapping any text child; only the last is wrapped.
427
+ # TODO.perf/30: one C walk — the batch fetch materialized
428
+ # every child pointer to keep one.
429
+ if @addr_reads_fast
430
+ return Leptris::XML::Native.last_element_child(
431
+ @document, @c_address)
432
+ end
418
433
  if is_a?(Leptris::XML::Element)
419
434
  kids = Leptris::XML::FFI.fetch_element_children(c_ptr)
420
435
  return nil if kids.empty?
@@ -492,6 +507,13 @@ class Leptris::XML::Node
492
507
  def visit(&block)
493
508
  return enum_for(:visit) unless block
494
509
  ensure_alive!
510
+ # TODO.perf/27: the ext's callback rb_yields directly — no
511
+ # FFI::Function closure per call.
512
+ if @addr_reads_fast
513
+ Leptris::XML::Native.visit_binding(@document, @c_address,
514
+ &block)
515
+ return self
516
+ end
495
517
  document = @document
496
518
  visitor = ::FFI::Function.new(
497
519
  :void, [:pointer, :pointer, :int, :int], blocking: true) do |_, node_ptr, entering, depth|
@@ -524,9 +546,18 @@ class Leptris::XML::Node
524
546
  # exception and returns non-zero, aborting the C walk — without
525
547
  # it the FFI dispatch silently swallowed the exception and the
526
548
  # walk continued with partially processed data.
527
- def traverse
528
- return enum_for(:traverse) unless block_given?
549
+ def traverse(&block)
550
+ return enum_for(:traverse) unless block
529
551
  ensure_alive!
552
+ # TODO.perf/27: post-order + abort-at-self + stash-abort-raise
553
+ # all preserved in the C callback (walk state on its stack).
554
+ # The block forwards explicitly — rb_yield needs it on the C
555
+ # entry's own frame.
556
+ if @addr_reads_fast
557
+ Leptris::XML::Native.traverse_binding(@document, @c_address,
558
+ &block)
559
+ return self
560
+ end
530
561
  error = nil
531
562
  self_address = @c_address
532
563
  callback = ::FFI::Function.new(:int, [:pointer, :pointer], blocking: true) do |node_ptr, _|
@@ -547,8 +578,12 @@ class Leptris::XML::Node
547
578
  def path
548
579
  return @path if memo_hit?(@path_version)
549
580
  ensure_alive!
550
- str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(c_ptr)
551
- result = str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
581
+ result = if @addr_reads_fast
582
+ Leptris::XML::Native.fast_path(@c_address)
583
+ else
584
+ str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(c_ptr)
585
+ str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
586
+ end
552
587
  if @document
553
588
  @path = result
554
589
  @path_version = @document.version
@@ -580,6 +615,23 @@ class Leptris::XML::Node
580
615
  # non-element kinds rebuild by value — #161).
581
616
  def dup
582
617
  ensure_alive!
618
+ # TODO.perf/30: elements copy in one C dispatch (engine
619
+ # create + handle + element_copy + rooted wrap). The
620
+ # namespace lift stays a Ruby decision — the copy_of seam's
621
+ # semantics (#696/#721/#812) run below when needed.
622
+ if element? && @addr_reads_fast
623
+ doc = Leptris::XML::Native.copy_binding_element(
624
+ @document, @c_address)
625
+ unless doc.nil?
626
+ # copy_of's contract returns the copied ROOT (the last
627
+ # expression of the root= assignment), not the document.
628
+ copied = doc.root
629
+ unless Leptris::XML::Element.skip_adoption_lift?(copied)
630
+ Leptris::XML::Element.lift_namespaces_for_adoption(copied, {})
631
+ end
632
+ return copied
633
+ end
634
+ end
583
635
  Leptris::XML::Document.copy_of(self)
584
636
  end
585
637
  alias_method :clone, :dup
@@ -4,7 +4,11 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
4
4
  def name
5
5
  return @name if memo_hit?(@name_version)
6
6
  ensure_alive!
7
- result = Leptris::XML::FFI.leptris_pi_node_get_target(c_ptr)
7
+ result = if @addr_reads_fast
8
+ Leptris::XML::Native.fast_pi_target(@c_address)
9
+ else
10
+ Leptris::XML::FFI.leptris_pi_node_get_target(c_ptr)
11
+ end
8
12
  if @document
9
13
  @name = result
10
14
  @name_version = @document.version
@@ -16,8 +20,12 @@ class Leptris::XML::ProcessingInstruction < Leptris::XML::Node
16
20
  def content
17
21
  return @content if memo_hit?(@content_version)
18
22
  ensure_alive!
19
- result = Leptris::XML::FFI.read_pi_data(
20
- Leptris::XML::FFI.leptris_pi_node_get_data(c_ptr)).to_s
23
+ raw = if @addr_reads_fast
24
+ Leptris::XML::Native.fast_pi_data(@c_address)
25
+ else
26
+ Leptris::XML::FFI.leptris_pi_node_get_data(c_ptr)
27
+ end
28
+ result = Leptris::XML::FFI.read_pi_data(raw).to_s
21
29
  if @document
22
30
  @content = result
23
31
  @content_version = @document.version
@@ -69,7 +69,16 @@ module Leptris::XML::Searchable
69
69
  doc_ptr, context_ptr, expr,
70
70
  Leptris::XML::Searchable.xpath_version_code(version), nil)
71
71
  elsif ns && !ns.empty?
72
- xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
72
+ # TODO.perf/29: the compiled handle is ns-independent —
73
+ # ride the cache and the cached ns set instead of the
74
+ # engine's string parse per call.
75
+ if (compiled = Leptris::XML::Searchable.compiled_expression(expr))
76
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
77
+ compiled.eval_ns_ptrs(doc_ptr, context_ptr, set)
78
+ end
79
+ else
80
+ xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
81
+ end
73
82
  elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
74
83
  compiled.eval_ptrs(doc_ptr, context_ptr)
75
84
  else
@@ -105,7 +114,16 @@ module Leptris::XML::Searchable
105
114
  doc_ptr, context_ptr, expr,
106
115
  Leptris::XML::Searchable.xpath_version_code(version), nil)
107
116
  elsif ns && !ns.empty?
108
- xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
117
+ # TODO.perf/29: the compiled handle is ns-independent —
118
+ # ride the cache and the cached ns set instead of the
119
+ # engine's string parse per call.
120
+ if (compiled = Leptris::XML::Searchable.compiled_expression(expr))
121
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
122
+ compiled.eval_ns_ptrs(doc_ptr, context_ptr, set)
123
+ end
124
+ else
125
+ xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
126
+ end
109
127
  elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
110
128
  compiled.eval_ptrs(doc_ptr, context_ptr)
111
129
  else
@@ -6,7 +6,11 @@ class Leptris::XML::Text < Leptris::XML::Node
6
6
  def content
7
7
  return @content if memo_hit?(@content_version)
8
8
  ensure_alive!
9
- result = Leptris::XML::FFI.leptris_text_node_get_content(c_ptr)
9
+ result = if @addr_reads_fast
10
+ Leptris::XML::Native.fast_text_content(@c_address)
11
+ else
12
+ Leptris::XML::FFI.leptris_text_node_get_content(c_ptr)
13
+ end
10
14
  if @document
11
15
  @content = result
12
16
  @content_version = @document.version
@@ -40,6 +40,14 @@ class Leptris::XML::XPath
40
40
  @handle, doc_ptr, context_ptr)
41
41
  end
42
42
 
43
+ # Namespace-bound evaluation against the compiled handle
44
+ # (TODO.perf/29): the handle is ns-independent — compile once,
45
+ # bind per call through the cached ns set.
46
+ def eval_ns_ptrs(doc_ptr, context_ptr, ns_set_ptr)
47
+ Leptris::XML::FFI.leptris_xpath_compiled_eval_ns(
48
+ @handle, doc_ptr, context_ptr, ns_set_ptr)
49
+ end
50
+
43
51
  def initialize(expression, handle, version_code = nil)
44
52
  @expression = expression
45
53
  @handle = handle
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.174.5
4
+ version: 1.9.174.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -95,6 +95,10 @@ files:
95
95
  - TODO.perf/24-immutable-read-lanes.md
96
96
  - TODO.perf/25-scope-owned-bulk-path.md
97
97
  - TODO.perf/26-post-mutation-memo-seeding.md
98
+ - TODO.perf/27-c-yield-traversal.md
99
+ - TODO.perf/28-address-fills.md
100
+ - TODO.perf/29-ns-xpath-compiled.md
101
+ - TODO.perf/30-copy-and-element-child-faces.md
98
102
  - TODO.restructure/01-constraint-compliance-audit.md
99
103
  - TODO.restructure/02-deep-copy-seam.md
100
104
  - TODO.restructure/03-evaluation-context-seam.md