leptris 1.9.174.4 → 1.9.174.6

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: 3052fa875df89194fa904610e20786deaf2076de881242382afe9bbc9fd7fa0d
4
- data.tar.gz: 86806a382948d948d2ec5c2550302a8ae52b976edd224aaff4c130f3a4606f3e
3
+ metadata.gz: 0ac7143c5a7730e13077c52448c2b90a7705959a4fe458e7c29e7782d7ee6df1
4
+ data.tar.gz: 23d3530348907214d341704fa7b098728fa8b677422c8f595e12ae33c2b88ff3
5
5
  SHA512:
6
- metadata.gz: 81e9fe81a94ddd6d82ef044569b8d43ff5c663ca41cf2a59798660638e4f73cfc23f7aabf4eb3123757e3590d0d7524438f378c6b1bd447a2b39af95bb0089d5
7
- data.tar.gz: e161ced4c4ab3634ae6a4fa6ebb0e95e970e1634b72a9d1b4ef5e1ad3800461170a2893bb89477c404f22b992813b7abf66abc083272e851c341affc699488fc
6
+ metadata.gz: 6395fec9ab3701df1b77f32bbe950feff8b918b9f79374b5c107d9e282bb5e9e5fdd91c152dc46526fc201910e69b7c57e8ee968d11e222be3cfdd346c6c7ab8
7
+ data.tar.gz: a55e0b100034203af05855e8881fbfa161d30d6043b3831080ebff8d0f9ca658ca74fd11e1d30aef6887b3108493e4f690f0e08133ca4cf3bca36399cbcacdc4
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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.6] - 2026-09-15
9
+
10
+ ### Changed
11
+
12
+ - **C-yield traversal (TODO.perf/27)**: `traverse` and `visit`
13
+ no longer allocate an FFI::Function closure per call — the
14
+ ext's callbacks rb_yield directly, preserving post-order +
15
+ abort-at-self + stash-abort-raise (traverse) and the
16
+ (node, entering, depth) visit contract. Measured (load 34):
17
+ **traverse over 8k nodes 1663.5µs → 379.9µs (4.4x; 6.81x
18
+ Nokogiri); visit 2288.6µs → 545.4µs (4.2x)**.
19
+ - **Address-based first-touch fills (TODO.perf/28)**:
20
+ text/comment/CDATA/PI content and `path` fill through address
21
+ faces (scope-eligible); the bulk attribute faces accept
22
+ scope-owned elements.
23
+
24
+ ## [1.9.174.5] - 2026-09-15
25
+
26
+ ### Changed
27
+
28
+ - **Iterparse rides the bulk path (TODO.perf/25)**: scope-owned
29
+ elements materialize children through the one-pass C faces —
30
+ the IterationScope flows as the cache/version authority (its
31
+ cache resets per yield, so recycled pool addresses cannot
32
+ collide), with scope-aware wrapper stamps; pure address-based
33
+ reads (attribute/text/name) become scope-eligible. Mutations
34
+ on scope elements keep the Ruby path. Measured (load ~6): the
35
+ 20k-record streaming walk 124.8ms → 100.5ms (~20%).
36
+ - **Post-mutation memo seeding (TODO.perf/26)**: []= seeds the
37
+ attribute memo at the new version (partial memos extend,
38
+ full faces restart — the cold-[] rules); content= seeds
39
+ @content; the write-then-read cycle runs ~545ns combined.
40
+
8
41
  ## [1.9.174.4] - 2026-09-15
9
42
 
10
43
  ### Changed
