leptris 1.9.162.9 → 1.9.163.2

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: 245dd6ccedb905fe340d1ed3117b8a0fd8cc37cfc39c5cb0d3412c1f84dbee80
4
- data.tar.gz: f91b66d536994043a4dad8bbf7cf8801b3779bff2d620a2725bf4057f7f3e4bb
3
+ metadata.gz: c7718cf666bbec3e128c6dd2458d85e59f58757654b67021137e931e92271d19
4
+ data.tar.gz: 9cece9a47663dcc7c0665201beac6914edc30f08d3680d9ba8bfee64bb2819bd
5
5
  SHA512:
6
- metadata.gz: 1447bfb2999b6dbd50053281883fadfb45d3730ea8b67990fecaf24ce35cd219b479523a1e0e7b65b1e8f255769a8a77fdc3f82b7daba3b94e167f847dbd8bac
7
- data.tar.gz: add08727ca40f2cf397e5e0a256629c2cd5a7f25f5a21ec07fed80241dd4b78b1b89432717cc1edbce081ecae5203a4fbddfd52572220c1a9d37dd5c7ac9b529
6
+ metadata.gz: 49695f1c96011b812601dfaa6a200403523563737bdb1e470d9f8d5c67fb9e3e6948ede1fef108a2a344df68ef31d69ac931a3f2363d173b7ac7546673ca9340
7
+ data.tar.gz: a02fe7e50a379fe9d84d1a705234d6c49fba0c52fcf6c4867899ae91aa7b2bc618590bed20bf01a518a6d83b268e440e0beab58f2752eb6d6808a68bdef3b46b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ 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.2] - 2026-09-14
9
+
10
+ ### Added
11
+
12
+ - **TODO.perf/05 — NativeNode surface**: `#attributes` (the
13
+ flat name=>value hash in one C walk — the `[]` shape, not the
14
+ binding's Attr-valued hash), `#line`, `#byte_offset` (binding
15
+ parity). Binding classes inside the ext now resolve lazily —
16
+ `Init_native` can run before the binding's autoload entries
17
+ load, and `require "leptris/xml/native_layer"` standalone
18
+ crashed on undefined `FFI::Pointer` before the ffi require was
19
+ made explicit.
20
+
21
+ ## [1.9.163.1] - 2026-09-14
22
+
23
+ ### Performance
24
+
25
+ - **TODO.perf/04 — ext-bound serialization**: `to_xml`/`inner_html`
26
+ run the whole sized-buffer cycle in C (options on the stack, probe
27
+ the largest owned buffer first so large documents serialize once,
28
+ grow-only scratch under the GVL). inner_html per-call batch
29
+ **84 -> 50 ms (1.7x)**; document serialization at parity (single
30
+ call both paths); `encoding:` keeps the FFI options path.
31
+
32
+ ## [1.9.163.0] - 2026-09-14
33
+
34
+ ### Performance
35
+
36
+ - **TODO.perf/01 complete — bulk children for binding wrappers**:
37
+ with the native layer auto-loaded (1.9.162.9's TODO.perf/02),
38
+ `Node#children` and `#element_children` construct every binding
39
+ wrapper in ONE C pass (class dispatch, ivars, identity-cache
40
+ check/store — the per-child Ruby wrap frames disappear). Cold
41
+ one-shot walk (fresh doc, element_children + name/[]/content
42
+ per node): **9.2 -> 1.2 ms (~8x)**. Warm paths unchanged
43
+ (memoized); scope-owned iterparse elements stay on the FFI path.
44
+ - **TODO.perf/03 — bulk XPath result materialization**: NodeSet
45
+ materializes through one C pass (kind dispatch with synthetic
46
+ text/attribute value capture in-C; node-type consult for real
47
+ Text/CDATA under XPATH's TEXT). One-shot xpath rows gain ~1.4x
48
+ on the materialization slice (the call is parse-dominated);
49
+ >512-entry results and rare kinds keep the per-index fallback.
50
+
8
51
  ## [1.9.162.9] - 2026-09-14
9
52
 
10
53
  ### Performance
@@ -7,7 +7,10 @@ cannot see it (memos serve after first touch) — the gate is the
7
7
  COLD one-shot pass (fresh doc, one read per node): 13-25% on the
8
8
  #187 shape. UTF-8 encoding via rb_utf8_str_new_cstr (suite specs
9
9
  run both paths in one process: native_layer_spec sets the flag
10
- mid-suite). Remaining: children bulk for binding classes.
10
+ mid-suite). COMPLETE (1.9.163.0): bulk children + bulk element_children for
11
+ binding wrappers (one C pass: class dispatch + ivars + identity
12
+ cache) — cold one-shot walk 9.2->1.2ms (7.8x, element_children +
13
+ name/[]/content). Both TODO items done.
11
14
 
12
15
  The default binding's hot reads each pay FFI per-call marshaling
13
16
  (:string returns allocate through FFI, args marshal through the
@@ -1,6 +1,11 @@
1
1
  # 03 — Bulk XPath result materialization
2
2
 
3
- Status: TODO
3
+ Status: SHIPPED (1.9.163.0) — one C pass (kind dispatch incl.
4
+ synthetic text/attr value capture, node-type consult for real
5
+ Text/CDATA under XPATH text, identity cache). HONEST FINDING: the
6
+ xpath one-shot is parse-dominated; the materialization slice gains
7
+ ~1.4x. >512-entry results and rare kinds fall back to the per-index
8
+ path. No warm-path change.
4
9
 
5
10
  NodeSet#fetch materializes results per node (wrap per item +
6
11
  ResultText/ResultAttr captures). The #185 profile counted
