moxml 0.1.6 → 0.1.8

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 (215) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +5 -0
  3. data/.github/workflows/dependent-tests.yml +20 -0
  4. data/.github/workflows/docs.yml +59 -0
  5. data/.github/workflows/rake.yml +12 -4
  6. data/.github/workflows/release.yml +5 -3
  7. data/.gitignore +37 -0
  8. data/.rubocop.yml +15 -7
  9. data/.rubocop_todo.yml +238 -40
  10. data/Gemfile +14 -9
  11. data/LICENSE.md +6 -2
  12. data/README.adoc +535 -373
  13. data/Rakefile +53 -0
  14. data/benchmarks/.gitignore +6 -0
  15. data/benchmarks/generate_report.rb +550 -0
  16. data/docs/Gemfile +13 -0
  17. data/docs/_config.yml +138 -0
  18. data/docs/_guides/advanced-features.adoc +87 -0
  19. data/docs/_guides/development-testing.adoc +165 -0
  20. data/docs/_guides/index.adoc +45 -0
  21. data/docs/_guides/modifying-xml.adoc +293 -0
  22. data/docs/_guides/parsing-xml.adoc +231 -0
  23. data/docs/_guides/sax-parsing.adoc +603 -0
  24. data/docs/_guides/working-with-documents.adoc +118 -0
  25. data/docs/_pages/adapter-compatibility.adoc +369 -0
  26. data/docs/_pages/adapters/headed-ox.adoc +237 -0
  27. data/docs/_pages/adapters/index.adoc +98 -0
  28. data/docs/_pages/adapters/libxml.adoc +286 -0
  29. data/docs/_pages/adapters/nokogiri.adoc +252 -0
  30. data/docs/_pages/adapters/oga.adoc +292 -0
  31. data/docs/_pages/adapters/ox.adoc +55 -0
  32. data/docs/_pages/adapters/rexml.adoc +293 -0
  33. data/docs/_pages/best-practices.adoc +430 -0
  34. data/docs/_pages/compatibility.adoc +468 -0
  35. data/docs/_pages/configuration.adoc +251 -0
  36. data/docs/_pages/error-handling.adoc +350 -0
  37. data/docs/_pages/headed-ox-limitations.adoc +558 -0
  38. data/docs/_pages/headed-ox.adoc +1025 -0
  39. data/docs/_pages/index.adoc +35 -0
  40. data/docs/_pages/installation.adoc +141 -0
  41. data/docs/_pages/node-api-reference.adoc +50 -0
  42. data/docs/_pages/performance.adoc +36 -0
  43. data/docs/_pages/quick-start.adoc +244 -0
  44. data/docs/_pages/thread-safety.adoc +29 -0
  45. data/docs/_references/document-api.adoc +408 -0
  46. data/docs/_references/index.adoc +48 -0
  47. data/docs/_tutorials/basic-usage.adoc +268 -0
  48. data/docs/_tutorials/builder-pattern.adoc +343 -0
  49. data/docs/_tutorials/index.adoc +33 -0
  50. data/docs/_tutorials/namespace-handling.adoc +325 -0
  51. data/docs/_tutorials/xpath-queries.adoc +359 -0
  52. data/docs/index.adoc +122 -0
  53. data/examples/README.md +124 -0
  54. data/examples/api_client/README.md +424 -0
  55. data/examples/api_client/api_client.rb +394 -0
  56. data/examples/api_client/example_response.xml +48 -0
  57. data/examples/headed_ox_example/README.md +90 -0
  58. data/examples/headed_ox_example/headed_ox_demo.rb +71 -0
  59. data/examples/rss_parser/README.md +194 -0
  60. data/examples/rss_parser/example_feed.xml +93 -0
  61. data/examples/rss_parser/rss_parser.rb +189 -0
  62. data/examples/sax_parsing/README.md +50 -0
  63. data/examples/sax_parsing/data_extractor.rb +75 -0
  64. data/examples/sax_parsing/example.xml +21 -0
  65. data/examples/sax_parsing/large_file.rb +78 -0
  66. data/examples/sax_parsing/simple_parser.rb +55 -0
  67. data/examples/web_scraper/README.md +352 -0
  68. data/examples/web_scraper/example_page.html +201 -0
  69. data/examples/web_scraper/web_scraper.rb +312 -0
  70. data/lib/moxml/adapter/base.rb +107 -28
  71. data/lib/moxml/adapter/customized_libxml/cdata.rb +28 -0
  72. data/lib/moxml/adapter/customized_libxml/comment.rb +24 -0
  73. data/lib/moxml/adapter/customized_libxml/declaration.rb +85 -0
  74. data/lib/moxml/adapter/customized_libxml/element.rb +39 -0
  75. data/lib/moxml/adapter/customized_libxml/node.rb +44 -0
  76. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +31 -0
  77. data/lib/moxml/adapter/customized_libxml/text.rb +27 -0
  78. data/lib/moxml/adapter/customized_oga/xml_generator.rb +1 -1
  79. data/lib/moxml/adapter/customized_ox/attribute.rb +28 -3
  80. data/lib/moxml/adapter/customized_ox/namespace.rb +0 -2
  81. data/lib/moxml/adapter/customized_ox/text.rb +0 -2
  82. data/lib/moxml/adapter/customized_rexml/formatter.rb +11 -6
  83. data/lib/moxml/adapter/headed_ox.rb +161 -0
  84. data/lib/moxml/adapter/libxml.rb +1548 -0
  85. data/lib/moxml/adapter/nokogiri.rb +121 -9
  86. data/lib/moxml/adapter/oga.rb +123 -12
  87. data/lib/moxml/adapter/ox.rb +283 -27
  88. data/lib/moxml/adapter/rexml.rb +127 -20
  89. data/lib/moxml/adapter.rb +21 -4
  90. data/lib/moxml/attribute.rb +6 -0
  91. data/lib/moxml/builder.rb +40 -4
  92. data/lib/moxml/config.rb +8 -3
  93. data/lib/moxml/context.rb +39 -1
  94. data/lib/moxml/doctype.rb +13 -1
  95. data/lib/moxml/document.rb +39 -6
  96. data/lib/moxml/document_builder.rb +27 -5
  97. data/lib/moxml/element.rb +71 -2
  98. data/lib/moxml/error.rb +175 -6
  99. data/lib/moxml/node.rb +94 -3
  100. data/lib/moxml/node_set.rb +34 -0
  101. data/lib/moxml/sax/block_handler.rb +194 -0
  102. data/lib/moxml/sax/element_handler.rb +124 -0
  103. data/lib/moxml/sax/handler.rb +113 -0
  104. data/lib/moxml/sax.rb +31 -0
  105. data/lib/moxml/version.rb +1 -1
  106. data/lib/moxml/xml_utils/encoder.rb +4 -4
  107. data/lib/moxml/xml_utils.rb +7 -4
  108. data/lib/moxml/xpath/ast/node.rb +159 -0
  109. data/lib/moxml/xpath/cache.rb +91 -0
  110. data/lib/moxml/xpath/compiler.rb +1768 -0
  111. data/lib/moxml/xpath/context.rb +26 -0
  112. data/lib/moxml/xpath/conversion.rb +124 -0
  113. data/lib/moxml/xpath/engine.rb +52 -0
  114. data/lib/moxml/xpath/errors.rb +101 -0
  115. data/lib/moxml/xpath/lexer.rb +304 -0
  116. data/lib/moxml/xpath/parser.rb +485 -0
  117. data/lib/moxml/xpath/ruby/generator.rb +269 -0
  118. data/lib/moxml/xpath/ruby/node.rb +193 -0
  119. data/lib/moxml/xpath.rb +37 -0
  120. data/lib/moxml.rb +5 -2
  121. data/moxml.gemspec +3 -1
  122. data/old-specs/moxml/adapter/customized_libxml/.gitkeep +6 -0
  123. data/spec/consistency/README.md +77 -0
  124. data/spec/{moxml/examples/adapter_spec.rb → consistency/adapter_parity_spec.rb} +4 -4
  125. data/spec/examples/README.md +75 -0
  126. data/spec/{support/shared_examples/examples/attribute.rb → examples/attribute_examples_spec.rb} +1 -1
  127. data/spec/{support/shared_examples/examples/basic_usage.rb → examples/basic_usage_spec.rb} +2 -2
  128. data/spec/{support/shared_examples/examples/namespace.rb → examples/namespace_examples_spec.rb} +3 -3
  129. data/spec/{support/shared_examples/examples/readme_examples.rb → examples/readme_examples_spec.rb} +6 -4
  130. data/spec/{support/shared_examples/examples/xpath.rb → examples/xpath_examples_spec.rb} +10 -6
  131. data/spec/integration/README.md +71 -0
  132. data/spec/{moxml/all_with_adapters_spec.rb → integration/all_adapters_spec.rb} +3 -2
  133. data/spec/integration/headed_ox_integration_spec.rb +326 -0
  134. data/spec/{support → integration}/shared_examples/edge_cases.rb +37 -10
  135. data/spec/integration/shared_examples/high_level/.gitkeep +0 -0
  136. data/spec/{support/shared_examples/context.rb → integration/shared_examples/high_level/context_behavior.rb} +2 -1
  137. data/spec/{support/shared_examples/integration.rb → integration/shared_examples/integration_workflows.rb} +23 -6
  138. data/spec/integration/shared_examples/node_wrappers/.gitkeep +0 -0
  139. data/spec/{support/shared_examples/cdata.rb → integration/shared_examples/node_wrappers/cdata_behavior.rb} +6 -1
  140. data/spec/{support/shared_examples/comment.rb → integration/shared_examples/node_wrappers/comment_behavior.rb} +2 -1
  141. data/spec/{support/shared_examples/declaration.rb → integration/shared_examples/node_wrappers/declaration_behavior.rb} +5 -2
  142. data/spec/{support/shared_examples/doctype.rb → integration/shared_examples/node_wrappers/doctype_behavior.rb} +2 -2
  143. data/spec/{support/shared_examples/document.rb → integration/shared_examples/node_wrappers/document_behavior.rb} +1 -1
  144. data/spec/{support/shared_examples/node.rb → integration/shared_examples/node_wrappers/node_behavior.rb} +9 -2
  145. data/spec/{support/shared_examples/node_set.rb → integration/shared_examples/node_wrappers/node_set_behavior.rb} +1 -18
  146. data/spec/{support/shared_examples/processing_instruction.rb → integration/shared_examples/node_wrappers/processing_instruction_behavior.rb} +6 -2
  147. data/spec/moxml/README.md +41 -0
  148. data/spec/moxml/adapter/.gitkeep +0 -0
  149. data/spec/moxml/adapter/README.md +61 -0
  150. data/spec/moxml/adapter/base_spec.rb +27 -0
  151. data/spec/moxml/adapter/headed_ox_spec.rb +311 -0
  152. data/spec/moxml/adapter/libxml_spec.rb +14 -0
  153. data/spec/moxml/adapter/ox_spec.rb +9 -8
  154. data/spec/moxml/adapter/shared_examples/.gitkeep +0 -0
  155. data/spec/{support/shared_examples/xml_adapter.rb → moxml/adapter/shared_examples/adapter_contract.rb} +39 -12
  156. data/spec/moxml/adapter_spec.rb +16 -0
  157. data/spec/moxml/attribute_spec.rb +30 -0
  158. data/spec/moxml/builder_spec.rb +33 -0
  159. data/spec/moxml/cdata_spec.rb +31 -0
  160. data/spec/moxml/comment_spec.rb +31 -0
  161. data/spec/moxml/config_spec.rb +3 -3
  162. data/spec/moxml/context_spec.rb +28 -0
  163. data/spec/moxml/declaration_spec.rb +36 -0
  164. data/spec/moxml/doctype_spec.rb +33 -0
  165. data/spec/moxml/document_builder_spec.rb +30 -0
  166. data/spec/moxml/document_spec.rb +105 -0
  167. data/spec/moxml/element_spec.rb +143 -0
  168. data/spec/moxml/error_spec.rb +266 -22
  169. data/spec/{moxml_spec.rb → moxml/moxml_spec.rb} +9 -9
  170. data/spec/moxml/namespace_spec.rb +32 -0
  171. data/spec/moxml/node_set_spec.rb +39 -0
  172. data/spec/moxml/node_spec.rb +37 -0
  173. data/spec/moxml/processing_instruction_spec.rb +34 -0
  174. data/spec/moxml/sax_spec.rb +1067 -0
  175. data/spec/moxml/text_spec.rb +31 -0
  176. data/spec/moxml/version_spec.rb +14 -0
  177. data/spec/moxml/xml_utils/.gitkeep +0 -0
  178. data/spec/moxml/xml_utils/encoder_spec.rb +27 -0
  179. data/spec/moxml/xml_utils_spec.rb +49 -0
  180. data/spec/moxml/xpath/ast/node_spec.rb +83 -0
  181. data/spec/moxml/xpath/axes_spec.rb +296 -0
  182. data/spec/moxml/xpath/cache_spec.rb +358 -0
  183. data/spec/moxml/xpath/compiler_spec.rb +406 -0
  184. data/spec/moxml/xpath/context_spec.rb +210 -0
  185. data/spec/moxml/xpath/conversion_spec.rb +365 -0
  186. data/spec/moxml/xpath/fixtures/sample.xml +25 -0
  187. data/spec/moxml/xpath/functions/boolean_functions_spec.rb +114 -0
  188. data/spec/moxml/xpath/functions/node_functions_spec.rb +145 -0
  189. data/spec/moxml/xpath/functions/numeric_functions_spec.rb +164 -0
  190. data/spec/moxml/xpath/functions/position_functions_spec.rb +93 -0
  191. data/spec/moxml/xpath/functions/special_functions_spec.rb +89 -0
  192. data/spec/moxml/xpath/functions/string_functions_spec.rb +381 -0
  193. data/spec/moxml/xpath/lexer_spec.rb +488 -0
  194. data/spec/moxml/xpath/parser_integration_spec.rb +210 -0
  195. data/spec/moxml/xpath/parser_spec.rb +364 -0
  196. data/spec/moxml/xpath/ruby/generator_spec.rb +421 -0
  197. data/spec/moxml/xpath/ruby/node_spec.rb +291 -0
  198. data/spec/moxml/xpath_capabilities_spec.rb +199 -0
  199. data/spec/moxml/xpath_spec.rb +77 -0
  200. data/spec/performance/README.md +83 -0
  201. data/spec/performance/benchmark_spec.rb +64 -0
  202. data/spec/{support/shared_examples/examples/memory.rb → performance/memory_usage_spec.rb} +3 -1
  203. data/spec/{support/shared_examples/examples/thread_safety.rb → performance/thread_safety_spec.rb} +3 -1
  204. data/spec/performance/xpath_benchmark_spec.rb +259 -0
  205. data/spec/spec_helper.rb +58 -1
  206. data/spec/support/xml_matchers.rb +1 -1
  207. metadata +176 -35
  208. data/lib/ox/node.rb +0 -9
  209. data/spec/support/shared_examples/examples/benchmark_spec.rb +0 -51
  210. /data/spec/{support/shared_examples/builder.rb → integration/shared_examples/high_level/builder_behavior.rb} +0 -0
  211. /data/spec/{support/shared_examples/document_builder.rb → integration/shared_examples/high_level/document_builder_behavior.rb} +0 -0
  212. /data/spec/{support/shared_examples/attribute.rb → integration/shared_examples/node_wrappers/attribute_behavior.rb} +0 -0
  213. /data/spec/{support/shared_examples/element.rb → integration/shared_examples/node_wrappers/element_behavior.rb} +0 -0
  214. /data/spec/{support/shared_examples/namespace.rb → integration/shared_examples/node_wrappers/namespace_behavior.rb} +0 -0
  215. /data/spec/{support/shared_examples/text.rb → integration/shared_examples/node_wrappers/text_behavior.rb} +0 -0