@@ -0,0 +1,39 @@
1
+ # 25 — Scope-owned (iterparse) elements on the bulk/native read path
2
+
3
+ Status: DONE (1.9.174.5)
4
+
5
+ iterparse is moxml's streaming core, yet scope-owned elements
6
+ ride the FFI read path (two-call fetch + per-child Ruby wrap
7
+ frames; #4ca5b3c kept them off deliberately). The IterationScope
8
+ exposes exactly the seams the C faces read — @wrapper_cache
9
+ (reset per yield by new_subtree!, killing recycled-address
10
+ entries) and @version (advances per yield and on mutation) — so
11
+ the bulk children faces can take the SCOPE as their "document"
12
+ argument safely, provided the constructed wrappers are stamped
13
+ scope-aware: @structure_memoizable/@native_fast false,
14
+ @pub_document nil.
15
+
16
+ Pure address-based reads (fast_attribute / element text / name)
17
+ touch no cache — scope elements can use them via a construction
18
+ -time @addr_reads_fast that ignores scope ownership.
19
+
20
+ Gates: iterparse children/element_children identical via the C
21
+ path; identity holds within a yielded subtree and resets across
22
+ yields (the new_subtree! discipline); document stays nil for
23
+ scope yields; mutations on scope elements keep the Ruby path;
24
+ suite both modes.
25
+
26
+ ## Outcome (1.9.174.5)
27
+
28
+ The bulk children faces take the IterationScope itself as their
29
+ "document" (its @wrapper_cache resets per yield via new_subtree!
30
+ — recycled pool addresses cannot collide — and its @version
31
+ advances per yield and on mutation); the C face stamps
32
+ scope-aware (@structure_memoizable/@native_fast false,
33
+ @pub_document nil) while document-owned stamps stay Qtrue.
34
+ Pure address-based reads (fast_attribute / element text / name)
35
+ switch to @addr_reads_fast — true for scope elements too (they
36
+ touch no cache). Mutations on scope elements keep the Ruby path.
37
+ Measured (load ~6): the 20k-record iterparse walk (attr + child
38
+ name + content per record) 124.8ms (FFI) -> 100.5ms (native
39
+ bulk) — ~20%.
@@ -0,0 +1,26 @@
1
+ # 26 — Post-mutation memo seeding
2
+
3
+ Status: DONE (1.9.174.5)
4
+
5
+ Every mutation invalidates the read memos through the version
6
+ bump, so a write-then-read cycle pays a full re-derivation even
7
+ when the new value is known at write time. Seed it: Element#[]=
8
+ re-stamps @attr_values at the new version and writes the value
9
+ into it (a partial fill stays partial — same rules); content=
10
+ stores the new text as the @content memo at the new version;
11
+ name= already seeds @name.
12
+
13
+ Gates: []=/content=/name= followed by reads answers from the
14
+ seeded memo (spec-observable via identity for strings? — no,
15
+ values only); mixed attribute sets then attributes() still
16
+ rebuild the full face on demand; invalidation semantics
17
+ unchanged for other mutators.
18
+
19
+ ## Outcome (1.9.174.5)
20
+
21
+ Element#[]= seeds the version-stamped @attr_values at the new
22
+ version (a just-invalidated valid partial memo extends in place;
23
+ a full face or anything older restarts partial, mirroring the
24
+ cold-[] rules); content= seeds @content; name= seeds @name
25
+ (already). Measured: the []= + [] read-back cycle runs ~545ns
26
+ (write + read combined).
@@ -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).
@@ -87,8 +87,18 @@ 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 *);
90
96
  static set_str_fn f_elem_set_name, f_elem_set_text, f_text_set_content;
91
97
  static node_unlink_fn f_node_unlink;
98
+ static traverse_fn f_node_traverse;
99
+ static visit_fn f_node_visit;
100
+ static free_str_fn f_free_str;
101
+ static node_xpath_fn f_node_xpath;
92
102
  static set_root_fn f_set_root;
93
103
  static doc_free_fn f_doc_free;
94
104
 
@@ -105,6 +115,8 @@ static VALUE c_use_after_free_error;
105
115
  static VALUE native_cache_of(VALUE document);
106
116
  static void resolve_binding_classes(void);
107
117
  static VALUE binding_cache_of(VALUE document);
118
+ static VALUE binding_klass_for(int kind);
119
+ static VALUE c_iteration_scope;
108
120
 
109
121
  #define NT_ELEMENT 0
110
122
 
@@ -200,6 +212,10 @@ static void resolve_symbols(const char *lib_path)
200
212
  f_elem_set_text = (set_str_fn)lib_sym(h, "leptris_element_set_text");
201
213
  f_text_set_content = (set_str_fn)lib_sym(h, "leptris_text_node_set_content");
202
214
  f_node_unlink = (node_unlink_fn)lib_sym(h, "leptris_node_unlink");
