makiri 0.3.0 → 0.4.0

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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/conformance.yml +22 -0
  3. data/.github/workflows/libfuzzer.yml +83 -0
  4. data/.github/workflows/security.yml +88 -3
  5. data/.github/workflows/valgrind.yml +135 -0
  6. data/CHANGELOG.md +60 -2
  7. data/README.md +81 -77
  8. data/Rakefile +194 -3
  9. data/ext/makiri/bridge/ruby_string.c +119 -66
  10. data/ext/makiri/core/mkr_alloc.c +40 -3
  11. data/ext/makiri/core/mkr_alloc.h +27 -4
  12. data/ext/makiri/core/mkr_buf.c +13 -3
  13. data/ext/makiri/core/mkr_buf.h +80 -5
  14. data/ext/makiri/core/mkr_core.c +143 -0
  15. data/ext/makiri/core/mkr_core.h +10 -1
  16. data/ext/makiri/core/mkr_span.h +186 -0
  17. data/ext/makiri/core/mkr_utf8.c +101 -0
  18. data/ext/makiri/core/mkr_utf8.h +88 -0
  19. data/ext/makiri/extconf.rb +104 -9
  20. data/ext/makiri/fuzz/Makefile +95 -0
  21. data/ext/makiri/fuzz/check_fuzzer.cc +4 -0
  22. data/ext/makiri/fuzz/xml_fuzz.c +24 -0
  23. data/ext/makiri/fuzz/xpath_fuzz.c +109 -0
  24. data/ext/makiri/glue/glue.h +8 -0
  25. data/ext/makiri/glue/ruby_doc.c +20 -24
  26. data/ext/makiri/glue/ruby_html_css.c +58 -12
  27. data/ext/makiri/glue/ruby_html_mutate.c +11 -6
  28. data/ext/makiri/glue/ruby_html_node.c +3 -32
  29. data/ext/makiri/glue/ruby_node.c +39 -0
  30. data/ext/makiri/glue/ruby_xml.c +198 -16
  31. data/ext/makiri/glue/ruby_xml_node.c +46 -59
  32. data/ext/makiri/glue/ruby_xpath.c +4 -4
  33. data/ext/makiri/lexbor_compat/source_loc.c +14 -16
  34. data/ext/makiri/lexbor_compat/utf8_input.c +5 -78
  35. data/ext/makiri/makiri.c +45 -0
  36. data/ext/makiri/xml/mkr_xml.h +2 -3
  37. data/ext/makiri/xml/mkr_xml_chars.c +67 -97
  38. data/ext/makiri/xml/mkr_xml_index.c +169 -0
  39. data/ext/makiri/xml/mkr_xml_index.h +48 -0
  40. data/ext/makiri/xml/mkr_xml_mutate.c +63 -121
  41. data/ext/makiri/xml/mkr_xml_node.c +147 -15
  42. data/ext/makiri/xml/mkr_xml_node.h +71 -6
  43. data/ext/makiri/xml/mkr_xml_tree.c +185 -149
  44. data/ext/makiri/xpath/mkr_css.c +1023 -0
  45. data/ext/makiri/xpath/mkr_css.h +65 -0
  46. data/ext/makiri/xpath/mkr_xpath.c +37 -0
  47. data/ext/makiri/xpath/mkr_xpath.h +13 -0
  48. data/ext/makiri/xpath/mkr_xpath_eval_body.h +373 -90
  49. data/ext/makiri/xpath/mkr_xpath_funcs_body.h +249 -231
  50. data/ext/makiri/xpath/mkr_xpath_internal.h +89 -9
  51. data/ext/makiri/xpath/mkr_xpath_lex.c +94 -124
  52. data/ext/makiri/xpath/mkr_xpath_node_access_xml.h +6 -3
  53. data/ext/makiri/xpath/mkr_xpath_number.c +109 -0
  54. data/ext/makiri/xpath/mkr_xpath_parse.c +79 -90
  55. data/ext/makiri/xpath/mkr_xpath_shared.c +40 -24
  56. data/ext/makiri/xpath/mkr_xpath_value_body.h +50 -24
  57. data/lib/makiri/cdata_section.rb +1 -3
  58. data/lib/makiri/comment.rb +1 -3
  59. data/lib/makiri/document.rb +8 -0
  60. data/lib/makiri/element.rb +1 -3
  61. data/lib/makiri/processing_instruction.rb +1 -3
  62. data/lib/makiri/text.rb +1 -3
  63. data/lib/makiri/version.rb +1 -1
  64. data/lib/makiri/xml/builder.rb +263 -0
  65. data/lib/makiri/xml/node_methods.rb +47 -0
  66. data/lib/makiri.rb +1 -0
  67. data/script/check_alloc_failures.rb +266 -0
  68. data/script/check_c_safety.rb +45 -2
  69. data/script/check_c_safety_allowlist.yml +19 -0
  70. data/script/check_leaks.rb +64 -0
  71. data/script/leaks_harness.rb +64 -0
  72. data/vendor/lexbor/CMakeLists.txt +6 -0
  73. data/vendor/lexbor/README.md +12 -0
  74. data/vendor/lexbor/config.cmake +1 -1
  75. data/vendor/lexbor/source/lexbor/core/base.h +1 -1
  76. data/vendor/lexbor/source/lexbor/core/config.cmake +9 -1
  77. data/vendor/lexbor/source/lexbor/css/selectors/pseudo_state.c +2 -3
  78. data/vendor/lexbor/source/lexbor/css/selectors/state.c +3 -0
  79. data/vendor/lexbor/source/lexbor/dom/interfaces/element.c +21 -0
  80. data/vendor/lexbor/source/lexbor/dom/interfaces/element.h +5 -0
  81. data/vendor/lexbor/source/lexbor/encoding/decode.c +33 -4
  82. data/vendor/lexbor/source/lexbor/html/base.h +1 -1
  83. data/vendor/lexbor/source/lexbor/html/interfaces/select_element.c +4 -0
  84. data/vendor/lexbor/source/lexbor/html/serialize.c +545 -41
  85. data/vendor/lexbor/source/lexbor/html/serialize.h +2 -1
  86. data/vendor/lexbor/source/lexbor/html/tokenizer.h +2 -2
  87. data/vendor/lexbor/source/lexbor/html/tree/insertion_mode/in_body.c +1 -1
  88. data/vendor/lexbor/source/lexbor/html/tree.c +6 -6
  89. data/vendor/lexbor/source/lexbor/selectors/selectors.c +12 -3
  90. data/vendor/lexbor/source/lexbor/url/base.h +1 -1
  91. data/vendor/lexbor/source/lexbor/url/url.c +5 -2
  92. data/vendor/lexbor/source/lexbor/url/url.h +9 -0
  93. data/vendor/lexbor/version +1 -1
  94. metadata +19 -1