@@ -0,0 +1,1768 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module XPath
5
+ # Compiler for transforming XPath AST into executable Ruby code.
6
+ #
7
+ # This class takes an XPath AST (produced by Parser) and compiles it into
8
+ # a Ruby Proc that can be executed against XML documents. The compilation
9
+ # process:
10
+ #
11
+ # 1. Traverse the XPath AST
12
+ # 2. Generate Ruby::Node AST representing Ruby code
13
+ # 3. Use Ruby::Generator to convert to Ruby source string
14
+ # 4. Evaluate source in Context to get a Proc
15
+ #
16
+ # @example
17
+ # ast = Parser.parse("//book")
18
+ # proc = Compiler.compile_with_cache(ast)
19
+ # result = proc.call(document)
20
+ #
21
+ # @private
22
+ class Compiler
23
+ # Shared context for compiled Procs
24
+ CONTEXT = Context.new
25
+
26
+ # Expression cache
27
+ CACHE = Cache.new
28
+
29
+ # Wildcard for node names/namespace prefixes
30
+ STAR = "*"
31
+
32
+ # Node types that require a NodeSet to push nodes into
33
+ RETURN_NODESET = %i[path absolute_path relative_path axis
34
+ predicate].freeze
35
+
36
+ # Compiles and caches an AST
37
+ #
38
+ # @param ast [AST::Node] XPath AST to compile
39
+ # @param namespaces [Hash, nil] Optional namespace prefix mappings
40
+ # @return [Proc] Compiled Proc that accepts a document
41
+ def self.compile_with_cache(ast, namespaces: nil)
42
+ cache_key = namespaces ? [ast, namespaces] : ast
43
+ CACHE.get_or_set(cache_key) { new(namespaces: namespaces).compile(ast) }
44
+ end
45
+
46
+ # Initialize compiler
47
+ #
48
+ # @param namespaces [Hash, nil] Optional namespace prefix mappings
49
+ def initialize(namespaces: nil)
50
+ @namespaces = namespaces
51
+ @literal_id = 0
52
+ @predicate_nodesets = []
53
+ @predicate_indexes = []
54
+ end
55
+
56
+ # Compiles an XPath AST into a Ruby Proc
57
+ #
58
+ # @param ast [AST::Node] XPath AST to compile
59
+ # @return [Proc] Executable Proc
60
+ def compile(ast)
61
+ document = literal(:node)
62
+ matched = matched_literal
63
+ context_var = context_literal
64
+
65
+ # Enable debug output
66
+ debug = ENV["DEBUG_XPATH"] == "1"
67
+ if debug
68
+ puts "\n#{'=' * 60}"
69
+ puts "COMPILING XPath"
70
+ puts "=" * 60
71
+ puts "AST: #{ast.inspect}"
72
+ puts
73
+ end
74
+
75
+ ruby_ast = if return_nodeset?(ast)
76
+ process(ast, document) { |node| matched.push(node) }
77
+ else
78
+ process(ast, document)
79
+ end
80
+
81
+ proc_ast = literal(:lambda).add_block(document) do
82
+ # Get context from document
83
+ context_assign = context_var.assign(document.context)
84
+
85
+ if return_nodeset?(ast)
86
+ # Create NodeSet using send node: Moxml::NodeSet.new([], context)
87
+ nodeset_class = const_ref("Moxml", "NodeSet")
88
+ empty_array = Ruby::Node.new(:array, [])
89
+ nodeset_new = Ruby::Node.new(:send,
90
+ [nodeset_class, "new", empty_array,
91
+ context_var])
92
+
93
+ body = matched.assign(nodeset_new)
94
+ .followed_by(ruby_ast)
95
+ .followed_by(matched)
96
+ else
97
+ body = ruby_ast
98
+ end
99
+
100
+ context_assign.followed_by(body)
101
+ end
102
+
103
+ generator = Ruby::Generator.new
104
+ source = generator.process(proc_ast)
105
+
106
+ if debug
107
+ puts "GENERATED RUBY CODE:"
108
+ puts "-" * 60
109
+ puts source
110
+ puts "=" * 60
111
+ puts
112
+ end
113
+
114
+ CONTEXT.evaluate(source)
115
+ ensure
116
+ @literal_id = 0
117
+ @predicate_nodesets.clear
118
+ @predicate_indexes.clear
119
+ end
120
+
121
+ # Process a single XPath AST node
122
+ #
123
+ # @param ast [AST::Node] AST node to process
124
+ # @param input [Ruby::Node] Input node
125
+ # @yield [Ruby::Node] Yields matched nodes if block given
126
+ # @return [Ruby::Node] Ruby AST node
127
+ def process(ast, input, &block)
128
+ send(:"on_#{ast.type}", ast, input, &block)
129
+ end
130
+
131
+ # Dispatcher for generic binary operator nodes
132
+ def on_binary_op(ast, input, &block)
133
+ operator = ast.value # :eq, :lt, :add, :plus, :star, etc.
134
+
135
+ # Map token names to handler method names
136
+ method_name = case operator
137
+ when :plus then :add
138
+ when :minus then :sub
139
+ when :star then :mul
140
+ else operator # eq, lt, gt, div, mod, etc.
141
+ end
142
+
143
+ send(:"on_#{method_name}", ast, input, &block)
144
+ end
145
+
146
+ # Dispatcher for generic unary operator nodes
147
+ def on_unary_op(ast, input, &block)
148
+ operator = ast.value # :minus
149
+ send(:"on_#{operator}", ast, input, &block)
150
+ end
151
+
152
+ # Dispatcher for union nodes (parser creates :union, compiler uses :pipe)
153
+ def on_union(ast, input, &block)
154
+ on_pipe(ast, input, &block)
155
+ end
156
+
157
+ private
158
+
159
+ # Helper methods for creating Ruby AST nodes
160
+
161
+ def literal(value)
162
+ case value
163
+ when Symbol, String
164
+ end
165
+ Ruby::Node.new(:lit, [value.to_s])
166
+ end
167
+
168
+ # Create a constant reference like Moxml::Document
169
+ def const_ref(*parts)
170
+ Ruby::Node.new(:const, parts)
171
+ end
172
+
173
+ def unique_literal(name)
174
+ @literal_id += 1
175
+ literal("#{name}#{@literal_id}")
176
+ end
177
+
178
+ def string(value)
179
+ Ruby::Node.new(:string, [value.to_s])
180
+ end
181
+
182
+ def symbol(value)
183
+ Ruby::Node.new(:symbol, [value.to_sym])
184
+ end
185
+
186
+ def matched_literal
187
+ literal(:matched)
188
+ end
189
+
190
+ def context_literal
191
+ literal(:context)
192
+ end
193
+
194
+ def self_nil
195
+ @self_nil ||= literal(:nil)
196
+ end
197
+
198
+ def self_true
199
+ @self_true ||= literal(true)
200
+ end
201
+
202
+ def self_false
203
+ @self_false ||= literal(false)
204
+ end
205
+
206
+ def return_nodeset?(ast)
207
+ # Special cases where relative_path returns node directly:
208
+ # - "." (current node)
209
+ # - ".." (parent node)
210
+ if ast.type == :relative_path && ast.children.size == 1
211
+ child_type = ast.children[0].type
212
+ return false if %i[current parent].include?(child_type)
213
+ end
214
+
215
+ RETURN_NODESET.include?(ast.type)
216
+ end
217
+
218
+ # Type checking helpers
219
+
220
+ def document_or_node(node)
221
+ doc_class = const_ref("Moxml", "Document")
222
+ node_class = const_ref("Moxml", "Node")
223
+ node.is_a?(doc_class).or(node.is_a?(node_class))
224
+ end
225
+
226
+ def element_or_attribute(node)
227
+ elem_class = const_ref("Moxml", "Element")
228
+ attr_class = const_ref("Moxml", "Attribute")
229
+ node.is_a?(elem_class).or(node.is_a?(attr_class))
230
+ end
231
+
232
+ def attribute_or_node(node)
233
+ attr_class = const_ref("Moxml", "Attribute")
234
+ node_class = const_ref("Moxml", "Node")
235
+ node.is_a?(attr_class).or(node.is_a?(node_class))
236
+ end
237
+
238
+ # Path handling
239
+
240
+ # Handle absolute paths like /root or //descendant
241
+ def on_absolute_path(ast, input, &block)
242
+ if ast.children.empty?
243
+ # Just "/" - return the document/root
244
+ yield input if block
245
+ input
246
+ else
247
+ # Process steps from the input (which should be a document)
248
+ # Don't call input.root - that would skip a level
249
+ first_child = ast.children[0]
250
+
251
+ # For absolute paths, we process from the document itself
252
+ if ast.children.size == 1
253
+ process(first_child, input, &block)
254
+ else
255
+ # Multiple steps - create a path
256
+ path_node = AST::Node.new(:path, ast.children)
257
+ process(path_node, input, &block)
258
+ end
259
+ end
260
+ end
261
+
262
+ # Handle relative paths
263
+ def on_relative_path(ast, input, &block)
264
+ on_path(ast, input, &block)
265
+ end
266
+
267
+ # Handle path (series of steps)
268
+ def on_path(ast, input, &block)
269
+ return input if ast.children.empty?
270
+
271
+ # First step from input
272
+ first_step = ast.children[0]
273
+
274
+ if ast.children.size == 1
275
+ # Single step
276
+ process(first_step, input, &block)
277
+ else
278
+ # Multiple steps - need to accumulate results
279
+ temp_results = unique_literal(:temp_results)
280
+ context_var = context_literal
281
+
282
+ # Create NodeSet for temp results
283
+ nodeset_class = const_ref("Moxml", "NodeSet")
284
+ empty_array = Ruby::Node.new(:array, [])
285
+ nodeset_new = Ruby::Node.new(:send,
286
+ [nodeset_class, "new", empty_array,
287
+ context_var])
288
+
289
+ temp_results.assign(nodeset_new)
290
+ .followed_by do
291
+ process(first_step, input) do |node|
292
+ temp_results.push(node)
293
+ end
294
+ .followed_by do
295
+ # Process remaining steps on each result
296
+ remaining_steps = AST::Node.new(:path, ast.children[1..])
297
+ temp_node = unique_literal(:temp_node)
298
+
299
+ temp_results.each.add_block(temp_node) do
300
+ process(remaining_steps, temp_node, &block)
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
306
+
307
+ # Axis handling
308
+
309
+ # Dispatch axes to specific handlers
310
+ def on_axis(ast, input, &block)
311
+ axis_name, test, *_predicates = ast.children
312
+
313
+ handler = axis_name.gsub("-", "_")
314
+
315
+ send(:"on_axis_#{handler}", test, input, &block)
316
+ end
317
+
318
+ # Handle step with predicates (created by parser)
319
+ def on_step_with_predicates(ast, input, &block)
320
+ step, *predicates = ast.children
321
+
322
+ # If no predicates, just process the step
323
+ return process(step, input, &block) if predicates.empty?
324
+
325
+ # Build predicate chain: step -> pred1 -> pred2 -> ...
326
+ # Each predicate wraps the previous result as its test
327
+ result_ast = step
328
+
329
+ predicates.each do |pred_wrapper|
330
+ # pred_wrapper is :predicate node with children [expression]
331
+ # Build proper :predicate node with [test, expression, nil]
332
+ predicate_expr = pred_wrapper.children[0]
333
+ result_ast = AST::Node.new(:predicate,
334
+ [result_ast, predicate_expr, nil])
335
+ end
336
+
337
+ # Process the final chained AST
338
+ process(result_ast, input, &block)
339
+ end
340
+
341
+ # AXIS: child - direct children
342
+ def on_axis_child(ast, input)
343
+ child = unique_literal(:child)
344
+
345
+ document_or_node(input).if_true do
346
+ input.children.each.add_block(child) do
347
+ condition = process(ast, child)
348
+ if block_given?
349
+ condition.if_true { yield child }
350
+ else
351
+ condition.if_true { child }
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ # AXIS: self - the node itself
358
+ def on_axis_self(ast, input)
359
+ condition = process(ast, input)
360
+ if block_given?
361
+ condition.if_true { yield input }
362
+ else
363
+ condition.if_true { input }
364
+ end
365
+ end
366
+
367
+ # AXIS: parent - parent node
368
+ def on_axis_parent(ast, input)
369
+ parent = unique_literal(:parent)
370
+
371
+ attribute_or_node(input).if_true do
372
+ parent.assign(input.parent).followed_by do
373
+ condition = process(ast, parent)
374
+ if block_given?
375
+ condition.if_true { yield parent }
376
+ else
377
+ condition.if_true { parent }
378
+ end
379
+ end
380
+ end
381
+ end
382
+
383
+ # AXIS: descendant-or-self - Enables // operator
384
+ def on_axis_descendant_or_self(ast, input)
385
+ node = unique_literal(:descendant)
386
+ doc_class = const_ref("Moxml", "Document")
387
+
388
+ document_or_node(input).if_true do
389
+ # Create a proper if-else structure that prevents double traversal
390
+ input.is_a?(doc_class).if_true do
391
+ # DOCUMENT PATH: test root, then traverse from root
392
+ root = unique_literal(:root)
393
+ root.assign(input.root).followed_by do
394
+ root.if_true do
395
+ # Test root first
396
+ condition = process(ast, root)
397
+ (if block_given?
398
+ condition.if_true { yield root }
399
+ else
400
+ condition.if_true { root }
401
+ end)
402
+ .followed_by do
403
+ # Traverse descendants FROM root only (not document.each_node)
404
+ root.each_node.add_block(node) do
405
+ desc_condition = process(ast, node)
406
+ if block_given?
407
+ desc_condition.if_true { yield node }
408
+ else
409
+ desc_condition.if_true { node }
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end.else do
416
+ # NON-DOCUMENT PATH: test self, then traverse from self
417
+ condition = process(ast, input)
418
+ (if block_given?
419
+ condition.if_true { yield input }
420
+ else
421
+ condition.if_true { input }
422
+ end)
423
+ .followed_by do
424
+ # Traverse descendants FROM input
425
+ input.each_node.add_block(node) do
426
+ desc_condition = process(ast, node)
427
+ if block_given?
428
+ desc_condition.if_true { yield node }
429
+ else
430
+ desc_condition.if_true { node }
431
+ end
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end
437
+
438
+ # AXIS: attribute - Enables @attribute syntax
439
+ def on_axis_attribute(ast, input)
440
+ elem_class = const_ref("Moxml", "Element")
441
+ attribute = unique_literal(:attribute)
442
+
443
+ input.is_a?(elem_class).if_true do
444
+ input.attributes.each.add_block(attribute) do
445
+ # Use process to handle both :test and :wildcard nodes
446
+ condition = process(ast, attribute)
447
+
448
+ if block_given?
449
+ condition.if_true { yield attribute }
450
+ else
451
+ condition.if_true { attribute }
452
+ end
453
+ end
454
+ end
455
+ end
456
+
457
+ # AXIS: descendant - All descendant nodes (without self)
458
+ def on_axis_descendant(ast, input)
459
+ node = unique_literal(:descendant)
460
+
461
+ document_or_node(input).if_true do
462
+ input.each_node.add_block(node) do
463
+ condition = process(ast, node)
464
+ if block_given?
465
+ condition.if_true { yield node }
466
+ else
467
+ condition.if_true { node }
468
+ end
469
+ end
470
+ end
471
+ end
472
+
473
+ # Helper: Recursively traverse all descendants
474
+ def traverse_all_descendants(input, &block)
475
+ child = unique_literal(:child)
476
+
477
+ input.children.each.add_block(child) do
478
+ # Yield this child
479
+ yield child
480
+ # Then recursively traverse its descendants
481
+ traverse_all_descendants(child, &block)
482
+ end
483
+ end
484
+
485
+ # Node test handling
486
+
487
+ # Handle node tests (name matching)
488
+ def on_test(ast, input)
489
+ condition = element_or_attribute(input)
490
+ name_match = match_name_and_namespace(ast, input)
491
+
492
+ name_match ? condition.and(name_match) : condition
493
+ end
494
+
495
+ # Handle wildcard test (*)
496
+ def on_wildcard(_ast, input)
497
+ element_or_attribute(input)
498
+ end
499
+
500
+ # Match element/attribute names and namespaces
501
+ def match_name_and_namespace(ast, input)
502
+ ns = ast.value[:namespace]
503
+ name = ast.value[:name]
504
+
505
+ # Wildcard for both name and namespace means match all - return nil
506
+ # nil means "no additional constraint beyond type check"
507
+ return nil if name == STAR && (!ns || ns == STAR)
508
+
509
+ condition = nil
510
+ name_str = string(name)
511
+ zero = literal(0)
512
+
513
+ # Match name (case-insensitive) unless wildcard
514
+ if name != STAR
515
+ # If we have a namespace prefix, we need to compare local names
516
+ # For elements like "ns:item", we should compare against "item" not "ns:item"
517
+ if ns && ns != STAR && @namespaces && @namespaces[ns]
518
+ # Extract local name by splitting on ':' and taking the last part
519
+ # This handles both "ns:item" -> "item" and "item" -> "item"
520
+ local_name_expr = input.name.split(string(":")).last
521
+ condition = local_name_expr.eq(name_str)
522
+ .or(local_name_expr.casecmp(name_str).eq(zero))
523
+ else
524
+ # No namespace or no mapping - compare full name
525
+ condition = input.name.eq(name_str)
526
+ .or(input.name.casecmp(name_str).eq(zero))
527
+ end
528
+ end
529
+
530
+ # Match namespace if specified
531
+ if ns && ns != STAR
532
+ if @namespaces && @namespaces[ns]
533
+ # Resolve prefix to URI using namespace mappings
534
+ ns_uri = @namespaces[ns]
535
+ ns_match = input.namespace.and(input.namespace.uri.eq(string(ns_uri)))
536
+ else
537
+ # No mapping provided - check against element's namespace prefix
538
+ # Need to ensure input.namespace exists first
539
+ ns_match = input.namespace.and(input.namespace.prefix.eq(string(ns)))
540
+ end
541
+
542
+ condition = condition ? condition.and(ns_match) : ns_match
543
+ end
544
+
545
+ condition
546
+ end
547
+
548
+ # Literal value handling
549
+
550
+ # String literals
551
+ def on_string(ast, *)
552
+ string(ast.value)
553
+ end
554
+
555
+ # Number literals (both int and float)
556
+ def on_number(ast, *)
557
+ literal(ast.value.to_f.to_s)
558
+ end
559
+
560
+ # Current node (.)
561
+ def on_current(_ast, input)
562
+ if block_given?
563
+ yield input # Block returns Ruby::Node for matched.push(input)
564
+ else
565
+ input
566
+ end
567
+ end
568
+
569
+ # Parent node (..)
570
+ def on_parent(_ast, input)
571
+ input.parent
572
+ end
573
+
574
+ # ===== OPERATORS =====
575
+
576
+ # Comparison: = (equality)
577
+ def on_eq(ast, input, &block)
578
+ conv = literal(Moxml::XPath::Conversion)
579
+
580
+ operator(ast, input) do |left, right|
581
+ mass_assign([left, right], conv.to_compatible_types(left, right))
582
+ .followed_by do
583
+ operation = left.eq(right)
584
+
585
+ block ? operation.if_true(&block) : operation
586
+ end
587
+ end
588
+ end
589
+
590
+ # Comparison: != (inequality)
591
+ def on_neq(ast, input, &block)
592
+ conv = literal(Moxml::XPath::Conversion)
593
+
594
+ operator(ast, input) do |left, right|
595
+ mass_assign([left, right], conv.to_compatible_types(left, right))
596
+ .followed_by do
597
+ operation = left != right
598
+
599
+ block ? operation.if_true(&block) : operation
600
+ end
601
+ end
602
+ end
603
+
604
+ # Comparison: < (less than)
605
+ def on_lt(ast, input, &block)
606
+ conversion = literal(Moxml::XPath::Conversion)
607
+
608
+ operator(ast, input) do |left, right|
609
+ lval = conversion.to_float(left)
610
+ rval = conversion.to_float(right)
611
+ operation = lval < rval
612
+
613
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
614
+ end
615
+ end
616
+
617
+ # Comparison: > (greater than)
618
+ def on_gt(ast, input, &block)
619
+ conversion = literal(Moxml::XPath::Conversion)
620
+
621
+ operator(ast, input) do |left, right|
622
+ lval = conversion.to_float(left)
623
+ rval = conversion.to_float(right)
624
+ operation = lval > rval
625
+
626
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
627
+ end
628
+ end
629
+
630
+ # Comparison: <= (less than or equal)
631
+ def on_lte(ast, input, &block)
632
+ conversion = literal(Moxml::XPath::Conversion)
633
+
634
+ operator(ast, input) do |left, right|
635
+ lval = conversion.to_float(left)
636
+ rval = conversion.to_float(right)
637
+ operation = lval <= rval
638
+
639
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
640
+ end
641
+ end
642
+
643
+ # Comparison: >= (greater than or equal)
644
+ def on_gte(ast, input, &block)
645
+ conversion = literal(Moxml::XPath::Conversion)
646
+
647
+ operator(ast, input) do |left, right|
648
+ lval = conversion.to_float(left)
649
+ rval = conversion.to_float(right)
650
+ operation = lval >= rval
651
+
652
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
653
+ end
654
+ end
655
+
656
+ # Arithmetic: + (addition)
657
+ def on_add(ast, input, &block)
658
+ conversion = literal(Moxml::XPath::Conversion)
659
+
660
+ operator(ast, input) do |left, right|
661
+ lval = conversion.to_float(left)
662
+ rval = conversion.to_float(right)
663
+ operation = lval + rval
664
+
665
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
666
+ end
667
+ end
668
+
669
+ # Arithmetic: - (subtraction)
670
+ def on_sub(ast, input, &block)
671
+ conversion = literal(Moxml::XPath::Conversion)
672
+
673
+ operator(ast, input) do |left, right|
674
+ lval = conversion.to_float(left)
675
+ rval = conversion.to_float(right)
676
+ operation = lval - rval
677
+
678
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
679
+ end
680
+ end
681
+
682
+ # Arithmetic: * (multiplication)
683
+ def on_mul(ast, input, &block)
684
+ conversion = literal(Moxml::XPath::Conversion)
685
+
686
+ operator(ast, input) do |left, right|
687
+ lval = conversion.to_float(left)
688
+ rval = conversion.to_float(right)
689
+ operation = lval * rval
690
+
691
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
692
+ end
693
+ end
694
+
695
+ # Arithmetic: div (division)
696
+ def on_div(ast, input, &block)
697
+ conversion = literal(Moxml::XPath::Conversion)
698
+
699
+ operator(ast, input) do |left, right|
700
+ lval = conversion.to_float(left)
701
+ rval = conversion.to_float(right)
702
+ operation = lval / rval
703
+
704
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
705
+ end
706
+ end
707
+
708
+ # Arithmetic: mod (modulo)
709
+ def on_mod(ast, input, &block)
710
+ conversion = literal(Moxml::XPath::Conversion)
711
+
712
+ operator(ast, input) do |left, right|
713
+ lval = conversion.to_float(left)
714
+ rval = conversion.to_float(right)
715
+ operation = lval % rval
716
+
717
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
718
+ end
719
+ end
720
+
721
+ # Unary: minus (negation)
722
+ def on_minus(ast, input, &block)
723
+ operand = ast.children[0]
724
+ operand_ast = process(operand, input)
725
+ conversion = literal(Moxml::XPath::Conversion)
726
+
727
+ operand_var = unique_literal(:unary_operand)
728
+ operand_var.assign(operand_ast)
729
+ .followed_by do
730
+ negated = literal(0) - conversion.to_float(operand_var)
731
+ block ? conversion.to_boolean(negated).if_true(&block) : negated
732
+ end
733
+ end
734
+
735
+ # Logical: and
736
+ def on_and(ast, input, &block)
737
+ conversion = literal(Moxml::XPath::Conversion)
738
+
739
+ operator(ast, input) do |left, right|
740
+ lval = conversion.to_boolean(left)
741
+ rval = conversion.to_boolean(right)
742
+ operation = lval.and(rval)
743
+
744
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
745
+ end
746
+ end
747
+
748
+ # Logical: or
749
+ def on_or(ast, input, &block)
750
+ conversion = literal(Moxml::XPath::Conversion)
751
+
752
+ operator(ast, input) do |left, right|
753
+ lval = conversion.to_boolean(left)
754
+ rval = conversion.to_boolean(right)
755
+ operation = lval.or(rval)
756
+
757
+ block ? conversion.to_boolean(operation).if_true(&block) : operation
758
+ end
759
+ end
760
+
761
+ # Union: | (pipe)
762
+ def on_pipe(ast, input)
763
+ left, right = ast.children
764
+
765
+ union = unique_literal(:union)
766
+ context_var = context_literal
767
+
768
+ # Create NodeSet for union results
769
+ nodeset_class = const_ref("Moxml", "NodeSet")
770
+ empty_array = Ruby::Node.new(:array, [])
771
+
772
+ # Expressions such as "a | b | c"
773
+ if left.type == :pipe
774
+ union.assign(process(left, input))
775
+ .followed_by(process(right, input) { |node| union << node })
776
+ .followed_by(union)
777
+ # Expressions such as "a | b"
778
+ else
779
+ nodeset_new = Ruby::Node.new(:send,
780
+ [nodeset_class, "new", empty_array,
781
+ context_var])
782
+
783
+ union.assign(nodeset_new)
784
+ .followed_by(process(left, input) { |node| union << node })
785
+ .followed_by(process(right, input) { |node| union << node })
786
+ .followed_by(union)
787
+ end
788
+ end
789
+
790
+ # Variable: $variable
791
+ def on_var(ast, *)
792
+ name = ast.children[0]
793
+
794
+ variables_literal.and(variables_literal[string(name)])
795
+ .or(send_message(:raise, string("Undefined XPath variable: #{name}")))
796
+ end
797
+
798
+ # Predicate handling: //book[@price < 20]
799
+ def on_predicate(ast, input, &block)
800
+ test, predicate, following = ast.children
801
+
802
+ index_var = unique_literal(:index)
803
+
804
+ # Check predicate type to determine strategy
805
+ method = if number?(predicate)
806
+ :on_predicate_index
807
+ elsif has_call_node?(predicate, "last")
808
+ :on_predicate_temporary
809
+ else
810
+ :on_predicate_direct
811
+ end
812
+
813
+ @predicate_indexes << index_var
814
+
815
+ result = index_var.assign(literal(1)).followed_by do
816
+ send(method, input, test, predicate) do |matched|
817
+ if following
818
+ process(following, matched, &block)
819
+ else
820
+ yield matched
821
+ end
822
+ end
823
+ end
824
+
825
+ @predicate_indexes.pop
826
+
827
+ result
828
+ end
829
+
830
+ # Predicate that requires temporary NodeSet (for last())
831
+ def on_predicate_temporary(input, test, predicate)
832
+ temp_set = unique_literal(:temp_set)
833
+ pred_node = unique_literal(:pred_node)
834
+ pred_var = unique_literal(:pred_var)
835
+ conversion = literal(Moxml::XPath::Conversion)
836
+ context_var = context_literal
837
+
838
+ index_var = predicate_index
839
+ index_step = literal(1)
840
+
841
+ @predicate_nodesets << temp_set
842
+
843
+ # Create NodeSet for temp results
844
+ nodeset_class = const_ref("Moxml", "NodeSet")
845
+ empty_array = Ruby::Node.new(:array, [])
846
+ nodeset_new = Ruby::Node.new(:send,
847
+ [nodeset_class, "new", empty_array,
848
+ context_var])
849
+
850
+ ast = temp_set.assign(nodeset_new)
851
+ .followed_by do
852
+ process(test, input) { |node| temp_set << node }
853
+ end
854
+ .followed_by do
855
+ temp_set.each.add_block(pred_node) do
856
+ pred_ast = process(predicate, pred_node)
857
+
858
+ pred_var.assign(pred_ast)
859
+ .followed_by do
860
+ pred_var.is_a?(literal(:Numeric)).if_true do
861
+ pred_var.assign(pred_var.to_i.eq(index_var))
862
+ end
863
+ end
864
+ .followed_by do
865
+ conversion.to_boolean(pred_var).if_true { yield pred_node }
866
+ end
867
+ .followed_by do
868
+ index_var.assign(index_var + index_step)
869
+ end
870
+ end
871
+ end
872
+
873
+ @predicate_nodesets.pop
874
+
875
+ ast
876
+ end
877
+
878
+ # Predicate that doesn't require temporary NodeSet
879
+ def on_predicate_direct(input, test, predicate)
880
+ pred_var = unique_literal(:pred_var)
881
+ index_var = predicate_index
882
+ index_step = literal(1)
883
+ conversion = literal(Moxml::XPath::Conversion)
884
+
885
+ process(test, input) do |matched_test_node|
886
+ pred_ast = if return_nodeset?(predicate)
887
+ # Use catch/throw for early return
888
+ catch_message(:predicate_matched) do
889
+ process(predicate, matched_test_node) do
890
+ throw_message(:predicate_matched, self_true)
891
+ end
892
+ end
893
+ else
894
+ process(predicate, matched_test_node)
895
+ end
896
+
897
+ pred_var.assign(pred_ast)
898
+ .followed_by do
899
+ pred_var.is_a?(literal(:Numeric)).if_true do
900
+ pred_var.assign(pred_var.to_i.eq(index_var))
901
+ end
902
+ end
903
+ .followed_by do
904
+ conversion.to_boolean(pred_var).if_true do
905
+ yield matched_test_node
906
+ end
907
+ end
908
+ .followed_by do
909
+ index_var.assign(index_var + index_step)
910
+ end
911
+ end
912
+ end
913
+
914
+ # Predicate with literal index: //book[1]
915
+ def on_predicate_index(input, test, predicate)
916
+ index_var = predicate_index
917
+ index_step = literal(1)
918
+
919
+ index = process(predicate, input).to_i
920
+
921
+ process(test, input) do |matched_test_node|
922
+ index_var.eq(index)
923
+ .if_true do
924
+ yield matched_test_node
925
+ end
926
+ .followed_by do
927
+ index_var.assign(index_var + index_step)
928
+ end
929
+ end
930
+ end
931
+
932
+ # ===== XPATH FUNCTIONS =====
933
+
934
+ # XPath function dispatcher
935
+ def on_call(ast, input, &block)
936
+ # Function name is stored in value field, not children
937
+ name = ast.value
938
+ args = ast.children
939
+
940
+ handler = name.to_s.gsub("-", "_")
941
+
942
+ send(:"on_call_#{handler}", input, *args, &block)
943
+ end
944
+
945
+ # Alias for function nodes (parser creates :function, compiler uses on_call)
946
+ alias on_function on_call
947
+
948
+ # 1. string() - Convert value to string
949
+ def on_call_string(input, arg = nil)
950
+ convert_var = unique_literal(:convert)
951
+ conversion = literal(Moxml::XPath::Conversion)
952
+
953
+ argument_or_first_node(input, arg) do |arg_var|
954
+ convert_var.assign(conversion.to_string(arg_var))
955
+ .followed_by do
956
+ if block_given?
957
+ convert_var.empty?.if_false { yield convert_var }
958
+ else
959
+ convert_var
960
+ end
961
+ end
962
+ end
963
+ end
964
+
965
+ # 2. concat() - Concatenate strings
966
+ def on_call_concat(input, *args)
967
+ conversion = literal(Moxml::XPath::Conversion)
968
+ assigns = []
969
+ conversions = []
970
+
971
+ args.each do |arg|
972
+ arg_var = unique_literal(:concat_arg)
973
+ arg_ast = try_match_first_node(arg, input)
974
+
975
+ assigns << arg_var.assign(arg_ast)
976
+ conversions << conversion.to_string(arg_var)
977
+ end
978
+
979
+ concatted = assigns.inject(:followed_by)
980
+ .followed_by(conversions.inject(:+))
981
+
982
+ block_given? ? concatted.empty?.if_false { yield concatted } : concatted
983
+ end
984
+
985
+ # 3. starts-with() - Check string prefix
986
+ def on_call_starts_with(input, haystack, needle)
987
+ haystack_var = unique_literal(:haystack)
988
+ needle_var = unique_literal(:needle)
989
+ conversion = literal(Moxml::XPath::Conversion)
990
+
991
+ haystack_var.assign(try_match_first_node(haystack, input))
992
+ .followed_by do
993
+ needle_var.assign(try_match_first_node(needle, input))
994
+ end
995
+ .followed_by do
996
+ haystack_var.assign(conversion.to_string(haystack_var))
997
+ .followed_by do
998
+ needle_var.assign(conversion.to_string(needle_var))
999
+ end
1000
+ .followed_by do
1001
+ equal = needle_var.empty?
1002
+ .or(haystack_var.start_with?(needle_var))
1003
+
1004
+ block_given? ? equal.if_true { yield equal } : equal
1005
+ end
1006
+ end
1007
+ end
1008
+
1009
+ # 4. contains() - Check substring
1010
+ def on_call_contains(input, haystack, needle)
1011
+ haystack_lit = unique_literal(:haystack)
1012
+ needle_lit = unique_literal(:needle)
1013
+ conversion = literal(Moxml::XPath::Conversion)
1014
+
1015
+ haystack_lit.assign(try_match_first_node(haystack, input))
1016
+ .followed_by do
1017
+ needle_lit.assign(try_match_first_node(needle, input))
1018
+ end
1019
+ .followed_by do
1020
+ converted = conversion.to_string(haystack_lit)
1021
+ .include?(conversion.to_string(needle_lit))
1022
+
1023
+ block_given? ? converted.if_true { yield converted } : converted
1024
+ end
1025
+ end
1026
+
1027
+ # 5. substring-before() - Get part before separator
1028
+ def on_call_substring_before(input, haystack, needle)
1029
+ haystack_var = unique_literal(:haystack)
1030
+ needle_var = unique_literal(:needle)
1031
+ conversion = literal(Moxml::XPath::Conversion)
1032
+
1033
+ before = unique_literal(:before)
1034
+ sep = unique_literal(:sep)
1035
+ after = unique_literal(:after)
1036
+
1037
+ haystack_var.assign(try_match_first_node(haystack, input))
1038
+ .followed_by do
1039
+ needle_var.assign(try_match_first_node(needle, input))
1040
+ end
1041
+ .followed_by do
1042
+ converted = conversion.to_string(haystack_var)
1043
+ .partition(conversion.to_string(needle_var))
1044
+
1045
+ mass_assign([before, sep, after], converted).followed_by do
1046
+ sep.empty?
1047
+ .if_true { sep }
1048
+ .else { block_given? ? yield : before }
1049
+ end
1050
+ end
1051
+ end
1052
+
1053
+ # 6. substring-after() - Get part after separator
1054
+ def on_call_substring_after(input, haystack, needle)
1055
+ haystack_var = unique_literal(:haystack)
1056
+ needle_var = unique_literal(:needle)
1057
+ conversion = literal(Moxml::XPath::Conversion)
1058
+
1059
+ before = unique_literal(:before)
1060
+ sep = unique_literal(:sep)
1061
+ after = unique_literal(:after)
1062
+
1063
+ haystack_var.assign(try_match_first_node(haystack, input))
1064
+ .followed_by do
1065
+ needle_var.assign(try_match_first_node(needle, input))
1066
+ end
1067
+ .followed_by do
1068
+ converted = conversion.to_string(haystack_var)
1069
+ .partition(conversion.to_string(needle_var))
1070
+
1071
+ mass_assign([before, sep, after], converted).followed_by do
1072
+ sep.empty?
1073
+ .if_true { sep }
1074
+ .else { block_given? ? yield : after }
1075
+ end
1076
+ end
1077
+ end
1078
+
1079
+ # 7. substring() - Extract substring
1080
+ def on_call_substring(input, haystack, start, length = nil)
1081
+ haystack_var = unique_literal(:haystack)
1082
+ start_var = unique_literal(:start)
1083
+ length_var = unique_literal(:length)
1084
+ result_var = unique_literal(:result)
1085
+ ruby_start = unique_literal(:ruby_start)
1086
+ effective_length = unique_literal(:effective_length)
1087
+ conversion = literal(Moxml::XPath::Conversion)
1088
+
1089
+ haystack_var.assign(try_match_first_node(haystack, input))
1090
+ .followed_by do
1091
+ haystack_var.assign(conversion.to_string(haystack_var))
1092
+ end
1093
+ .followed_by do
1094
+ start_var.assign(try_match_first_node(start, input))
1095
+ .followed_by do
1096
+ # Round the start position first (XPath 1.0 spec requires rounding)
1097
+ start_var.assign(conversion.to_float(start_var).round.to_i)
1098
+ end
1099
+ end
1100
+ .followed_by do
1101
+ if length
1102
+ length_var.assign(try_match_first_node(length, input))
1103
+ .followed_by do
1104
+ # Round the length (XPath 1.0 spec requires rounding)
1105
+ length_var.assign(conversion.to_float(length_var).round.to_i)
1106
+ end
1107
+ .followed_by do
1108
+ # XPath 1.0 algorithm:
1109
+ # If start < 1, some positions fall before the string
1110
+ # We need to adjust the effective length accordingly
1111
+ # effective_length = (start + length) - max(start, 1)
1112
+ # lua_start = max(start, 1) - 1 (since we start from position 1)
1113
+
1114
+ # Calculate how many positions to skip before position 1
1115
+ # If start is 0, we lose 1 position; if -2, we lose 3 positions
1116
+ ruby_start.assign(
1117
+ (start_var < literal(1))
1118
+ .if_true { literal(0) }
1119
+ .else { start_var - literal(1) },
1120
+ )
1121
+ end
1122
+ .followed_by do
1123
+ # Calculate effective length accounting for positions before string
1124
+ effective_length.assign(
1125
+ (start_var < literal(1))
1126
+ .if_true do
1127
+ # Some positions are before position 1
1128
+ # end_pos = start + length
1129
+ # effective = end_pos - 1 (since we start from position 1)
1130
+ # But clamp to 0 if entirely before string
1131
+ ((start_var + length_var) - literal(1))
1132
+ .if_true { (start_var + length_var) - literal(1) }
1133
+ .else { literal(0) }
1134
+ end
1135
+ .else { length_var },
1136
+ )
1137
+ end
1138
+ .followed_by do
1139
+ # Clamp effective length to non-negative
1140
+ effective_length.assign(
1141
+ (effective_length < literal(0))
1142
+ .if_true { literal(0) }
1143
+ .else { effective_length },
1144
+ )
1145
+ end
1146
+ .followed_by do
1147
+ # Extract substring with effective length
1148
+ result_var.assign(haystack_var[ruby_start, effective_length])
1149
+ .followed_by do
1150
+ # Ensure we return empty string instead of nil
1151
+ result_var.assign(result_var.if_true do
1152
+ result_var
1153
+ end.else { string("") })
1154
+ end
1155
+ end
1156
+ .followed_by do
1157
+ if block_given?
1158
+ result_var.empty?.if_false do
1159
+ yield result_var
1160
+ end
1161
+ else
1162
+ result_var
1163
+ end
1164
+ end
1165
+ else
1166
+ # No length specified - go to end of string
1167
+ # Convert to 0-based index, clamping to 0
1168
+ ruby_start.assign(
1169
+ (start_var < literal(1))
1170
+ .if_true { literal(0) }
1171
+ .else { start_var - literal(1) },
1172
+ ).followed_by do
1173
+ # Extract from start to end
1174
+ result_var.assign(haystack_var[range(ruby_start, literal(-1))])
1175
+ .followed_by do
1176
+ # Ensure we return empty string instead of nil
1177
+ result_var.assign(result_var.if_true do
1178
+ result_var
1179
+ end.else { string("") })
1180
+ end
1181
+ end
1182
+ .followed_by do
1183
+ if block_given?
1184
+ result_var.empty?.if_false do
1185
+ yield result_var
1186
+ end
1187
+ else
1188
+ result_var
1189
+ end
1190
+ end
1191
+ end
1192
+ end
1193
+ end
1194
+
1195
+ # 8. string-length() - Get string length
1196
+ def on_call_string_length(input, arg = nil)
1197
+ convert_var = unique_literal(:convert)
1198
+ conversion = literal(Moxml::XPath::Conversion)
1199
+
1200
+ argument_or_first_node(input, arg) do |arg_var|
1201
+ convert_var.assign(conversion.to_string(arg_var).length)
1202
+ .followed_by do
1203
+ if block_given?
1204
+ convert_var.zero?.if_false { yield convert_var }
1205
+ else
1206
+ convert_var.to_f
1207
+ end
1208
+ end
1209
+ end
1210
+ end
1211
+
1212
+ # 9. normalize-space() - Normalize whitespace
1213
+ def on_call_normalize_space(input, arg = nil)
1214
+ conversion = literal(Moxml::XPath::Conversion)
1215
+ norm_var = unique_literal(:normalized)
1216
+
1217
+ # Create regex for matching whitespace sequences
1218
+ # Use Regexp.new to create /\s+/ pattern at runtime
1219
+ regexp_class = const_ref("Regexp")
1220
+ whitespace_pattern = string('\\s+')
1221
+ whitespace_regex = Ruby::Node.new(:send,
1222
+ [regexp_class, "new",
1223
+ whitespace_pattern])
1224
+ replace = string(" ")
1225
+
1226
+ argument_or_first_node(input, arg) do |arg_var|
1227
+ norm_var
1228
+ .assign(conversion.to_string(arg_var).strip.gsub(whitespace_regex,
1229
+ replace))
1230
+ .followed_by do
1231
+ norm_var.empty?
1232
+ .if_true { string("") }
1233
+ .else { block_given? ? yield : norm_var }
1234
+ end
1235
+ end
1236
+ end
1237
+
1238
+ # 10. translate() - Character replacement
1239
+ def on_call_translate(input, source, find, replace)
1240
+ source_var = unique_literal(:source)
1241
+ find_var = unique_literal(:find)
1242
+ replace_var = unique_literal(:replace)
1243
+ replaced_var = unique_literal(:replaced)
1244
+ conversion = literal(Moxml::XPath::Conversion)
1245
+
1246
+ char = unique_literal(:char)
1247
+ index = unique_literal(:index)
1248
+
1249
+ source_var.assign(try_match_first_node(source, input))
1250
+ .followed_by do
1251
+ replaced_var.assign(conversion.to_string(source_var))
1252
+ end
1253
+ .followed_by do
1254
+ find_var.assign(try_match_first_node(find, input))
1255
+ end
1256
+ .followed_by do
1257
+ find_var.assign(conversion.to_string(find_var).chars.to_array)
1258
+ end
1259
+ .followed_by do
1260
+ replace_var.assign(try_match_first_node(replace, input))
1261
+ end
1262
+ .followed_by do
1263
+ replace_var.assign(conversion.to_string(replace_var).chars.to_array)
1264
+ end
1265
+ .followed_by do
1266
+ find_var.each_with_index.add_block(char, index) do
1267
+ replace_with = replace_var[index]
1268
+ .if_true { replace_var[index] }
1269
+ .else { string("") }
1270
+
1271
+ replaced_var.assign(replaced_var.gsub(char, replace_with))
1272
+ end
1273
+ end
1274
+ .followed_by { replaced_var }
1275
+ end
1276
+
1277
+ # ===== NUMERIC FUNCTIONS =====
1278
+
1279
+ # 1. number() - Convert to number
1280
+ def on_call_number(input, arg = nil, &block)
1281
+ convert_var = unique_literal(:convert)
1282
+ conversion = literal(Moxml::XPath::Conversion)
1283
+
1284
+ argument_or_first_node(input, arg) do |arg_var|
1285
+ convert_var.assign(conversion.to_float(arg_var)).followed_by do
1286
+ if block
1287
+ convert_var.zero?.if_false(&block)
1288
+ else
1289
+ convert_var
1290
+ end
1291
+ end
1292
+ end
1293
+ end
1294
+
1295
+ # 2. sum() - Sum node values
1296
+ def on_call_sum(input, arg, &block)
1297
+ unless return_nodeset?(arg)
1298
+ raise TypeError, "sum() can only operate on a path, axis or predicate"
1299
+ end
1300
+
1301
+ sum_var = unique_literal(:sum)
1302
+ conversion = literal(Moxml::XPath::Conversion)
1303
+
1304
+ sum_var.assign(literal(0.0))
1305
+ .followed_by do
1306
+ process(arg, input) do |matched_node|
1307
+ sum_var.assign(sum_var + conversion.to_float(matched_node.text))
1308
+ end
1309
+ end
1310
+ .followed_by do
1311
+ block ? sum_var.zero?.if_false(&block) : sum_var
1312
+ end
1313
+ end
1314
+
1315
+ # 3. count() - Count nodes
1316
+ def on_call_count(input, arg, &block)
1317
+ count = unique_literal(:count)
1318
+
1319
+ unless return_nodeset?(arg)
1320
+ raise TypeError, "count() can only operate on NodeSet instances"
1321
+ end
1322
+
1323
+ count.assign(literal(0.0))
1324
+ .followed_by do
1325
+ process(arg, input) { count.assign(count + literal(1)) }
1326
+ end
1327
+ .followed_by do
1328
+ block ? count.zero?.if_false(&block) : count
1329
+ end
1330
+ end
1331
+
1332
+ # 4. floor() - Round down
1333
+ def on_call_floor(input, arg)
1334
+ arg_ast = try_match_first_node(arg, input)
1335
+ call_arg = unique_literal(:call_arg)
1336
+ conversion = literal(Moxml::XPath::Conversion)
1337
+
1338
+ call_arg.assign(arg_ast)
1339
+ .followed_by do
1340
+ call_arg.assign(conversion.to_float(call_arg))
1341
+ end
1342
+ .followed_by do
1343
+ call_arg.nan?
1344
+ .if_true { call_arg }
1345
+ .else { block_given? ? yield : call_arg.floor.to_f }
1346
+ end
1347
+ end
1348
+
1349
+ # 5. ceiling() - Round up
1350
+ def on_call_ceiling(input, arg)
1351
+ arg_ast = try_match_first_node(arg, input)
1352
+ call_arg = unique_literal(:call_arg)
1353
+ conversion = literal(Moxml::XPath::Conversion)
1354
+
1355
+ call_arg.assign(arg_ast)
1356
+ .followed_by do
1357
+ call_arg.assign(conversion.to_float(call_arg))
1358
+ end
1359
+ .followed_by do
1360
+ call_arg.nan?
1361
+ .if_true { call_arg }
1362
+ .else { block_given? ? yield : call_arg.ceil.to_f }
1363
+ end
1364
+ end
1365
+
1366
+ # 6. round() - Round to nearest
1367
+ def on_call_round(input, arg)
1368
+ arg_ast = try_match_first_node(arg, input)
1369
+ call_arg = unique_literal(:call_arg)
1370
+ conversion = literal(Moxml::XPath::Conversion)
1371
+
1372
+ call_arg.assign(arg_ast)
1373
+ .followed_by do
1374
+ call_arg.assign(conversion.to_float(call_arg))
1375
+ end
1376
+ .followed_by do
1377
+ call_arg.nan?
1378
+ .if_true { call_arg }
1379
+ .else { block_given? ? yield : call_arg.round.to_f }
1380
+ end
1381
+ end
1382
+
1383
+ # ===== BOOLEAN FUNCTIONS =====
1384
+
1385
+ # 1. boolean() - Convert to boolean
1386
+ def on_call_boolean(input, arg, &block)
1387
+ arg_ast = try_match_first_node(arg, input)
1388
+ call_arg = unique_literal(:call_arg)
1389
+ conversion = literal(Moxml::XPath::Conversion)
1390
+
1391
+ call_arg.assign(arg_ast).followed_by do
1392
+ converted = conversion.to_boolean(call_arg)
1393
+
1394
+ block ? converted.if_true(&block) : converted
1395
+ end
1396
+ end
1397
+
1398
+ # 2. not() - Negate boolean
1399
+ def on_call_not(input, arg, &block)
1400
+ arg_ast = try_match_first_node(arg, input)
1401
+ call_arg = unique_literal(:call_arg)
1402
+ conversion = literal(Moxml::XPath::Conversion)
1403
+
1404
+ call_arg.assign(arg_ast).followed_by do
1405
+ converted = conversion.to_boolean(call_arg).not
1406
+
1407
+ block ? converted.if_true(&block) : converted
1408
+ end
1409
+ end
1410
+
1411
+ # 3. true() - Return true
1412
+ def on_call_true(*)
1413
+ block_given? ? yield : self_true
1414
+ end
1415
+
1416
+ # 4. false() - Return false
1417
+ def on_call_false(*)
1418
+ self_false
1419
+ end
1420
+
1421
+ # ===== NODE FUNCTIONS =====
1422
+
1423
+ # 1. local-name() - Get local name without namespace prefix
1424
+ def on_call_local_name(input, arg = nil)
1425
+ argument_or_first_node(input, arg) do |arg_var|
1426
+ arg_var
1427
+ .if_true do
1428
+ ensure_element_or_attribute(arg_var)
1429
+ .followed_by { block_given? ? yield : arg_var.name }
1430
+ end
1431
+ .else { string("") }
1432
+ end
1433
+ end
1434
+
1435
+ # 2. name() - Get expanded/qualified name with namespace
1436
+ def on_call_name(input, arg = nil)
1437
+ argument_or_first_node(input, arg) do |arg_var|
1438
+ arg_var
1439
+ .if_true do
1440
+ ensure_element_or_attribute(arg_var)
1441
+ .followed_by { block_given? ? yield : arg_var.expanded_name }
1442
+ end
1443
+ .else { string("") }
1444
+ end
1445
+ end
1446
+
1447
+ # 3. namespace-uri() - Get namespace URI
1448
+ def on_call_namespace_uri(input, arg = nil)
1449
+ default = string("")
1450
+
1451
+ argument_or_first_node(input, arg) do |arg_var|
1452
+ arg_var
1453
+ .if_true do
1454
+ ensure_element_or_attribute(arg_var).followed_by do
1455
+ arg_var.namespace
1456
+ .if_true { block_given? ? yield : arg_var.namespace.uri }
1457
+ .else { default }
1458
+ end
1459
+ end
1460
+ .else { default }
1461
+ end
1462
+ end
1463
+
1464
+ # 4. lang() - Check xml:lang attribute
1465
+ def on_call_lang(input, arg)
1466
+ lang_var = unique_literal("lang")
1467
+ node = unique_literal("node")
1468
+ found = unique_literal("found")
1469
+ xml_lang = unique_literal("xml_lang")
1470
+ matched = unique_literal("matched")
1471
+
1472
+ conversion = literal(Moxml::XPath::Conversion)
1473
+
1474
+ ast = lang_var.assign(try_match_first_node(arg, input))
1475
+ .followed_by do
1476
+ lang_var.assign(conversion.to_string(lang_var))
1477
+ end
1478
+ .followed_by do
1479
+ matched.assign(self_false)
1480
+ end
1481
+ .followed_by do
1482
+ node.assign(input)
1483
+ end
1484
+ .followed_by do
1485
+ xml_lang.assign(string("xml:lang"))
1486
+ end
1487
+ .followed_by do
1488
+ node.respond_to?(symbol(:attribute)).while_true do
1489
+ found.assign(node.get(xml_lang))
1490
+ .followed_by do
1491
+ found.if_true do
1492
+ found.eq(lang_var)
1493
+ .if_true do
1494
+ if block_given?
1495
+ yield
1496
+ else
1497
+ matched.assign(self_true).followed_by(break_loop)
1498
+ end
1499
+ end
1500
+ .else { break_loop }
1501
+ end
1502
+ end
1503
+ .followed_by(node.assign(node.parent))
1504
+ end
1505
+ end
1506
+
1507
+ block_given? ? ast : ast.followed_by(matched)
1508
+ end
1509
+
1510
+ # ===== POSITION FUNCTIONS =====
1511
+
1512
+ # 1. position() - Current position in predicate context
1513
+ def on_call_position(*)
1514
+ index = predicate_index
1515
+
1516
+ unless index
1517
+ raise InvalidContextError.new(
1518
+ "position() requires a predicate context. " \
1519
+ "Use position() within a predicate like: //item[position() = 1]",
1520
+ function_name: "position()",
1521
+ required_context: "predicate",
1522
+ )
1523
+ end
1524
+
1525
+ index.to_f
1526
+ end
1527
+
1528
+ # 2. last() - Size of current predicate context
1529
+ def on_call_last(*)
1530
+ set = predicate_nodeset
1531
+
1532
+ unless set
1533
+ raise InvalidContextError.new(
1534
+ "last() requires a predicate context. " \
1535
+ "Use last() within a predicate like: //item[position() = last()]",
1536
+ function_name: "last()",
1537
+ required_context: "predicate",
1538
+ )
1539
+ end
1540
+
1541
+ set.length.to_f
1542
+ end
1543
+
1544
+ # ===== SPECIAL FUNCTIONS =====
1545
+
1546
+ # 1. id() - Find nodes by ID attribute
1547
+ def on_call_id(input, arg)
1548
+ orig_input = original_input_literal
1549
+ node = unique_literal(:node)
1550
+ ids_var = unique_literal("ids")
1551
+ matched = unique_literal("id_matched")
1552
+ id_str_var = unique_literal("id_string")
1553
+ attr_var = unique_literal("attr")
1554
+
1555
+ nodeset_class = const_ref("Moxml", "NodeSet")
1556
+ context_var = context_literal
1557
+ empty_array = Ruby::Node.new(:array, [])
1558
+
1559
+ matched.assign(Ruby::Node.new(:send,
1560
+ [nodeset_class, "new", empty_array,
1561
+ context_var]))
1562
+ .followed_by do
1563
+ # When using a path, get text of all matched nodes
1564
+ if return_nodeset?(arg)
1565
+ empty_ids = Ruby::Node.new(:array, [])
1566
+ ids_var.assign(empty_ids).followed_by do
1567
+ process(arg, input) { |element| ids_var << element.text }
1568
+ end
1569
+ # Otherwise cast to string and split on spaces
1570
+ else
1571
+ conversion = literal(Moxml::XPath::Conversion)
1572
+ ids_var.assign(process(arg, input))
1573
+ .followed_by do
1574
+ ids_var.assign(conversion.to_string(ids_var).split(string(" ")))
1575
+ end
1576
+ end
1577
+ end
1578
+ .followed_by do
1579
+ id_str_var.assign(string("id"))
1580
+ end
1581
+ .followed_by do
1582
+ orig_input.each_node.add_block(node) do
1583
+ node.is_a?(const_ref("Moxml", "Element")).if_true do
1584
+ attr_var.assign(node.attribute(id_str_var)).followed_by do
1585
+ attr_var.and(ids_var.include?(attr_var.value))
1586
+ .if_true { block_given? ? yield : matched << node }
1587
+ end
1588
+ end
1589
+ end
1590
+ end
1591
+ .followed_by(matched)
1592
+ end
1593
+
1594
+ # Helper methods
1595
+
1596
+ # Helper: Get argument or use current node's first child
1597
+ def argument_or_first_node(input, arg = nil)
1598
+ arg_ast = arg ? try_match_first_node(arg, input) : input
1599
+ arg_var = unique_literal(:argument_or_first_node)
1600
+
1601
+ arg_var.assign(arg_ast).followed_by { yield arg_var }
1602
+ end
1603
+
1604
+ # Helper: Try to match first node v1
1605
+ def try_match_first_node_v1(ast, input, optimize_first = true)
1606
+ if return_nodeset?(ast) && optimize_first
1607
+ matched_set = unique_literal(:matched_set)
1608
+ first_node = unique_literal(:first_node)
1609
+ context_var = context_literal
1610
+
1611
+ # Create NodeSet for results
1612
+ nodeset_class = const_ref("Moxml", "NodeSet")
1613
+ empty_array = Ruby::Node.new(:array, [])
1614
+ nodeset_new = Ruby::Node.new(:send,
1615
+ [nodeset_class, "new", empty_array,
1616
+ context_var])
1617
+
1618
+ matched_set.assign(nodeset_new)
1619
+ .followed_by do
1620
+ # Process with block to accumulate results
1621
+ process(ast, input) { |node| matched_set.push(node) }
1622
+ end
1623
+ .followed_by do
1624
+ first_node.assign(matched_set[literal(0)])
1625
+ end
1626
+ .followed_by do
1627
+ first_node.if_true { first_node }.else { string("") }
1628
+ end
1629
+ else
1630
+ process(ast, input)
1631
+ end
1632
+ end
1633
+
1634
+ # Helper: Create mass assignment node
1635
+ def mass_assign(vars, value)
1636
+ Ruby::Node.new(:massign, [vars, value])
1637
+ end
1638
+
1639
+ # Helper: Create range node for Ruby AST
1640
+ def range(start, stop)
1641
+ Ruby::Node.new(:range, [start, stop])
1642
+ end
1643
+
1644
+ # Helper: Ensure node is Element or Attribute
1645
+ def ensure_element_or_attribute(input)
1646
+ element_or_attribute(input).if_false do
1647
+ raise_message(TypeError, "argument is not an Element or Attribute")
1648
+ end
1649
+ end
1650
+
1651
+ # Helper: Raise an error with message
1652
+ def raise_message(klass, message)
1653
+ send_message(:raise, literal(klass), string(message))
1654
+ end
1655
+
1656
+ # Helper: Send a message (for method calls like raise, break)
1657
+ def send_message(name, *args)
1658
+ Ruby::Node.new(:send, [nil, name.to_s] + args)
1659
+ end
1660
+
1661
+ # Helper: Break statement
1662
+ def break_loop
1663
+ send_message(:break)
1664
+ end
1665
+
1666
+ # Helper: Get current predicate index
1667
+ def predicate_index
1668
+ @predicate_indexes.last
1669
+ end
1670
+
1671
+ # Helper: Get current predicate nodeset
1672
+ def predicate_nodeset
1673
+ @predicate_nodesets.last
1674
+ end
1675
+
1676
+ # Helper: Get original input literal for traversal
1677
+ def original_input_literal
1678
+ literal(:node)
1679
+ end
1680
+
1681
+ # Helper: Generate code for an operator
1682
+ #
1683
+ # Processes left and right operands, optimizing to match only first node
1684
+ # when appropriate (path, axis, predicate)
1685
+ def operator(ast, input, optimize_first = true)
1686
+ left, right = ast.children
1687
+
1688
+ left_var = unique_literal(:op_left)
1689
+ right_var = unique_literal(:op_right)
1690
+
1691
+ left_ast = try_match_first_node(left, input, optimize_first)
1692
+ right_ast = try_match_first_node(right, input, optimize_first)
1693
+
1694
+ left_var.assign(left_ast)
1695
+ .followed_by(right_var.assign(right_ast))
1696
+ .followed_by { yield left_var, right_var }
1697
+ end
1698
+
1699
+ # Helper: Try to match first node in a set, otherwise process as usual
1700
+ def try_match_first_node(ast, input, optimize_first = true)
1701
+ if return_nodeset?(ast) && optimize_first
1702
+ matched_set = unique_literal(:matched_set)
1703
+ first_node = unique_literal(:first_node)
1704
+ context_var = context_literal
1705
+
1706
+ # Create NodeSet for results
1707
+ nodeset_class = const_ref("Moxml", "NodeSet")
1708
+ empty_array = Ruby::Node.new(:array, [])
1709
+ nodeset_new = Ruby::Node.new(:send,
1710
+ [nodeset_class, "new", empty_array,
1711
+ context_var])
1712
+
1713
+ matched_set.assign(nodeset_new)
1714
+ .followed_by do
1715
+ # Process with block to accumulate results
1716
+ process(ast, input) { |node| matched_set.push(node) }
1717
+ end
1718
+ .followed_by do
1719
+ first_node.assign(matched_set[literal(0)])
1720
+ end
1721
+ .followed_by { first_node }
1722
+ else
1723
+ process(ast, input)
1724
+ end
1725
+ end
1726
+
1727
+ # Helper: Check if AST node is a number
1728
+ def number?(ast)
1729
+ %i[int float number].include?(ast.type)
1730
+ end
1731
+
1732
+ # Helper: Check if AST contains a call node with given name
1733
+ def has_call_node?(ast, name)
1734
+ visit = [ast]
1735
+
1736
+ until visit.empty?
1737
+ current = visit.pop
1738
+
1739
+ return true if current.type == :call && current.children[0] == name
1740
+
1741
+ current.children.each do |child|
1742
+ visit << child if child.is_a?(AST::Node)
1743
+ end
1744
+ end
1745
+
1746
+ false
1747
+ end
1748
+
1749
+ # Helper: Catch a message (for early returns)
1750
+ def catch_message(name)
1751
+ send_message(:catch, symbol(name)).add_block do
1752
+ # Ensure catch only returns value when throw is invoked
1753
+ yield.followed_by(self_nil)
1754
+ end
1755
+ end
1756
+
1757
+ # Helper: Throw a message with optional arguments
1758
+ def throw_message(name, *args)
1759
+ send_message(:throw, symbol(name), *args)
1760
+ end
1761
+
1762
+ # Helper: Variables literal for variable support
1763
+ def variables_literal
1764
+ literal(:variables)
1765
+ end
1766
+ end
1767
+ end
1768
+ end