leptris 1.9.163.6 → 1.9.163.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: ed19c6383e8e83433ffcc99c8843337eb64ba48ac4f40349d5c5e0a537a31ab9
4
- data.tar.gz: 3096440c8ba68d7eb54bfb2fd133098c795b025ec74389d09fd09cacd12f6311
3
+ metadata.gz: a191c85c5149a0f263ca7d7ee5f8ec63b58570f2f56ecd9c3cfc6333a974eff0
4
+ data.tar.gz: 7f126b1b774331af49faaea1b2dadffa7e8a170a47b917a9c3ea03d89377bbee
5
5
  SHA512:
6
- metadata.gz: 5bef6e81be43ea7d536252201a4a59477d6c296a14ef186f9adb28fd304a13fb533218d46ac4d2245e55b724a62d13ab6d39088f82a750bbdfdb797c26c64c93
7
- data.tar.gz: 9a61e4f09d0df6fc73479e1a25b83a146e77e28bae50b1d4dd82e5ed3b5f10be0e9b879e866d70f1207ac0abeda302d32f92c7b7efb4a27da8ee1958c6c63edd
6
+ metadata.gz: 47c7ae31a849c74b9dc651439de4b55d23a038098ed7b6dbfc8ff6ec2b740d45dfb39bb3ee42ac0b0d8a7ef467f03807e77c5cba70dfa5246a957f24c901726e
7
+ data.tar.gz: 8530bd56c3a373dd9bff555ed3fae96212db9792de187be21a002081be24e961601816016a3dc899482b61527424b818910cfeb85288156e95f5cd96fa7e4d8a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ 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.163.7] - 2026-09-15
9
+
10
+ ### Changed
11
+
12
+ - **at_xpath single-result seam (TODO.perf/16)**: nodeset entry-0
13
+ materialization (identity cache, kind dispatch, attribute/text
14
+ value capture) plus the result free run as one C dispatch —
15
+ repeat `at_xpath` measured 3.7µs (pre-cache baseline) → ~1.3µs
16
+ across TODO.perf/15+16 (2.8x). Non-nodeset results keep the
17
+ exact Ruby scalar path.
18
+ - **inner_html in one C pass (TODO.perf/18)**: the child chain,
19
+ per-kind serialization (elements through the engine's
20
+ serialize_into, text escaped with the binding's entity set,
21
+ CDATA/comments/PIs wrapped), and the buffer growth all in C —
22
+ byte-identical to the Ruby loop (spec-pinned per kind and past
23
+ the growth floor); ~160ns/child vs ~500-800ns/child.
24
+ - **Precomputed fast-path flags (TODO.perf/17)**: `@native_fast`
25
+ at construction (Ruby and every C site) replaces the
26
+ `defined?`+`scope_owned?` method chain at every gate.
27
+ `Element#[]=` reads attribute names via StringValue+RSTRING_PTR
28
+ (values keep the embedded-NUL raise); the row measured
29
+ 454ns → 236ns (~Nokogiri parity in the same run).
30
+
31
+ ### Added
32
+
33
+ - `Native.at_xpath_first` / `Native.fast_inner_xml` module faces;
34
+ `materialize_xp_entry` shared by the bulk and single-result
35
+ paths.
36
+
8
37
  ## [1.9.163.6] - 2026-09-15
9
38
 
10
39
  ### Changed