@@ -729,19 +729,9 @@ mkr_node_line(VALUE self)
729
729
  /* ------------------------------------------------------------------ */
730
730
  /* identity */
731
731
  /* ------------------------------------------------------------------ */
732
-
733
- /* Pointer identity: equal iff both wrap the same lxb_dom_node_t. */
734
- static VALUE
735
- mkr_node_equals(VALUE self, VALUE other)
736
- {
737
- if (!rb_obj_is_kind_of(other, mkr_cNode)) {
738
- return Qfalse;
739
- }
740
- /* Identity by pointer, kind-agnostic (an HTML node is simply never equal to an
741
- * XML node) - mkr_node_id never dereferences, so comparing across
742
- * representations is safe. */
743
- return mkr_node_id(self) == mkr_node_id(other) ? Qtrue : Qfalse;
744
- }
732
+ /* ==/eql?/hash/pointer_id are representation-neutral (mkr_node_id only) and live
733
+ * in ruby_node.c (the shared node core); this file just binds them below. The
734
+ * HTML-only <=> (document-order comparison) stays here. */
745
735
 
746
736
  /* Distance from `n` to the root (a node with no parent). */
747
737
  static size_t
@@ -812,25 +802,6 @@ mkr_node_spaceship(VALUE self, VALUE other)
812
802
  return Qnil; /* unreachable for a well-formed tree */
813
803
  }
814
804
 
815
- /* Nokogiri-compatible identity: the underlying lxb_dom_node_t pointer as an
816
- * Integer. Stable for the node's lifetime and unique among currently-live
817
- * nodes; a freed-then-reallocated node may reuse an address (same caveat as
818
- * Nokogiri::XML::Node#pointer_id). a.pointer_id == b.pointer_id iff a.eql?(b). */
819
- static VALUE
820
- mkr_node_pointer_id(VALUE self)
821
- {
822
- return ULL2NUM((unsigned long long)mkr_node_id(self));
823
- }
824
-
825
- /* Stable hash derived from the node pointer, so a == b implies a.hash ==
826
- * b.hash even across separately-created wrappers. Shares the pointer value
827
- * with #pointer_id. */
828
- static VALUE
829
- mkr_node_hash(VALUE self)
830
- {
831
- return mkr_node_pointer_id(self);
832
- }
833
-
834
805
  void
835
806
  mkr_init_node(void)
836
807
  {
@@ -104,3 +104,42 @@ mkr_node_document(VALUE rb_node)
104
104
  TypedData_Get_Struct(rb_node, mkr_node_data_t, &mkr_node_type, nd);
105
105
  return nd->document;
106
106
  }
107
+
108
+ /* ------------------------------------------------------------------ */
109
+ /* identity (representation-neutral) */
110
+ /* ------------------------------------------------------------------ */
111
+ /* These depend only on the kind-agnostic mkr_node_id (which never dereferences a
112
+ * node), so they are identical for HTML and XML and live here, in the shared
113
+ * node core, rather than duplicated in each representation's reader file. The
114
+ * HTML and XML NodeMethods modules both bind their ==/eql?/hash/pointer_id to
115
+ * these. */
116
+
117
+ /* Pointer identity: equal iff both wrappers resolve to the same node pointer (an
118
+ * HTML node is thus never equal to an XML node). */
119
+ VALUE
120
+ mkr_node_equals(VALUE self, VALUE other)
121
+ {
122
+ if (!rb_obj_is_kind_of(other, mkr_cNode)) {
123
+ return Qfalse;
124
+ }
125
+ return mkr_node_id(self) == mkr_node_id(other) ? Qtrue : Qfalse;
126
+ }
127
+
128
+ /* Nokogiri-compatible identity: the underlying node pointer as an Integer. Stable
129
+ * for the node's lifetime and unique among currently-live nodes; a
130
+ * freed-then-reallocated node may reuse an address (same caveat as
131
+ * Nokogiri::XML::Node#pointer_id). a.pointer_id == b.pointer_id iff a.eql?(b). */
132
+ VALUE
133
+ mkr_node_pointer_id(VALUE self)
134
+ {
135
+ return ULL2NUM((unsigned long long)mkr_node_id(self));
136
+ }
137
+
138
+ /* Stable hash derived from the node pointer, so a == b implies a.hash == b.hash
139
+ * even across separately-created wrappers. Shares the pointer value with
140
+ * #pointer_id. */
141
+ VALUE
142
+ mkr_node_hash(VALUE self)
143
+ {
144
+ return mkr_node_pointer_id(self);
145
+ }
@@ -14,9 +14,38 @@
14
14
  #include "glue.h" /* mkr_wrap_document, mkr_parsed_* (via compat.h) */
15
15
  #include "ruby_xpath.h" /* mkr_xpath_value_to_ruby / mkr_xpath_raise (shared) */
16
16
  #include "../xpath/mkr_xpath.h"