215
+ f_node_traverse = (traverse_fn)lib_sym(h, "leptris_node_traverse");
216
+ f_node_visit = (visit_fn)lib_sym(h, "leptris_node_visit");
217
+ f_free_str = (free_str_fn)lib_sym(h, "leptris_free_string");
218
+ f_node_xpath = (node_xpath_fn)lib_sym(h, "leptris_node_get_xpath");
203
219
  f_set_root = (set_root_fn)lib_sym(h, "leptris_document_set_root");
204
220
  f_doc_free = (doc_free_fn)lib_sym(h, "leptris_document_free");
205
221
  if (!f_elem_name || !f_text_content || !f_attr ||
@@ -211,6 +227,8 @@ static void resolve_symbols(const char *lib_path)
211
227
  !f_comment_content || !f_cdata_content || !f_pi_target ||
212
228
  !f_pi_data || !f_elem_set_name || !f_elem_set_text ||
213
229
  !f_text_set_content || !f_node_unlink ||
230
+ !f_node_traverse || !f_node_visit || !f_free_str ||
231
+ !f_node_xpath ||
214
232
  !f_doc_free ||
215
233
  !f_elem_prefix || !f_xp_count || !f_xp_nodes_ex ||
216
234
  !f_xp_node_kind || !f_xp_node_name || !f_xp_node_value ||
@@ -707,6 +725,211 @@ static VALUE nf_unlink_binding_node(VALUE self, VALUE document,
707
725
  return INT2FIX(st);
708
726
  }
709
727
 
728
+ /* ---- C-yield traversal (TODO.perf/27) ----------------------------
729
+ * Same contracts as the Ruby FFI::Function versions without the
730
+ * per-call closure: traverse is post-order with abort-at-self
731
+ * (the receiver is the LAST node of its subtree in post-order,
732
+ * so stopping at self bounds the walk exactly) and
733
+ * stash-abort-raise exception discipline; visit passes (node,
734
+ * entering, depth). Walk state lives on the caller's C stack —
735
+ * nested traversals are independent. */
736
+ struct trav_state {
737
+ VALUE document;
738
+ VALUE err;
739
+ void *self_ptr;
740
+ int for_visit;
741
+ };
742
+
743
+ static VALUE trav_yield_one(VALUE arg)
744
+ {
745
+ return rb_yield(arg);
746
+ }
747
+
748
+ static int trav_cb(void *node_ptr, void *user)
749
+ {
750
+ struct trav_state *st = user;
751
+ VALUE cache, key, node;
752
+ int kind, state;
753
+
754
+ if (st->err != Qnil)
755
+ return 1;
756
+ kind = f_node_type(node_ptr);
757
+ cache = binding_cache_of(st->document);
758
+ key = ULL2NUM((uint64_t)(uintptr_t)node_ptr);
759
+ node = rb_hash_aref(cache, key);
760
+ if (NIL_P(node)) {
761
+ node = rb_obj_alloc(binding_klass_for(kind));
762
+ rb_iv_set(node, "@c_address", key);
763
+ rb_iv_set(node, "@document", st->document);
764
+ rb_iv_set(node, "@parent", Qnil);
765
+ if (rb_obj_class(st->document) == c_iteration_scope) {
766
+ rb_iv_set(node, "@structure_memoizable", Qfalse);
767
+ rb_iv_set(node, "@native_fast", Qfalse);
768
+ rb_iv_set(node, "@pub_document", Qnil);
769
+ } else {
770
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
771
+ rb_iv_set(node, "@native_fast", Qtrue);
772
+ rb_iv_set(node, "@pub_document", st->document);
773
+ }
774
+ rb_iv_set(node, "@addr_reads_fast", Qtrue);
775
+ rb_iv_set(node, "@node_type", INT2FIX(kind));
776
+ rb_hash_aset(cache, key, node);
777
+ }
778
+ rb_protect(trav_yield_one, node, &state);
779
+ if (state) {
780
+ st->err = rb_errinfo();
781
+ return 1; /* abort the walk */
782
+ }
783
+ return node_ptr == st->self_ptr ? 1 : 0; /* abort-at-self */
784
+ }
785
+
786
+ static VALUE nf_traverse_binding(VALUE self, VALUE document,
787
+ VALUE addr)
788
+ {
789
+ struct trav_state st;
790
+
791
+ (void)self;
792
+ resolve_binding_classes();
793
+ st.document = document;
794
+ st.err = Qnil;
795
+ st.self_ptr = (void *)(uintptr_t)NUM2ULL(addr);
796
+ st.for_visit = 0;
797
+ f_node_traverse(st.self_ptr, 1 /* TRAVERSE_POST_ORDER */,
798
+ trav_cb, &st);
799
+ if (st.err != Qnil)
800
+ rb_exc_raise(st.err);
801
+ return Qnil;
802
+ }
803
+
804
+ struct visit_yield_args {
805
+ VALUE node, entering, depth;
806
+ };
807
+
808
+ static VALUE trav_yield_args(VALUE arg)
809
+ {
810
+ struct visit_yield_args *a = (struct visit_yield_args *)arg;
811
+ return rb_yield_values(3, a->node, a->entering, a->depth);
812
+ }
813
+
814
+ /* Engine visit callback order is (user, node, entering, depth) —
815
+ * the Ruby closure's |_, node_ptr, entering, depth| mirrors it. */
816
+ static void visit_cb(void *user, void *node_ptr, int entering,
817
+ int depth)
818
+ {
819
+ /* visit's engine signature cannot abort; exceptions are
820
+ * stashed and every later node is skipped — the raise
821
+ * happens after the walk, same observable outcome. */
822
+ struct trav_state *st = user;
823
+ struct visit_yield_args va;
824
+ VALUE cache, key, node;
825
+ int kind, state;
826
+
827
+ if (st->err != Qnil)
828
+ return;
829
+ kind = f_node_type(node_ptr);
830
+ cache = binding_cache_of(st->document);
831
+ key = ULL2NUM((uint64_t)(uintptr_t)node_ptr);
832
+ node = rb_hash_aref(cache, key);
833
+ if (NIL_P(node)) {
834
+ node = rb_obj_alloc(binding_klass_for(kind));
835
+ rb_iv_set(node, "@c_address", key);
836
+ rb_iv_set(node, "@document", st->document);
837
+ rb_iv_set(node, "@parent", Qnil);
838
+ if (rb_obj_class(st->document) == c_iteration_scope) {
839
+ rb_iv_set(node, "@structure_memoizable", Qfalse);
840
+ rb_iv_set(node, "@native_fast", Qfalse);
841
+ rb_iv_set(node, "@pub_document", Qnil);
842
+ } else {
843
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
844
+ rb_iv_set(node, "@native_fast", Qtrue);
845
+ rb_iv_set(node, "@pub_document", st->document);
846
+ }
847
+ rb_iv_set(node, "@addr_reads_fast", Qtrue);
848
+ rb_iv_set(node, "@node_type", INT2FIX(kind));
849
+ rb_hash_aset(cache, key, node);
850
+ }
851
+ va.node = node;
852
+ va.entering = entering ? Qtrue : Qfalse;
853
+ va.depth = INT2NUM(depth);
854
+ rb_protect(trav_yield_args, (VALUE)&va, &state);
855
+ if (state)
856
+ st->err = rb_errinfo();
857
+ }
858
+
859
+ static VALUE nf_visit_binding(VALUE self, VALUE document, VALUE addr)
860
+ {
861
+ struct trav_state st;
862
+
863
+ (void)self;
864
+ resolve_binding_classes();
865
+ st.document = document;
866
+ st.err = Qnil;
867
+ st.self_ptr = (void *)(uintptr_t)NUM2ULL(addr);
868
+ st.for_visit = 1;
869
+ f_node_visit(st.self_ptr, visit_cb, &st);
870
+ if (st.err != Qnil)
871
+ rb_exc_raise(st.err);
872
+ return Qnil;
873
+ }
874
+
875
+ /* ---- Address-based fills (TODO.perf/28) -------------------------
876
+ * Pure reads of node-local data — no cache, no version — the
877
+ * same shapes the inner_html pass uses. */
878
+ static VALUE nf_fast_text_content(VALUE self, VALUE addr)
879
+ {
880
+ const char *s;
881
+ (void)self;
882
+ s = f_text_content((void *)(uintptr_t)NUM2ULL(addr));
883
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
884
+ }
885
+
886
+ static VALUE nf_fast_comment_content(VALUE self, VALUE addr)
887
+ {
888
+ const char *s;
889
+ (void)self;
890
+ s = f_comment_content((void *)(uintptr_t)NUM2ULL(addr));
891
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
892
+ }
893
+
894
+ static VALUE nf_fast_cdata_content(VALUE self, VALUE addr)
895
+ {
896
+ const char *s;
897
+ (void)self;
898
+ s = f_cdata_content((void *)(uintptr_t)NUM2ULL(addr));
899
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
900
+ }
901
+
902
+ static VALUE nf_fast_pi_target(VALUE self, VALUE addr)
903
+ {
904
+ const char *s;
905
+ (void)self;
906
+ s = f_pi_target((void *)(uintptr_t)NUM2ULL(addr));
907
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
908
+ }
909
+
910
+ static VALUE nf_fast_pi_data(VALUE self, VALUE addr)
911
+ {
912
+ const char *s;
913
+ (void)self;
914
+ s = f_pi_data((void *)(uintptr_t)NUM2ULL(addr));
915
+ return s ? rb_utf8_str_new_cstr(s) : Qnil;
916
+ }
917
+
918
+ /* Owned string: copy + free through the engine's seam (the
919
+ * read_owned_string protocol). */
920
+ static VALUE nf_fast_path(VALUE self, VALUE addr)
921
+ {
922
+ const char *s;
923
+ VALUE out;
924
+ (void)self;
925
+ s = f_node_xpath((void *)(uintptr_t)NUM2ULL(addr));
926
+ if (!s)
927
+ return Qnil;
928
+ out = rb_utf8_str_new_cstr(s);
929
+ f_free_str((void *)s);
930
+ return out;
931
+ }
932
+
710
933
  /* C-bound insert family (TODO.perf/14): prepend / insert-after /
711
934
  * insert-before share append's gates + predicate + bump shape.
712
935
  * mode: 1 prepend, 2 after, 3 before (anchor = receiver). */
@@ -874,6 +1097,7 @@ static VALUE nn_address(VALUE self)
874
1097
  static VALUE c_b_element = Qundef, c_b_text, c_b_comment, c_b_cdata,
875
1098
  c_b_pi, c_b_node, c_b_result_text, c_b_result_attr,
876
1099
  c_b_attr, c_ffi_pointer, c_b_document, c_b_freed;
1100
+ static VALUE c_iteration_scope;
877
1101
  static ID id_ptr_new;
878
1102
 
879
1103
  /* Binding classes resolve LAZILY: Init_native can run before the
@@ -895,6 +1119,7 @@ static void resolve_binding_classes(void)
895
1119
  c_b_attr = rb_path2class("Leptris::XML::Attr");
896
1120
  c_b_document = rb_path2class("Leptris::XML::Document");
897
1121
  c_b_freed = rb_path2class("Leptris::XML::Document::Freed");
1122
+ c_iteration_scope = rb_path2class("Leptris::XML::IterationScope");
898
1123
  id_ptr_new = rb_intern("new");
899
1124
  rb_gc_register_mark_object(c_b_element);
900
1125
  rb_gc_register_mark_object(c_b_text);
@@ -908,6 +1133,7 @@ static void resolve_binding_classes(void)
908
1133
  rb_gc_register_mark_object(c_b_attr);
909
1134
  rb_gc_register_mark_object(c_b_document);
910
1135
  rb_gc_register_mark_object(c_b_freed);
1136
+ rb_gc_register_mark_object(c_iteration_scope);
911
1137
  }
912
1138
 
913
1139
  static VALUE binding_klass_for(int kind)
@@ -951,9 +1177,20 @@ static VALUE bulk_children_impl(VALUE document, VALUE parent_addr,
951
1177
  rb_iv_set(node, "@c_address", key);
952
1178
  rb_iv_set(node, "@document", document);
953
1179
  rb_iv_set(node, "@parent", Qnil);
954
- rb_iv_set(node, "@structure_memoizable", Qtrue);
955
- rb_iv_set(node, "@native_fast", Qtrue);
1180
+ /* TODO.perf/25: scope-aware stamps — the IterationScope
1181
+ * flows through here as "document" for iterparse
1182
+ * elements; those stay memo-exempt and FFI-mutating,
1183
+ * but address-based reads are cache-free and safe. */
1184
+ if (rb_obj_class(document) == c_iteration_scope) {
1185
+ rb_iv_set(node, "@structure_memoizable", Qfalse);
1186
+ rb_iv_set(node, "@native_fast", Qfalse);
1187
+ rb_iv_set(node, "@pub_document", Qnil);
1188
+ } else {
1189
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
1190
+ rb_iv_set(node, "@native_fast", Qtrue);
956
1191
  rb_iv_set(node, "@pub_document", document);
1192
+ }
1193
+ rb_iv_set(node, "@addr_reads_fast", Qtrue);
957
1194
  rb_iv_set(node, "@node_type", INT2FIX(kinds[i]));
958
1195
  rb_hash_aset(cache, key, node);
959
1196
  }
