makiri 0.2.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 (130) 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/release.yml +12 -7
  5. data/.github/workflows/security.yml +88 -3
  6. data/.github/workflows/valgrind.yml +135 -0
  7. data/CHANGELOG.md +152 -15
  8. data/README.md +183 -13
  9. data/Rakefile +294 -7
  10. data/ext/makiri/bridge/bridge.h +28 -0
  11. data/ext/makiri/bridge/ruby_string.c +282 -12
  12. data/ext/makiri/core/mkr_alloc.c +40 -3
  13. data/ext/makiri/core/mkr_alloc.h +28 -5
  14. data/ext/makiri/core/mkr_buf.c +47 -3
  15. data/ext/makiri/core/mkr_buf.h +112 -3
  16. data/ext/makiri/core/mkr_core.c +143 -0
  17. data/ext/makiri/core/mkr_core.h +11 -2
  18. data/ext/makiri/core/mkr_hash.h +1 -1
  19. data/ext/makiri/core/mkr_span.h +186 -0
  20. data/ext/makiri/core/mkr_text.h +8 -8
  21. data/ext/makiri/core/mkr_utf8.c +101 -0
  22. data/ext/makiri/core/mkr_utf8.h +88 -0
  23. data/ext/makiri/extconf.rb +123 -10
  24. data/ext/makiri/fuzz/Makefile +95 -0
  25. data/ext/makiri/fuzz/check_fuzzer.cc +4 -0
  26. data/ext/makiri/fuzz/xml_fuzz.c +24 -0
  27. data/ext/makiri/fuzz/xpath_fuzz.c +109 -0
  28. data/ext/makiri/glue/glue.h +55 -11
  29. data/ext/makiri/glue/ruby_doc.c +129 -59
  30. data/ext/makiri/glue/ruby_html_css.c +292 -0
  31. data/ext/makiri/glue/{ruby_mutate.c → ruby_html_mutate.c} +248 -52
  32. data/ext/makiri/glue/ruby_html_node.c +859 -0
  33. data/ext/makiri/glue/ruby_html_serialize.c +154 -0
  34. data/ext/makiri/glue/ruby_node.c +74 -729
  35. data/ext/makiri/glue/ruby_node_set.c +167 -32
  36. data/ext/makiri/glue/ruby_xml.c +602 -0
  37. data/ext/makiri/glue/ruby_xml_node.c +1373 -0
  38. data/ext/makiri/glue/ruby_xpath.c +63 -30
  39. data/ext/makiri/glue/ruby_xpath.h +19 -0
  40. data/ext/makiri/lexbor_compat/compat.h +42 -9
  41. data/ext/makiri/lexbor_compat/compat_internal.h +1 -1
  42. data/ext/makiri/lexbor_compat/dom_index.c +2 -2
  43. data/ext/makiri/lexbor_compat/post_parse.c +100 -10
  44. data/ext/makiri/lexbor_compat/source_loc.c +15 -13
  45. data/ext/makiri/lexbor_compat/text_index.c +14 -8
  46. data/ext/makiri/lexbor_compat/utf8_input.c +19 -33
  47. data/ext/makiri/makiri.c +184 -6
  48. data/ext/makiri/makiri.h +43 -2
  49. data/ext/makiri/xml/mkr_xml.h +125 -0
  50. data/ext/makiri/xml/mkr_xml_chars.c +195 -0
  51. data/ext/makiri/xml/mkr_xml_index.c +169 -0
  52. data/ext/makiri/xml/mkr_xml_index.h +48 -0
  53. data/ext/makiri/xml/mkr_xml_mutate.c +817 -0
  54. data/ext/makiri/xml/mkr_xml_mutate.h +139 -0
  55. data/ext/makiri/xml/mkr_xml_node.c +399 -0
  56. data/ext/makiri/xml/mkr_xml_node.h +184 -0
  57. data/ext/makiri/xml/mkr_xml_tree.c +1515 -0
  58. data/ext/makiri/xpath/mkr_css.c +1023 -0
  59. data/ext/makiri/xpath/mkr_css.h +65 -0
  60. data/ext/makiri/xpath/mkr_xpath.c +96 -32
  61. data/ext/makiri/xpath/mkr_xpath.h +109 -4
  62. data/ext/makiri/xpath/mkr_xpath_engine_html.c +17 -0
  63. data/ext/makiri/xpath/mkr_xpath_engine_xml.c +12 -0
  64. data/ext/makiri/xpath/{mkr_xpath_eval.c → mkr_xpath_eval_body.h} +551 -241
  65. data/ext/makiri/xpath/{mkr_xpath_funcs.c → mkr_xpath_funcs_body.h} +318 -276
  66. data/ext/makiri/xpath/mkr_xpath_internal.h +177 -206
  67. data/ext/makiri/xpath/mkr_xpath_lex.c +95 -125
  68. data/ext/makiri/xpath/mkr_xpath_node_access_html.h +138 -0
  69. data/ext/makiri/xpath/mkr_xpath_node_access_xml.h +145 -0
  70. data/ext/makiri/xpath/mkr_xpath_number.c +109 -0
  71. data/ext/makiri/xpath/mkr_xpath_parse.c +83 -94
  72. data/ext/makiri/xpath/mkr_xpath_prelude_html.h +30 -0
  73. data/ext/makiri/xpath/mkr_xpath_prelude_xml.h +28 -0
  74. data/ext/makiri/xpath/mkr_xpath_shared.c +609 -0
  75. data/ext/makiri/xpath/mkr_xpath_value_body.h +801 -0
  76. data/ext/makiri/xpath/mkr_xpath_xml_selftest.c +76 -0
  77. data/lib/makiri/{attribute.rb → attr.rb} +7 -3
  78. data/lib/makiri/cdata_section.rb +19 -0
  79. data/lib/makiri/comment.rb +10 -0
  80. data/lib/makiri/compat_aliases.rb +30 -0
  81. data/lib/makiri/document.rb +9 -73
  82. data/lib/makiri/document_fragment.rb +14 -9
  83. data/lib/makiri/element.rb +4 -4
  84. data/lib/makiri/html/document.rb +106 -0
  85. data/lib/makiri/html/node_methods.rb +19 -0
  86. data/lib/makiri/html.rb +12 -0
  87. data/lib/makiri/node.rb +58 -15
  88. data/lib/makiri/node_set.rb +8 -0
  89. data/lib/makiri/processing_instruction.rb +10 -0
  90. data/lib/makiri/text.rb +1 -1
  91. data/lib/makiri/version.rb +1 -1
  92. data/lib/makiri/xml/builder.rb +263 -0
  93. data/lib/makiri/xml/document.rb +24 -0
  94. data/lib/makiri/xml/node_methods.rb +84 -0
  95. data/lib/makiri/xml.rb +10 -0
  96. data/lib/makiri/xpath_context.rb +1 -1
  97. data/lib/makiri.rb +24 -5
  98. data/script/build_native_gem.rb +2 -2
  99. data/script/check_alloc_failures.rb +266 -0
  100. data/script/check_c_safety.rb +77 -2
  101. data/script/check_c_safety_allowlist.yml +102 -0
  102. data/script/check_leaks.rb +64 -0
  103. data/script/leaks_harness.rb +64 -0
  104. data/vendor/lexbor/CMakeLists.txt +6 -0
  105. data/vendor/lexbor/README.md +12 -0
  106. data/vendor/lexbor/config.cmake +1 -1
  107. data/vendor/lexbor/source/lexbor/core/base.h +1 -1
  108. data/vendor/lexbor/source/lexbor/core/config.cmake +9 -1
  109. data/vendor/lexbor/source/lexbor/css/selectors/pseudo_state.c +2 -3
  110. data/vendor/lexbor/source/lexbor/css/selectors/state.c +3 -0
  111. data/vendor/lexbor/source/lexbor/dom/interfaces/element.c +21 -0
  112. data/vendor/lexbor/source/lexbor/dom/interfaces/element.h +5 -0
  113. data/vendor/lexbor/source/lexbor/encoding/decode.c +33 -4
  114. data/vendor/lexbor/source/lexbor/html/base.h +1 -1
  115. data/vendor/lexbor/source/lexbor/html/interfaces/select_element.c +4 -0
  116. data/vendor/lexbor/source/lexbor/html/serialize.c +545 -41
  117. data/vendor/lexbor/source/lexbor/html/serialize.h +2 -1
  118. data/vendor/lexbor/source/lexbor/html/tokenizer.h +2 -2
  119. data/vendor/lexbor/source/lexbor/html/tree/insertion_mode/in_body.c +1 -1
  120. data/vendor/lexbor/source/lexbor/html/tree.c +6 -6
  121. data/vendor/lexbor/source/lexbor/selectors/selectors.c +12 -3
  122. data/vendor/lexbor/source/lexbor/url/base.h +1 -1
  123. data/vendor/lexbor/source/lexbor/url/url.c +5 -2
  124. data/vendor/lexbor/source/lexbor/url/url.h +9 -0
  125. data/vendor/lexbor/version +1 -1
  126. metadata +53 -9
  127. data/ext/makiri/glue/ruby_css.c +0 -185
  128. data/ext/makiri/glue/ruby_serialize.c +0 -92
  129. data/ext/makiri/xpath/mkr_xpath_value.c +0 -1286
  130. data/lib/makiri/cdata.rb +0 -6