17
+ #include "../xpath/mkr_css.h" /* mkr_css_compile - CSS selectors over XML via the XPath engine */
18
+ #include "../xml/mkr_xml_index.h" /* element-name index for the //name fast path */
17
19
 
18
20
  #include <ruby/thread.h>
19
21
 
22
+ /* void-typed adapters so the engine's representation-neutral name-index hooks
23
+ * can reach the XML element-name index (mkr_xml_index.c) without the engine
24
+ * knowing its concrete types. */
25
+ static void *
26
+ mkr_xml_name_index_get_v(void *owner)
27
+ {
28
+ return mkr_xml_name_index_get((mkr_xml_doc_t *)owner);
29
+ }
30
+
31
+ static void *const *
32
+ mkr_xml_name_index_lookup_v(const void *idx, const char *local, size_t local_len,
33
+ const char *ns_uri, size_t ns_uri_len, size_t *count)
34
+ {
35
+ return (void *const *)mkr_xml_name_index_lookup(
36
+ (const mkr_xml_name_index_t *)idx, local, local_len, ns_uri, ns_uri_len, count);
37
+ }
38
+
39
+ /* Install the document's element-name index hooks on +ctx+ (the engine lazily
40
+ * builds it only when a document-rooted descendant name test runs). */
41
+ static void
42
+ mkr_xml_install_name_index(mkr_xpath_context_t *ctx, mkr_xml_doc_t *xdoc)
43
+ {
44
+ mkr_xpath_context_set_name_index(ctx, (void *)xdoc,
45
+ mkr_xml_name_index_get_v,
46
+ mkr_xml_name_index_lookup_v);
47
+ }
48
+
20
49
  /* ---- GVL-released parse ---- */
21
50
  typedef struct {
22
51
  const char *src;
@@ -93,23 +122,26 @@ mkr_xml_s_parse(int argc, VALUE *argv, VALUE self)
93
122
  * document is not materialised twice for a doomed parse. */
94
123
  VALUE decoded = mkr_xml_decode_input(rb_String(rb_source), budget);
95
124
 
96
- /* Build an empty XML handle and wrap it first (doc == NULL) so a failure
97
- * mid-parse frees cleanly via GC (mkr_parsed_destroy -> the XML branch ->
98
- * mkr_xml_doc_destroy(NULL), a no-op). */
125
+ /* Copy the decoded bytes into a private C buffer up front - BEFORE allocating
126
+ * any Ruby object (the wrap below) - so there is NO GC point between obtaining
127
+ * +decoded+ and copying it, and the parse can then run with the GVL released
128
+ * without racing GC/compaction on the String's backing store. */
129
+ mkr_owned_bytes_t source = {0};
130
+ if (mkr_ruby_copy_bytes(decoded, &source) != 0) {
131
+ rb_raise(mkr_eError, "out of memory copying XML source");
132
+ }
133
+
134
+ /* Build an empty XML handle and wrap it (doc == NULL) so a failure mid-parse
135
+ * frees cleanly via GC (mkr_parsed_destroy -> the XML branch ->
136
+ * mkr_xml_doc_destroy(NULL), a no-op). The source is already copied, so this
137
+ * Ruby allocation cannot disturb it. */
99
138
  mkr_parsed_t *parsed = mkr_parsed_new_xml(NULL);
100
139
  if (parsed == NULL) {
140
+ mkr_owned_bytes_clear(&source);
101
141
  rb_raise(mkr_eError, "out of memory allocating XML document");
102
142
  }
103
143
  VALUE obj = mkr_wrap_document(parsed); /* GC owns +parsed+ from here */
104
144
 
105
- /* Copy the decoded bytes so the parse can run with the GVL released without
106
- * racing GC/compaction on the String's backing store. */
107
- mkr_owned_bytes_t source = {0};
108
- if (mkr_ruby_copy_bytes(decoded, &source) != 0) {
109
- rb_raise(mkr_eError, "out of memory copying XML source");
110
- }
111
- RB_GC_GUARD(decoded);
112
-
113
145
  mkr_xml_parse_nogvl_t args = { source.ptr, source.len, limits, NULL, MKR_XML_OK };
114
146
  rb_thread_call_without_gvl(mkr_xml_parse_nogvl, &args, NULL, NULL);
115
147
  mkr_owned_bytes_clear(&source);
@@ -124,7 +156,6 @@ mkr_xml_s_parse(int argc, VALUE *argv, VALUE self)
124
156
  }
125
157
  }
126
158
  mkr_parsed_set_xml_doc(parsed, args.result);
127
- RB_GC_GUARD(obj);
128
159
  return obj;
129
160
  }
130
161
 
@@ -182,7 +213,6 @@ mkr_xml_register_query_namespaces(mkr_xpath_context_t *ctx, VALUE rb_ns)
182
213
  rb_raise(mkr_eError, "failed to register namespace");
183
214
  }
184
215
  }
185
- RB_GC_GUARD(keys);
186
216
  }
187
217
 
188
218
  /* Makiri::XML::{Document,*}#xpath(expr, namespaces = nil) / #at_xpath(...):
@@ -211,6 +241,7 @@ mkr_xml_doc_xpath_run(VALUE self, VALUE rb_expr, VALUE rb_ns, int first_only)
211
241
  rb_raise(mkr_eError, "failed to allocate XPath context");
212
242
  }
213
243
  mkr_xpath_set_engine_kind(ctx, 1);
244
+ mkr_xml_install_name_index(ctx, xdoc);
214
245
  mkr_xml_register_query_namespaces(ctx, rb_ns); /* frees ctx + raises on error */
215
246
 