@@ -989,6 +1226,8 @@ static VALUE m_native_sentinel; /* module object = fallback marker */
989
1226
  static VALUE materialize_xp_entry(VALUE document, VALUE cache,
990
1227
  void *result, void *ptr, int kind,
991
1228
  int idx);
1229
+ static VALUE binding_klass_for(int kind);
1230
+ static VALUE c_iteration_scope;
992
1231
 
993
1232
  static VALUE bulk_xpath_array(VALUE document, void *result);
994
1233
 
@@ -1697,6 +1936,22 @@ void Init_native(void)
1697
1936
  nf_set_binding_text, 3);
1698
1937
  rb_define_module_function(m_native, "unlink_binding_node",
1699
1938
  nf_unlink_binding_node, 2);
1939
+ rb_define_module_function(m_native, "traverse_binding",
1940
+ nf_traverse_binding, 2);
1941
+ rb_define_module_function(m_native, "visit_binding",
1942
+ nf_visit_binding, 2);
1943
+ rb_define_module_function(m_native, "fast_text_content",
1944
+ nf_fast_text_content, 1);
1945
+ rb_define_module_function(m_native, "fast_comment_content",
1946
+ nf_fast_comment_content, 1);
1947
+ rb_define_module_function(m_native, "fast_cdata_content",
1948
+ nf_fast_cdata_content, 1);
1949
+ rb_define_module_function(m_native, "fast_pi_target",
1950
+ nf_fast_pi_target, 1);
1951
+ rb_define_module_function(m_native, "fast_pi_data",
1952
+ nf_fast_pi_data, 1);
1953
+ rb_define_module_function(m_native, "fast_path",
1954
+ nf_fast_path, 1);
1700
1955
  rb_define_module_function(m_native, "fast_inner_xml",
1701
1956
  nf_fast_inner_xml, 1);