@@ -1,6 +1,10 @@
1
1
  # 04 — Serialization buffer cycle in C
2
2
 
3
- Status: TODO
3
+ Status: SHIPPED (1.9.163.1) — the sized-buffer cycle runs in C
4
+ (probe largest owned buffer first: single serialization for
5
+ big docs; grow-only scratch under the GVL). inner_html 84->50ms
6
+ (1.7x per-call batch); doc serialize at parity with the FFI seam
7
+ (both single-call); encoding: paths keep the FFI options path.
4
8
 
5
9
  FFI.serialize_into_string does the two-call sized protocol
6
10
  (needed probe, then copy) with Ruby-side buffer management per
@@ -1,6 +1,13 @@
1
1
  # 05 — Complete the NativeNode read surface
2
2
 
3
- Status: TODO
3
+ Status: SHIPPED (1.9.163.2) — #attributes (one C walk, string
4
+ values — deliberately the flat [] shape, not the binding's
5
+ Attr-valued hash), #line, #byte_offset with binding parity. Also:
6
+ binding classes in the ext resolve LAZILY (Init may run before
7
+ autoload loads Element — the standalone native_layer require
8
+ crashed before; fixed) and native_layer requires ffi first.
9
+ Remaining cold: writes stay FFI by design; attribute_nodes/
10
+ namespaces deferred until a consumer asks.
4
11
 
5
12
  Missing on NativeNode vs Element: #attributes (bulk attr hash),
6
13
  #attribute_nodes, #namespaces / #prefix, #line / #byte_offset,
@@ -1,6 +1,12 @@
1
1
  # 06 — Native-layer legs in the perf battery + drift record
2
2
 
3
- Status: TODO
3
+ Status: SHIPPED (1.9.163.2) — benchmark/native_vs_binding.rb runs
4
+ the walk/read/serialize rows through both surfaces under the
5
+ TODO.restructure/17 load gate. Honest findings recorded: warm rows
6
+ favor the binding's memoization (attr 0.08 vs 0.30 ms; the native
7
+ API pays per-call dispatch with no memo layer); cold one-shot
8
+ favors the native API (1.81 vs 2.39 ms — on TOP of the binding's
9
+ own bulk/fast acceleration); serialize at parity.
4
10
 
5
11
  benchmark/ gains native-layer legs (walk/build/attr rows via
6
12
  NativeNode beside the binding rows) so the drift record tracks
@@ -17,11 +17,23 @@
17
17
  typedef const char *(*elem_name_fn)(void *);
18
18
  typedef const char *(*text_content_fn)(void *);
19
19
  typedef const char *(*attr_fn)(void *, const char *);
20
- typedef int (*children_ex_fn)(void *, void **, int *, int);
20
+ typedef size_t (*children_ex_fn)(void *, void **, int *, size_t);
21
21
  typedef int (*node_type_fn)(void *);
22
22
  typedef void *(*next_sibling_fn)(void *);
23
23
  typedef void *(*parent_fn)(void *);
24
24
  typedef const char *(*elem_prefix_fn)(void *);
25
+ typedef size_t (*xp_count_fn)(void *);
26
+ typedef size_t (*xp_nodes_ex_fn)(void *, void **, int *, size_t);
27
+ typedef int (*xp_node_kind_fn)(void *, int);
28
+ typedef const char *(*xp_node_name_fn)(void *, int);
29
+ typedef const char *(*xp_node_value_fn)(void *, int);
30
+ typedef size_t (*serialize_into_fn)(void *, char *, size_t, void *, void *);
31
+ typedef void *(*attr_first_fn)(void *);
32
+ typedef void *(*attr_next_fn)(void *);
33
+ typedef const char *(*attr_name_fn)(void *);
34
+ typedef const char *(*attr_value_fn)(void *, void *);
35
+ typedef unsigned int (*node_line_fn)(void *);
36
+ typedef size_t (*node_offset_fn)(void *);
25
37
  typedef const char *(*element_text_fn)(void *);
26
38
  typedef void *(*doc_create_fn)(void);
27
39
  typedef void *(*elem_create_fn)(void *, const char *);
@@ -39,6 +51,18 @@ static node_type_fn f_node_type;
39
51
  static next_sibling_fn f_next_sibling;
40
52
  static parent_fn f_parent;
41
53
  static elem_prefix_fn f_elem_prefix;
54
+ static xp_count_fn f_xp_count;
55
+ static xp_nodes_ex_fn f_xp_nodes_ex;
56
+ static xp_node_kind_fn f_xp_node_kind;
57
+ static xp_node_name_fn f_xp_node_name;
58
+ static xp_node_value_fn f_xp_node_value;
59
+ static serialize_into_fn f_doc_serialize, f_elem_serialize;
60
+ static attr_first_fn f_attr_first;
61
+ static attr_next_fn f_attr_next;
62
+ static attr_name_fn f_attr_name;
63
+ static attr_value_fn f_attr_value;
64
+ static node_line_fn f_node_line;
65
+ static node_offset_fn f_node_offset;
42
66
  static element_text_fn f_element_text;
43
67
  static doc_create_fn f_doc_create;
44
68
  static elem_create_fn f_elem_create;
@@ -49,7 +73,8 @@ static set_root_fn f_set_root;
49
73
  static doc_free_fn f_doc_free;
50
74
 
51
75
  static VALUE c_native_node;