@@ -0,0 +1,38 @@
1
+ # 16 — C-bound at_xpath single-result seam
2
+
3
+ Status: DONE (1.9.163.7)
4
+
5
+ at_xpath is the hottest query shape (adapters picking one node),
6
+ and its result path is five FFI calls per hit: result_type,
7
+ get_node(0), node_kind(0), Node.wrap (pointer + construct), and
8
+ result_free — plus the Ruby case dispatch between them. The bulk
9
+ machinery from TODO.perf/03 already knows how to materialize a
10
+ result entry as a binding wrapper in C (identity cache, kind
11
+ dispatch, attribute/text value capture).
12
+
13
+ Add Native.at_xpath_first(document, result_ptr): one C dispatch
14
+ checks the result type; nodesets materialize entry 0 through the
15
+ bulk machinery (get_nodes_ex with capacity 1 carries pointer +
16
+ kind in one call), free the result handle, and return the binding
17
+ node (or Qnil on empty); non-nodeset results return Qundef so the
18
+ existing Ruby scalar path keeps its exact semantics. Searchable's
19
+ wrap_xpath_first_result routes through the face when native is
20
+ enabled.
21
+
22
+ Gates: at_xpath parity for elements, attributes (ResultAttr name/
23
+ value capture), text-kind results, empty results, and scalars;
24
+ the result handle always freed exactly once.
25
+
26
+ ## Outcome (1.9.163.7)
27
+
28
+ Native.at_xpath_first(document, result_ptr): one dispatch — type
29
+ check, entry-0 materialization through the extracted
30
+ materialize_xp_entry (shared with the TODO.perf/03 bulk loop:
31
+ identity cache, kind dispatch, attribute/text value capture),
32
+ result free. Non-nodeset results return the Native module object
33
+ (Qundef must never cross into Ruby) and keep the exact Ruby
34
+ scalar path. Measured: repeat at_xpath 3.7µs baseline -> 1.9µs
35
+ (TODO.perf/15) -> ~1.3µs with the seam (2.8x total, load 8-30;
36
+ the same-run Nokogiri comparison on this box is anomalous and not
37
+ reported). Specs pin element identity, attribute capture, text
38
+ kinds, nil misses, scalars, and xpath().first agreement.
@@ -0,0 +1,35 @@
1
+ # 17 — Precomputed fast-path flags + set_attribute tightening
2
+
3
+ Status: DONE (1.9.163.7)
4
+
5
+ The native guards on hot paths are method chains:
6
+ native_fast_children? resolves as defined?(NATIVE_FAST) plus
7
+ scope_owned? (another method) — paid per mutation ([]=, add_child,
8
+ the insert family) and per cold read fill. @structure_memoizable
9
+ (TODO.perf/13) already carries "document-owned and alive" as an
10
+ ivar; NATIVE_FAST availability is fixed at load time before any
11
+ node exists, so the conjunction is construct-time constant.
12
+
13
+ Add @native_fast to every wrapper construction (Ruby initialize
14
+ and every C construction site): one ivar read replaces the chain
15
+ at every gate. Fold in the set_attribute shaves: the C face reads
16
+ attribute NAMES via StringValue+RSTRING_PTR (names cannot contain
17
+ NULs — same rationale as nn_attribute); VALUES keep StringValueCStr
18
+ so an embedded-NUL value still raises rather than truncating.
19
+
20
+ Gates: suite green in both modes (the FFI fallback semantics must
21
+ be unchanged for nodes constructed before a late native require —
22
+ worst case they stay on the FFI path); set_attribute row improves.
23
+
24
+ ## Outcome (1.9.163.7)
25
+
26
+ @native_fast computed once at construction (Ruby initialize and
27
+ every C construction site — the bundle running implies the
28
+ constant), replacing the defined?+scope_owned? method chain at
29
+ every gate (native_fast?/native_fast_children? are ivar readers
30
+ now). []= additionally reads attribute names via
31
+ StringValue+RSTRING_PTR (values keep StringValueCStr — an
32
+ embedded-NUL value still raises; spec-pinned). Measured (load
33
+ 17-31): binding []= 454ns mandate baseline -> 236ns (~2x on the
34
+ row; 0.88x vs Nokogiri's 209ns in the same run — effectively
35
+ parity).
@@ -0,0 +1,32 @@
1
+ # 18 — inner_html in one C pass
2
+
3
+ Status: DONE (1.9.163.7)
4
+
5
+ Element#inner_html walks children (bulk since TODO.perf/01) and
6
+ then serializes EACH child from Ruby: element children get one
7
+ ext dispatch each, text/comment/PI children are string-built in
8
+ Ruby and joined. An N-child element pays N dispatches plus the
9
+ case ladder plus the join.
10
+
11
+ Bind the whole pass in C: iterate the child chain once, appending
12
+ each child's serialization into one growable buffer — elements
13
+ through leptris_element_serialize_into (the fast_serialize
14
+ protocol), text nodes XML-escaped (& < > — the binding's
15
+ escape_text set), CDATA wrapped raw, comments and PIs wrapped.
16
+ One dispatch, one string mint, no Ruby per child.
17
+
18
+ Gates: inner_html parity for every child kind (elements, text
19
+ with entities, CDATA, comments, PIs, mixed), empty elements,
20
+ large children past the 4096-byte stack probe (growth path).
21
+
22
+ ## Outcome (1.9.163.7)
23
+
24
+ Native.fast_inner_xml: one pass over the child chain into a
25
+ grow-only scratch — elements through leptris_element_serialize_into
26
+ (needed-1 protocol, grow+retry past capacity), text escaped with
27
+ the binding's entity set (& < > \r), CDATA raw-wrapped, comments
28
+ and PIs wrapped (PI data leading-whitespace-stripped, read_pi_data
29
+ parity). Byte-identical to the Ruby loop for every child kind and
30
+ past the 4KB growth floor (specs). Measured (load 17-31): 30-child
31
+ inner_html ~4.9µs (~160ns/child vs the loop's ~500-800ns/child
32
+ estimate).
@@ -76,6 +76,15 @@ static create_child_fn f_create_child;
76
76
  static append_child_fn f_append_child;