1702
1957
  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.4"
4
+ VERSION = "1.9.174.6"
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
@@ -15,7 +15,7 @@ class Leptris::XML::Element < Leptris::XML::Node
15
15
  def name
16
16
  return @name if @name
17
17
  ensure_alive!
18
- @name = if native_fast?
18
+ @name = if @addr_reads_fast
19
19
  Leptris::XML::Native.fast_name(@c_address)
20
20
  else
21
21
  Leptris::XML::FFI.leptris_element_name(c_ptr)
@@ -40,7 +40,7 @@ class Leptris::XML::Element < Leptris::XML::Node
40
40
  def content
41
41
  return @content if memo_hit?(@content_version)
42
42
  ensure_alive!
43
- result = if native_fast?
43
+ result = if @addr_reads_fast
44
44
  Leptris::XML::Native.fast_element_text(@c_address)
45
45
  else
46
46
  Leptris::XML::FFI.leptris_element_text(c_ptr)
@@ -57,11 +57,15 @@ class Leptris::XML::Element < Leptris::XML::Node
57
57
  Leptris::XML::FFI.check_status(
58
58
  Leptris::XML::Native.set_binding_text(
59
59
  @document, @c_address, new_content.to_s))
60
+ @content = new_content.to_s
61
+ @content_version = @document.version if @document
60
62
  return new_content