52
- static ID id_wrapper_cache;
76
+ static ID id_wrapper_cache; /* native_cache: NativeNode identity */
77
+ static ID id_binding_cache; /* wrapper_cache: binding Node identity */
53
78
 
54
79
  #define NT_ELEMENT 0
55
80
 
@@ -97,6 +122,19 @@ static void resolve_symbols(const char *lib_path)
97
122
  f_next_sibling = (next_sibling_fn)lib_sym(h, "leptris_node_next_sibling");
98
123
  f_parent = (parent_fn)lib_sym(h, "leptris_node_parent");
99
124
  f_elem_prefix = (elem_prefix_fn)lib_sym(h, "leptris_element_prefix");
125
+ f_xp_count = (xp_count_fn)lib_sym(h, "leptris_xpath_result_count");
126
+ f_xp_nodes_ex = (xp_nodes_ex_fn)lib_sym(h, "leptris_xpath_result_get_nodes_ex");
127
+ f_xp_node_kind = (xp_node_kind_fn)lib_sym(h, "leptris_xpath_result_node_kind");
128
+ f_xp_node_name = (xp_node_name_fn)lib_sym(h, "leptris_xpath_result_node_name");
129
+ f_xp_node_value = (xp_node_value_fn)lib_sym(h, "leptris_xpath_result_node_value");
130
+ f_doc_serialize = (serialize_into_fn)lib_sym(h, "leptris_document_serialize_into");
131
+ f_elem_serialize = (serialize_into_fn)lib_sym(h, "leptris_element_serialize_into");
132
+ f_attr_first = (attr_first_fn)lib_sym(h, "leptris_element_first_attribute");
133
+ f_attr_next = (attr_next_fn)lib_sym(h, "leptris_attribute_next");
134
+ f_attr_name = (attr_name_fn)lib_sym(h, "leptris_attribute_get_name");
135
+ f_attr_value = (attr_value_fn)lib_sym(h, "leptris_attribute_get_value");
136
+ f_node_line = (node_line_fn)lib_sym(h, "leptris_node_line");
137
+ f_node_offset = (node_offset_fn)lib_sym(h, "leptris_node_byte_offset");
100
138
  f_element_text = (element_text_fn)lib_sym(h, "leptris_element_text");
101
139
  f_doc_create = (doc_create_fn)lib_sym(h, "leptris_document_create");
102
140
  f_elem_create = (elem_create_fn)lib_sym(h, "leptris_element_create");
@@ -109,7 +147,11 @@ static void resolve_symbols(const char *lib_path)
109
147
  !f_children_ex || !f_node_type || !f_next_sibling || !f_parent ||
110
148
  !f_element_text || !f_doc_create || !f_elem_create || !f_text_create ||
111
149
  !f_create_child || !f_append_child || !f_set_root || !f_doc_free ||
112
- !f_elem_prefix)
150
+ !f_elem_prefix || !f_xp_count || !f_xp_nodes_ex ||
151
+ !f_xp_node_kind || !f_xp_node_name || !f_xp_node_value ||
152
+ !f_doc_serialize || !f_elem_serialize || !f_attr_first ||
153
+ !f_attr_next || !f_attr_name || !f_attr_value ||
154
+ !f_node_line || !f_node_offset)
113
155
  rb_raise(rb_eRuntimeError, "libleptris symbols missing");
114
156
  }
115
157
 
@@ -152,18 +194,47 @@ static VALUE nn_allocate(VALUE klass)
152
194
  /* Bulk children: one native pass; cache check/store inline; the
153
195
  * document's wrapper_cache (a Ruby Hash keyed by address) provides
154
196
  * identity for nodes seen through other paths. */
197
+ /* Two-call children fetch (#202): children_ex copies
198
+ * min(total, cap); a saturated fill means the list may be longer —
199
+ * query the true count (buf NULL) and refetch grown. */
200
+ static int fetch_children_two_call(void *node, void ***buf_out,
201
+ int **kinds_out)
202
+ {
203
+ size_t cap = 512;
204
+ void *b = ruby_xmalloc(cap * sizeof(void *));
205
+ int *k = ruby_xmalloc((int)cap * sizeof(int));
206
+ size_t count = f_children_ex(node, b, k, cap);
207
+ if (count == cap) {
208
+ size_t total = f_children_ex(node, NULL, NULL, 0);
209
+ if (total > cap) {
210
+ ruby_xfree(b);
211
+ ruby_xfree(k);
212
+ cap = total;
213
+ b = ruby_xmalloc(cap * sizeof(void *));
214
+ k = ruby_xmalloc((int)cap * sizeof(int));
215
+ count = f_children_ex(node, b, k, cap);
216
+ }
217
+ }
218
+ *buf_out = b;
219
+ *kinds_out = k;
220
+ return (int)count;
221
+ }
222
+
155
223
  static VALUE nn_children(VALUE self)
156
224
  {
157
225
  struct native_node *n;
158
- void *buf[512];
159
- int kinds[512];
226
+ void **buf;
227
+ int *kinds;
160
228
  int count, i;
161
229
  VALUE cache, out;
162
230
 
163
231
  TypedData_Get_Struct(self, struct native_node, &nn_type, n);
164
- count = f_children_ex(n->ptr, buf, kinds, 512);
165
- if (count <= 0)
232
+ count = fetch_children_two_call(n->ptr, &buf, &kinds);
233
+ if (count <= 0) {
234
+ ruby_xfree(buf);
235
+ ruby_xfree(kinds);
166
236
  return rb_ary_new2(0);
237
+ }
167
238
 
168
239
  cache = rb_funcall(n->document, id_wrapper_cache, 0);
169
240
  out = rb_ary_new2(count);
@@ -181,6 +252,8 @@ static VALUE nn_children(VALUE self)
181
252
  }
