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
@@ -13,13 +13,13 @@
13
13
  * native context. Phase 1 implements: child / attribute / self /
14
14
  * descendant-or-self / parent axes; predicates with position; the
15
15
  * full set of XPath 1.0 binary operators (semantically); arithmetic;
16
- * and a small built-in function table (see mkr_xpath_funcs.c).
16
+ * and a small built-in function table (see mkr_xpath_funcs_body.h).
17
17
  */
18
18
 
19
19
  /* ---------- forward decls ---------- */
20
20
 
21
21
  static int eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
22
- lxb_dom_node_t *self_node, size_t self_pos, size_t self_size,
22
+ const mkr_focus_t *focus,
23
23
  mkr_val_t *out, mkr_xpath_error_t *err);
24
24
 
25
25
  /* ---------- node test ---------- */
@@ -29,49 +29,126 @@ static int eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
29
29
  * explicit namespace (LXB_NS__UNDEF). For other ids it looks up the URI
30
30
  * in the document's ns hash. */
31
31
  static mkr_borrowed_text_t
32
- node_ns_text(lxb_dom_node_t *node, lxb_dom_document_t *doc)
32
+ node_ns_text(MKR_DOM_NODE *node, MKR_DOM_DOCUMENT *doc)
33
33
  {
34
- if (node->ns == LXB_NS__UNDEF) return mkr_borrowed_text_lit("");
35
- if (doc == NULL || doc->ns == NULL) return mkr_borrowed_text_lit("");
36
34
  size_t len = 0;
37
- const lxb_char_t *uri = lxb_ns_by_id(doc->ns, node->ns, &len);
38
- return uri ? mkr_borrowed_text((const char *)uri, len)
39
- : mkr_borrowed_text_lit("");
35
+ const char *uri = MKR_NODE_NS_URI(node, doc, &len);
36
+ return uri ? mkr_borrowed_text(uri, len) : mkr_borrowed_text_lit("");
40
37
  }
41
38
 
39
+ /* Host-specific element/attribute name-test match (§8.6). The principal-node-type
40
+ * filter has already passed (node is an element, or an attribute on the attribute
41
+ * axis); this decides whether its name and namespace match +test+.
42
+ *
43
+ * HTML uses the qualified-name model: an unprefixed test compares the node's
44
+ * qualified name (== local name for HTML, which has no element prefixes) and, in
45
+ * strict mode, restricts elements to the HTML/no namespace; a prefixed test
46
+ * compares the local name plus the namespace URI bound to the prefix. The XML
47
+ * instance defines MKR_HOST_XML and provides its own local-name + ns_uri match. */
48
+ /* +have_pre+ supplies the prefix's already-resolved namespace URI (pre_uri /
49
+ * pre_uri_len), so a hot multi-node walk resolves the test prefix once in
50
+ * eval_step instead of per node. have_pre == 0 resolves it here as before. */
42
51
  static int