61
63
  end
62
64
  ensure_writable!
63
65
  Leptris::XML::FFI.check_status(
64
66
  Leptris::XML::FFI.leptris_element_set_text(c_ptr, new_content.to_s))
67
+ @content = new_content.to_s
68
+ @content_version = @document.version if @document
65
69
  new_content
66
70
  end
67
71
 
@@ -91,7 +95,7 @@ class Leptris::XML::Element < Leptris::XML::Node
91
95
  # engine call resolves it, then the name is cached.
92
96
  return v if !v.nil? || @attributes
93
97
  ensure_alive!
94
- v = if native_fast?
98
+ v = if @addr_reads_fast
95
99
  Leptris::XML::Native.fast_attribute(@c_address, name)
96
100
  else
97
101
  Leptris::XML::FFI.leptris_element_attribute(c_ptr, name)
@@ -104,7 +108,7 @@ class Leptris::XML::Element < Leptris::XML::Node
104
108
  # post-mutation cold [] would stamp the new version onto a
105
109
  # stale @attributes hash and attributes would serve it.
106
110
  ensure_alive!
107
- v = if native_fast?
111
+ v = if @addr_reads_fast
108
112
  Leptris::XML::Native.fast_attribute(@c_address, name)
109
113
  else
110
114
  Leptris::XML::FFI.leptris_element_attribute(c_ptr, name)