77
77
  static append_child_fn f_prepend_child, f_insert_after, f_insert_before;
78
78
  static set_attr_fn f_set_attr;
79
+ typedef int (*xp_type_fn)(void *);
80
+ typedef void (*xp_free_fn)(void *);
81
+ typedef void *(*first_child_fn)(void *);
82
+ typedef const char *(*node_str_fn)(void *);
83
+ static xp_type_fn f_xp_type;
84
+ static xp_free_fn f_xp_free;
85
+ static first_child_fn f_first_child;
86
+ static node_str_fn f_comment_content, f_cdata_content, f_pi_target,
87
+ f_pi_data;
79
88
  static set_root_fn f_set_root;
80
89
  static doc_free_fn f_doc_free;
81
90
 
@@ -176,6 +185,13 @@ static void resolve_symbols(const char *lib_path)
176
185
  f_insert_after = (append_child_fn)lib_sym(h, "leptris_element_insert_after");
177
186
  f_insert_before = (append_child_fn)lib_sym(h, "leptris_element_insert_before");
178
187
  f_set_attr = (set_attr_fn)lib_sym(h, "leptris_element_set_attribute");
188
+ f_xp_type = (xp_type_fn)lib_sym(h, "leptris_xpath_result_type");
189
+ f_xp_free = (xp_free_fn)lib_sym(h, "leptris_xpath_result_free");
190
+ f_first_child = (first_child_fn)lib_sym(h, "leptris_node_first_child");
191
+ f_comment_content = (node_str_fn)lib_sym(h, "leptris_comment_node_get_content");
192
+ f_cdata_content = (node_str_fn)lib_sym(h, "leptris_cdata_node_get_content");
193
+ f_pi_target = (node_str_fn)lib_sym(h, "leptris_pi_node_get_target");
194
+ f_pi_data = (node_str_fn)lib_sym(h, "leptris_pi_node_get_data");
179
195
  f_set_root = (set_root_fn)lib_sym(h, "leptris_document_set_root");
180
196
  f_doc_free = (doc_free_fn)lib_sym(h, "leptris_document_free");