43
- node_principal_match(const mkr_nodetest_t *test, lxb_dom_node_t *node,
44
- mkr_axis_t axis, mkr_xpath_context_t *ctx)
52
+ name_test_match(const mkr_nodetest_t *test, MKR_DOM_NODE *node,
53
+ mkr_axis_t axis, mkr_xpath_context_t *ctx,
54
+ const char *pre_uri, size_t pre_uri_len, int have_pre)
55
+ {
56
+ #ifdef MKR_HOST_XML
57
+ /* XML name match (local name + namespace URI). An unprefixed test matches a
58
+ * no-namespace node only (a prefixed or default-namespaced element needs a
59
+ * registered prefix, §8.6); a prefixed test matches the local name plus the
60
+ * URI bound to the prefix. There is no qualified-name buffer on the custom
61
+ * node, so the comparison is always against the local name. */
62
+ size_t got_len = 0;
63
+ const lxb_char_t *got = (axis == MKR_AXIS_ATTRIBUTE)
64
+ ? MKR_ATTR_LOCAL_NAME(node, &got_len)
65
+ : MKR_ELEM_LOCAL_NAME(node, &got_len);
66
+ if (got == NULL || test->local.ptr == NULL) return 0;
67
+ if (!mkr_borrowed_text_eq(mkr_borrowed_text_from_owned(test->local),
68
+ mkr_borrowed_text((const char *)got, got_len))) return 0;
69
+
70
+ size_t node_uri_len = 0;
71
+ const char *node_uri = MKR_NODE_NS_URI(node, mkr_ctx_document(ctx), &node_uri_len);
72
+ if (test->prefix.ptr != NULL) {
73
+ size_t want_uri_len = pre_uri_len;
74
+ const char *want_uri = have_pre ? pre_uri
75
+ : mkr_ctx_lookup_ns(ctx, test->prefix.ptr, test->prefix.len, &want_uri_len);
76
+ if (want_uri == NULL) return 0;
77
+ if (!mkr_bytes_eq(want_uri, want_uri_len, node_uri, node_uri_len)) return 0;
78
+ } else if (node_uri_len != 0 && !mkr_ctx_unprefixed_lax(ctx)) {
79
+ /* unprefixed test, strict: the node must be in no namespace */
80
+ return 0;
81
+ }
82
+ return 1;
83
+ #else
84
+ /* HTML name match (qualified-name model). */
85
+ size_t got_len = 0;
86
+ const lxb_char_t *got;
87
+ if (test->prefix.ptr != NULL) {
88
+ got = (axis == MKR_AXIS_ATTRIBUTE) ? MKR_ATTR_LOCAL_NAME(node, &got_len)
89
+ : MKR_ELEM_LOCAL_NAME(node, &got_len);
90
+ } else {
91
+ got = (axis == MKR_AXIS_ATTRIBUTE) ? MKR_ATTR_QUALIFIED_NAME(node, &got_len)
92
+ : MKR_ELEM_QUALIFIED_NAME(node, &got_len);
93
+ }
94
+ if (got == NULL || test->local.ptr == NULL) return 0;
95
+ if (!mkr_borrowed_text_eq(mkr_borrowed_text_from_owned(test->local),
96
+ mkr_borrowed_text((const char *)got, got_len))) return 0;
97
+
98
+ if (test->prefix.ptr != NULL) {
99
+ size_t want_uri_len = pre_uri_len;
100
+ const char *want_uri = have_pre ? pre_uri
101
+ : mkr_ctx_lookup_ns(ctx, test->prefix.ptr, test->prefix.len, &want_uri_len);
102
+ if (want_uri == NULL) return 0; /* unknown prefix -> non-match (step driver reports) */
103
+ mkr_borrowed_text_t node_uri = node_ns_text(node, mkr_ctx_document(ctx));
104
+ if (!mkr_bytes_eq(want_uri, want_uri_len, node_uri.ptr, node_uri.len)) return 0;
105
+ } else if (!mkr_ctx_unprefixed_lax(ctx)
106
+ && axis != MKR_AXIS_ATTRIBUTE
107
+ && MKR_NODE_IS_FOREIGN_NS(node)) {
108
+ /* strict mode: unprefixed ELEMENT tests resolve in the HTML namespace, so a
109
+ * foreign (SVG/MathML) element needs a prefix. Attributes are exempt (an
110
+ * unprefixed attribute test matches by no-namespace local name; the
111
+ * qualified-name compare above already excludes prefixed foreign attrs). */
112
+ return 0;
113
+ }
114
+ return 1;
115
+ #endif /* MKR_HOST_XML */
116
+ }
117
+
118
+ static int
119
+ node_principal_match_pre(const mkr_nodetest_t *test, MKR_DOM_NODE *node,
120
+ mkr_axis_t axis, mkr_xpath_context_t *ctx,
121
+ const char *pre_uri, size_t pre_uri_len, int have_pre)
45
122
  {
46
123
  switch (test->kind) {
47
124
  case MKR_NT_NODE:
48
125
  /* XPath 1.0 §5 data model: only element, attribute, text,
49
126
  * namespace, processing-instruction, comment, and the root are
50
127
  * model nodes. Lexbor additionally exposes DOCUMENT_TYPE, ENTITY,
51
- * ENTITY_REFERENCE, and NOTATION nodes none belong in the
128
+ * ENTITY_REFERENCE, and NOTATION nodes - none belong in the
52
129
  * model, so node() must not match them. DocumentFragment IS
53
130
  * matched: it acts as the root for fragment-rooted contexts,
54
131
  * so '.' / 'self::node()' over a fragment has to see it. */
55
- switch (node->type) {
56
- case LXB_DOM_NODE_TYPE_DOCUMENT_TYPE:
57
- case LXB_DOM_NODE_TYPE_ENTITY:
58
- case LXB_DOM_NODE_TYPE_ENTITY_REFERENCE:
59
- case LXB_DOM_NODE_TYPE_NOTATION:
132
+ switch (MKR_NODE_TYPE(node)) {
133
+ case MKR_NTYPE_DOCUMENT_TYPE:
134
+ case MKR_NTYPE_ENTITY:
135
+ case MKR_NTYPE_ENTITY_REFERENCE:
136
+ case MKR_NTYPE_NOTATION:
60
137
  return 0;
61
138
  default:
62
139
  return 1;
63
140
  }
64
141
  case MKR_NT_TEXT:
65
- return node->type == LXB_DOM_NODE_TYPE_TEXT
66
- || node->type == LXB_DOM_NODE_TYPE_CDATA_SECTION;
142
+ return MKR_NODE_TYPE(node) == MKR_NTYPE_TEXT
143
+ || MKR_NODE_TYPE(node) == MKR_NTYPE_CDATA_SECTION;
67
144
  case MKR_NT_COMMENT:
68
- return node->type == LXB_DOM_NODE_TYPE_COMMENT;
145
+ return MKR_NODE_TYPE(node) == MKR_NTYPE_COMMENT;
69
146
  case MKR_NT_PI:
70
- if (node->type != LXB_DOM_NODE_TYPE_PROCESSING_INSTRUCTION) return 0;
147
+ if (MKR_NODE_TYPE(node) != MKR_NTYPE_PI) return 0;
71
148
  if (test->pi_target.ptr == NULL) return 1;
72
149
  {
73
150
  size_t nlen = 0;
74
- const lxb_char_t *nm = lxb_dom_node_name(node, &nlen);
151
+ const lxb_char_t *nm = MKR_NODE_PI_NAME(node, &nlen);
75
152
  return mkr_borrowed_text_eq(mkr_borrowed_text_from_owned(test->pi_target),
76
153
  mkr_borrowed_text((const char *)nm, nlen));
77
154
  }
@@ -80,183 +157,139 @@ node_principal_match(const mkr_nodetest_t *test, lxb_dom_node_t *node,
80
157
  return 0; /* see internal.h §6 */
81
158
  /* Principal node type. */
82
159
  if (axis == MKR_AXIS_ATTRIBUTE) {
83
- if (node->type != LXB_DOM_NODE_TYPE_ATTRIBUTE) return 0;
84
- } else if (node->type != LXB_DOM_NODE_TYPE_ELEMENT) {
160
+ if (MKR_NODE_TYPE(node) != MKR_NTYPE_ATTRIBUTE) return 0;
161
+ } else if (MKR_NODE_TYPE(node) != MKR_NTYPE_ELEMENT) {
85
162
  return 0;
86
163
  }
87
164
  /* `*` matches any namespace; `prefix:*` matches only the namespace bound
88
165
  * to prefix (internal.h §5). Unknown prefix is reported up front by the
89
166
  * step driver; here it is a non-match. */
90
167
  if (test->prefix.ptr != NULL) {
91
- size_t want_uri_len = 0;
92
- const char *want_uri = mkr_ctx_lookup_ns(ctx, test->prefix.ptr, test->prefix.len, &want_uri_len);
168
+ size_t want_uri_len = pre_uri_len;
169
+ const char *want_uri = have_pre ? pre_uri
170
+ : mkr_ctx_lookup_ns(ctx, test->prefix.ptr, test->prefix.len, &want_uri_len);
93
171
  if (want_uri == NULL) return 0;
94
172
  mkr_borrowed_text_t node_uri = node_ns_text(node, mkr_ctx_document(ctx));
95
- if (want_uri_len != node_uri.len || memcmp(want_uri, node_uri.ptr, want_uri_len) != 0) return 0;
173
+ if (!mkr_bytes_eq(want_uri, want_uri_len, node_uri.ptr, node_uri.len)) return 0;
96
174
  }
97
175
  return 1;
98
176
  }
99
- case MKR_NT_NAME: {
177
+ case MKR_NT_NAME:
100
178
  /* Principal node type filter. */
101
179
  if (axis == MKR_AXIS_ATTRIBUTE) {
102
- if (node->type != LXB_DOM_NODE_TYPE_ATTRIBUTE) return 0;
103
- } else if (node->type != LXB_DOM_NODE_TYPE_ELEMENT) {
104
- return 0;
105
- }
106
-
107
- /* Local-name comparison. For prefixed tests we compare local names
108
- * (no prefix); for unprefixed tests we compare against the
109
- * qualified name, which for HTML is equivalent to the local name. */
110
- size_t got_len = 0;
111
- const lxb_char_t *got;
112
- if (test->prefix.ptr != NULL) {
113
- if (axis == MKR_AXIS_ATTRIBUTE) {
114
- got = lxb_dom_attr_local_name((lxb_dom_attr_t *)node, &got_len);
115
- } else {
116
- got = lxb_dom_element_local_name((lxb_dom_element_t *)node, &got_len);
117
- }
118
- } else {
119
- if (axis == MKR_AXIS_ATTRIBUTE) {
120
- got = lxb_dom_attr_qualified_name((lxb_dom_attr_t *)node, &got_len);
121
- } else {
122
- got = mkr_dom_node_name_qualified(node, &got_len);
123
- }
124
- }
125
- if (got == NULL) return 0;
126
- if (test->local.ptr == NULL) return 0;
127
- if (!mkr_borrowed_text_eq(mkr_borrowed_text_from_owned(test->local),
128
- mkr_borrowed_text((const char *)got, got_len))) return 0;
129
-
130
- /* Namespace check (see internal.h §2–§4).
131
- * - Prefixed test: the node's namespace URI must equal the URI bound to
132
- * the prefix.
133
- * - Unprefixed test, strict (default, HTML5/WHATWG-faithful): the name
134
- * resolves in the HTML namespace, so it matches only HTML-namespace or
135
- * no-namespace nodes — foreign (SVG/MathML) content needs a prefix.
136
- * This mirrors browsers' document.evaluate and Nokogiri::HTML5.
137
- * - Unprefixed test, lax: namespace ignored (matched by local name
138
- * already), the HTML4 / Nokogiri::HTML-style convenience. */
139
- if (test->prefix.ptr != NULL) {
140
- size_t want_uri_len = 0;
141
- const char *want_uri = mkr_ctx_lookup_ns(ctx, test->prefix.ptr, test->prefix.len, &want_uri_len);
142
- if (want_uri == NULL) {
143
- /* Unknown prefix: leave for the step driver to report so the
144
- * error is uniform across all hit nodes. We treat it as
145
- * non-match here; node_principal_match cannot raise on its own. */
146
- return 0;
147
- }
148
- mkr_borrowed_text_t node_uri = node_ns_text(node, mkr_ctx_document(ctx));
149
- if (want_uri_len != node_uri.len || memcmp(want_uri, node_uri.ptr, want_uri_len) != 0) return 0;
150
- } else if (!mkr_ctx_unprefixed_lax(ctx)
151
- && axis != MKR_AXIS_ATTRIBUTE
152
- && node->ns != LXB_NS_HTML && node->ns != LXB_NS__UNDEF) {
153
- /* Strict mode restricts ELEMENT name tests to the HTML namespace; foreign
154
- * (SVG/MathML) elements need a prefix. Attributes are exempt: an
155
- * unprefixed attribute test matches by no-namespace local name (the DOM
156
- * model — `id` on an <svg> is still a null-namespace attribute), and the
157
- * qualified-name comparison above already excludes prefixed foreign
158
- * attributes like xlink:href. Lexbor tags attributes with their element's
159
- * ns, which does not reflect the DOM, so we must not gate attributes on
160
- * node->ns here. */
180
+ if (MKR_NODE_TYPE(node) != MKR_NTYPE_ATTRIBUTE) return 0;
181
+ } else if (MKR_NODE_TYPE(node) != MKR_NTYPE_ELEMENT) {
161
182
  return 0;
162
183
  }
163
- return 1;
164
- }
184
+ /* The local-name + namespace match for an element/attribute name test is
185
+ * host-specific (HTML's qualified-name model vs XML's local+namespace-URI
186
+ * model, §8.6), so it lives in name_test_match - defined per instance. */
187
+ return name_test_match(test, node, axis, ctx, pre_uri, pre_uri_len, have_pre);
165
188
  }
166
189
  return 0;
167
190
  }
168
191
 
192
+ /* Behaviour-preserving wrapper: resolve the prefix per call, as before. Used by
193
+ * the first-match path and the //tag index re-check (not hot enough to pre-
194
+ * resolve); the hot step walk calls node_principal_match_pre directly. */
195
+ static int
196
+ node_principal_match(const mkr_nodetest_t *test, MKR_DOM_NODE *node,
197
+ mkr_axis_t axis, mkr_xpath_context_t *ctx)
198
+ {
199
+ return node_principal_match_pre(test, node, axis, ctx, NULL, 0, 0);
200
+ }
201
+
169
202
  /* ---------- axis walkers ---------- */
170
203
 
171
204
  static int
172
- walk_axis(mkr_axis_t axis, lxb_dom_node_t *context,
173
- int (*visit)(lxb_dom_node_t *n, void *u), void *u)
205
+ walk_axis(mkr_axis_t axis, MKR_DOM_NODE *context,
206
+ int (*visit)(MKR_DOM_NODE *n, void *u), void *u)
174
207
  {
175
208
  switch (axis) {
176
209
  case MKR_AXIS_SELF:
177
210
  if (visit(context, u)) return 1;
178
211
  return 0;
179
212
  case MKR_AXIS_PARENT: {
180
- lxb_dom_node_t *p = context->parent;
213
+ MKR_DOM_NODE *p = MKR_NODE_PARENT(context);
181
214
  if (p && visit(p, u)) return 1;
182
215
  return 0;
183
216
  }
184
217
  case MKR_AXIS_CHILD:
185
- for (lxb_dom_node_t *c = context->first_child; c != NULL; c = c->next) {
218
+ for (MKR_DOM_NODE *c = MKR_NODE_FIRST_CHILD(context); c != NULL; c = MKR_NODE_NEXT(c)) {
186
219
  if (visit(c, u)) return 1;
187
220
  }
188
221
  return 0;
189
222
  case MKR_AXIS_ATTRIBUTE: {
190
- if (context->type != LXB_DOM_NODE_TYPE_ELEMENT) return 0;
191
- lxb_dom_element_t *el = (lxb_dom_element_t *)context;
192
- for (lxb_dom_attr_t *a = el->first_attr; a != NULL; a = a->next) {
193
- if (visit((lxb_dom_node_t *)a, u)) return 1;
223
+ if (MKR_NODE_TYPE(context) != MKR_NTYPE_ELEMENT) return 0;
224
+ MKR_DOM_ELEMENT *el = (MKR_DOM_ELEMENT *)context;
225
+ for (MKR_DOM_ATTR *a = MKR_ELEM_FIRST_ATTR(el); a != NULL; a = MKR_ATTR_NEXT(a)) {
226
+ if (visit((MKR_DOM_NODE *)a, u)) return 1;
194
227
  }
195
228
  return 0;
196
229
  }
197
230
  case MKR_AXIS_DESCENDANT_OR_SELF: {
198
231
  /* DFS pre-order. */
199
232
  if (visit(context, u)) return 1;
200
- lxb_dom_node_t *n = context->first_child;
233
+ MKR_DOM_NODE *n = MKR_NODE_FIRST_CHILD(context);
201
234
  while (n != NULL && n != context) {
202
235
  if (visit(n, u)) return 1;
203
- if (n->first_child) {
204
- n = n->first_child;
236
+ if (MKR_NODE_FIRST_CHILD(n)) {
237
+ n = MKR_NODE_FIRST_CHILD(n);
205
238
  } else {
206
- while (n != context && n->next == NULL) n = n->parent;
239
+ while (n != context && MKR_NODE_NEXT(n) == NULL) n = MKR_NODE_PARENT(n);
207
240
  if (n == context) break;
208
- n = n->next;
241
+ n = MKR_NODE_NEXT(n);
209
242
  }
210
243
  }
211
244
  return 0;
212
245
  }
213
246
  case MKR_AXIS_DESCENDANT: {
214
- lxb_dom_node_t *n = context->first_child;
247
+ MKR_DOM_NODE *n = MKR_NODE_FIRST_CHILD(context);
215
248
  while (n != NULL && n != context) {
216
249
  if (visit(n, u)) return 1;
217
- if (n->first_child) {
218
- n = n->first_child;
250
+ if (MKR_NODE_FIRST_CHILD(n)) {
251
+ n = MKR_NODE_FIRST_CHILD(n);
219
252
  } else {
220
- while (n != context && n->next == NULL) n = n->parent;
253
+ while (n != context && MKR_NODE_NEXT(n) == NULL) n = MKR_NODE_PARENT(n);
221
254
  if (n == context) break;
222
- n = n->next;
255
+ n = MKR_NODE_NEXT(n);
223
256
  }
224
257
  }
225
258
  return 0;
226
259
  }
227
260
  case MKR_AXIS_ANCESTOR:
228
- for (lxb_dom_node_t *p = context->parent; p != NULL; p = p->parent) {
261
+ for (MKR_DOM_NODE *p = MKR_NODE_PARENT(context); p != NULL; p = MKR_NODE_PARENT(p)) {
229
262
  if (visit(p, u)) return 1;
230
263
  }
231
264
  return 0;
232
265
  case MKR_AXIS_ANCESTOR_OR_SELF:
233
- for (lxb_dom_node_t *p = context; p != NULL; p = p->parent) {
266
+ for (MKR_DOM_NODE *p = context; p != NULL; p = MKR_NODE_PARENT(p)) {
234
267
  if (visit(p, u)) return 1;
235
268
  }
236
269
  return 0;
237
270
  case MKR_AXIS_FOLLOWING_SIBLING:
238
- for (lxb_dom_node_t *s = context->next; s != NULL; s = s->next) {
271
+ for (MKR_DOM_NODE *s = MKR_NODE_NEXT(context); s != NULL; s = MKR_NODE_NEXT(s)) {
239
272
  if (visit(s, u)) return 1;
240
273
  }
241
274
  return 0;
242
275
  case MKR_AXIS_PRECEDING_SIBLING:
243
- for (lxb_dom_node_t *s = context->prev; s != NULL; s = s->prev) {
276
+ for (MKR_DOM_NODE *s = MKR_NODE_PREV(context); s != NULL; s = MKR_NODE_PREV(s)) {
244
277
  if (visit(s, u)) return 1;
245
278
  }
246
279
  return 0;
247
280
  case MKR_AXIS_FOLLOWING: {
248
281
  /* Start at the next node in doc order after context's subtree. */
249
- lxb_dom_node_t *cur = context;
250
- while (cur != NULL && cur->next == NULL) cur = cur->parent;
282
+ MKR_DOM_NODE *cur = context;
283
+ while (cur != NULL && MKR_NODE_NEXT(cur) == NULL) cur = MKR_NODE_PARENT(cur);
251
284
  if (cur == NULL) return 0;
252
- cur = cur->next;
285
+ cur = MKR_NODE_NEXT(cur);
253
286
  while (cur != NULL) {
254
287
  if (visit(cur, u)) return 1;
255
- if (cur->first_child) {
256
- cur = cur->first_child;
288
+ if (MKR_NODE_FIRST_CHILD(cur)) {
289
+ cur = MKR_NODE_FIRST_CHILD(cur);
257
290
  } else {
258
- while (cur != NULL && cur->next == NULL) cur = cur->parent;
259
- if (cur != NULL) cur = cur->next;
291
+ while (cur != NULL && MKR_NODE_NEXT(cur) == NULL) cur = MKR_NODE_PARENT(cur);
292
+ if (cur != NULL) cur = MKR_NODE_NEXT(cur);
260
293
  }
261
294
  }
262
295
  return 0;
@@ -269,18 +302,18 @@ walk_axis(mkr_axis_t axis, lxb_dom_node_t *context,
269
302
  * ancestor of context: it's an ancestor only when we're climbing
270
303
  * the chain from context itself, not when we're climbing back out
271
304
  * of a preceding sibling's subtree. */
272
- lxb_dom_node_t *cur = context;
305
+ MKR_DOM_NODE *cur = context;
273
306
  while (cur != NULL) {
274
- if (cur->prev) {
275
- cur = cur->prev;
276
- while (cur->last_child) cur = cur->last_child;
307
+ if (MKR_NODE_PREV(cur)) {
308
+ cur = MKR_NODE_PREV(cur);
309
+ while (MKR_NODE_LAST_CHILD(cur)) cur = MKR_NODE_LAST_CHILD(cur);
277
310
  if (visit(cur, u)) return 1;
278
311
  } else {
279
- cur = cur->parent;
312
+ cur = MKR_NODE_PARENT(cur);
280
313
  if (cur == NULL) return 0;
281
314
  /* Is cur an ancestor of the original context? */
282
315
  int is_ancestor = 0;
283
- for (lxb_dom_node_t *p = context->parent; p != NULL; p = p->parent) {
316
+ for (MKR_DOM_NODE *p = MKR_NODE_PARENT(context); p != NULL; p = MKR_NODE_PARENT(p)) {
284
317
  if (p == cur) { is_ancestor = 1; break; }
285
318
  }
286
319
  if (!is_ancestor) {
@@ -308,7 +341,7 @@ walk_axis(mkr_axis_t axis, lxb_dom_node_t *context,
308
341
  * throwaway node-set (malloc/free) per context node for the attribute path plus
309
342
  * a string-cache insert; recognising the shape lets us filter with a direct
310
343
  * attribute lookup instead. Both shapes are boolean (no position dependence),
311
- * so this is a pure per-node filter identical result to the generic path.
344
+ * so this is a pure per-node filter - identical result to the generic path.
312
345
  */
313
346
  typedef struct {
314
347
  const char *name;
@@ -319,7 +352,7 @@ typedef struct {
319
352
  } mkr_attr_pred_t;
320
353
 
321
354
  /* Shape A: a relative path that is a single unprefixed attribute name test with
322
- * no predicates i.e. `@name`. Fills name/name_len; returns 1 on match. */
355
+ * no predicates - i.e. `@name`. Fills name/name_len; returns 1 on match. */
323
356
  static int
324
357
  mkr_match_attr_step(const mkr_node_t *n, const char **name, size_t *name_len)
325
358
  {
@@ -371,23 +404,43 @@ mkr_match_attr_pred(const mkr_node_t *p, mkr_attr_pred_t *out)
371
404
  /* Find an element's attribute whose qualified name equals +name+ exactly
372
405
  * (case-SENSITIVE), or NULL. We scan rather than calling
373
406
  * lxb_dom_element_get_attribute, because that does an HTML case-INsensitive
374
- * lookup which would make `[@Id]` match `id`, diverging from XPath 1.0, from
407
+ * lookup - which would make `[@Id]` match `id`, diverging from XPath 1.0, from
375
408
  * Nokogiri::HTML5, AND from Makiri's own case-sensitive attribute-axis name
376
409
  * test (node_principal_match compares the qualified name byte-for-byte). The
377
410
  * fast path only handles unprefixed names, matching that comparison. */
378
- static lxb_dom_attr_t *
379
- mkr_attr_by_qualified_name(lxb_dom_element_t *el, const char *name, size_t name_len)
411
+ static MKR_DOM_ATTR *
412
+ mkr_attr_by_qualified_name(MKR_DOM_ELEMENT *el, const char *name, size_t name_len)
380
413
  {
381
- for (lxb_dom_attr_t *a = el->first_attr; a != NULL; a = a->next) {
414
+ for (MKR_DOM_ATTR *a = MKR_ELEM_FIRST_ATTR(el); a != NULL; a = MKR_ATTR_NEXT(a)) {
382
415
  size_t qlen = 0;
383
- const lxb_char_t *q = lxb_dom_attr_qualified_name(a, &qlen);
384
- if (q != NULL && qlen == name_len && memcmp(q, name, name_len) == 0) {
416
+ const lxb_char_t *q = MKR_ATTR_QUALIFIED_NAME(a, &qlen);
417
+ if (q != NULL && mkr_bytes_eq(q, qlen, name, name_len)) {
385
418
  return a;
386
419
  }
387
420
  }
388
421
  return NULL;
389
422
  }
390
423
 
424
+ /* Does element node +n+ satisfy the recognised attribute predicate +ap+
425
+ * ([@name] or [@name='value'])? This is THE single per-node test - shared by
426
+ * the predicate filter (mkr_filter_attr_pred) and the at_xpath first-match path
427
+ * (mkr_first_node_ok) so the two stay byte-identical by construction rather than
428
+ * by a hand-kept copy. Position-independent (no context size/position), matching
429
+ * the recogniser's "boolean predicate only" contract. */
430
+ static int
431
+ mkr_attr_pred_matches_node(const mkr_attr_pred_t *ap, MKR_DOM_NODE *n)
432
+ {
433
+ if (MKR_NODE_TYPE(n) != MKR_NTYPE_ELEMENT) return 0;
434
+ MKR_DOM_ATTR *a =
435
+ mkr_attr_by_qualified_name(MKR_NODE_AS_ELEMENT(n), ap->name, ap->name_len);
436
+ if (a == NULL) return 0;
437
+ if (!ap->has_value) return 1;
438
+ size_t got_len = 0;
439
+ const lxb_char_t *got = MKR_ATTR_VALUE(a, &got_len);
440
+ if (got == NULL) got_len = 0;
441
+ return mkr_bytes_eq(got, got_len, ap->value, ap->value_len);
442
+ }
443
+
391
444
  /* Filter `inout` by a recognised attribute predicate into `kept`. */
392
445
  static int
393
446
  mkr_filter_attr_pred(mkr_xpath_context_t *ctx, const mkr_attr_pred_t *ap,
@@ -395,27 +448,13 @@ mkr_filter_attr_pred(mkr_xpath_context_t *ctx, const mkr_attr_pred_t *ap,
395
448
  mkr_xpath_error_t *err)
396
449
  {
397
450
  for (size_t i = 0; i < inout->count; ++i) {
398
- lxb_dom_node_t *n = inout->items[i];
399
- int keep = 0;
400
- if (n->type == LXB_DOM_NODE_TYPE_ELEMENT) {
401
- lxb_dom_attr_t *a =
402
- mkr_attr_by_qualified_name(lxb_dom_interface_element(n), ap->name, ap->name_len);
403
- if (a != NULL) {
404
- if (!ap->has_value) {
405
- keep = 1;
406
- } else {
407
- size_t got_len = 0;
408
- const lxb_char_t *got = lxb_dom_attr_value(a, &got_len);
409
- if (got == NULL) {
410
- got_len = 0;
411
- }
412
- keep = (got_len == ap->value_len
413
- && (ap->value_len == 0
414
- || memcmp(got, ap->value, ap->value_len) == 0));
415
- }
416
- }
417
- }
418
- if (keep && mkr_nodeset_push(kept, n, mkr_ctx_limits(ctx), err) != 0) {
451
+ /* Per-candidate op charge: this fast path replaces a per-node generic
452
+ * predicate eval (which would tick via eval_node), so tick here too to keep
453
+ * the [@a]/[@a='v'] filter under the same budget as the path it shortcuts. */
454
+ if (mkr_limit_eval_op(mkr_ctx_limits(ctx), err) != 0) return -1;
455
+ MKR_DOM_NODE *n = inout->items[i];
456
+ if (mkr_attr_pred_matches_node(ap, n)
457
+ && mkr_nodeset_push(kept, n, mkr_ctx_limits(ctx), err) != 0) {
419
458
  return -1;
420
459
  }
421
460
  }
@@ -432,7 +471,7 @@ apply_predicates(mkr_xpath_context_t *ctx,
432
471
  mkr_nodeset_t kept;
433
472
  mkr_nodeset_init(&kept);
434
473
 
435
- /* Specialise [@name] / [@name='lit'] a position-independent filter, so
474
+ /* Specialise [@name] / [@name='lit'] - a position-independent filter, so
436
475
  * applying it per predicate (even amid others) matches the generic path. */
437
476
  mkr_attr_pred_t ap;
438
477
  if (mkr_match_attr_pred(preds[p], &ap)) {
@@ -448,7 +487,8 @@ apply_predicates(mkr_xpath_context_t *ctx,
448
487
  size_t size = inout->count;
449
488
  for (size_t i = 0; i < size; ++i) {
450
489
  mkr_val_t v = {0};
451
- if (eval_node(ctx, preds[p], inout->items[i], i + 1, size, &v, err) != 0) {
490
+ mkr_focus_t pf = { inout->items[i], i + 1, size };
491
+ if (eval_node(ctx, preds[p], &pf, &v, err) != 0) {
452
492
  mkr_nodeset_clear(&kept);
453
493
  return -1;
454
494
  }
@@ -483,13 +523,31 @@ typedef struct {
483
523
  int aborted; /* set when push or match failed; the walk
484
524
  * is short-circuited and the step driver
485
525
  * propagates the error. */
526
+ /* The name test's prefix resolved once by eval_step (see have_pre), so a
527
+ * multi-node walk does not re-resolve it per node. */
528
+ const char *pre_uri;
529
+ size_t pre_uri_len;
530
+ int have_pre;
486
531
  } step_visit_t;
487
532
 
488
533
  static int
489
- step_visit_cb(lxb_dom_node_t *n, void *u)
534
+ step_visit_cb(MKR_DOM_NODE *n, void *u)
490
535
  {
491
536
  step_visit_t *st = (step_visit_t *)u;
492
- if (node_principal_match(&st->step->test, n, st->step->axis, st->ctx)) {
537
+ /* Charge every visited node to the op budget. The axis walk is the dominant
538
+ * work of a step, and a low-selectivity walk visits (and name-tests) many
539
+ * nodes while pushing few or none - so without this the node-set cap (which
540
+ * only bounds the matched/pushed nodes) leaves the walk itself bounded only
541
+ * by document size, defeating max_eval_ops on a descendant walk that matches
542
+ * nothing. The index/first-match fast paths already tick per node; this
543
+ * brings the general walk to parity. On overrun we abort the walk exactly
544
+ * like a push failure. */
545
+ if (mkr_limit_eval_op(mkr_ctx_limits(st->ctx), st->err) != 0) {
546
+ st->aborted = 1;
547
+ return 1;
548
+ }
549
+ if (node_principal_match_pre(&st->step->test, n, st->step->axis, st->ctx,
550
+ st->pre_uri, st->pre_uri_len, st->have_pre)) {
493
551
  if (mkr_nodeset_push(st->out, n, mkr_ctx_limits(st->ctx), st->err) != 0) {
494
552
  st->aborted = 1;
495
553
  return 1; /* stop the walk; eval_step inspects st.aborted */
@@ -527,7 +585,7 @@ axis_can_alias(mkr_axis_t a)
527
585
  }
528
586
 
529
587
  /*
530
- * 13 axes Phase 2b adds the remaining six. The namespace axis stays
588
+ * 13 axes - Phase 2b adds the remaining six. The namespace axis stays
531
589
  * unimplemented for now (spec section 6 in mkr_xpath_internal.h).
532
590
  */
533
591
  static int
@@ -590,7 +648,7 @@ is_reverse_axis(mkr_axis_t a)
590
648
  }
591
649
 
592
650
  /*
593
- * Fast path for `//tag` a descendant, unprefixed, predicate-free name-test
651
+ * Fast path for `//tag` - a descendant, unprefixed, predicate-free name-test
594
652
  * whose context is exactly the document node. `descendant::tag` from the
595
653
  * document is precisely "every element whose name is tag", which the
596
654
  * document's element index already groups by tag id in document order, so we
@@ -600,12 +658,13 @@ is_reverse_axis(mkr_axis_t a)
600
658
  * context does not qualify (caller falls back to the walk), -1 on error.
601
659
  *
602
660
  * Correctness: only taken for pure-HTML documents (mkr_element_index_has_foreign
603
- * is false) in foreign content an element's qualified name need not equal its
661
+ * is false) - in foreign content an element's qualified name need not equal its
604
662
  * tag's canonical name, so a match could live in another tag bucket. Each
605
663
  * candidate is still re-checked with node_principal_match, so the result is
606
664
  * byte-identical to the walk (this also makes case/normalization quirks in the
607
665
  * tag-name lookup harmless: a non-matching candidate is simply dropped).
608
666
  */
667
+ #ifndef MKR_HOST_XML
609
668
  static int
610
669
  try_descendant_tag_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
611
670
  const mkr_nodeset_t *context_set, mkr_nodeset_t *result,
@@ -616,7 +675,7 @@ try_descendant_tag_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
616
675
  || step->test.prefix.ptr != NULL
617
676
  || step->test.local.ptr == NULL
618
677
  || context_set->count != 1
619
- || context_set->items[0] != (lxb_dom_node_t *)mkr_ctx_document(ctx)) {
678
+ || context_set->items[0] != (MKR_DOM_NODE *)mkr_ctx_document(ctx)) {
620
679
  return 0;
621
680
  }
622
681
  void *eidx = mkr_ctx_element_index(ctx);
@@ -625,13 +684,12 @@ try_descendant_tag_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
625
684
  if (eidx == NULL || lookup == NULL || has_foreign == NULL || has_foreign(eidx)) {
626
685
  return 0;
627
686
  }
628
- lxb_dom_document_t *doc = mkr_ctx_document(ctx);
687
+ MKR_DOM_DOCUMENT *doc = mkr_ctx_document(ctx);
629
688
  if (doc == NULL) {
630
689
  return 0;
631
690
  }
632
- lxb_tag_id_t tag = lxb_tag_id_by_name(doc->tags,
633
- (const lxb_char_t *)step->test.local.ptr,
634
- step->test.local.len);
691
+ lxb_tag_id_t tag = MKR_DOC_TAG_ID_BY_NAME(doc, step->test.local.ptr,
692
+ step->test.local.len);
635
693
  /* The index covers only Lexbor's static tag-id range; a custom element's tag
636
694
  * id is a (huge) pointer value and is not indexed. For such a name, fall back
637
695
  * to the tree walk so those elements are still found. */
@@ -639,9 +697,13 @@ try_descendant_tag_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
639
697
  return 0;
640
698
  }
641
699
  size_t cnt = 0;
642
- lxb_dom_node_t *const *bucket = lookup(eidx, tag, &cnt);
700
+ /* The element index is HTML-only (the lookup hook is typed for lxb_dom); this
701
+ * whole fast path is unreachable for XML (its element index is NULL), but the
702
+ * cast keeps the shared body warning-clean under the XML instantiation. */
703
+ MKR_DOM_NODE *const *bucket = (MKR_DOM_NODE *const *)lookup(eidx, tag, &cnt);
643
704
  for (size_t i = 0; i < cnt; ++i) {
644
- lxb_dom_node_t *n = bucket[i];
705
+ if (mkr_limit_eval_op(mkr_ctx_limits(ctx), err) != 0) return -1;
706
+ MKR_DOM_NODE *n = bucket[i];
645
707
  if (node_principal_match(&step->test, n, step->axis, ctx)) {
646
708
  if (mkr_nodeset_push(result, n, mkr_ctx_limits(ctx), err) != 0) {
647
709
  return -1;
@@ -650,6 +712,60 @@ try_descendant_tag_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
650
712
  }
651
713
  return 1;
652
714
  }
715
+ #else /* MKR_HOST_XML */
716
+ /*
717
+ * XML analogue of the //tag fast path: a document-rooted, predicate-free
718
+ * descendant name test answered from the element-name index (mkr_xml_index.c)
719
+ * instead of walking. The index is keyed by (local name + namespace URI), so it
720
+ * serves a prefixed test (resolved URI) and an unprefixed strict test (the
721
+ * no-namespace bucket); an unprefixed LAX test means "any namespace", which a
722
+ * single bucket cannot express, so it falls back to the walk. The bucket already
723
+ * holds exactly the matching elements in document order, so they are pushed
724
+ * directly. Returns 1 (filled), 0 (does not qualify -> walk), -1 (error).
725
+ */
726
+ static int
727
+ try_descendant_name_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
728
+ const mkr_nodeset_t *context_set, mkr_nodeset_t *result,
729
+ const char *pre_uri, size_t pre_uri_len, int have_pre,
730
+ mkr_xpath_error_t *err)
731
+ {
732
+ if (step->axis != MKR_AXIS_DESCENDANT
733
+ || step->test.kind != MKR_NT_NAME
734
+ || step->test.local.ptr == NULL
735
+ || context_set->count != 1
736
+ || context_set->items[0] != (MKR_DOM_NODE *)mkr_ctx_document(ctx)) {
737
+ return 0;
738
+ }
739
+
740
+ const char *ns_uri; size_t ns_uri_len;
741
+ if (step->test.prefix.ptr != NULL) {
742
+ if (!have_pre) return 0; /* should not happen: eval_step pre-resolves */
743
+ ns_uri = pre_uri; ns_uri_len = pre_uri_len;
744
+ } else if (mkr_ctx_unprefixed_lax(ctx)) {
745
+ return 0; /* "any namespace" - not a single bucket */
746
+ } else {
747
+ ns_uri = ""; ns_uri_len = 0; /* strict unprefixed -> no namespace */
748
+ }
749
+
750
+ void *owner = mkr_ctx_name_index_owner(ctx);
751
+ mkr_name_index_get_t get = mkr_ctx_name_index_get(ctx);
752
+ mkr_name_index_lookup_t lookup = mkr_ctx_name_index_lookup(ctx);
753
+ if (owner == NULL || get == NULL || lookup == NULL) return 0;
754
+ void *idx = get(owner); /* lazily builds + caches; NULL on OOM */
755
+ if (idx == NULL) return 0;
756
+
757
+ size_t cnt = 0;
758
+ void *const *bucket = lookup(idx, step->test.local.ptr, step->test.local.len,
759
+ ns_uri, ns_uri_len, &cnt);
760
+ for (size_t i = 0; i < cnt; ++i) {
761
+ if (mkr_limit_eval_op(mkr_ctx_limits(ctx), err) != 0) return -1;
762
+ if (mkr_nodeset_push(result, (MKR_DOM_NODE *)bucket[i], mkr_ctx_limits(ctx), err) != 0) {
763
+ return -1;
764
+ }
765
+ }
766
+ return 1;
767
+ }
768
+ #endif /* MKR_HOST_XML */
653
769
 
654
770
  /* ---------------------------------------------------------------------------
655
771
  * at_xpath() first-match short-circuit (Node#at_xpath / Node#at).
@@ -657,7 +773,7 @@ try_descendant_tag_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
657
773
  * Node#at_xpath wants only the first node in document order; today it builds the
658
774
  * whole node-set and takes [0]. For the common "find a descendant by name (plus
659
775
  * a simple attribute predicate)" shapes we can instead walk the subtree in
660
- * document order and stop at the first match the XPath-side analogue of
776
+ * document order and stop at the first match - the XPath-side analogue of
661
777
  * at_css's lxb_selectors MATCH_FIRST.
662
778
  *
663
779
  * Recognised shapes (after the parser's // peephole, see mkr_apply_peephole):
@@ -668,7 +784,7 @@ try_descendant_tag_index(mkr_xpath_context_t *ctx, const mkr_step_t *step,
668
784
  * position-independent [@name] / [@name='lit'] (exactly mkr_match_attr_pred's
669
785
  * shape). Each denotes "the strict descendants of <start> matching the
670
786
  * test+predicates, in document order", so the first node the pre-order walk
671
- * reaches IS node-set[0] of the full evaluation byte-identical, just without
787
+ * reaches IS node-set[0] of the full evaluation - byte-identical, just without
672
788
  * building the rest. Anything else (positional predicates, functions/variables,
673
789
  * reverse axes, unions, prefixes, longer paths) returns 0 and the caller falls
674
790
  * back to the full evaluator.
@@ -691,9 +807,11 @@ mkr_first_recognise(const mkr_node_t *ast, const mkr_step_t **out_step)
691
807
  } else {
692
808
  return 0;
693
809
  }
694
- /* A prefixed name test needs the step driver's "unknown prefix -> RUNTIME
695
- * error" semantics, which this path does not reproduce; fall back. */
696
- if (nt->test.prefix.ptr != NULL) return 0;
810
+ /* A prefixed name test is allowed; mkr_try_first_match reproduces the step
811
+ * driver's "unknown prefix -> RUNTIME error" before walking, and
812
+ * node_principal_match resolves the prefix to a URI exactly as the full
813
+ * evaluator does. (Prefixed ATTRIBUTE predicates still fall back: their
814
+ * mkr_match_attr_step requires an unprefixed @name.) */
697
815
  /* Every predicate must be a position-independent attribute predicate (no
698
816
  * position()/last()/numeric, no function/variable -> no handler involvement). */
699
817
  for (size_t p = 0; p < nt->npredicates; p++) {
@@ -705,26 +823,15 @@ mkr_first_recognise(const mkr_node_t *ast, const mkr_step_t **out_step)
705
823
  }
706
824
 
707
825
  /* Does +n+ satisfy every (already-recognised) attribute predicate of +step+?
708
- * Mirrors mkr_filter_attr_pred's per-node test so the result is identical. */
826
+ * Uses the shared mkr_attr_pred_matches_node so it is identical to the
827
+ * predicate filter's per-node test by construction. */
709
828
  static int
710
- mkr_first_node_ok(const mkr_step_t *step, lxb_dom_node_t *n)
829
+ mkr_first_node_ok(const mkr_step_t *step, MKR_DOM_NODE *n)
711
830
  {
712
831
  for (size_t p = 0; p < step->npredicates; p++) {
713
832
  mkr_attr_pred_t ap;
714
833
  (void)mkr_match_attr_pred(step->predicates[p], &ap); /* recogniser ensured it matches */
715
- if (n->type != LXB_DOM_NODE_TYPE_ELEMENT) return 0;
716
- lxb_dom_attr_t *a =
717
- mkr_attr_by_qualified_name(lxb_dom_interface_element(n), ap.name, ap.name_len);
718
- if (a == NULL) return 0;
719
- if (ap.has_value) {
720
- size_t got_len = 0;
721
- const lxb_char_t *got = lxb_dom_attr_value(a, &got_len);
722
- if (got == NULL) got_len = 0;
723
- if (got_len != ap.value_len
724
- || (ap.value_len != 0 && memcmp(got, ap.value, ap.value_len) != 0)) {
725
- return 0;
726
- }
727
- }
834
+ if (!mkr_attr_pred_matches_node(&ap, n)) return 0;
728
835
  }
729
836
  return 1;
730
837
  }
@@ -732,31 +839,49 @@ mkr_first_node_ok(const mkr_step_t *step, lxb_dom_node_t *n)
732
839
  /* If +ast+ is a recognised at_xpath() first-match shape, walk <start>'s strict
733
840
  * descendants in document order and set *out_node to the first match (NULL if
734
841
  * none). Returns 1 when handled (caller skips the full evaluator), 0 when the
735
- * shape is not recognised. Never raises (the recognised shape cannot error). */
842
+ * shape is not recognised, and -1 when the per-evaluate op budget is exceeded
843
+ * during the walk (*err filled with MKR_XPATH_ERR_LIMIT). Every visited node is
844
+ * charged to max_eval_ops, so a huge late-/no-match document fails closed here
845
+ * exactly as it would in the full evaluator instead of bypassing the budget. */
736
846
  int
737
847
  mkr_try_first_match(mkr_xpath_context_t *ctx, const mkr_node_t *ast,
738
- lxb_dom_node_t **out_node)
848
+ MKR_DOM_NODE **out_node, mkr_xpath_error_t *err)
739
849
  {
740
850
  const mkr_step_t *step;
741
851
  if (out_node == NULL || !mkr_first_recognise(ast, &step)) return 0;
742
852
  *out_node = NULL;
743
853
 
744
- lxb_dom_node_t *start = ast->u.path.absolute
745
- ? (lxb_dom_node_t *)mkr_ctx_document(ctx)
854
+ /* Reproduce the step driver's namespace-prefix validation: a prefixed name
855
+ * test with an unbound prefix is a RUNTIME error, not a silently empty match,
856
+ * so the fast path stays byte-identical to the full evaluator (incl. errors). */
857
+ if (step->test.prefix.ptr != NULL
858
+ && mkr_ctx_lookup_ns(ctx, step->test.prefix.ptr, step->test.prefix.len, NULL) == NULL) {
859
+ mkr_err_setf(err, MKR_XPATH_ERR_RUNTIME,
860
+ "unknown namespace prefix '%s' in name test", step->test.prefix.ptr);
861
+ return -1;
862
+ }
863
+
864
+ MKR_DOM_NODE *start = ast->u.path.absolute
865
+ ? (MKR_DOM_NODE *)mkr_ctx_document(ctx)
746
866
  : mkr_ctx_node(ctx);
747
867
  if (start == NULL) return 1; /* recognised; no context -> no match */
748
868
 
869
+ mkr_xpath_limits_t *limits = mkr_ctx_limits(ctx);
870
+
749
871
  /* Iterative pre-order (document order) over STRICT descendants of start. */
750
- for (lxb_dom_node_t *n = start->first_child; n != NULL; ) {
872
+ for (MKR_DOM_NODE *n = MKR_NODE_FIRST_CHILD(start); n != NULL; ) {
873
+ /* Charge each visited node to the per-evaluate op budget so this fast path
874
+ * is bounded by max_eval_ops just like the full evaluator (fail-closed). */
875
+ if (mkr_limit_eval_op(limits, err) != 0) return -1;
751
876
  if (node_principal_match(&step->test, n, step->axis, ctx)
752
877
  && mkr_first_node_ok(step, n)) {
753
878
  *out_node = n;
754
879
  return 1;
755
880
  }
756
- if (n->first_child != NULL) { n = n->first_child; continue; }
757
- while (n != start && n->next == NULL) n = n->parent;
881
+ if (MKR_NODE_FIRST_CHILD(n) != NULL) { n = MKR_NODE_FIRST_CHILD(n); continue; }
882
+ while (n != start && MKR_NODE_NEXT(n) == NULL) n = MKR_NODE_PARENT(n);
758
883
  if (n == start) break;
759
- n = n->next;
884
+ n = MKR_NODE_NEXT(n);
760
885
  }
761
886
  return 1; /* recognised; *out_node is the first match or NULL */
762
887
  }
@@ -772,15 +897,20 @@ eval_step(mkr_xpath_context_t *ctx, const mkr_step_t *step,
772
897
  axis_name_str(step->axis));
773
898
  return -1;
774
899
  }
775
- /* Validate the namespace prefix once up front so we get a uniform
776
- * RUNTIME error instead of a silently empty match. Covers both `prefix:local`
777
- * (MKR_NT_NAME) and `prefix:*` (MKR_NT_WILDCARD). */
778
- if (step->test.prefix.ptr != NULL
779
- && mkr_ctx_lookup_ns(ctx, step->test.prefix.ptr, step->test.prefix.len, NULL) == NULL) {
780
- mkr_err_setf(err, MKR_XPATH_ERR_RUNTIME,
781
- "unknown namespace prefix '%s' in name test",
782
- step->test.prefix.ptr);
783
- return -1;
900
+ /* Resolve the namespace prefix once up front (covers `prefix:local` and
901
+ * `prefix:*`): a uniform RUNTIME error instead of a silently empty match, and
902
+ * the resolved URI is then handed to every per-node match via step_visit_t so
903
+ * the walk does not re-resolve it per node. */
904
+ const char *pre_uri = NULL; size_t pre_uri_len = 0; int have_pre = 0;
905
+ if (step->test.prefix.ptr != NULL) {
906
+ pre_uri = mkr_ctx_lookup_ns(ctx, step->test.prefix.ptr, step->test.prefix.len, &pre_uri_len);
907
+ if (pre_uri == NULL) {
908
+ mkr_err_setf(err, MKR_XPATH_ERR_RUNTIME,
909
+ "unknown namespace prefix '%s' in name test",
910
+ step->test.prefix.ptr);
911
+ return -1;
912
+ }
913
+ have_pre = 1;
784
914
  }
785
915
 
786
916
  /* Post-pass = sort to doc order, then optional adjacent-dedup. We
@@ -792,7 +922,7 @@ eval_step(mkr_xpath_context_t *ctx, const mkr_step_t *step,
792
922
  * 3. The axis does NOT aliase but multiple contexts can still
793
923
  * produce results that interleave in doc order. child is the
794
924
  * canonical case: html's children include head and body, and
795
- * head's children include title concatenated naively gives
925
+ * head's children include title - concatenated naively gives
796
926
  * [head, body, title, ...] but doc order is [head, title, body,
797
927
  * ...]. Only SELF and ATTRIBUTE are safe to concatenate without
798
928
  * sorting (their outputs stay in doc order when inputs are). */
@@ -806,9 +936,14 @@ eval_step(mkr_xpath_context_t *ctx, const mkr_step_t *step,
806
936
  mkr_nodeset_init(&result);
807
937
 
808
938
  if (step->npredicates == 0) {
809
- /* `//tag` from the document: serve from the element index, skipping the
810
- * tree walk entirely. */
939
+ /* A document-rooted descendant name test served from the element index,
940
+ * skipping the tree walk entirely (HTML: tag-id index; XML: name index). */
941
+ #ifdef MKR_HOST_XML
942
+ int fp = try_descendant_name_index(ctx, step, context_set, &result,
943
+ pre_uri, pre_uri_len, have_pre, err);
944
+ #else
811
945
  int fp = try_descendant_tag_index(ctx, step, context_set, &result, err);
946
+ #endif
812
947
  if (fp < 0) {
813
948
  mkr_nodeset_clear(&result);
814
949
  return -1;
@@ -820,7 +955,8 @@ eval_step(mkr_xpath_context_t *ctx, const mkr_step_t *step,
820
955
  * path needs. Big win for //h3/parent::*, //h3/ancestor::div, and
821
956
  * other multi-context reverse-axis sweeps where every context
822
957
  * produces a single result. */
823
- step_visit_t st = { .out = &result, .step = step, .ctx = ctx, .err = err, .aborted = 0 };
958
+ step_visit_t st = { .out = &result, .step = step, .ctx = ctx, .err = err, .aborted = 0,
959
+ .pre_uri = pre_uri, .pre_uri_len = pre_uri_len, .have_pre = have_pre };
824
960
  for (size_t ci = 0; ci < context_set->count; ++ci) {
825
961
  walk_axis(step->axis, context_set->items[ci], step_visit_cb, &st);
826
962
  if (st.aborted) {
@@ -832,14 +968,15 @@ eval_step(mkr_xpath_context_t *ctx, const mkr_step_t *step,
832
968
  } else {
833
969
  /* Predicate path: position() / last() are per-context, so we have
834
970
  * to materialise each context's fragment before filtering. Reuse
835
- * a single fragment buffer across iterations its items[] grows
971
+ * a single fragment buffer across iterations - its items[] grows
836
972
  * to the largest single-context cardinality once instead of
837
973
  * malloc/freeing on every iteration. */
838
974
  mkr_nodeset_t fragment;
839
975
  mkr_nodeset_init(&fragment);
840
976
  for (size_t ci = 0; ci < context_set->count; ++ci) {
841
977
  fragment.count = 0;
842
- step_visit_t st = { .out = &fragment, .step = step, .ctx = ctx, .err = err, .aborted = 0 };
978
+ step_visit_t st = { .out = &fragment, .step = step, .ctx = ctx, .err = err, .aborted = 0,
979
+ .pre_uri = pre_uri, .pre_uri_len = pre_uri_len, .have_pre = have_pre };
843
980
  walk_axis(step->axis, context_set->items[ci], step_visit_cb, &st);
844
981
  if (st.aborted) {
845
982
  mkr_nodeset_clear(&fragment);
@@ -883,13 +1020,175 @@ eval_step(mkr_xpath_context_t *ctx, const mkr_step_t *step,
883
1020
  return 0;
884
1021
  }
885
1022
 
1023
+ /* --- `//name[N]` index fast path (shared shape + per-parent emit) -----------
1024
+ *
1025
+ * `//name[N]` (the two leading steps `descendant-or-self::node()` /
1026
+ * `child::name[N]`, N a positive integer literal, rooted at the document)
1027
+ * selects, for every node c, c's Nth name-child - i.e. every name element that
1028
+ * is the Nth name-child of its parent. This is NOT `(//name)[N]` nor
1029
+ * `descendant::name[N]`. Both hosts answer it from their element index (XML: the
1030
+ * name index; HTML: the tag-id index) with per-parent counting, instead of the
1031
+ * descendant-or-self full-tree walk: the index lists matching elements in
1032
+ * document order, so a parent's name-children appear among them in child order;
1033
+ * one sweep with a pointer-keyed parent->count map emits exactly those whose
1034
+ * running count reaches N, already in document order (no sort/dedup). The bucket
1035
+ * retrieval is host-specific; the shape check and the count loop are shared. */
1036
+
1037
+ /* Host-neutral shape check: sets *need = N. 1 = matches, 0 = no. */
1038
+ static int
1039
+ nth_shape(mkr_xpath_context_t *ctx, const mkr_step_t *s0, const mkr_step_t *s1,
1040
+ const mkr_nodeset_t *seed, size_t *need)
1041
+ {
1042
+ if (s0->axis != MKR_AXIS_DESCENDANT_OR_SELF || s0->test.kind != MKR_NT_NODE
1043
+ || s0->test.prefix.ptr != NULL || s0->npredicates != 0)
1044
+ return 0;
1045
+ if (s1->axis != MKR_AXIS_CHILD || s1->test.kind != MKR_NT_NAME
1046
+ || s1->test.local.ptr == NULL || s1->npredicates != 1)
1047
+ return 0;
1048
+ /* The sole predicate must be a bare positive-integer literal `[N]` (= position()
1049
+ * == N). `[position()=N]`, `[last()]`, etc. are binops/calls -> fall back. */
1050
+ const mkr_node_t *pred = s1->predicates[0];
1051
+ if (pred == NULL || pred->kind != MKR_NK_LITERAL_NUM) return 0;
1052
+ double dn = pred->u.literal_num;
1053
+ if (!(dn >= 1.0) || dn != (double)(size_t)dn) return 0;
1054
+ /* Context must be exactly the document node (the index's scope). */
1055
+ if (seed->count != 1 || seed->items[0] != (MKR_DOM_NODE *)mkr_ctx_document(ctx))
1056
+ return 0;
1057
+ *need = (size_t)dn;
1058
+ return 1;
1059
+ }
1060
+
1061
+ /* Emit, in document order, each bucket element that is the +need+th of its
1062
+ * parent. When +test+ != NULL only elements matching it count (the HTML tag
1063
+ * bucket may include tag-id-quirk non-matches; node_principal_match re-checks).
1064
+ * The op budget is charged per element. Returns 0, -1 (OOM/limit), or -2
1065
+ * (cap-sizer overflow -> caller falls back to the generic evaluator). */
1066
+ static int
1067
+ emit_nth_per_parent(mkr_xpath_context_t *ctx, MKR_DOM_NODE *const *bucket, size_t cnt,
1068
+ size_t need, const mkr_nodetest_t *test, mkr_axis_t axis,
1069
+ mkr_nodeset_t *result, mkr_xpath_error_t *err)
1070
+ {
1071
+ if (cnt == 0) return 0;
1072
+ size_t cap;
1073
+ if (!mkr_pow2_ceil(cnt + (cnt >> 1) + 1, &cap)) return -2;
1074
+ struct nth_slot { const void *parent; size_t count; } *tab =
1075
+ mkr_callocarray(cap, sizeof(*tab));
1076
+ if (tab == NULL) { mkr_err_set(err, MKR_XPATH_ERR_OOM, "out of memory (//name[N])"); return -1; }
1077
+ const size_t mask = cap - 1;
1078
+ mkr_xpath_limits_t *limits = mkr_ctx_limits(ctx);
1079
+ int rc = 0;
1080
+ for (size_t i = 0; i < cnt; ++i) {
1081
+ if (mkr_limit_eval_op(limits, err) != 0) { rc = -1; break; } /* same budget as the walk */
1082
+ MKR_DOM_NODE *e = bucket[i];
1083
+ if (test != NULL && !node_principal_match(test, e, axis, ctx)) continue;
1084
+ const void *par = (const void *)MKR_NODE_PARENT(e);
1085
+ size_t h = (size_t)mkr_ptr_hash(par) & mask;
1086
+ while (tab[h].parent != NULL && tab[h].parent != par) h = (h + 1) & mask;
1087
+ if (tab[h].parent == NULL) { tab[h].parent = par; tab[h].count = 0; }
1088
+ if (++tab[h].count == need) { /* e is the Nth name-child of its parent */
1089
+ if (mkr_nodeset_push(result, e, limits, err) != 0) { rc = -1; break; }
1090
+ }
1091
+ }
1092
+ free(tab);
1093
+ return rc;
1094
+ }
1095
+
1096
+ /* Returns 1 (filled *result; the caller consumes the two leading steps),
1097
+ * 0 (not eligible -> caller evaluates normally), -1 (error). */
1098
+ #ifdef MKR_HOST_XML
1099
+ static int
1100
+ try_descendant_index_nth(mkr_xpath_context_t *ctx,
1101
+ const mkr_step_t *s0, const mkr_step_t *s1,
1102
+ const mkr_nodeset_t *seed,
1103
+ mkr_nodeset_t *result, mkr_xpath_error_t *err)
1104
+ {
1105
+ size_t need;
1106
+ if (!nth_shape(ctx, s0, s1, seed, &need)) return 0;
1107
+
1108
+ /* Resolve the name test's namespace, mirroring try_descendant_name_index. */
1109
+ const char *ns_uri; size_t ns_uri_len;
1110
+ if (s1->test.prefix.ptr != NULL) {
1111
+ ns_uri = mkr_ctx_lookup_ns(ctx, s1->test.prefix.ptr, s1->test.prefix.len, &ns_uri_len);
1112
+ if (ns_uri == NULL) {
1113
+ mkr_err_setf(err, MKR_XPATH_ERR_RUNTIME,
1114
+ "unknown namespace prefix '%s' in name test", s1->test.prefix.ptr);
1115
+ return -1;
1116
+ }
1117
+ } else if (mkr_ctx_unprefixed_lax(ctx)) {
1118
+ return 0; /* any namespace -> not a single bucket */
1119
+ } else {
1120
+ ns_uri = ""; ns_uri_len = 0; /* strict unprefixed -> no namespace */
1121
+ }
1122
+
1123
+ void *owner = mkr_ctx_name_index_owner(ctx);
1124
+ mkr_name_index_get_t get = mkr_ctx_name_index_get(ctx);
1125
+ mkr_name_index_lookup_t lookup = mkr_ctx_name_index_lookup(ctx);
1126
+ if (owner == NULL || get == NULL || lookup == NULL) return 0;
1127
+ void *idx = get(owner);
1128
+ if (idx == NULL) return 0;
1129
+
1130
+ size_t cnt = 0;
1131
+ void *const *bucket = lookup(idx, s1->test.local.ptr, s1->test.local.len,
1132
+ ns_uri, ns_uri_len, &cnt);
1133
+ /* The name-index bucket already holds exactly the matches, so no re-check. */
1134
+ int rc = emit_nth_per_parent(ctx, (MKR_DOM_NODE *const *)bucket, cnt, need,
1135
+ NULL, s1->axis, result, err);
1136
+ return rc == -2 ? 0 : (rc < 0 ? -1 : 1);
1137
+ }
1138
+ #else /* HTML: tag-id index analogue (unprefixed, pure-HTML, static tag range) */
1139
+ static int
1140
+ try_descendant_index_nth(mkr_xpath_context_t *ctx,
1141
+ const mkr_step_t *s0, const mkr_step_t *s1,
1142
+ const mkr_nodeset_t *seed,
1143
+ mkr_nodeset_t *result, mkr_xpath_error_t *err)
1144
+ {
1145
+ size_t need;
1146
+ if (!nth_shape(ctx, s0, s1, seed, &need)) return 0;
1147
+ if (s1->test.prefix.ptr != NULL) return 0; /* the tag index is unprefixed-only */
1148
+
1149
+ void *eidx = mkr_ctx_element_index(ctx);
1150
+ mkr_tag_index_lookup_t lookup = mkr_ctx_tag_lookup(ctx);
1151
+ mkr_tag_index_foreign_t has_foreign = mkr_ctx_tag_has_foreign(ctx);
1152
+ if (eidx == NULL || lookup == NULL || has_foreign == NULL || has_foreign(eidx)) return 0;
1153
+ MKR_DOM_DOCUMENT *doc = mkr_ctx_document(ctx);
1154
+ if (doc == NULL) return 0;
1155
+ lxb_tag_id_t tag = MKR_DOC_TAG_ID_BY_NAME(doc, s1->test.local.ptr, s1->test.local.len);
1156
+ if (tag == LXB_TAG__UNDEF || (uintptr_t)tag >= LXB_TAG__LAST_ENTRY) return 0;
1157
+
1158
+ size_t cnt = 0;
1159
+ MKR_DOM_NODE *const *bucket = (MKR_DOM_NODE *const *)lookup(eidx, tag, &cnt);
1160
+ /* Re-check each candidate (tag-name lookup can have case/normalization quirks),
1161
+ * so the per-parent count is over the actual matches - byte-identical to the
1162
+ * walk, exactly as try_descendant_tag_index does for the no-predicate case. */
1163
+ int rc = emit_nth_per_parent(ctx, bucket, cnt, need, &s1->test, s1->axis, result, err);
1164
+ return rc == -2 ? 0 : (rc < 0 ? -1 : 1);
1165
+ }
1166
+ #endif
1167
+
886
1168
  static int
887
1169
  eval_steps(mkr_xpath_context_t *ctx, mkr_step_t *steps, size_t nsteps,
888
1170
  mkr_nodeset_t *seed, mkr_val_t *out, mkr_xpath_error_t *err)
889
1171
  {
890
1172
  mkr_nodeset_t current = *seed; /* take ownership */
891
1173
  memset(seed, 0, sizeof(*seed));
892
- for (size_t s = 0; s < nsteps; ++s) {
1174
+ size_t s_start = 0;
1175
+ /* `//name[N]` (descendant-or-self::node()/child::name[N]) from the document:
1176
+ * serve the two leading steps from the element index (XML: name index; HTML:
1177
+ * tag-id index) with per-parent counting instead of the full-tree walk. */
1178
+ if (nsteps >= 2) {
1179
+ mkr_nodeset_t nth;
1180
+ mkr_nodeset_init(&nth);
1181
+ int fp = try_descendant_index_nth(ctx, &steps[0], &steps[1], &current, &nth, err);
1182
+ if (fp < 0) { mkr_nodeset_clear(&nth); mkr_nodeset_clear(&current); return -1; }
1183
+ if (fp == 1) {
1184
+ mkr_nodeset_clear(&current);
1185
+ current = nth; /* result of //name[N]; continue from the 3rd step */
1186
+ s_start = 2;
1187
+ } else {
1188
+ mkr_nodeset_clear(&nth);
1189
+ }
1190
+ }
1191
+ for (size_t s = s_start; s < nsteps; ++s) {
893
1192
  mkr_nodeset_t next;
894
1193
  mkr_nodeset_init(&next);
895
1194
  if (eval_step(ctx, &steps[s], &current, &next, err) != 0) {
@@ -923,13 +1222,17 @@ compare_eq(mkr_xpath_context_t *ctx, const mkr_val_t *l, const mkr_val_t *r,
923
1222
  /* All node string-values go through the per-eval cache. For an
924
1223
  * M-by-N nodeset comparison this turns O(M*N) computations into
925
1224
  * at most O(M+N). */
926
- (void)L;
1225
+ /* O(M*N) pair scan: string-value CONSTRUCTION is O(M+N) via the cache, but
1226
+ * the comparison loop itself is M*N, so charge each pair to the op budget -
1227
+ * otherwise an all-pairs node-set equality drives up to ~1e14 memcmps as a
1228
+ * handful of ops. */
927
1229
  for (size_t i = 0; i < l->u.nodeset.count; ++i) {
928
1230
  mkr_borrowed_text_t ls;
929
1231
  if (mkr_get_cached_node_text(ctx, l->u.nodeset.items[i], &ls, err) != 0) {
930
1232
  return -1;
931
1233
  }
932
1234
  for (size_t j = 0; j < r->u.nodeset.count; ++j) {
1235
+ if (mkr_limit_eval_op(L, err) != 0) return -1;
933
1236
  mkr_borrowed_text_t rs;
934
1237
  if (mkr_get_cached_node_text(ctx, r->u.nodeset.items[j], &rs, err) != 0) {
935
1238
  return -1;
@@ -949,6 +1252,7 @@ compare_eq(mkr_xpath_context_t *ctx, const mkr_val_t *l, const mkr_val_t *r,
949
1252
  if (sc->type == MKR_XPATH_TYPE_NUMBER) {
950
1253
  double target = sc->u.number;
951
1254
  for (size_t i = 0; i < ns->u.nodeset.count; ++i) {
1255
+ if (mkr_limit_eval_op(L, err) != 0) return -1;
952
1256
  mkr_borrowed_text_t s;
953
1257
  if (mkr_get_cached_node_text(ctx, ns->u.nodeset.items[i], &s, err) != 0) {
954
1258
  return -1;
@@ -969,6 +1273,7 @@ compare_eq(mkr_xpath_context_t *ctx, const mkr_val_t *l, const mkr_val_t *r,
969
1273
  mkr_owned_text_t target;
970
1274
  if (mkr_val_to_owned_text_or_fail(sc, L, err, &target) != 0) return -1;
971
1275
  for (size_t i = 0; i < ns->u.nodeset.count; ++i) {
1276
+ if (mkr_limit_eval_op(L, err) != 0) { mkr_owned_text_clear(&target); return -1; }
972
1277
  mkr_borrowed_text_t s;
973
1278
  if (mkr_get_cached_node_text(ctx, ns->u.nodeset.items[i], &s, err) != 0) {
974
1279
  mkr_owned_text_clear(&target); return -1;
@@ -989,7 +1294,7 @@ compare_eq(mkr_xpath_context_t *ctx, const mkr_val_t *l, const mkr_val_t *r,
989
1294
  }
990
1295
  if (l->type == MKR_XPATH_TYPE_NUMBER || r->type == MKR_XPATH_TYPE_NUMBER) {
991
1296
  /* Both operands are non-nodeset (the nodeset case is handled
992
- * above); _unchecked is the right entry no allocation possible. */
1297
+ * above); _unchecked is the right entry - no allocation possible. */
993
1298
  double a = mkr_val_to_number_unchecked(l);
994
1299
  double b = mkr_val_to_number_unchecked(r);
995
1300
  int eq = (a == b);
@@ -1025,11 +1330,13 @@ compare_rel(mkr_xpath_context_t *ctx, const mkr_val_t *l, const mkr_val_t *r,
1025
1330
  {
1026
1331
  mkr_xpath_limits_t *L = mkr_ctx_limits(ctx);
1027
1332
 
1028
- /* node-set vs node-set (XPath 1.0 §3.4): true iff SOME pair of nodes one
1029
- * from each set satisfies the relation on their numeric string-values.
1333
+ /* node-set vs node-set (XPath 1.0 §3.4): true iff SOME pair of nodes - one
1334
+ * from each set - satisfies the relation on their numeric string-values.
1030
1335
  * Must compare every pair, not just the first node of each side. */
1031
1336
  if (l->type == MKR_XPATH_TYPE_NODESET && r->type == MKR_XPATH_TYPE_NODESET) {
1032
- (void)L;
1337
+ /* O(M*N) pair scan (see compare_eq): charge each pair to the op budget so
1338
+ * an all-pairs relational compare cannot drive M*N number conversions as a
1339
+ * handful of ops. */
1033
1340
  for (size_t i = 0; i < l->u.nodeset.count; ++i) {
1034
1341
  mkr_borrowed_text_t ls;
1035
1342
  if (mkr_get_cached_node_text(ctx, l->u.nodeset.items[i], &ls, err) != 0) {
@@ -1037,6 +1344,7 @@ compare_rel(mkr_xpath_context_t *ctx, const mkr_val_t *l, const mkr_val_t *r,
1037
1344
  }
1038
1345
  double a = mkr_borrowed_text_to_number(ls);
1039
1346
  for (size_t j = 0; j < r->u.nodeset.count; ++j) {
1347
+ if (mkr_limit_eval_op(L, err) != 0) return -1;
1040
1348
  mkr_borrowed_text_t rs;
1041
1349
  if (mkr_get_cached_node_text(ctx, r->u.nodeset.items[j], &rs, err) != 0) {
1042
1350
  return -1;
@@ -1058,6 +1366,7 @@ compare_rel(mkr_xpath_context_t *ctx, const mkr_val_t *l, const mkr_val_t *r,
1058
1366
  double scn;
1059
1367
  if (mkr_val_to_number_or_fail(sc, L, err, &scn) != 0) return -1;
1060
1368
  for (size_t i = 0; i < ns->u.nodeset.count; ++i) {
1369
+ if (mkr_limit_eval_op(L, err) != 0) return -1;
1061
1370
  mkr_borrowed_text_t s;
1062
1371
  if (mkr_get_cached_node_text(ctx, ns->u.nodeset.items[i], &s, err) != 0) {
1063
1372
  return -1;
@@ -1095,7 +1404,7 @@ union_nodeset(mkr_xpath_context_t *ctx, mkr_val_t *l, mkr_val_t *r, mkr_val_t *o
1095
1404
  }
1096
1405
  /* Push both sides without per-insert deduplication (the old contains()
1097
1406
  * loop was O(n^2)). Sort once at the end and collapse adjacent
1098
- * duplicates total cost is O(n log n) plus a single linear pass. */
1407
+ * duplicates - total cost is O(n log n) plus a single linear pass. */
1099
1408
  mkr_nodeset_t merged;
1100
1409
  mkr_nodeset_init(&merged);
1101
1410
  for (size_t i = 0; i < l->u.nodeset.count; ++i) {
@@ -1122,13 +1431,13 @@ union_nodeset(mkr_xpath_context_t *ctx, mkr_val_t *l, mkr_val_t *r, mkr_val_t *o
1122
1431
 
1123
1432
  static int
1124
1433
  eval_path(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1125
- lxb_dom_node_t *self_node,
1434
+ MKR_DOM_NODE *self_node,
1126
1435
  mkr_val_t *out, mkr_xpath_error_t *err)
1127
1436
  {
1128
1437
  mkr_nodeset_t seed;
1129
1438
  mkr_nodeset_init(&seed);
1130
1439
  if (n->u.path.absolute) {
1131
- lxb_dom_node_t *root = (lxb_dom_node_t *)mkr_ctx_document(ctx);
1440
+ MKR_DOM_NODE *root = (MKR_DOM_NODE *)mkr_ctx_document(ctx);
1132
1441
  if (root == NULL) {
1133
1442
  mkr_err_set(err, MKR_XPATH_ERR_RUNTIME, "absolute path with no document");
1134
1443
  return -1;
@@ -1148,11 +1457,11 @@ eval_path(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1148
1457
 
1149
1458
  static int
1150
1459
  eval_filter(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1151
- lxb_dom_node_t *self_node, size_t self_pos, size_t self_size,
1460
+ const mkr_focus_t *focus,
1152
1461
  mkr_val_t *out, mkr_xpath_error_t *err)
1153
1462
  {
1154
1463
  mkr_val_t primary = {0};
1155
- if (eval_node(ctx, n->u.filter.expr, self_node, self_pos, self_size, &primary, err) != 0) return -1;
1464
+ if (eval_node(ctx, n->u.filter.expr, focus, &primary, err) != 0) return -1;
1156
1465
  if (n->u.filter.npreds > 0) {
1157
1466
  if (primary.type != MKR_XPATH_TYPE_NODESET) {
1158
1467
  mkr_val_clear(&primary);
@@ -1181,11 +1490,12 @@ eval_filter(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1181
1490
 
1182
1491
  /* ---------- function library bridge ---------- */
1183
1492
 
1184
- extern mkr_func_impl_t mkr_lookup_function(const char *ns_uri, const char *local_name);
1493
+ /* mkr_lookup_function is the file-static function table, defined earlier in this
1494
+ * merged engine TU (mkr_xpath_funcs_body.h, included before this body). */
1185
1495
 
1186
1496
  static int
1187
1497
  eval_fncall(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1188
- lxb_dom_node_t *self_node, size_t self_pos, size_t self_size,
1498
+ const mkr_focus_t *focus,
1189
1499
  mkr_val_t *out, mkr_xpath_error_t *err)
1190
1500
  {
1191
1501
  const char *ns_uri = NULL;
@@ -1208,7 +1518,7 @@ eval_fncall(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1208
1518
  return -1;
1209
1519
  }
1210
1520
  for (size_t i = 0; i < nargs; ++i) {
1211
- if (eval_node(ctx, n->u.fncall.args[i], self_node, self_pos, self_size, &args[i], err) != 0) {
1521
+ if (eval_node(ctx, n->u.fncall.args[i], focus, &args[i], err) != 0) {
1212
1522
  for (size_t j = 0; j <= i; ++j) mkr_val_clear(&args[j]);
1213
1523
  free(args);
1214
1524
  return -1;
@@ -1218,7 +1528,7 @@ eval_fncall(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1218
1528
 
1219
1529
  int rc;
1220
1530
  if (fn != NULL) {
1221
- rc = fn(ctx, self_node, self_pos, self_size, args, nargs, out, err);
1531
+ rc = fn(ctx, focus, args, nargs, out, err);
1222
1532
  } else {
1223
1533
  /* No built-in match. Delegate to the per-call resolver (the Ruby
1224
1534
  * handler bridge installs one for the duration of evaluate()). */
@@ -1226,7 +1536,7 @@ eval_fncall(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1226
1536
  int resolved = 1; /* not found by default */
1227
1537
  if (resolver != NULL) {
1228
1538
  resolved = resolver(mkr_xpath_get_user_data(ctx),
1229
- ctx, (void *)self_node, self_pos, self_size,
1539
+ ctx, (void *)focus->node, focus->pos, focus->size,
1230
1540
  ns_uri, n->u.fncall.name.ptr,
1231
1541
  (void *)args, nargs, (void *)out, err);
1232
1542
  }
@@ -1248,13 +1558,13 @@ eval_fncall(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1248
1558
 
1249
1559
  static int
1250
1560
  eval_binop(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1251
- lxb_dom_node_t *self_node, size_t self_pos, size_t self_size,
1561
+ const mkr_focus_t *focus,
1252
1562
  mkr_val_t *out, mkr_xpath_error_t *err)
1253
1563
  {
1254
1564
  /* AND/OR short-circuit. */
1255
1565
  if (n->u.binop.op == MKR_OP_OR || n->u.binop.op == MKR_OP_AND) {
1256
1566
  mkr_val_t l = {0};
1257
- if (eval_node(ctx, n->u.binop.lhs, self_node, self_pos, self_size, &l, err) != 0) return -1;
1567
+ if (eval_node(ctx, n->u.binop.lhs, focus, &l, err) != 0) return -1;
1258
1568
  int lb = mkr_val_to_boolean(&l);
1259
1569
  mkr_val_clear(&l);
1260
1570
  if (n->u.binop.op == MKR_OP_OR && lb) {
@@ -1264,7 +1574,7 @@ eval_binop(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1264
1574
  out->type = MKR_XPATH_TYPE_BOOLEAN; out->u.boolean = 0; return 0;
1265
1575
  }
1266
1576
  mkr_val_t r = {0};
1267
- if (eval_node(ctx, n->u.binop.rhs, self_node, self_pos, self_size, &r, err) != 0) return -1;
1577
+ if (eval_node(ctx, n->u.binop.rhs, focus, &r, err) != 0) return -1;
1268
1578
  int rb = mkr_val_to_boolean(&r);
1269
1579
  mkr_val_clear(&r);
1270
1580
  out->type = MKR_XPATH_TYPE_BOOLEAN; out->u.boolean = rb;
@@ -1272,8 +1582,8 @@ eval_binop(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1272
1582
  }
1273
1583
 
1274
1584
  mkr_val_t l = {0}, r = {0};
1275
- if (eval_node(ctx, n->u.binop.lhs, self_node, self_pos, self_size, &l, err) != 0) return -1;
1276
- if (eval_node(ctx, n->u.binop.rhs, self_node, self_pos, self_size, &r, err) != 0) {
1585
+ if (eval_node(ctx, n->u.binop.lhs, focus, &l, err) != 0) return -1;
1586
+ if (eval_node(ctx, n->u.binop.rhs, focus, &r, err) != 0) {
1277
1587
  mkr_val_clear(&l); return -1;
1278
1588
  }
1279
1589
  int rc = 0;
@@ -1342,7 +1652,7 @@ eval_binop(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1342
1652
 
1343
1653
  static int
1344
1654
  eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1345
- lxb_dom_node_t *self_node, size_t self_pos, size_t self_size,
1655
+ const mkr_focus_t *focus,
1346
1656
  mkr_val_t *out, mkr_xpath_error_t *err)
1347
1657
  {
1348
1658
  /* Budget + recursion bookkeeping. Every AST node visit counts as one
@@ -1354,7 +1664,7 @@ eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1354
1664
 
1355
1665
  /* Hoisting fast path: a CI subtree that's already been computed in
1356
1666
  * this evaluate is returned as a clone. The clone keeps ownership
1357
- * semantics clean mkr_val_clear on either copy is safe. */
1667
+ * semantics clean - mkr_val_clear on either copy is safe. */
1358
1668
  if (n->is_context_independent && n->memoized) {
1359
1669
  int rc = mkr_val_clone(&n->memo_value, out, err);
1360
1670
  mkr_limit_recurse_leave(L);
@@ -1399,11 +1709,11 @@ eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1399
1709
  break;
1400
1710
  }
1401
1711
  case MKR_NK_FNCALL:
1402
- rc = eval_fncall(ctx, n, self_node, self_pos, self_size, out, err);
1712
+ rc = eval_fncall(ctx, n, focus, out, err);
1403
1713
  break;
1404
1714
  case MKR_NK_UNARY: {
1405
1715
  mkr_val_t v = {0};
1406
- if (eval_node(ctx, n->u.unary.expr, self_node, self_pos, self_size, &v, err) != 0) {
1716
+ if (eval_node(ctx, n->u.unary.expr, focus, &v, err) != 0) {
1407
1717
  rc = -1;
1408
1718
  break;
1409
1719
  }
@@ -1420,13 +1730,13 @@ eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1420
1730
  break;
1421
1731
  }
1422
1732
  case MKR_NK_BINOP:
1423
- rc = eval_binop(ctx, n, self_node, self_pos, self_size, out, err);
1733
+ rc = eval_binop(ctx, n, focus, out, err);
1424
1734
  break;
1425
1735
  case MKR_NK_PATH:
1426
- rc = eval_path(ctx, n, self_node, out, err);
1736
+ rc = eval_path(ctx, n, focus->node, out, err);
1427
1737
  break;
1428
1738
  case MKR_NK_FILTER:
1429
- rc = eval_filter(ctx, n, self_node, self_pos, self_size, out, err);
1739
+ rc = eval_filter(ctx, n, focus, out, err);
1430
1740
  break;
1431
1741
  default:
1432
1742
  mkr_err_set(err, MKR_XPATH_ERR_INTERNAL, "unknown AST node");
@@ -1435,7 +1745,7 @@ eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1435
1745
  }
1436
1746
 
1437
1747
  /* Memoize on success when the subtree is CI. The clone keeps the
1438
- * caller's out value independent of the cached one important
1748
+ * caller's out value independent of the cached one - important
1439
1749
  * because the caller is free to consume/modify their copy. */
1440
1750
  if (rc == 0 && n->is_context_independent && !n->memoized) {
1441
1751
  mkr_val_t memo = {0};
@@ -1446,7 +1756,7 @@ eval_node(mkr_xpath_context_t *ctx, const mkr_node_t *n,
1446
1756
  mut->memo_value = memo;
1447
1757
  mut->memoized = 1;
1448
1758
  } else {
1449
- /* OOM during clone the caller's `out` is still valid. We
1759
+ /* OOM during clone - the caller's `out` is still valid. We
1450
1760
  * leave the node unmemoized and surface the error. */
1451
1761
  rc = -1;
1452
1762
  }
@@ -1460,6 +1770,6 @@ int
1460
1770
  mkr_eval_ast(mkr_xpath_context_t *ctx, const mkr_node_t *ast,
1461
1771
  mkr_val_t *out, mkr_xpath_error_t *err)
1462
1772
  {
1463
- lxb_dom_node_t *self_node = mkr_ctx_node(ctx);
1464
- return eval_node(ctx, ast, self_node, 1, 1, out, err);
1773
+ mkr_focus_t focus = { mkr_ctx_node(ctx), 1, 1 };
1774
+ return eval_node(ctx, ast, &focus, out, err);
1465
1775
  }