216
247
  /* Mint the borrowed expression view AFTER namespace registration: that step
@@ -260,6 +291,153 @@ mkr_xml_doc_at_xpath(int argc, VALUE *argv, VALUE self)
260
291
  return mkr_xml_doc_xpath_run(self, expr, ns, 1);
261
292
  }
262
293
 
294
+ /* ---- CSS selectors over XML (lowered to the native XPath engine) ----
295
+ *
296
+ * Makiri::XML CSS support compiles a selector to the engine's AST (mkr_css.c)
297
+ * and runs it through the SAME evaluator as #xpath, so case-sensitivity,
298
+ * namespaces, budgets and document order are identical. The Ruby wrappers
299
+ * (lib/makiri/xml/node_methods.rb) collect the document's namespaces and pass a
300
+ * normalised {prefix => uri} hash; the default namespace, if any, arrives under
301
+ * the synthetic prefix "xmlns" (Nokogiri's convention), and a bare type selector
302
+ * binds to it. These are the private `_css` / `_at_css` / `_css_matches?`
303
+ * primitives those wrappers call. */
304
+
305
+ /* The synthetic default-namespace prefix, present iff the (already
306
+ * prefix-normalised) namespace hash carries an "xmlns" key. */
307
+ static const char *
308
+ mkr_css_default_prefix(VALUE rb_ns)
309
+ {
310
+ if (RB_TYPE_P(rb_ns, T_HASH)
311
+ && !NIL_P(rb_hash_aref(rb_ns, rb_str_new_cstr(MKR_CSS_DEFAULT_NS_PREFIX)))) {
312
+ return MKR_CSS_DEFAULT_NS_PREFIX;
313
+ }
314
+ return NULL;
315
+ }
316
+
317
+ /* Compile +rb_selector+ to an AST under +ctx+ (its namespaces already
318
+ * registered). On a CSS syntax error raises Makiri::CSS::SyntaxError; on any
319
+ * other engine error frees ctx and raises via the shared raiser. Returns the AST
320
+ * (caller frees with mkr_node_free). */
321
+ static mkr_node_t *
322
+ mkr_css_compile_or_raise(mkr_xpath_context_t *ctx, VALUE rb_selector, VALUE rb_ns)
323
+ {
324
+ mkr_css_ns_t cns = { mkr_css_default_prefix(rb_ns) };
325
+ mkr_ruby_borrowed_text_t sv = mkr_ruby_verified_text(rb_selector, "CSS selector");
326
+ mkr_xpath_error_t error = {0};
327
+ mkr_xpath_limits_t *limits = mkr_ctx_limits(ctx);
328
+ limits->ast_nodes = 0;
329
+ mkr_node_t *ast = mkr_css_compile(mkr_verified_text_from_view(sv), &cns, limits, &error);
330
+ RB_GC_GUARD(sv.value);
331
+ if (ast == NULL) {
332
+ int syntax = (error.status == MKR_XPATH_ERR_SYNTAX);
333
+ mkr_xpath_context_free(ctx);
334
+ if (syntax) {
335
+ VALUE msg = error.message ? rb_utf8_str_new_cstr(error.message)
336
+ : rb_str_new_cstr("invalid CSS selector");
337
+ mkr_xpath_error_clear(&error);
338
+ rb_raise(mkr_eCSSSyntaxError, "%" PRIsVALUE, msg);
339
+ }
340
+ mkr_xpath_raise(&error); /* frees error.message, never returns */
341
+ }
342
+ return ast;
343
+ }
344
+
345
+ static VALUE
346
+ mkr_xml_doc_css_run(VALUE self, VALUE rb_selector, VALUE rb_ns, int first_only)
347
+ {
348
+ VALUE document = Qnil;
349
+ mkr_xml_node_t *context = mkr_xml_query_context(self, &document);
350
+ if (context == NULL) {
351
+ return first_only ? Qnil : mkr_node_set_new(document);
352
+ }
353
+ mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(document));
354
+
355
+ mkr_xpath_context_t *ctx =
356
+ mkr_xpath_context_new((void *)xdoc->doc_node, (void *)context);
357
+ if (ctx == NULL) {
358
+ rb_raise(mkr_eError, "failed to allocate XPath context");
359
+ }
360
+ mkr_xpath_set_engine_kind(ctx, 1);
361
+ mkr_xml_install_name_index(ctx, xdoc);
362
+ mkr_xml_register_query_namespaces(ctx, rb_ns); /* frees ctx + raises on error */
363
+
364
+ mkr_node_t *ast = mkr_css_compile_or_raise(ctx, rb_selector, rb_ns); /* frees ctx + raises on error */
365
+
366
+ mkr_xpath_value_t value = {0};
367
+ mkr_xpath_error_t error = {0};
368
+ int rc = first_only ? mkr_xpath_eval_compiled_first(ctx, ast, &value, &error)
369
+ : mkr_xpath_eval_compiled(ctx, ast, &value, &error);
370
+ mkr_node_free(ast);
371
+ if (rc != 0) {
372
+ mkr_xpath_context_free(ctx);
373
+ mkr_xpath_raise(&error);
374
+ }
375
+ VALUE result = mkr_xpath_value_to_ruby(&value, document); /* converts AND clears value */
376
+ mkr_xpath_context_free(ctx);
377
+
378
+ if (first_only && rb_obj_is_kind_of(result, mkr_cNodeSet)) {
379
+ return rb_funcall(result, rb_intern("first"), 0);
380
+ }
381
+ return result;
382
+ }
383
+
384
+ static VALUE
385
+ mkr_xml_node_css(VALUE self, VALUE selector, VALUE ns)
386
+ {
387
+ return mkr_xml_doc_css_run(self, selector, ns, 0);
388
+ }
389
+
390
+ static VALUE
391
+ mkr_xml_node_at_css(VALUE self, VALUE selector, VALUE ns)
392
+ {
393
+ return mkr_xml_doc_css_run(self, selector, ns, 1);
394
+ }
395
+
396
+ /* #matches?(selector): does THIS node match the selector? Evaluated by selecting
397
+ * every matching node in the whole document (context = the document node, so the
398
+ * descendant-rooted selector scans the entire tree) and testing membership by
399
+ * node identity - the full-combinator-correct semantics. */
400
+ static VALUE
401
+ mkr_xml_node_css_matches(VALUE self, VALUE selector, VALUE ns)
402
+ {
403
+ VALUE document = Qnil;
404
+ mkr_xml_node_t *node = mkr_xml_query_context(self, &document);
405
+ if (node == NULL) {
406
+ return Qfalse;
407
+ }
408
+ mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(document));
409
+
410
+ mkr_xpath_context_t *ctx =
411
+ mkr_xpath_context_new((void *)xdoc->doc_node, (void *)xdoc->doc_node);
412
+ if (ctx == NULL) {
413
+ rb_raise(mkr_eError, "failed to allocate XPath context");
414
+ }
415
+ mkr_xpath_set_engine_kind(ctx, 1);
416
+ mkr_xml_install_name_index(ctx, xdoc);
417
+ mkr_xml_register_query_namespaces(ctx, ns); /* frees ctx + raises on error */
418
+
419
+ mkr_node_t *ast = mkr_css_compile_or_raise(ctx, selector, ns); /* frees ctx + raises on error */
420
+
421
+ mkr_xpath_value_t value = {0};
422
+ mkr_xpath_error_t error = {0};
423
+ int rc = mkr_xpath_eval_compiled(ctx, ast, &value, &error);
424
+ mkr_node_free(ast);
425
+ if (rc != 0) {
426
+ mkr_xpath_context_free(ctx);
427
+ mkr_xpath_raise(&error);
428
+ }
429
+
430
+ int found = 0;
431
+ if (value.type == MKR_XPATH_TYPE_NODESET) {
432
+ for (size_t i = 0; i < value.u.nodeset.count; i++) {
433
+ if (value.u.nodeset.nodes[i] == (void *)node) { found = 1; break; }
434
+ }
435
+ }
436
+ mkr_xpath_value_clear(&value);
437
+ mkr_xpath_context_free(ctx);
438
+ return found ? Qtrue : Qfalse;
439
+ }
440
+
263
441
  /* The document's root element. */