182
253
  rb_ary_push(out, child);
183
254
  }
255
+ ruby_xfree(buf);
256
+ ruby_xfree(kinds);
184
257
  return out;
185
258
  }
186
259
 
@@ -203,15 +276,18 @@ static VALUE nn_type_sym(VALUE self)
203
276
  static VALUE nn_element_children(VALUE self)
204
277
  {
205
278
  struct native_node *n;
206
- void *buf[512];
207
- int kinds[512];
279
+ void **buf;
280
+ int *kinds;
208
281
  int count, i;
209
282
  VALUE cache, out;
210
283
 
211
284
  TypedData_Get_Struct(self, struct native_node, &nn_type, n);
212
- count = f_children_ex(n->ptr, buf, kinds, 512);
213
- if (count <= 0)
285
+ count = fetch_children_two_call(n->ptr, &buf, &kinds);
286
+ if (count <= 0) {
287
+ ruby_xfree(buf);
288
+ ruby_xfree(kinds);
214
289
  return rb_ary_new2(0);
290
+ }
215
291
 
216
292
  cache = rb_funcall(n->document, id_wrapper_cache, 0);
217
293
  out = rb_ary_new();
@@ -230,6 +306,8 @@ static VALUE nn_element_children(VALUE self)
230
306
  }
231
307
  rb_ary_push(out, child);
232
308
  }
309
+ ruby_xfree(buf);
310
+ ruby_xfree(kinds);
233
311
  return out;
234
312
  }
235
313
 
@@ -352,6 +430,43 @@ static VALUE nn_set_root(VALUE klass, VALUE document, VALUE element)
352
430
  return element;
353
431
  }
354
432
 
433
+ /* NativeNode surface completion (TODO.perf/05): bulk attribute
434
+ * hash (name => value, one C walk, no per-attr Ruby frames) and
435
+ * the position readers. */
436
+ static VALUE nn_attributes(VALUE self)
437
+ {
438
+ struct native_node *n;
439
+ VALUE hash;
440
+ void *attr;
441
+
442
+ TypedData_Get_Struct(self, struct native_node, &nn_type, n);
443
+ hash = rb_hash_new();
444
+ attr = f_attr_first(n->ptr);
445
+ while (attr) {
446
+ const char *name = f_attr_name(attr);
447
+ const char *value = f_attr_value(n->ptr, attr);
448
+ rb_hash_aset(hash,
449
+ name ? rb_utf8_str_new_cstr(name) : Qnil,
450
+ value ? rb_utf8_str_new_cstr(value) : Qnil);
451
+ attr = f_attr_next(attr);
452
+ }
453
+ return hash;
454
+ }
455
+
456
+ static VALUE nn_line(VALUE self)
457
+ {
458
+ struct native_node *n;
459
+ TypedData_Get_Struct(self, struct native_node, &nn_type, n);
460
+ return UINT2NUM(f_node_line(n->ptr));
461
+ }
462
+
463
+ static VALUE nn_byte_offset(VALUE self)
464
+ {
465
+ struct native_node *n;
466
+ TypedData_Get_Struct(self, struct native_node, &nn_type, n);
467
+ return ULL2NUM((uint64_t)f_node_offset(n->ptr));
468
+ }
469
+
355
470
  static VALUE nn_address(VALUE self)
356
471
  {
357
472
  struct native_node *n;
@@ -359,6 +474,289 @@ static VALUE nn_address(VALUE self)
359
474
  return ULL2NUM((uint64_t)(uintptr_t)n->ptr);
360
475
  }
361
476
 