@@ -137,15 +141,39 @@ class Leptris::XML::Element < Leptris::XML::Node
137
141
  Leptris::XML::FFI.check_status(
138
142
  Leptris::XML::Native.set_binding_attribute(
139
143
  @document, @c_address, key.to_s, value.to_s))
144
+ seed_attribute_memo(key.to_s, value.to_s)
140
145
  return value
141
146
  end
142
147
  ensure_writable!
143
148
  Leptris::XML::FFI.check_status(
144
149
  Leptris::XML::FFI.leptris_element_set_attribute(c_ptr, key.to_s, value.to_s))
150
+ seed_attribute_memo(key.to_s, value.to_s)
145
151
  value
146
152
  end
147
153
  alias_method :set_attribute, :[]=
148
154
 
155
+ # TODO.perf/26: the write knows the new value — seed the
156
+ # version-stamped memo so a read-back skips re-derivation. A
157
+ # valid-and-just-invalidated partial memo extends in place; a
158
+ # full face (or anything older) restarts partial, mirroring the
159
+ # cold-[] rules.
160
+ def seed_attribute_memo(name, written)
161
+ return unless @document
162
+ values = @attr_values
163
+ if values && !@attributes &&
164
+ @attributes_version == @document.version - 1
165
+ values[name] = written
166
+ else
167
+ @attr_values = { name => written }
168
+ @attributes = nil
169
+ @attribute_nodes = nil
170
+ @keys = nil
171
+ @values = nil
172
+ end
173
+ @attributes_version = @document.version
174
+ end
175
+ private :seed_attribute_memo
176
+
149
177
  def key?(name)
150
178
  ensure_alive!
151
179
  # Versioned attribute memo first (TODO.perf/21): a hit proves
@@ -232,7 +260,9 @@ class Leptris::XML::Element < Leptris::XML::Node
232
260
  # Require the full Attr-hash face — a cold [] may have set the
233
261
  # version with only a partial @attr_values (ruby#150).
234
262
  return @attributes if @attributes && memo_hit?(@attributes_version)
235
- 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
236
266
  # TODO.perf/10: both memo faces in one C walk.
237
267
  result, values = Leptris::XML::Native.bulk_attr_faces(
238
268
  @c_address, self)
@@ -36,6 +36,9 @@ class Leptris::XML::Node
36
36
  @structure_memoizable =
37
37
  !document.nil? && !document.is_a?(Leptris::XML::IterationScope)
38
38
  @pub_document = @structure_memoizable ? document : nil
39
+ # TODO.perf/25: pure address-based native reads touch no
40
+ # cache — safe for scope-owned (iterparse) elements too.
41
+ @addr_reads_fast = !!defined?(Leptris::XML::NATIVE_FAST)
39
42
  # NATIVE_FAST availability is fixed at load time before any
40
43
  # node exists, so the conjunction with document-ownership is
41
44
  # construct-time constant — the hot gates read one ivar.
@@ -164,6 +167,16 @@ class Leptris::XML::Node
164
167
  @native_fast
165
168
  end
166
169
 
170
+ # TODO.perf/25: the bulk children faces accept the
171
+ # IterationScope itself (its @wrapper_cache resets per yield —
172
+ # recycled addresses cannot collide — and its @version advances
173
+ # per yield and on mutation), so streaming children ride the
174
+ # one-pass materialization too. The scope-aware stamps live in
175
+ # the C face.
176
+ def native_bulk_children?
177
+ defined?(Leptris::XML::NATIVE_FAST)
178
+ end
179
+
167
180
  # Content-defined 64-bit Merkle digest of this subtree