264
442
  static VALUE
265
443
  mkr_xml_doc_root(VALUE self)
@@ -311,7 +489,6 @@ mkr_xml_fragment_into(mkr_xml_doc_t *xdoc, VALUE rb_source, int inherit_doc_ns)
311
489
  if (mkr_ruby_copy_bytes(decoded, &src) != 0) {
312
490
  rb_raise(mkr_eError, "out of memory copying XML fragment source");
313
491
  }
314
- RB_GC_GUARD(decoded);
315
492
 
316
493
  mkr_xml_status_t st = MKR_XML_OK;
317
494
  mkr_xml_node_t *frag = mkr_xml_parse_fragment(xdoc, src.ptr, src.len, inherit_doc_ns, &st);
@@ -369,7 +546,6 @@ mkr_xml_fragment_s_parse(VALUE klass, VALUE rb_source)
369
546
  mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(doc_obj));
370
547
  mkr_xml_node_t *frag = mkr_xml_fragment_into(xdoc, rb_source, 0);
371
548
  VALUE result = mkr_wrap_xml_node(frag, doc_obj);
372
- RB_GC_GUARD(doc_obj);
373
549
  return result;
374
550
  }
375
551
 
@@ -386,7 +562,6 @@ mkr_xml_doc_fragment(VALUE self, VALUE rb_source)
386
562
  }
387
563
  mkr_xml_node_t *frag = mkr_xml_fragment_into(xdoc, rb_source, 1);
388
564
  VALUE result = mkr_wrap_xml_node(frag, self);
389
- RB_GC_GUARD(self);
390
565
  return result;
391
566
  }
392
567
 
@@ -413,6 +588,13 @@ mkr_init_xml(void)
413
588
  rb_define_method(mkr_mXmlNodeMethods, "xpath", mkr_xml_doc_xpath, -1);
414
589
  rb_define_method(mkr_mXmlNodeMethods, "at_xpath", mkr_xml_doc_at_xpath, -1);
415
590
 
591
+ /* CSS selectors over XML: private primitives called by the Ruby #css /
592
+ * #at_css / #matches? wrappers (which collect the document namespaces). The
593
+ * selector is lowered to the native XPath engine (mkr_css.c). */
594
+ rb_define_private_method(mkr_mXmlNodeMethods, "_css", mkr_xml_node_css, 2);
595
+ rb_define_private_method(mkr_mXmlNodeMethods, "_at_css", mkr_xml_node_at_css, 2);
596
+ rb_define_private_method(mkr_mXmlNodeMethods, "_css_matches", mkr_xml_node_css_matches, 2);
597
+
416
598
  /* The native XML parser, exposed as XML::Document.parse, mirroring HTML
417
599
  * (HTML::Document.parse). The Makiri::XML(source) convenience delegates to it
418
600
  * in Ruby (lib/makiri.rb), as Makiri.HTML does for HTML::Document.parse. */
@@ -13,6 +13,7 @@
13
13
  #include "glue.h"
14
14
  #include "../xml/mkr_xml_node.h"
15
15
  #include "../xml/mkr_xml_mutate.h"
16
+ #include "../xml/mkr_xml_index.h" /* element-name index invalidation on mutation */
16
17
  #include "../core/mkr_core.h" /* mkr_buf */
17
18
 
18
19
  #include <ruby/encoding.h> /* rb_to_encoding / rb_str_encode (output encoding) */
@@ -339,7 +340,7 @@ mkr_xml_node_aref(VALUE self, VALUE rb_name)
339
340
  mkr_ruby_borrowed_text_t nv = mkr_ruby_verified_text(rb_name, "attribute name");
340
341
  VALUE out = Qnil;