477
+ /* ---- Bulk children for BINDING wrappers (TODO.perf/01 tail) ----
478
+ * One C pass: children_ex + binding-class dispatch + ivar set +
479
+ * identity-cache check/store. Returns a Ruby Array of Element/
480
+ * Text/... wrappers — Node#children swaps this in when the ext
481
+ * is loaded. Class constants and FFI::Pointer resolve once at
482
+ * Init; the per-node Ruby frames (wrap/construct/memo/alive)
483
+ * disappear. */
484
+ static VALUE c_b_element = Qundef, c_b_text, c_b_comment, c_b_cdata,
485
+ c_b_pi, c_b_node, c_b_result_text, c_b_result_attr,
486
+ c_ffi_pointer;
487
+ static ID id_ptr_new;
488
+
489
+ /* Binding classes resolve LAZILY: Init_native can run before the
490
+ * binding's autoload entries load (require "leptris" flow) —
491
+ * rb_path2class at Init time raises for undefined constants. */
492
+ static void resolve_binding_classes(void)
493
+ {
494
+ if (c_b_element != Qundef)
495
+ return;
496
+ c_b_element = rb_path2class("Leptris::XML::Element");
497
+ c_b_text = rb_path2class("Leptris::XML::Text");
498
+ c_b_comment = rb_path2class("Leptris::XML::Comment");
499
+ c_b_cdata = rb_path2class("Leptris::XML::CDATA");
500
+ c_b_pi = rb_path2class("Leptris::XML::ProcessingInstruction");
501
+ c_b_node = rb_path2class("Leptris::XML::Node");
502
+ c_b_result_text = rb_path2class("Leptris::XML::ResultText");
503
+ c_b_result_attr = rb_path2class("Leptris::XML::ResultAttr");
504
+ c_ffi_pointer = rb_path2class("FFI::Pointer");
505
+ id_ptr_new = rb_intern("new");
506
+ rb_gc_register_mark_object(c_b_element);
507
+ rb_gc_register_mark_object(c_b_text);
508
+ rb_gc_register_mark_object(c_b_comment);
509
+ rb_gc_register_mark_object(c_b_cdata);
510
+ rb_gc_register_mark_object(c_b_pi);
511
+ rb_gc_register_mark_object(c_b_node);
512
+ rb_gc_register_mark_object(c_b_result_text);
513
+ rb_gc_register_mark_object(c_b_result_attr);
514
+ rb_gc_register_mark_object(c_ffi_pointer);
515
+ }
516
+
517
+ static VALUE binding_klass_for(int kind)
518
+ {
519
+ switch (kind) {
520
+ case 0: return c_b_element;
521
+ case 1: return c_b_text;
522
+ case 2: return c_b_comment;
523
+ case 3: return c_b_cdata;
524
+ case 4: return c_b_pi;
525
+ default: return c_b_node;
526
+ }
527
+ }
528
+
529
+ static VALUE bulk_children_impl(VALUE document, VALUE parent_addr,
530
+ int elements_only)
531
+ {
532
+ void **buf;
533
+ int *kinds;
534
+ int count, i;
535
+ VALUE cache, out;
536
+
537
+ count = fetch_children_two_call((void *)(uintptr_t)NUM2ULL(parent_addr),
538
+ &buf, &kinds);
539
+ if (count <= 0) {
540
+ ruby_xfree(buf);
541
+ ruby_xfree(kinds);
542
+ return rb_ary_new2(0);
543
+ }
544
+
545
+ cache = rb_funcall(document, id_binding_cache, 0);
546
+ out = rb_ary_new2(elements_only ? 8 : count);
547
+ for (i = 0; i < count; i++) {
548
+ VALUE key, node;
549
+ if (elements_only && kinds[i] != 0)
550
+ continue;
551
+ key = ULL2NUM((uint64_t)(uintptr_t)buf[i]);
552
+ node = rb_hash_aref(cache, key);
553
+ if (NIL_P(node)) {
554
+ VALUE ptr = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
555
+ node = rb_obj_alloc(binding_klass_for(kinds[i]));
556
+ rb_iv_set(node, "@c_ptr", ptr);
557
+ rb_iv_set(node, "@document", document);
558
+ rb_iv_set(node, "@parent", Qnil);
559
+ rb_iv_set(node, "@node_type", INT2FIX(kinds[i]));
560
+ rb_hash_aset(cache, key, node);
561
+ }
562
+ rb_ary_push(out, node);
563
+ }
564
+ ruby_xfree(buf);
565
+ ruby_xfree(kinds);
566
+ return out;
567
+ }
568
+
569
+ static VALUE nf_bulk_children(VALUE self, VALUE document, VALUE parent_addr)
570
+ {
571
+ (void)self;
572
+ resolve_binding_classes();
573
+ return bulk_children_impl(document, parent_addr, 0);
574
+ }
575
+
576
+ static VALUE nf_bulk_element_children(VALUE self, VALUE document,
577
+ VALUE parent_addr)
578
+ {
579
+ (void)self;
580
+ resolve_binding_classes();
581
+ return bulk_children_impl(document, parent_addr, 1);
582
+ }
583
+
584
+ /* ---- Bulk XPath result materialization (TODO.perf/03) ----------
585
+ * One C pass over leptris_xpath_result_get_nodes_ex: binding-class
586
+ * dispatch (XPATH_NODE space: 0 element, 1 attribute, 2 text,
587
+ * 3 other), value capture for synthetic text/attribute items
588
+ * while the result handle is alive, identity-cache check/store
589
+ * for elements. Returns the Ruby Array. */
590
+ static VALUE m_native_sentinel; /* module object = fallback marker */
591
+
592
+ static VALUE nf_bulk_xpath(VALUE self, VALUE document, VALUE result_ptr_val)
593
+ {
594
+ void *result = (void *)(uintptr_t)NUM2ULL(result_ptr_val);
595
+ void **buf;
596
+ int *kinds;
597
+ int count, i;
598
+ VALUE cache, out;
599
+
600
+ (void)self;
601
+ resolve_binding_classes();
602
+ size_t scount = f_xp_count(result);
603
+ if (scount == 0)
604
+ return rb_ary_new2(0);
605
+
606
+ buf = ruby_xmalloc(scount * sizeof(void *));
607
+ kinds = ruby_xmalloc(scount * sizeof(int));
608
+ scount = f_xp_nodes_ex(result, buf, kinds, scount);
609
+ count = (int)scount;
610
+ if (count <= 0) {
611
+ ruby_xfree(buf);
612
+ ruby_xfree(kinds);
613
+ return rb_ary_new2(0);
614
+ }
615
+ cache = rb_funcall(document, id_binding_cache, 0);
616
+ out = rb_ary_new2(count);
617
+ for (i = 0; i < count; i++) {
618
+ VALUE node, key;
619
+ const char *s;
620
+ if (buf[i] == NULL)
621
+ continue;
622
+ key = ULL2NUM((uint64_t)(uintptr_t)buf[i]);
623
+ switch (kinds[i]) {
624
+ case 0: /* element */
625
+ node = rb_hash_aref(cache, key);
626
+ if (NIL_P(node)) {
627
+ VALUE ptr = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
628
+ node = rb_obj_alloc(c_b_element);
629
+ rb_iv_set(node, "@c_ptr", ptr);
630
+ rb_iv_set(node, "@document", document);
631
+ rb_iv_set(node, "@parent", Qnil);
632
+ rb_iv_set(node, "@node_type", INT2FIX(0));
633
+ rb_hash_aset(cache, key, node);
634
+ }
635
+ break;
636
+ case 1: /* synthetic attribute */
637
+ {
638
+ const char *name = f_xp_node_name(result, i);
639
+ const char *value = f_xp_node_value(result, i);
640
+ VALUE ptr = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
641
+ node = rb_obj_alloc(c_b_result_attr);
642
+ rb_iv_set(node, "@c_ptr", ptr);
643
+ rb_iv_set(node, "@document", document);
644
+ rb_iv_set(node, "@attr_name",
645
+ name ? rb_utf8_str_new_cstr(name) : Qnil);
646
+ rb_iv_set(node, "@attr_value",
647
+ value ? rb_utf8_str_new_cstr(value) : Qnil);
648
+ }
649
+ break;
650
+ case 2: /* text-kind: synthetic sequence carrier, or a real
651
+ * Text/CDATA node (XPath reports both as TEXT) —
652
+ * dispatch on the node's own type like Node.wrap. */
653
+ {
654
+ int nt = f_node_type(buf[i]);
655
+ if (nt == 8) { /* NODE_SYNTHETIC_TEXT */
656
+ s = f_xp_node_value(result, i);
657
+ node = rb_obj_alloc(c_b_result_text);
658
+ rb_iv_set(node, "@c_ptr",
659
+ rb_funcall(c_ffi_pointer, id_ptr_new, 1, key));
660
+ rb_iv_set(node, "@document", document);
661
+ rb_iv_set(node, "@value",
662
+ s ? rb_utf8_str_new_cstr(s) : Qnil);
663
+ } else {
664
+ VALUE ptr = rb_funcall(c_ffi_pointer, id_ptr_new, 1, key);
665
+ node = rb_hash_aref(cache, key);
666
+ if (NIL_P(node)) {
667
+ node = rb_obj_alloc(binding_klass_for(nt));
668
+ rb_iv_set(node, "@c_ptr", ptr);
669
+ rb_iv_set(node, "@document", document);
670
+ rb_iv_set(node, "@parent", Qnil);
671
+ rb_iv_set(node, "@node_type", INT2FIX(nt));
672
+ rb_hash_aset(cache, key, node);
673
+ }
674
+ }
675
+ }
676
+ break;
677
+ default: /* other: rare — signal Ruby fallback for this index */
678
+ rb_ary_push(out, m_native_sentinel);
679
+ continue;
680
+ }
681
+ rb_ary_push(out, node);
682
+ }
683
+ ruby_xfree(buf);
684
+ ruby_xfree(kinds);
685
+ return out;
686
+ }
687
+
688
+ /* ---- Ext-bound serialization (TODO.perf/04) ---------------------
689
+ * The whole sized-buffer cycle in C: build SerializeOptions on
690
+ * the stack, call (buf, cap, NULL status, opts), grow if needed,
691
+ * return a UTF-8 String. No FFI marshaling, no Ruby buffer
692
+ * management per to_xml call. */
693
+ struct serialize_opts {
694
+ int indent;
695
+ int xml_declaration;
696
+ const char *encoding;
697
+ };
698
+
699
+ /* Grow-only scratch for large serializations. Safe under the GVL:
700
+ * synchronous C calls hold it, so no two Ruby threads run this
701
+ * concurrently. */
702
+ static char *ser_buf;
703
+ static size_t ser_cap;
704
+
705
+ static VALUE fast_serialize(serialize_into_fn fn, void *node,
706
+ int indent, int xml_declaration)
707
+ {
708
+ struct serialize_opts opts;
709
+ char stack_buf[4096];
710
+ size_t needed;
711
+
712
+ char *probe;
713
+ size_t probe_cap;
714
+
715
+ opts.indent = indent;
716
+ opts.xml_declaration = xml_declaration;
717
+ opts.encoding = NULL;
718
+ /* Probe the largest buffer we own: a big scratch from a prior
719
+ * call usually fits (single serialization); cold calls start
720
+ * on the stack buffer. */
721
+ if (ser_cap > sizeof(stack_buf)) {
722
+ probe = ser_buf;
723
+ probe_cap = ser_cap;
724
+ } else {
725
+ probe = stack_buf;
726
+ probe_cap = sizeof(stack_buf);
727
+ }
728
+ needed = fn(node, probe, probe_cap, NULL, &opts);
729
+ if (needed == 0)
730
+ return rb_utf8_str_new_cstr("");
731
+ if (needed <= probe_cap)
732
+ return rb_utf8_str_new(probe, needed - 1);
733
+ if (needed > ser_cap) {
734
+ ser_buf = ruby_xrealloc(ser_buf, needed);
735
+ ser_cap = needed;
736
+ }
737
+ needed = fn(node, ser_buf, ser_cap, NULL, &opts);
738
+ return needed > 0 ? rb_utf8_str_new(ser_buf, needed - 1)
739
+ : rb_utf8_str_new_cstr("");
740
+ }
741
+
742
+ static VALUE nf_fast_document_xml(VALUE self, VALUE addr,
743
+ VALUE indent, VALUE decl)
744
+ {
745
+ (void)self;
746
+ return fast_serialize(f_doc_serialize,
747
+ (void *)(uintptr_t)NUM2ULL(addr),
748
+ NUM2INT(indent), RTEST(decl) ? 1 : 0);
749
+ }
750
+
751
+ static VALUE nf_fast_element_xml(VALUE self, VALUE addr,
752
+ VALUE indent, VALUE decl)
753
+ {
754
+ (void)self;
755
+ return fast_serialize(f_elem_serialize,
756
+ (void *)(uintptr_t)NUM2ULL(addr),
757
+ NUM2INT(indent), RTEST(decl) ? 1 : 0);
758
+ }
759
+
362
760
  /* ---- Address-based fast readers (TODO.perf/01) -----------------
363
761
  * The DEFAULT binding classes call these when the bundle is
364
762
  * loaded: one C-API dispatch + rb_utf8_str_new_cstr — no FFI
@@ -432,6 +830,7 @@ void Init_native(void)
432
830
  rb_undef_alloc_func(c_native_node);
433
831
 
434
832
  id_wrapper_cache = rb_intern("native_cache");
833
+ id_binding_cache = rb_intern("wrapper_cache");
435
834
 
436
835
  rb_define_singleton_method(c_native_node, "from", nn_from, 2);
437
836
  rb_define_singleton_method(c_native_node, "create_element", nn_create_element, 2);
@@ -441,6 +840,9 @@ void Init_native(void)
441
840
  rb_define_method(c_native_node, "append_child", nn_append_child, 1);
442
841
  rb_define_method(c_native_node, "add_child", nn_append_child, 1);
443
842
  rb_define_method(c_native_node, "address", nn_address, 0);
843
+ rb_define_method(c_native_node, "attributes", nn_attributes, 0);
844
+ rb_define_method(c_native_node, "line", nn_line, 0);
845
+ rb_define_method(c_native_node, "byte_offset", nn_byte_offset, 0);
444
846
  rb_define_method(c_native_node, "name", nn_name, 0);
445
847
  rb_define_method(c_native_node, "content", nn_content, 0);
446
848
  rb_define_method(c_native_node, "attribute", nn_attribute, 1);
@@ -454,8 +856,17 @@ void Init_native(void)
454
856
  leptris_native_resolve_rb, 1);
455
857
 
456
858
  m_native = rb_define_module_under(m_xml, "Native");
859
+ m_native_sentinel = m_native;
457
860
  rb_define_module_function(m_native, "fast_name", nf_name, 1);
458
861
  rb_define_module_function(m_native, "fast_element_text", nf_element_text, 1);
459
862
  rb_define_module_function(m_native, "fast_attribute", nf_attribute, 2);
460
863
  rb_define_module_function(m_native, "fast_prefix", nf_prefix, 1);
864
+ rb_define_module_function(m_native, "bulk_children", nf_bulk_children, 2);
865
+ rb_define_module_function(m_native, "bulk_element_children",
866
+ nf_bulk_element_children, 2);
867
+ rb_define_module_function(m_native, "bulk_xpath", nf_bulk_xpath, 2);
868
+ rb_define_module_function(m_native, "fast_document_xml",
869
+ nf_fast_document_xml, 3);
870
+ rb_define_module_function(m_native, "fast_element_xml",
871
+ nf_fast_element_xml, 3);
461
872
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.162.9"
4
+ VERSION = "1.9.163.2"
5
5
  end
@@ -14,6 +14,8 @@
14
14
  # installs without the compiled bundle (ruby-platform gem) raise
15
15
  # a clear LoadError from the require.
16
16
 
17
+ require "ffi" unless defined?(::FFI::Library)
18
+
17
19
  begin
18
20
  # The compiled bundle (feature "leptris/xml/native" resolves
19
21
  # native.bundle). A sanctioned require exception: a .bundle
@@ -52,7 +52,12 @@ class Leptris::XML::Node
52
52
  cache = document.wrapper_cache
53
53
  address = c_ptr.address
54
54
  if (cached = cache[address])
55
- return cached
55
+ # The shared cache also holds NativeNodes (opt-in layer).
56
+ # Only return a hit when it is a binding Node subclass —
57
+ # otherwise fall through and construct the binding wrapper
58
+ # (both views of the same C node can coexist; the native
59
+ # entry stays under its own key-path via NativeNode.from).
60
+ return cached if cached.is_a?(Leptris::XML::Node)
56
61
  end
57
62
  end
58
63
 
@@ -125,6 +130,13 @@ class Leptris::XML::Node
125
130
 
126
131
  def element?; type == Leptris::XML::FFI::NODE_ELEMENT; end
127
132
 
133
+ # The ext bulk path applies to document-owned trees only:
134
+ # scope-owned (iterparse) elements ride the IterationScope seam.
135
+ def native_fast_children?
136
+ defined?(Leptris::XML::NATIVE_FAST) &&
137
+ !@document.is_a?(Leptris::XML::IterationScope)
138
+ end
139
+
128
140
  # Content-defined 64-bit Merkle digest of this subtree
129
141
  # (libleptris 1.9.99, #869): element name/prefix/resolved
130
142
  # namespace URI, attributes sorted and first-wins-deduplicated,
@@ -228,6 +240,18 @@ class Leptris::XML::Node
228
240
  # (leptris_node_children_ex), so no per-child get_type.
229
241
  return @children if memo_hit?(@children_version)
230
242
  ensure_alive!
243
+ if native_fast_children?
244
+ # TODO.perf/01 tail: one C pass constructs every binding
245
+ # wrapper (class dispatch + ivars + identity cache) — the
246
+ # per-child Ruby wrap frames disappear.
247
+ nodes = Leptris::XML::Native.bulk_children(@document, @c_ptr.address)
248
+ result = Leptris::XML::NodeSet.new(@document, nodes)
249
+ if @document
250
+ @children = result
251
+ @children_version = @document.version
252
+ end
253
+ return result
254
+ end
231
255
  parent = as_element_or_self
232
256
  pointers, kinds = Leptris::XML::FFI.fetch_children(@c_ptr)
233
257
  nodes = Array.new(pointers.size) do |i|
@@ -303,7 +327,9 @@ class Leptris::XML::Node
303
327
  # children are never wrapped (nor their get_type paid — the
304
328
  # ELEMENT hint rides along). Other nodes keep the filter.
305
329
  result =
306
- if is_a?(Leptris::XML::Element)
330
+ if native_fast_children?
331
+ Leptris::XML::Native.bulk_element_children(@document, @c_ptr.address)
332
+ elsif is_a?(Leptris::XML::Element)
307
333
  parent = as_element_or_self
308
334
  Leptris::XML::FFI.fetch_element_children(@c_ptr).map do |ptr|
309
335
  Leptris::XML::Node.wrap(ptr, @document, parent: parent,
@@ -71,6 +71,11 @@ class Leptris::XML::NodeSet
71
71
 
72
72
  # Sequence/map/array items ride synthetic text nodes whose value
73
73
  # only the live result handle can serve — capture it now.
74
+ def native_fast?
75
+ defined?(Leptris::XML::NATIVE_FAST)
76
+ end
77
+ private :native_fast?
78
+
74
79
  def text_item_value(kind, index)
75
80
  case kind
76
81
  when Leptris::XML::FFI::XPATH_NODE_TEXT
@@ -95,7 +100,37 @@ class Leptris::XML::NodeSet
95
100
  unless @array
96
101
  return self unless @result_ptr
97
102
  n = length
98
- if n > 0
103
+ if n > 0 && native_fast?
104
+ # TODO.perf/03: one C pass materializes the whole result —
105
+ # class dispatch, synthetic text/attr value capture, and
106
+ # element identity-cache check/store in the ext. Qundef
107
+ # slots (rare non-element/attr/text kinds) and the >512
108
+ # remainder fall back to the per-index path below.
109
+ fast = Leptris::XML::Native.bulk_xpath(
110
+ @document, @result_ptr.to_i)
111
+ nodes = []
112
+ fast.each_with_index do |node, i|
113
+ if node.equal?(Leptris::XML::Native)
114
+ ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, i)
115
+ next if ptr.null?
116
+ nodes << Leptris::XML::Node.wrap(
117
+ ptr, @document,
118
+ result_value: text_item_value(
119
+ Leptris::XML::FFI.leptris_xpath_result_node_kind(@result_ptr, i), i))
120
+ else
121
+ nodes << node
122
+ end
123
+ end
124
+ (fast.length...n).each do |i|
125
+ ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(@result_ptr, i)
126
+ next if ptr.null?
127
+ nodes << Leptris::XML::Node.wrap(
128
+ ptr, @document,
129
+ result_value: text_item_value(
130
+ Leptris::XML::FFI.leptris_xpath_result_node_kind(@result_ptr, i), i))
131
+ end
132
+ @array = nodes
133
+ elsif n > 0
99
134
  # Batch-fetch all node pointers through the FFI seam
100
135
  # (leptris_xpath_result_get_nodes_ex copies every node kind,
101
136
  # not just elements); the batch accessor under-copies
@@ -32,6 +32,18 @@ module Leptris::XML::Serialization
32
32
  # cycle lives at the FFI seam (FFI.serialize_into_string); this
33
33
  # module owns only options selection and construction.
34
34
  def self.to_xml(ffi_function, c_ptr, indent: 0, no_decl: false, encoding: nil)
35
+ # TODO.perf/04: the common shape (no encoding) runs the whole
36
+ # sized-buffer cycle in the ext — one C dispatch, no options
37
+ # marshaling, no Ruby buffer management.
38
+ if defined?(Leptris::XML::NATIVE_FAST) && encoding.nil?
39
+ decl = !no_decl
40
+ return Leptris::XML::Native.fast_element_xml(
41
+ c_ptr.address, indent.to_i, decl) if
42
+ ffi_function == ELEMENT_SERIALIZE_INTO
43
+ return Leptris::XML::Native.fast_document_xml(
44
+ c_ptr.address, indent.to_i, decl) if
45
+ ffi_function == DOCUMENT_SERIALIZE_INTO
46
+ end
35
47
  opts =
36
48
  if indent.to_i.zero? && !no_decl && encoding.nil?
37
49
  DEFAULT_OPTIONS
@@ -47,6 +59,8 @@ module Leptris::XML::Serialization
47
59
  # loop: no kwargs, no options rebuild, no method allocation —
48
60
  # one FFI fast-path dispatch per child.
49
61
  def self.element_xml_default(c_ptr)
62
+ return Leptris::XML::Native.fast_element_xml(c_ptr.address, 0, true) if
63
+ defined?(Leptris::XML::NATIVE_FAST)
50
64
  Leptris::XML::FFI.serialize_into_string(
51
65
  ELEMENT_SERIALIZE_INTO, c_ptr, DEFAULT_OPTIONS.pointer)
52
66
  end
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.162.9
4
+ version: 1.9.163.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.