168
181
  # (libleptris 1.9.99, #869): element name/prefix/resolved
169
182
  # namespace URI, attributes sorted and first-wins-deduplicated,
@@ -318,7 +331,7 @@ class Leptris::XML::Node
318
331
  # (leptris_node_children_ex), so no per-child get_type.
319
332
  return @children if memo_hit?(@children_version)
320
333
  ensure_alive!
321
- if native_fast_children?
334
+ if native_bulk_children?
322
335
  # TODO.perf/01 tail: one C pass constructs every binding
323
336
  # wrapper (class dispatch + ivars + identity cache) — the
324
337
  # per-child Ruby wrap frames disappear.
@@ -419,7 +432,7 @@ class Leptris::XML::Node
419
432
  # children are never wrapped (nor their get_type paid — the
420
433
  # ELEMENT hint rides along). Other nodes keep the filter.
421
434
  result =
422
- if native_fast_children?
435
+ if native_bulk_children?
423
436
  Leptris::XML::Native.bulk_element_children(@document, @c_address)
424
437
  elsif is_a?(Leptris::XML::Element)
425
438
  parent = as_element_or_self
@@ -479,6 +492,13 @@ class Leptris::XML::Node
479
492
  def visit(&block)
480
493
  return enum_for(:visit) unless block
481
494
  ensure_alive!
495
+ # TODO.perf/27: the ext's callback rb_yields directly — no
496
+ # FFI::Function closure per call.
497
+ if @addr_reads_fast
498
+ Leptris::XML::Native.visit_binding(@document, @c_address,
499
+ &block)
500
+ return self
501
+ end
482
502
  document = @document
483
503
  visitor = ::FFI::Function.new(
484
504
  :void, [:pointer, :pointer, :int, :int], blocking: true) do |_, node_ptr, entering, depth|
@@ -511,9 +531,18 @@ class Leptris::XML::Node
511
531
  # exception and returns non-zero, aborting the C walk — without
512
532
  # it the FFI dispatch silently swallowed the exception and the
513
533
  # walk continued with partially processed data.
514
- def traverse
515
- return enum_for(:traverse) unless block_given?
534
+ def traverse(&block)
535
+ return enum_for(:traverse) unless block
516
536
  ensure_alive!
537
+ # TODO.perf/27: post-order + abort-at-self + stash-abort-raise
538
+ # all preserved in the C callback (walk state on its stack).
539
+ # The block forwards explicitly — rb_yield needs it on the C
540
+ # entry's own frame.
541
+ if @addr_reads_fast
542
+ Leptris::XML::Native.traverse_binding(@document, @c_address,
543
+ &block)
544
+ return self
545
+ end
517
546
  error = nil
518
547
  self_address = @c_address
519
548
  callback = ::FFI::Function.new(:int, [:pointer, :pointer], blocking: true) do |node_ptr, _|
@@ -534,8 +563,12 @@ class Leptris::XML::Node
534
563
  def path
535
564
  return @path if memo_hit?(@path_version)
536
565
  ensure_alive!
537
- str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(c_ptr)
538
- result = str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
566
+ result = if @addr_reads_fast
567
+ Leptris::XML::Native.fast_path(@c_address)
568
+ else
569
+ str_ptr = Leptris::XML::FFI.leptris_node_get_xpath(c_ptr)
570
+ str_ptr.null? ? nil : Leptris::XML::FFI.read_owned_string(str_ptr)
571
+ end
539
572
  if @document
540
573
  @path = result
541
574
  @path_version = @document.version
@@ -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
@@ -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
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.4
4
+ version: 1.9.174.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -93,6 +93,10 @@ files:
93
93
  - TODO.perf/22-eager-nodeset-materialization.md
94
94
  - TODO.perf/23-cbound-value-mutations.md
95
95
  - TODO.perf/24-immutable-read-lanes.md
96
+ - TODO.perf/25-scope-owned-bulk-path.md
97
+ - TODO.perf/26-post-mutation-memo-seeding.md
98
+ - TODO.perf/27-c-yield-traversal.md
99
+ - TODO.perf/28-address-fills.md
96
100
  - TODO.restructure/01-constraint-compliance-audit.md
97
101
  - TODO.restructure/02-deep-copy-seam.md
98
102
  - TODO.restructure/03-evaluation-context-seam.md