341
342
  for (mkr_xml_node_t *a = n->attrs; a != NULL; a = a->next) {
342
- if (a->qname_len == nv.len && memcmp(a->qname, nv.ptr, nv.len) == 0) {
343
+ if (mkr_bytes_eq(a->qname, a->qname_len, nv.ptr, nv.len)) {
343
344
  out = rb_utf8_str_new(a->value ? a->value : "", (long)a->value_len);
344
345
  break;
345
346
  }
@@ -565,15 +566,25 @@ mkr_xml_node_to_xml(int argc, VALUE *argv, VALUE self)
565
566
  int rc = 0;
566
567
 
567
568
  if (rb_obj_is_kind_of(self, mkr_cXmlDocument)) {
568
- static const char decl_a[] = "<?xml version=\"1.0\" encoding=\"";
569
- static const char decl_b[] = "\"?>\n";
570
- VALUE name = NIL_P(enc_name) ? rb_utf8_str_new_cstr("UTF-8") : enc_name;
571
- mkr_ruby_borrowed_bytes_t nv = mkr_ruby_bytes_view(name);
572
569
  mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(self));
573
- rc = (mkr_buf_append(&buf, decl_a, sizeof(decl_a) - 1) == MKR_OK) ? 0 : -1;
574
- if (rc == 0) rc = (mkr_buf_append(&buf, nv.ptr, nv.len) == MKR_OK) ? 0 : -1;
575
- if (rc == 0) rc = (mkr_buf_append(&buf, decl_b, sizeof(decl_b) - 1) == MKR_OK) ? 0 : -1;
576
- RB_GC_GUARD(nv.value);
570
+ /* Emit the encoding pseudo-attribute only when an explicit encoding: was
571
+ * requested or the parsed source declared one; otherwise a built or
572
+ * declaration-less document round-trips to a bare `<?xml version="1.0"?>`,
573
+ * matching Nokogiri (the output is UTF-8 either way). */
574
+ int emit_enc = !NIL_P(enc_name) || (xdoc != NULL && xdoc->has_encoding_decl);
575
+ if (emit_enc) {
576
+ static const char decl_a[] = "<?xml version=\"1.0\" encoding=\"";
577
+ static const char decl_b[] = "\"?>\n";
578
+ VALUE name = NIL_P(enc_name) ? rb_utf8_str_new_cstr("UTF-8") : enc_name;
579
+ mkr_ruby_borrowed_bytes_t nv = mkr_ruby_bytes_view(name);
580
+ rc = (mkr_buf_append(&buf, decl_a, sizeof(decl_a) - 1) == MKR_OK) ? 0 : -1;
581
+ if (rc == 0) rc = (mkr_buf_append(&buf, nv.ptr, nv.len) == MKR_OK) ? 0 : -1;
582
+ if (rc == 0) rc = (mkr_buf_append(&buf, decl_b, sizeof(decl_b) - 1) == MKR_OK) ? 0 : -1;
583
+ RB_GC_GUARD(nv.value);
584
+ } else {
585
+ static const char decl[] = "<?xml version=\"1.0\"?>\n";
586
+ rc = (mkr_buf_append(&buf, decl, sizeof(decl) - 1) == MKR_OK) ? 0 : -1;
587
+ }
577
588
  if (rc == 0 && xdoc != NULL && xdoc->doctype != NULL) {
578
589
  rc = mkr_xser_doctype(&buf, xdoc->doctype);
579
590
  if (rc == 0) rc = (mkr_buf_append(&buf, "\n", 1) == MKR_OK) ? 0 : -1;
@@ -678,8 +689,8 @@ mkr_c14n_nearest(const mkr_xml_node_t *node, const char *prefix, uint32_t plen,
678
689
  if (e->type != MKR_XML_NODE_TYPE_ELEMENT) continue;
679
690
  for (mkr_xml_node_t *a = e->attrs; a != NULL; a = a->next) {
680
691
  const char *p, *u; uint32_t pl, ul;
681
- if (mkr_xml_node_xmlns_decl(a, &p, &pl, &u, &ul) && pl == plen
682
- && (pl == 0 || memcmp(p, prefix, pl) == 0)) {
692
+ if (mkr_xml_node_xmlns_decl(a, &p, &pl, &u, &ul)
693
+ && mkr_bytes_eq(p, pl, prefix, plen)) {
683
694
  *uri = u; *ulen = ul; return 1;
684
695
  }
685
696
  }
@@ -691,7 +702,7 @@ static int
691
702
  mkr_c14n_has_prefix(const mkr_c14n_ns_t *arr, size_t n, const char *p, uint32_t pl)
692
703
  {
693
704
  for (size_t i = 0; i < n; i++)
694
- if (arr[i].plen == pl && (pl == 0 || memcmp(arr[i].prefix, p, pl) == 0)) return 1;
705
+ if (mkr_bytes_eq(arr[i].prefix, arr[i].plen, p, pl)) return 1;
695
706
  return 0;
696
707
  }
697
708
 
@@ -724,7 +735,7 @@ mkr_c14n_namespaces(const mkr_xml_node_t *n, int is_apex, mkr_c14n_ns_t **out)
724
735
  for (mkr_xml_node_t *a = e->attrs; a != NULL; a = a->next) {
725
736
  const char *p, *u; uint32_t pl, ul;
726
737
  if (!mkr_xml_node_xmlns_decl(a, &p, &pl, &u, &ul)) continue;
727
- if (pl == 3 && memcmp(p, "xml", 3) == 0) continue; /* implicit xml: */
738
+ if (mkr_bytes_eq(p, pl, "xml", 3)) continue; /* implicit xml: */
728
739
  if (is_apex) {
729
740
  if (pl == 0) { /* default: nearest decl wins */
730
741
  if (default_seen) continue;
@@ -736,7 +747,7 @@ mkr_c14n_namespaces(const mkr_xml_node_t *n, int is_apex, mkr_c14n_ns_t **out)
736
747
  } else { /* descendant: only changes to the binding */
737
748
  const char *au; uint32_t aul;
738
749
  int above = mkr_c14n_nearest(e->parent, p, pl, &au, &aul);
739
- if (above && aul == ul && (ul == 0 || memcmp(au, u, ul) == 0)) continue; /* superfluous */
750
+ if (above && mkr_bytes_eq(au, aul, u, ul)) continue; /* superfluous */
740
751
  if (pl == 0 && ul == 0 && !(above && aul > 0)) continue; /* xmlns="" only undeclares */
741
752
  }
742
753
  arr[cnt].prefix = p; arr[cnt].plen = pl;
@@ -884,27 +895,13 @@ mkr_xml_node_canonicalize(int argc, VALUE *argv, VALUE self)
884
895
  return str;
885
896
  }
886
897
 
887
- /* ---- fail-closed guards for the unsupported query / serialization surface ----
898
+ /* ---- fail-closed guard for the unsupported serialization surface ----
888
899
  *
889
- * Makiri::XML is a strict, read-only reader (§7.5 / §12). Two HTML-node
890
- * conveniences would silently misbehave on XML and are turned into explicit
891
- * NotImplementedError instead of a wrong result:
892
- * - CSS selectors: Lexbor's lxb_selectors lower-cases names, which destroys
893
- * XML's case- and namespace-sensitive matching - use #xpath.
894
- * - HTML serialization (to_html/inner_html/outer_html): emitting
895
- * HTML-serialized markup for an XML document would be wrong (escaping /
896
- * CDATA / void elements differ). Use #to_xml. */
897
- static VALUE
898
- mkr_xml_node_no_css(int argc, VALUE *argv, VALUE self)
899
- {
900
- (void)argc; (void)argv; (void)self;
901
- rb_raise(rb_eNotImpError,
902
- "Makiri::XML does not support CSS selectors; use #xpath. "
903
- "(CSS cannot preserve XML element-name case or namespaces.) "
904
- "Default-namespace documents (RSS/Atom) need a registered prefix, "
905
- "e.g. doc.xpath(\"//a:entry\", \"a\" => \"http://www.w3.org/2005/Atom\").");
906
- }
907
-
900
+ * HTML serialization (to_html/inner_html/outer_html) would silently misbehave on
901
+ * XML (escaping / CDATA / void elements differ), so it is an explicit
902
+ * NotImplementedError rather than a wrong result. Use #to_xml. (CSS selectors,
903
+ * once unsupported here, are now lowered to the native XPath engine - see
904
+ * ruby_xml.c.) */
908
905
  static VALUE
909
906
  mkr_xml_node_no_serialize(int argc, VALUE *argv, VALUE self)
910
907
  {
@@ -917,23 +914,10 @@ mkr_xml_node_no_serialize(int argc, VALUE *argv, VALUE self)
917
914
  /* ---- node identity (== / eql? / hash / pointer_id) ----
918
915
  *
919
916
  * XML nodes share the mkr_node_data_t typed-data with HTML nodes, so the
920
- * underlying node pointer (mkr_node_id, representation-agnostic) IS the
921
- * identity. Without these, two wrappers for the same XML node compared unequal
922
- * (Object identity), which broke #path, NodeSet/Set dedup, and Hash keys.
923
- * Same contract as the HTML nodes (ruby_node.c): a.pointer_id == b.pointer_id
924
- * iff a.eql?(b). */
925
- static VALUE
926
- mkr_xml_node_pointer_id(VALUE self)
927
- {
928
- return ULL2NUM((unsigned long long)mkr_node_id(self));
929
- }
930
-
931
- static VALUE
932
- mkr_xml_node_equals(VALUE self, VALUE other)
933
- {
934
- if (!rb_obj_is_kind_of(other, mkr_cNode)) return Qfalse;
935
- return mkr_node_id(self) == mkr_node_id(other) ? Qtrue : Qfalse;
936
- }
917
+ * underlying node pointer (mkr_node_id, representation-agnostic) IS the identity.
918
+ * The implementations are therefore representation-neutral and live once in
919
+ * ruby_node.c (mkr_node_equals / mkr_node_hash / mkr_node_pointer_id); the XML
920
+ * NodeMethods module just binds to them below, exactly as the HTML module does. */
937
921
 
938
922
  /* ---- mutation (Phase 1: in-place edits) ----------------------------------
939
923
  *
@@ -991,6 +975,10 @@ static mkr_xml_node_t *
991
975
  mkr_xml_node_unwrap_mutable(VALUE self)
992
976
  {
993
977
  rb_check_frozen(self);
978
+ /* Single mutation choke point (every mutator calls this): drop the cached
979
+ * element-name index so the next query rebuilds it. Same discipline as the
980
+ * HTML attr/text indices, in one place that cannot be forgotten. */
981
+ mkr_xml_name_index_invalidate(mkr_xml_node_xdoc(self));
994
982
  return mkr_xml_node_unwrap(self);
995
983
  }
996
984
 
@@ -1359,10 +1347,10 @@ mkr_init_xml_node(void)
1359
1347
 
1360
1348
  /* Node identity by underlying pointer, so #path / NodeSet dedup / Set / Hash
1361
1349
  * work (the same contract HTML nodes have). */
1362
- rb_define_method(mkr_mXmlNodeMethods, "==", mkr_xml_node_equals, 1);
1363
- rb_define_method(mkr_mXmlNodeMethods, "eql?", mkr_xml_node_equals, 1);
1364
- rb_define_method(mkr_mXmlNodeMethods, "hash", mkr_xml_node_pointer_id, 0);
1365
- rb_define_method(mkr_mXmlNodeMethods, "pointer_id", mkr_xml_node_pointer_id, 0);
1350
+ rb_define_method(mkr_mXmlNodeMethods, "==", mkr_node_equals, 1);
1351
+ rb_define_method(mkr_mXmlNodeMethods, "eql?", mkr_node_equals, 1);
1352
+ rb_define_method(mkr_mXmlNodeMethods, "hash", mkr_node_hash, 0);
1353
+ rb_define_method(mkr_mXmlNodeMethods, "pointer_id", mkr_node_pointer_id, 0);
1366
1354
 
1367
1355
  /* XML serialization: #to_xml / #to_s re-emit the subtree (a Document also
1368
1356
  * emits the declaration + DOCTYPE). Also defined on XML::Document below. */
@@ -1370,10 +1358,9 @@ mkr_init_xml_node(void)
1370
1358
  rb_define_method(mkr_mXmlNodeMethods, "to_s", mkr_xml_node_to_xml, -1);
1371
1359
  rb_define_method(mkr_mXmlNodeMethods, "canonicalize", mkr_xml_node_canonicalize, -1);
1372
1360
 
1373
- /* Fail-closed: CSS + HTML serialization are unsupported on XML; raise rather
1374
- * than return a wrong result (these shadow nothing on XML nodes otherwise). */
1375
- rb_define_method(mkr_mXmlNodeMethods, "css", mkr_xml_node_no_css, -1);
1376
- rb_define_method(mkr_mXmlNodeMethods, "at_css", mkr_xml_node_no_css, -1);
1361
+ /* CSS selectors (#css / #at_css / #matches?) are supported on XML via the
1362
+ * native XPath engine - registered in ruby_xml.c. Fail-closed: HTML
1363
+ * serialization is unsupported on XML; raise rather than emit wrong markup. */
1377
1364
  rb_define_method(mkr_mXmlNodeMethods, "to_html", mkr_xml_node_no_serialize, -1);
1378
1365
  rb_define_method(mkr_mXmlNodeMethods, "inner_html", mkr_xml_node_no_serialize, -1);
1379
1366
  rb_define_method(mkr_mXmlNodeMethods, "outer_html", mkr_xml_node_no_serialize, -1);
@@ -396,7 +396,6 @@ mkr_ruby_to_out(mkr_xpath_context_t *ctx, VALUE document, VALUE r, mkr_val_t *ou
396
396
  if (mkr_val_set_borrowed_text_copy(out, mkr_borrowed_text(vv.ptr, vv.len),
397
397
  NULL, NULL) != 0) {
398
398
  snprintf(errbuf, errlen, "out of memory converting handler result");
399
- RB_GC_GUARD(vv.value);
400
399
  return -1;
401
400
  }
402
401
  RB_GC_GUARD(vv.value);
@@ -642,10 +641,11 @@ static VALUE
642
641
  mkr_xpath_ctx_register_variable(VALUE self, VALUE rb_name, VALUE rb_value)
643
642
  {
644
643
  mkr_xpath_ctx_data_t *d = mkr_xpath_ctx_unwrap(self);
645
- mkr_ruby_borrowed_text_t nv = mkr_ruby_verified_text(rb_name, "variable name");
646
- /* The value gets the stricter engine-string check (adds the byte cap on top
647
- * of no-NUL / valid-UTF-8). */
644
+ /* Coerce the value FIRST (rb_obj_as_string allocates = a GC point), so no
645
+ * borrowed name bytes are held across it. The value gets the stricter
646
+ * engine-string check (adds the byte cap on top of no-NUL / valid-UTF-8). */
648
647
  VALUE value = rb_obj_as_string(rb_value);
648
+ mkr_ruby_borrowed_text_t nv = mkr_ruby_verified_text(rb_name, "variable name");
649
649
  mkr_ruby_borrowed_text_t vv;
650
650
  const char *bad =
651
651
  mkr_ruby_try_verified_text(value, mkr_ctx_limits(d->ctx)->max_string_bytes, &vv);
@@ -8,7 +8,6 @@
8
8
 
9
9
  #include <stdint.h>
10
10
  #include <stdlib.h>
11
- #include <string.h> /* memchr */
12
11
 
13
12
  /*
14
13
  * Source location tracking. Lexbor does not record where in the input a node
@@ -39,18 +38,15 @@ typedef struct {
39
38
  void *
40
39
  mkr_lines_build(const lxb_char_t *src, size_t len)
41
40
  {
42
- if (src == NULL) {
43
- len = 0;
44
- }
45
-
46
- const lxb_char_t *const end = src + len;
41
+ /* mkr_span normalizes src == NULL to the empty span, so the absent-input
42
+ * case (no newlines, one line) flows through with no special guard. */
47
43
 
48
- /* One pass to count newlines (vectorised memchr), so the array is sized
49
- * once. */
50
- size_t nl = 0;
51
- for (const lxb_char_t *p = src;
52
- (p = memchr(p, '\n', (size_t)(end - p))) != NULL;
53
- p++) {
44
+ /* One pass to count newlines (mkr_span_find is still one memchr per line,
45
+ * inside the audited core), so the array is sized once. */
46
+ mkr_span_t s = mkr_span((const char *)src, len);
47
+ size_t nl = 0, at;
48
+ while (mkr_span_find(&s, '\n', &at)) {
49
+ mkr_span_skip(&s, at + 1); /* past the '\n' (find proved it exists) */
54
50
  nl++;
55
51
  }
56
52
 
@@ -65,12 +61,14 @@ mkr_lines_build(const lxb_char_t *src, size_t len)
65
61
  return NULL;
66
62
  }
67
63
 
64
+ mkr_span_t f = mkr_span((const char *)src, len);
65
+ const char *start = mkr_span_mark(&f);
68
66
  size_t line = 0;
69
67
  t->starts[line++] = 0;
70
- for (const lxb_char_t *p = src;
71
- (p = memchr(p, '\n', (size_t)(end - p))) != NULL;
72
- p++) {
73
- t->starts[line++] = (size_t)(p - src) + 1;
68
+ while (mkr_span_find(&f, '\n', &at)) {
69
+ mkr_span_skip(&f, at + 1);
70
+ /* offset just past the '\n' = next line's start */
71
+ t->starts[line++] = mkr_span_since(&f, start);
74
72
  }
75
73
  return t;
76
74
  }