@@ -0,0 +1,602 @@
1
+ /* ruby_xml.c - Ruby boundary for the native XML reader (Phase 1).
2
+ *
3
+ * Makiri::XML::Document.parse(source) (and the Makiri::XML(source) convenience
4
+ * that delegates to it): strict-decode the input (§2.1), then run the Ruby-free
5
+ * parser with the GVL released, and return a
6
+ * Makiri::XML::Document. The document is held in a kind=MKR_DOC_XML mkr_parsed_t
7
+ * (the common document handle, §2.3) and wrapped by mkr_wrap_document, which GC
8
+ * frees via mkr_parsed_destroy (the XML branch whole-arena-frees).
9
+ */
10
+ #include "../makiri.h"
11
+ #include "../core/mkr_core.h"
12
+ #include "../xml/mkr_xml.h"
13
+ #include "../xml/mkr_xml_node.h"
14
+ #include "glue.h" /* mkr_wrap_document, mkr_parsed_* (via compat.h) */
15
+ #include "ruby_xpath.h" /* mkr_xpath_value_to_ruby / mkr_xpath_raise (shared) */
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 */
19
+
20
+ #include <ruby/thread.h>
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
+
49
+ /* ---- GVL-released parse ---- */
50
+ typedef struct {
51
+ const char *src;
52
+ size_t len;
53
+ mkr_xml_limits_t limits; /* per-parse budget overrides (0 fields = default) */
54
+ mkr_xml_doc_t *result;
55
+ mkr_xml_status_t status;
56
+ } mkr_xml_parse_nogvl_t;
57
+
58
+ static void *
59
+ mkr_xml_parse_nogvl(void *p)
60
+ {
61
+ mkr_xml_parse_nogvl_t *a = (mkr_xml_parse_nogvl_t *)p;
62
+ a->result = mkr_xml_parse_ex(a->src, a->len, &a->limits, &a->status);
63
+ return NULL;
64
+ }
65
+
66
+ /* Read the optional per-parse budget overrides from the keyword hash. Today only
67
+ * max_bytes (the arena memory ceiling) is configurable; rb_get_kwargs rejects any
68
+ * other keyword, and new budgets join the table here as they become runtime-
69
+ * configurable. max_bytes must be a positive Integer (0 / negative / non-Integer
70
+ * raise), and 0 in the struct means "use the compile-time default". */
71
+ static mkr_xml_limits_t
72
+ mkr_xml_parse_limits(VALUE rb_opts)
73
+ {
74
+ mkr_xml_limits_t limits = { 0 };
75
+ if (NIL_P(rb_opts)) return limits;
76
+
77
+ static ID kw_ids[1];
78
+ if (kw_ids[0] == 0) kw_ids[0] = rb_intern_const("max_bytes");
79
+ VALUE kw_vals[1];
80
+ rb_get_kwargs(rb_opts, kw_ids, 0, 1, kw_vals); /* unknown keyword -> ArgumentError */
81
+
82
+ if (kw_vals[0] != Qundef) {
83
+ if (!RB_INTEGER_TYPE_P(kw_vals[0])) {
84
+ rb_raise(rb_eTypeError, "max_bytes must be an Integer");
85
+ }
86
+ /* Reject <= 0 BEFORE the unsigned conversion: NUM2SIZET wraps a negative
87
+ * Integer into a huge size_t (an accidental budget bypass), so guard the
88
+ * sign first. A too-large positive still raises RangeError in NUM2SIZET. */
89
+ if (RTEST(rb_funcall(kw_vals[0], rb_intern("<="), 1, INT2FIX(0)))) {
90
+ rb_raise(rb_eArgError, "max_bytes must be positive");
91
+ }
92
+ limits.max_bytes = NUM2SIZET(kw_vals[0]);
93
+ }
94
+ return limits;
95
+ }
96
+
97
+ /* call-seq: Makiri::XML::Document.parse(source, max_bytes: nil) -> Makiri::XML::Document
98
+ * Makiri::XML(source, max_bytes: nil) -> Makiri::XML::Document
99
+ * +source+ is a String or any object responding to +#read+ (an IO / File /
100
+ * StringIO); +max_bytes+ overrides the default arena memory ceiling for this
101
+ * parse. Read a non-UTF-8 file in binary mode (File.binread / "rb") so the
102
+ * encoding is autodetected from its BOM / declaration. */
103
+ static VALUE
104
+ mkr_xml_s_parse(int argc, VALUE *argv, VALUE self)
105
+ {
106
+ (void)self;
107
+ VALUE rb_source, rb_opts;
108
+ rb_scan_args(argc, argv, "1:", &rb_source, &rb_opts);
109
+ mkr_xml_limits_t limits = mkr_xml_parse_limits(rb_opts); /* validates; may raise */
110
+ size_t budget = limits.max_bytes ? limits.max_bytes : (size_t)MKR_XML_MAX_BYTES;
111
+
112
+ /* Read an IO/File-like source (an object responding to #read), like the HTML
113
+ * entry; a String passes straight through. */
114
+ if (rb_respond_to(rb_source, rb_intern("read"))) {
115
+ rb_source = rb_funcall(rb_source, rb_intern("read"), 0);
116
+ }
117
+
118
+ /* Strict decode under the GVL: invalid UTF-8 / undecodable byte / NUL all
119
+ * raise Makiri::XML::SyntaxError here (no U+FFFD repair). Passing the budget
120
+ * lets decode reject an over-budget input (LimitExceeded) before its
121
+ * validation copy and the GVL-release copy below - so a hostile oversized
122
+ * document is not materialised twice for a doomed parse. */
123
+ VALUE decoded = mkr_xml_decode_input(rb_String(rb_source), budget);
124
+
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. */
138
+ mkr_parsed_t *parsed = mkr_parsed_new_xml(NULL);
139
+ if (parsed == NULL) {
140
+ mkr_owned_bytes_clear(&source);
141
+ rb_raise(mkr_eError, "out of memory allocating XML document");
142
+ }
143
+ VALUE obj = mkr_wrap_document(parsed); /* GC owns +parsed+ from here */
144
+
145
+ mkr_xml_parse_nogvl_t args = { source.ptr, source.len, limits, NULL, MKR_XML_OK };
146
+ rb_thread_call_without_gvl(mkr_xml_parse_nogvl, &args, NULL, NULL);
147
+ mkr_owned_bytes_clear(&source);
148
+
149
+ if (args.result == NULL) {
150
+ switch (args.status) {
151
+ case MKR_XML_ERR_SYNTAX: rb_raise(mkr_eXmlSyntaxError, "malformed XML"); break;
152
+ case MKR_XML_ERR_LIMIT: rb_raise(mkr_eXmlLimitExceeded, "XML document budget exceeded"); break;
153
+ case MKR_XML_ERR_VERSION: rb_raise(mkr_eXmlSyntaxError,
154
+ "unsupported XML version (only XML 1.0 is supported)"); break;
155
+ default: rb_raise(mkr_eError, "failed to parse XML document"); break;
156
+ }
157
+ }
158
+ mkr_parsed_set_xml_doc(parsed, args.result);
159
+ return obj;
160
+ }
161
+
162
+ /* XPath value -> Ruby and error -> exception are shared with the HTML query glue
163
+ * (mkr_xpath_value_to_ruby / mkr_xpath_raise, ruby_xpath.h): both query entry
164
+ * points run the same engine and return the same public value/error types, so
165
+ * the conversion lives in one place. mkr_node_set_new/push are kind-aware, so the
166
+ * shared value converter wraps these results as Makiri::XML::* nodes. */
167
+
168
+ /* Resolve the (document VALUE, context node) an XPath query runs against: for a
169
+ * Makiri::XML::Document the context is the document node, for a node it is that
170
+ * node (and its owning Document). */
171
+ static mkr_xml_node_t *
172
+ mkr_xml_query_context(VALUE self, VALUE *out_document)
173
+ {
174
+ /* mkr_xml_node_unwrap is kind-checked (raises on a non-XML node) and resolves
175
+ * an XML Document to its arena document node; mkr_node_document gives the
176
+ * keepalive Document for either. */
177
+ *out_document = mkr_node_document(self);
178
+ return mkr_xml_node_unwrap(self);
179
+ }
180
+
181
+ /* Register a {prefix => uri} Ruby Hash onto +ctx+ for a single query. On any bad
182
+ * entry (non-string-coercible, invalid UTF-8 / embedded NUL, or OOM) the context
183
+ * is freed and an exception is raised - never a partial registration. RSS/Atom
184
+ * live in a default namespace, so a prefix is the strict-mode way to select them
185
+ * (e.g. xpath("//a:entry", "a" => "http://www.w3.org/2005/Atom")). */
186
+ static void
187
+ mkr_xml_register_query_namespaces(mkr_xpath_context_t *ctx, VALUE rb_ns)
188
+ {
189
+ if (NIL_P(rb_ns)) return;
190
+ if (!RB_TYPE_P(rb_ns, T_HASH)) {
191
+ mkr_xpath_context_free(ctx);
192
+ rb_raise(rb_eTypeError, "namespaces must be a Hash of prefix => uri");
193
+ }
194
+ size_t cap = mkr_ctx_limits(ctx)->max_string_bytes;
195
+ VALUE keys = rb_funcall(rb_ns, rb_intern("keys"), 0);
196
+ for (long i = 0; i < RARRAY_LEN(keys); i++) {
197
+ VALUE k = rb_ary_entry(keys, i);
198
+ VALUE ks = rb_obj_as_string(k);
199
+ VALUE vs = rb_obj_as_string(rb_hash_aref(rb_ns, k));
200
+ mkr_ruby_borrowed_text_t pv, uv;
201
+ const char *bad = mkr_ruby_try_verified_text(ks, cap, &pv);
202
+ if (bad == NULL) bad = mkr_ruby_try_verified_text(vs, cap, &uv);
203
+ if (bad != NULL) {
204
+ mkr_xpath_context_free(ctx);
205
+ rb_raise(mkr_eError, "invalid namespace mapping: %s", bad);
206
+ }
207
+ int rc = mkr_xpath_register_ns(ctx, mkr_verified_text_from_view(pv),
208
+ mkr_verified_text_from_view(uv));
209
+ RB_GC_GUARD(ks);
210
+ RB_GC_GUARD(vs);
211
+ if (rc != 0) {
212
+ mkr_xpath_context_free(ctx);
213
+ rb_raise(mkr_eError, "failed to register namespace");
214
+ }
215
+ }
216
+ }
217
+
218
+ /* Makiri::XML::{Document,*}#xpath(expr, namespaces = nil) / #at_xpath(...):
219
+ * evaluate +expr+ over the XML engine instance, rooted at +self+'s context node,
220
+ * and return a NodeSet (node-set) or scalar. +namespaces+ is an optional
221
+ * {prefix => uri} Hash registered for this query (RSS/Atom default-namespace
222
+ * docs need a prefix under strict matching). Phase 1: no custom-function
223
+ * handler. Makiri::XPathContext is the alternative when many queries share one
224
+ * namespace set (it caches the registrations and the compiled ASTs). */
225
+ static VALUE
226
+ mkr_xml_doc_xpath_run(VALUE self, VALUE rb_expr, VALUE rb_ns, int first_only)
227
+ {
228
+ VALUE document = Qnil;
229
+ mkr_xml_node_t *context = mkr_xml_query_context(self, &document);
230
+ if (context == NULL) {
231
+ return first_only ? Qnil : mkr_node_set_new(document);
232
+ }
233
+ mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(document));
234
+
235
+ /* The document node is the "document" (the engine's XML namespace services
236
+ * ignore it) and the "/" root for absolute paths; +context+ is the relative
237
+ * context node (the document node for a Document, else the node itself). */
238
+ mkr_xpath_context_t *ctx =
239
+ mkr_xpath_context_new((void *)xdoc->doc_node, (void *)context);
240
+ if (ctx == NULL) {
241
+ rb_raise(mkr_eError, "failed to allocate XPath context");
242
+ }
243
+ mkr_xpath_set_engine_kind(ctx, 1);
244
+ mkr_xml_install_name_index(ctx, xdoc);
245
+ mkr_xml_register_query_namespaces(ctx, rb_ns); /* frees ctx + raises on error */
246
+
247
+ /* Mint the borrowed expression view AFTER namespace registration: that step
248
+ * allocates Ruby objects (and may run GC), and the borrowed bytes must not
249
+ * be held live across it. mkr_parse below runs pure C. */
250
+ mkr_ruby_borrowed_text_t ev = mkr_ruby_verified_text(rb_expr, "XPath expression");
251
+ mkr_xpath_error_t error = {0};
252
+ mkr_xpath_limits_t *limits = mkr_ctx_limits(ctx);
253
+ limits->ast_nodes = 0;
254
+ mkr_node_t *ast = mkr_parse(mkr_verified_text_from_view(ev), limits, &error);
255
+ RB_GC_GUARD(ev.value);
256
+ if (ast == NULL) {
257
+ mkr_xpath_context_free(ctx);
258
+ mkr_xpath_raise(&error);
259
+ }
260
+
261
+ mkr_xpath_value_t value = {0};
262
+ int rc = first_only ? mkr_xpath_eval_compiled_first(ctx, ast, &value, &error)
263
+ : mkr_xpath_eval_compiled(ctx, ast, &value, &error);
264
+ mkr_node_free(ast);
265
+ if (rc != 0) {
266
+ mkr_xpath_context_free(ctx);
267
+ mkr_xpath_raise(&error);
268
+ }
269
+ VALUE result = mkr_xpath_value_to_ruby(&value, document); /* converts AND clears value */
270
+ mkr_xpath_context_free(ctx);
271
+
272
+ if (first_only && rb_obj_is_kind_of(result, mkr_cNodeSet)) {
273
+ return rb_funcall(result, rb_intern("first"), 0);
274
+ }
275
+ return result;
276
+ }
277
+
278
+ static VALUE
279
+ mkr_xml_doc_xpath(int argc, VALUE *argv, VALUE self)
280
+ {
281
+ VALUE expr, ns;
282
+ rb_scan_args(argc, argv, "11", &expr, &ns);
283
+ return mkr_xml_doc_xpath_run(self, expr, ns, 0);
284
+ }
285
+
286
+ static VALUE
287
+ mkr_xml_doc_at_xpath(int argc, VALUE *argv, VALUE self)
288
+ {
289
+ VALUE expr, ns;
290
+ rb_scan_args(argc, argv, "11", &expr, &ns);
291
+ return mkr_xml_doc_xpath_run(self, expr, ns, 1);
292
+ }
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
+
441
+ /* The document's root element. */
442
+ static VALUE
443
+ mkr_xml_doc_root(VALUE self)
444
+ {
445
+ mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(self));
446
+ return (xdoc == NULL) ? Qnil : mkr_wrap_xml_node(xdoc->root, self);
447
+ }
448
+
449
+ /* The document's DOCTYPE as a Makiri::XML::DocumentType (aliased
450
+ * Makiri::XML::DTD), or nil if the document had no
451
+ * `<!DOCTYPE ...>`. Mirrors Nokogiri's Document#internal_subset. The DTD's name
452
+ * and external/system identifiers are read; the DTD body is NOT parsed (no
453
+ * entity/element declarations are loaded - &name; stays an undefined-entity
454
+ * error and no external subset is fetched). The doctype node is kept off the
455
+ * tree, so XPath never sees it (XPath 1.0 has no doctype node type). */
456
+ static VALUE
457
+ mkr_xml_doc_internal_subset(VALUE self)
458
+ {
459
+ mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(self));
460
+ return (xdoc == NULL || xdoc->doctype == NULL)
461
+ ? Qnil
462
+ : mkr_wrap_xml_node(xdoc->doctype, self);
463
+ }
464
+
465
+ /* Map a fragment-parse status to its Ruby exception (never returns on error). */
466
+ NORETURN(static void mkr_xml_raise_fragment_status(mkr_xml_status_t st));
467
+ static void
468
+ mkr_xml_raise_fragment_status(mkr_xml_status_t st)
469
+ {
470
+ switch (st) {
471
+ case MKR_XML_ERR_SYNTAX: rb_raise(mkr_eXmlSyntaxError, "malformed XML fragment");
472
+ case MKR_XML_ERR_LIMIT: rb_raise(mkr_eXmlLimitExceeded, "XML fragment budget exceeded");
473
+ case MKR_XML_ERR_VERSION: rb_raise(mkr_eXmlSyntaxError,
474
+ "unsupported XML version (only XML 1.0 is supported)");
475
+ default: rb_raise(mkr_eError, "failed to parse XML fragment");
476
+ }
477
+ }
478
+
479
+ /* Strict-decode +rb_source+ and parse it as a fragment into +xdoc+ (when
480
+ * +inherit_doc_ns+, names resolve against the document's root namespaces). The
481
+ * parse runs under the GVL: a fragment is small, and an existing document's arena
482
+ * must never be mutated with the GVL released. Returns the DOCUMENT_FRAGMENT node;
483
+ * raises on a decode/parse failure. */
484
+ static mkr_xml_node_t *
485
+ mkr_xml_fragment_into(mkr_xml_doc_t *xdoc, VALUE rb_source, int inherit_doc_ns)
486
+ {
487
+ VALUE decoded = mkr_xml_decode_input(rb_String(rb_source), xdoc->max_bytes);
488
+ mkr_owned_bytes_t src = { 0 };
489
+ if (mkr_ruby_copy_bytes(decoded, &src) != 0) {
490
+ rb_raise(mkr_eError, "out of memory copying XML fragment source");
491
+ }
492
+
493
+ mkr_xml_status_t st = MKR_XML_OK;
494
+ mkr_xml_node_t *frag = mkr_xml_parse_fragment(xdoc, src.ptr, src.len, inherit_doc_ns, &st);
495
+ mkr_owned_bytes_clear(&src);
496
+ if (frag == NULL) {
497
+ mkr_xml_raise_fragment_status(st);
498
+ }
499
+ return frag;
500
+ }
501
+
502
+ /* A fresh, empty XML Document VALUE: a backing arena holding a DOCUMENT node and
503
+ * no root element. Used by Document.new and as DocumentFragment.parse's backing
504
+ * document. Raises on OOM (with +parsed+ already GC-owned, so it frees cleanly). */
505
+ static VALUE
506
+ mkr_xml_new_empty_document(void)
507
+ {
508
+ mkr_parsed_t *parsed = mkr_parsed_new_xml(NULL);
509
+ if (parsed == NULL) {
510
+ rb_raise(mkr_eError, "out of memory allocating XML document");
511
+ }
512
+ VALUE doc_obj = mkr_wrap_document(parsed); /* GC owns +parsed+ from here */
513
+ mkr_xml_doc_t *xdoc = mkr_xml_doc_new();
514
+ if (xdoc == NULL) {
515
+ rb_raise(mkr_eError, "out of memory allocating XML document");
516
+ }
517
+ mkr_parsed_set_xml_doc(parsed, xdoc); /* GC now frees +xdoc+ via +parsed+ */
518
+ xdoc->doc_node = mkr_xml_arena_node(xdoc, MKR_XML_NODE_TYPE_DOCUMENT);
519
+ if (xdoc->doc_node == NULL) {
520
+ rb_raise(mkr_eError, "out of memory allocating XML document");
521
+ }
522
+ return doc_obj;
523
+ }
524
+
525
+ /* call-seq: Makiri::XML::Document.new -> Document
526
+ * A new, empty XML document (no root element) to build up programmatically with
527
+ * #create_element etc. and #add_child / #root=, like Nokogiri. Any arguments
528
+ * (Nokogiri accepts a version / encoding) are accepted and ignored. */
529
+ static VALUE
530
+ mkr_xml_document_s_new(int argc, VALUE *argv, VALUE klass)
531
+ {
532
+ (void)argc; (void)argv; (void)klass;
533
+ return mkr_xml_new_empty_document();
534
+ }
535
+
536
+ /* call-seq: Makiri::XML::DocumentFragment.parse(source) -> DocumentFragment
537
+ * Parse +source+ into a standalone fragment with its own (empty) backing
538
+ * document. The fragment is self-contained: a prefixed name must declare its
539
+ * namespace within the fragment itself (use Document#fragment to parse against an
540
+ * existing document's in-scope namespaces). */
541
+ static VALUE
542
+ mkr_xml_fragment_s_parse(VALUE klass, VALUE rb_source)
543
+ {
544
+ (void)klass;
545
+ VALUE doc_obj = mkr_xml_new_empty_document();
546
+ mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(doc_obj));
547
+ mkr_xml_node_t *frag = mkr_xml_fragment_into(xdoc, rb_source, 0);
548
+ VALUE result = mkr_wrap_xml_node(frag, doc_obj);
549
+ return result;
550
+ }
551
+
552
+ /* call-seq: doc.fragment(source) -> DocumentFragment
553
+ * Parse +source+ into a fragment bound to this document, resolving names against
554
+ * the document's in-scope (root) namespaces, so the fragment's nodes can be
555
+ * spliced in with Node#add_child and friends. */
556
+ static VALUE
557
+ mkr_xml_doc_fragment(VALUE self, VALUE rb_source)
558
+ {
559
+ mkr_xml_doc_t *xdoc = mkr_parsed_xml_doc(mkr_doc_parsed(self));
560
+ if (xdoc == NULL) {
561
+ rb_raise(mkr_eError, "the document has no arena");
562
+ }
563
+ mkr_xml_node_t *frag = mkr_xml_fragment_into(xdoc, rb_source, 1);
564
+ VALUE result = mkr_wrap_xml_node(frag, self);
565
+ return result;
566
+ }
567
+
568
+ void
569
+ mkr_init_xml(void)
570
+ {
571
+ /* XML::Document is a Makiri::Document leaf (§12): is_a?(Makiri::Document) is
572
+ * true, but it carries no HTML readers (those are on Makiri::HTML, which it
573
+ * does not include) - the read-only XML surface is structural. */
574
+ mkr_cXmlDocument = rb_define_class_under(mkr_mXML, "Document", mkr_cDocument);
575
+ rb_undef_alloc_func(mkr_cXmlDocument); /* created only from C, never .new */
576
+ rb_include_module(mkr_cXmlDocument, mkr_mXmlNodeMethods);
577
+
578
+ rb_define_method(mkr_cXmlDocument, "root", mkr_xml_doc_root, 0);
579
+ rb_define_method(mkr_cXmlDocument, "internal_subset", mkr_xml_doc_internal_subset, 0);
580
+ rb_define_method(mkr_cXmlDocument, "fragment", mkr_xml_doc_fragment, 1);
581
+ rb_define_singleton_method(mkr_cXmlDocument, "new", mkr_xml_document_s_new, -1);
582
+ rb_define_singleton_method(mkr_cXmlDocumentFragment, "parse", mkr_xml_fragment_s_parse, 1);
583
+
584
+ /* xpath / at_xpath work on the document and on any XML node (rooted at that
585
+ * node), so they live on the shared XML node behavior module + the document. */
586
+ rb_define_method(mkr_cXmlDocument, "xpath", mkr_xml_doc_xpath, -1);
587
+ rb_define_method(mkr_cXmlDocument, "at_xpath", mkr_xml_doc_at_xpath, -1);
588
+ rb_define_method(mkr_mXmlNodeMethods, "xpath", mkr_xml_doc_xpath, -1);
589
+ rb_define_method(mkr_mXmlNodeMethods, "at_xpath", mkr_xml_doc_at_xpath, -1);
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
+
598
+ /* The native XML parser, exposed as XML::Document.parse, mirroring HTML
599
+ * (HTML::Document.parse). The Makiri::XML(source) convenience delegates to it
600
+ * in Ruby (lib/makiri.rb), as Makiri.HTML does for HTML::Document.parse. */
601
+ rb_define_singleton_method(mkr_cXmlDocument, "parse", mkr_xml_s_parse, -1);
602
+ }