181
197
  if (!f_elem_name || !f_text_content || !f_attr ||
@@ -183,6 +199,9 @@ static void resolve_symbols(const char *lib_path)
183
199
  !f_element_text || !f_doc_create || !f_elem_create || !f_text_create ||
184
200
  !f_create_child || !f_append_child || !f_prepend_child ||
185
201
  !f_insert_after || !f_insert_before || !f_set_attr || !f_set_root ||
202
+ !f_xp_type || !f_xp_free || !f_first_child ||
203
+ !f_comment_content || !f_cdata_content || !f_pi_target ||
204
+ !f_pi_data ||
186
205
  !f_doc_free ||
187
206
  !f_elem_prefix || !f_xp_count || !f_xp_nodes_ex ||
188
207
  !f_xp_node_kind || !f_xp_node_name || !f_xp_node_value ||
@@ -603,7 +622,11 @@ static VALUE nf_set_binding_attribute(VALUE self, VALUE document,
603
622
  node = (void *)(uintptr_t)NUM2ULL(addr);
604
623
  rb_ivar_set(document, id_iv_version,
605
624
  LONG2FIX(FIX2LONG(rb_ivar_get(document, id_iv_version)) + 1));
606
- st = f_set_attr(node, StringValueCStr(name), StringValueCStr(value));
625
+ /* Attribute names cannot contain NULs (StringValueCStr's
626
+ * memchr is a wasted scan); VALUES keep the scan so an
627
+ * embedded-NUL value raises rather than truncates. */
628
+ st = f_set_attr(node, RSTRING_PTR(StringValue(name)),
629
+ StringValueCStr(value));
607
630
  return INT2FIX(st);
608
631
  }
609
632
 
@@ -853,6 +876,7 @@ static VALUE bulk_children_impl(VALUE document, VALUE parent_addr,
853
876
  rb_iv_set(node, "@document", document);
854
877
  rb_iv_set(node, "@parent", Qnil);
855
878
  rb_iv_set(node, "@structure_memoizable", Qtrue);
879
+ rb_iv_set(node, "@native_fast", Qtrue);
856
880
  rb_iv_set(node, "@node_type", INT2FIX(kinds[i]));
857
881
  rb_hash_aset(cache, key, node);
858
882
  }
@@ -885,6 +909,9 @@ static VALUE nf_bulk_element_children(VALUE self, VALUE document,
885
909
  * while the result handle is alive, identity-cache check/store
886
910
  * for elements. Returns the Ruby Array. */
887
911
  static VALUE m_native_sentinel; /* module object = fallback marker */
912
+ static VALUE materialize_xp_entry(VALUE document, VALUE cache,
913
+ void *result, void *ptr, int kind,
914
+ int idx);
888
915
 
889
916
  static VALUE nf_bulk_xpath(VALUE self, VALUE document, VALUE result_ptr_val)
890
917
  {
@@ -912,76 +939,124 @@ static VALUE nf_bulk_xpath(VALUE self, VALUE document, VALUE result_ptr_val)
912
939
  cache = binding_cache_of(document);
913
940
  out = rb_ary_new2(count);
914
941
  for (i = 0; i < count; i++) {
915
- VALUE node, key;
916
- const char *s;
917
942
  if (buf[i] == NULL)
918
943
  continue;
919
- key = ULL2NUM((uint64_t)(uintptr_t)buf[i]);
920
- switch (kinds[i]) {
921
- case 0: /* element */
944
+ rb_ary_push(out, materialize_xp_entry(document, cache, result,
945
+ buf[i], kinds[i], i));
946
+ }
947
+ ruby_xfree(buf);
948
+ ruby_xfree(kinds);
949
+ return out;
950
+ }
951
+
952
+ /* Materializes ONE result entry as a binding wrapper (or the
953
+ * module sentinel for the rare kinds the bulk path defers to
954
+ * Ruby). Shared by the bulk materializer (TODO.perf/03) and the
955
+ * at_xpath single-result seam (TODO.perf/16). */
956
+ static VALUE materialize_xp_entry(VALUE document, VALUE cache,
957
+ void *result, void *ptr, int kind,
958
+ int idx)
959
+ {
960
+ VALUE node, key;
961
+ const char *s;
962
+
963
+ key = ULL2NUM((uint64_t)(uintptr_t)ptr);
964
+ switch (kind) {
965
+ case 0: /* element */
966
+ node = rb_hash_aref(cache, key);
967
+ if (NIL_P(node)) {
968
+ VALUE p = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
969
+ node = rb_obj_alloc(c_b_element);
970
+ rb_iv_set(node, "@c_ptr", p);
971
+ rb_iv_set(node, "@document", document);
972
+ rb_iv_set(node, "@parent", Qnil);
973
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
974
+ rb_iv_set(node, "@native_fast", Qtrue);
975
+ rb_iv_set(node, "@node_type", INT2FIX(0));
976
+ rb_hash_aset(cache, key, node);
977
+ }
978
+ return node;
979
+ case 1: /* synthetic attribute */
980
+ {
981
+ const char *name = f_xp_node_name(result, idx);
982
+ const char *value = f_xp_node_value(result, idx);
983
+ VALUE p = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
984
+ node = rb_obj_alloc(c_b_result_attr);
985
+ rb_iv_set(node, "@c_ptr", p);
986
+ rb_iv_set(node, "@document", document);
987
+ rb_iv_set(node, "@attr_name",
988
+ name ? rb_utf8_str_new_cstr(name) : Qnil);
989
+ rb_iv_set(node, "@attr_value",
990
+ value ? rb_utf8_str_new_cstr(value) : Qnil);
991
+ }
992
+ return node;
993
+ case 2: /* text-kind: synthetic sequence carrier, or a real
994
+ * Text/CDATA node (XPath reports both as TEXT) —
995
+ * dispatch on the node's own type like Node.wrap. */
996
+ {
997
+ int nt = f_node_type(ptr);
998
+ if (nt == 8) { /* NODE_SYNTHETIC_TEXT */
999
+ s = f_xp_node_value(result, idx);
1000
+ node = rb_obj_alloc(c_b_result_text);
1001
+ rb_iv_set(node, "@c_ptr",
1002
+ rb_funcall(c_ffi_pointer, id_ptr_new, 1, key));
1003
+ rb_iv_set(node, "@document", document);
1004
+ rb_iv_set(node, "@value",
1005
+ s ? rb_utf8_str_new_cstr(s) : Qnil);
1006
+ return node;
1007
+ }
922
1008
  node = rb_hash_aref(cache, key);
923
1009
  if (NIL_P(node)) {
924
- VALUE ptr = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
925
- node = rb_obj_alloc(c_b_element);
926
- rb_iv_set(node, "@c_ptr", ptr);
1010
+ VALUE p = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
1011
+ node = rb_obj_alloc(binding_klass_for(nt));
1012
+ rb_iv_set(node, "@c_ptr", p);
927
1013
  rb_iv_set(node, "@document", document);
928
1014
  rb_iv_set(node, "@parent", Qnil);
929
1015
  rb_iv_set(node, "@structure_memoizable", Qtrue);
930
- rb_iv_set(node, "@node_type", INT2FIX(0));
1016
+ rb_iv_set(node, "@native_fast", Qtrue);
1017
+ rb_iv_set(node, "@node_type", INT2FIX(nt));
931
1018
  rb_hash_aset(cache, key, node);
932
1019
  }
933
- break;
934
- case 1: /* synthetic attribute */
935
- {
936
- const char *name = f_xp_node_name(result, i);
937
- const char *value = f_xp_node_value(result, i);
938
- VALUE ptr = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
939
- node = rb_obj_alloc(c_b_result_attr);
940
- rb_iv_set(node, "@c_ptr", ptr);
941
- rb_iv_set(node, "@document", document);
942
- rb_iv_set(node, "@attr_name",
943
- name ? rb_utf8_str_new_cstr(name) : Qnil);
944
- rb_iv_set(node, "@attr_value",
945
- value ? rb_utf8_str_new_cstr(value) : Qnil);
946
- }
947
- break;
948
- case 2: /* text-kind: synthetic sequence carrier, or a real
949
- * Text/CDATA node (XPath reports both as TEXT) —
950
- * dispatch on the node's own type like Node.wrap. */
951
- {
952
- int nt = f_node_type(buf[i]);
953
- if (nt == 8) { /* NODE_SYNTHETIC_TEXT */
954
- s = f_xp_node_value(result, i);
955
- node = rb_obj_alloc(c_b_result_text);
956
- rb_iv_set(node, "@c_ptr",
957
- rb_funcall(c_ffi_pointer, id_ptr_new, 1, key));
958
- rb_iv_set(node, "@document", document);
959
- rb_iv_set(node, "@value",
960
- s ? rb_utf8_str_new_cstr(s) : Qnil);
961
- } else {
962
- VALUE ptr = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
963
- node = rb_hash_aref(cache, key);
964
- if (NIL_P(node)) {
965
- node = rb_obj_alloc(binding_klass_for(nt));
966
- rb_iv_set(node, "@c_ptr", ptr);
967
- rb_iv_set(node, "@document", document);
968
- rb_iv_set(node, "@parent", Qnil);
969
- rb_iv_set(node, "@structure_memoizable", Qtrue);
970
- rb_iv_set(node, "@node_type", INT2FIX(nt));
971
- rb_hash_aset(cache, key, node);
972
- }
973
- }
974
- }
975
- break;
976
- default: /* other: rare — signal Ruby fallback for this index */
977
- rb_ary_push(out, m_native_sentinel);
978
- continue;
979
1020
  }
980
- rb_ary_push(out, node);
1021
+ return node;
1022
+ default: /* other: rare — signal Ruby fallback for this entry */
1023
+ return m_native_sentinel;
981
1024
  }
982
- ruby_xfree(buf);
983
- ruby_xfree(kinds);
984
- return out;
1025
+ }
1026
+
1027
+ /* ---- at_xpath single-result seam (TODO.perf/16) ----------------
1028
+ * One dispatch for the hottest query shape: type check, entry-0
1029
+ * materialization through the bulk machinery, result free.
1030
+ * Non-nodeset results return Qundef — the caller keeps the exact
1031
+ * Ruby scalar path (and owns the free there). */
1032
+ static VALUE nf_at_xpath_first(VALUE self, VALUE document,
1033
+ VALUE result_ptr_val)
1034
+ {
1035
+ void *result = (void *)(uintptr_t)NUM2ULL(result_ptr_val);
1036
+ void *buf;
1037
+ int kind;
1038
+ size_t scount;
1039
+ VALUE node;
1040
+
1041
+ (void)self;
1042
+ resolve_binding_classes();
1043
+ if (f_xp_type(result) != 0) /* XPATH_NODESET */
1044
+ return m_native_sentinel; /* caller keeps the scalar path */
1045
+ scount = f_xp_count(result);
1046
+ if (scount == 0) {
1047
+ f_xp_free(result);
1048
+ return Qnil;
1049
+ }
1050
+ buf = NULL;
1051
+ scount = f_xp_nodes_ex(result, &buf, &kind, 1);
1052
+ if (scount == 0 || buf == NULL) {
1053
+ f_xp_free(result);
1054
+ return Qnil;
1055
+ }
1056
+ node = materialize_xp_entry(document, binding_cache_of(document),
1057
+ result, buf, kind, 0);
1058
+ f_xp_free(result);
1059
+ return node;
985
1060
  }
986
1061
 
987
1062
  /* ---- Bulk attribute materialization (TODO.perf/10) ---------------
@@ -1027,6 +1102,7 @@ static VALUE nf_create_binding_element(VALUE self, VALUE document,
1027
1102
  rb_iv_set(node, "@document", document);
1028
1103
  rb_iv_set(node, "@parent", Qnil);
1029
1104
  rb_iv_set(node, "@structure_memoizable", Qtrue);
1105
+ rb_iv_set(node, "@native_fast", Qtrue);
1030
1106
  rb_iv_set(node, "@node_type", INT2FIX(0));
1031
1107
  cache = binding_cache_of(document);
1032
1108
  key = ULL2NUM((uint64_t)(uintptr_t)ptr);
@@ -1052,6 +1128,7 @@ static VALUE nf_create_binding_text(VALUE self, VALUE document,
1052
1128
  rb_iv_set(node, "@document", document);
1053
1129
  rb_iv_set(node, "@parent", Qnil);
1054
1130
  rb_iv_set(node, "@structure_memoizable", Qtrue);
1131
+ rb_iv_set(node, "@native_fast", Qtrue);
1055
1132
  rb_iv_set(node, "@node_type", INT2FIX(1));
1056
1133
  cache = binding_cache_of(document);
1057
1134
  key = ULL2NUM((uint64_t)(uintptr_t)ptr);
@@ -1163,6 +1240,117 @@ static VALUE nf_fast_element_xml(VALUE self, VALUE addr,
1163
1240
  NUM2INT(indent), RTEST(decl) ? 1 : 0);
1164
1241
  }
1165
1242
 
1243
+ /* ---- inner_html in one C pass (TODO.perf/18) --------------------
1244
+ * Serializes the receiver's children into one growable buffer:
1245
+ * elements via leptris_element_serialize_into (the same opts the
1246
+ * Ruby loop passed), text XML-escaped with the binding's entity
1247
+ * set (& < > \r), CDATA/comments/PIs wrapped raw — byte-identical
1248
+ * to Element#inner_html's per-child loop. */
1249
+ static char *inner_buf;
1250
+ static size_t inner_cap;
1251
+
1252
+ static void inner_ensure(size_t need)
1253
+ {
1254
+ if (need > inner_cap) {
1255
+ inner_cap = need < 64 ? 64 : need;
1256
+ inner_buf = ruby_xrealloc(inner_buf, inner_cap);
1257
+ }
1258
+ }
1259
+
1260
+ static size_t inner_append_raw(const char *s, size_t len, size_t at)
1261
+ {
1262
+ inner_ensure(at + len + 1);
1263
+ if (len)
1264
+ memcpy(inner_buf + at, s, len);
1265
+ return at + len;
1266
+ }
1267
+
1268
+ static size_t inner_append_escaped(const char *s, size_t at)
1269
+ {
1270
+ for (; s && *s; s++) {
1271
+ const char *repl;
1272
+ size_t rl;
1273
+ switch (*s) {
1274
+ case '&': repl = "&amp;"; rl = 5; break;
1275
+ case '<': repl = "&lt;"; rl = 4; break;
1276
+ case '>': repl = "&gt;"; rl = 4; break;
1277
+ case '\r': repl = "&#xD;"; rl = 5; break;
1278
+ default: repl = NULL; rl = 0; break;
1279
+ }
1280
+ if (repl) {
1281
+ at = inner_append_raw(repl, rl, at);
1282
+ } else {
1283
+ inner_ensure(at + 2);
1284
+ inner_buf[at++] = *s;
1285
+ }
1286
+ }
1287
+ return at;
1288
+ }
1289
+
1290
+ static VALUE nf_fast_inner_xml(VALUE self, VALUE addr)
1291
+ {
1292
+ void *node = (void *)(uintptr_t)NUM2ULL(addr);
1293
+ void *child;
1294
+ struct serialize_opts opts;
1295
+ size_t at = 0, needed;
1296
+
1297
+ (void)self;
1298
+ opts.indent = 0;
1299
+ opts.xml_declaration = 1; /* matches element_xml_default */
1300
+ opts.encoding = NULL;
1301
+ inner_ensure(4096);
1302
+ for (child = f_first_child(node); child != NULL;
1303
+ child = f_next_sibling(child)) {
1304
+ switch (f_node_type(child)) {
1305
+ case 0: /* element */
1306
+ needed = f_elem_serialize(child, inner_buf + at,
1307
+ inner_cap - at, NULL, &opts);
1308
+ if (needed > 0) {
1309
+ if (needed > inner_cap - at) {
1310
+ inner_ensure(at + needed);
1311
+ needed = f_elem_serialize(child, inner_buf + at,
1312
+ inner_cap - at, NULL, &opts);
1313
+ }
1314
+ at += needed - 1; /* size includes the NUL */
1315
+ }
1316
+ break;
1317
+ case 1: /* text */
1318
+ at = inner_append_escaped(f_text_content(child), at);
1319
+ break;
1320
+ case 2: /* comment */
1321
+ at = inner_append_raw("<!--", 4, at);
1322
+ at = inner_append_escaped(f_comment_content(child), at);
1323
+ at = inner_append_raw("-->", 3, at);
1324
+ break;
1325
+ case 3: { /* CDATA */
1326
+ const char *c = f_cdata_content(child);
1327
+ at = inner_append_raw("<![CDATA[", 9, at);
1328
+ at = inner_append_raw(c, c ? strlen(c) : 0, at);
1329
+ at = inner_append_raw("]]>", 3, at);
1330
+ break;
1331
+ }
1332
+ case 4: { /* PI — the binding strips leading whitespace from
1333
+ * the data before joining (read_pi_data parity). */
1334
+ const char *data = f_pi_data(child);
1335
+ while (data && (*data == ' ' || *data == '\t' ||
1336
+ *data == '\r' || *data == '\n'))
1337
+ data++;
1338
+ at = inner_append_raw("<?", 2, at);
1339
+ at = inner_append_escaped(f_pi_target(child), at);
1340
+ if (data && *data) {
1341
+ at = inner_append_raw(" ", 1, at);
1342
+ at = inner_append_escaped(data, at);
1343
+ }
1344
+ at = inner_append_raw("?>", 2, at);
1345
+ break;
1346
+ }
1347
+ default:
1348
+ break;
1349
+ }
1350
+ }
1351
+ return rb_utf8_str_new(inner_buf, at);
1352
+ }
1353
+
1166
1354
  /* ---- Adoption-lift predicate (TODO.perf/09, #204 ask 3) ---------
1167
1355
  * The #178/#208 namespace lift + pruning runs on EVERY attach and
1168
1356
  * costs several FFI round-trips even when provably a no-op. This
@@ -1394,6 +1582,10 @@ void Init_native(void)
1394
1582
  nf_set_binding_attribute, 4);
1395
1583
  rb_define_module_function(m_native, "insert_binding_child",
1396
1584
  nf_insert_binding_child, 4);
1585
+ rb_define_module_function(m_native, "at_xpath_first",
1586
+ nf_at_xpath_first, 2);
1587
+ rb_define_module_function(m_native, "fast_inner_xml",
1588
+ nf_fast_inner_xml, 1);
1397
1589
  rb_define_module_function(m_native, "doc_handle_attach",
1398
1590
  nf_doc_handle_attach, 1);
1399
1591
  rb_define_module_function(m_native, "doc_handle_release",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.163.6"
4
+ VERSION = "1.9.163.7"
5
5
  end
@@ -9,7 +9,7 @@ class Leptris::XML::Element < Leptris::XML::Node
9
9
  # rules ride the IterationScope seam — the ext fast path is for
10
10
  # document-owned trees.
11
11
  def native_fast?
12
- defined?(Leptris::XML::NATIVE_FAST) && !scope_owned?
12
+ @native_fast
13
13
  end
14
14
 
15
15
  def name
@@ -609,6 +609,11 @@ class Leptris::XML::Element < Leptris::XML::Node
609
609
  # of #inner_text, which returns the unescaped text content.
610
610
  def inner_html
611
611
  ensure_alive!
612
+ # One C pass (TODO.perf/18): the child chain, per-kind
613
+ # serialization, and the escape set all in one dispatch —
614
+ # byte-identical to the Ruby loop below.
615
+ return Leptris::XML::Native.fast_inner_xml(@c_ptr.address) if
616
+ @native_fast
612
617
  children.map do |child|
613
618
  case child
614
619
  when Leptris::XML::Element
@@ -27,6 +27,11 @@ class Leptris::XML::Node
27
27
  # keeps the memo-hit path free of method dispatch.
28
28
  @structure_memoizable =
29
29
  !document.nil? && !document.is_a?(Leptris::XML::IterationScope)
30
+ # NATIVE_FAST availability is fixed at load time before any
31
+ # node exists, so the conjunction with document-ownership is
32
+ # construct-time constant — the hot gates read one ivar.
33
+ @native_fast = @structure_memoizable &&
34
+ defined?(Leptris::XML::NATIVE_FAST) ? true : false
30
35
  # wrap() already calls leptris_node_get_type for dispatch; reusing
31
36
  # the result makes every predicate and #type call FFI-free.
32
37
  @node_type = node_type
@@ -145,9 +150,9 @@ class Leptris::XML::Node
145
150
 
146
151
  # The ext bulk path applies to document-owned trees only:
147
152
  # scope-owned (iterparse) elements ride the IterationScope seam.
153
+ # Precomputed at construction (TODO.perf/17) — one ivar read.
148
154
  def native_fast_children?
149
- defined?(Leptris::XML::NATIVE_FAST) &&
150
- !@document.is_a?(Leptris::XML::IterationScope)
155
+ @native_fast
151
156
  end
152
157
 
153
158
  # Content-defined 64-bit Merkle digest of this subtree
@@ -236,6 +236,14 @@ module Leptris::XML::Searchable
236
236
  # AutoPointer, one fewer FFI than xpath().first; scalars keep the
237
237
  # full-wrapper semantics.
238
238
  def self.wrap_xpath_first_result(document, result_ptr)
239
+ # C seam (TODO.perf/16): nodesets materialize entry 0, free
240
+ # the handle, and return in one dispatch. The module object
241
+ # back means non-nodeset — the scalar path below owns it.
242
+ if defined?(Leptris::XML::NATIVE_FAST)
243
+ first = Leptris::XML::Native.at_xpath_first(
244
+ document, result_ptr.address)
245
+ return first unless first.equal?(Leptris::XML::Native)
246
+ end
239
247
  type = Leptris::XML::FFI.leptris_xpath_result_type(result_ptr)
240
248
  if type == Leptris::XML::FFI::XPATH_NODESET
241
249
  ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(result_ptr, 0)
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.163.6
4
+ version: 1.9.163.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -84,6 +84,9 @@ files:
84
84
  - TODO.perf/13-structural-memos.md
85
85
  - TODO.perf/14-insert-family-c-bound.md
86
86
  - TODO.perf/15-compiled-expression-cache.md
87
+ - TODO.perf/16-at-xpath-single-result-seam.md
88
+ - TODO.perf/17-precomputed-fast-path-flags.md
89
+ - TODO.perf/18-inner-html-one-c-pass.md
87
90
  - TODO.restructure/01-constraint-compliance-audit.md
88
91
  - TODO.restructure/02-deep-copy-seam.md
89
92
  - TODO.restructure/03-evaluation-context-seam.md