moxml 0.1.7 → 0.1.9

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 +10 -10
  6. data/.github/workflows/release.yml +5 -3
  7. data/.gitignore +37 -0
  8. data/.rubocop.yml +15 -7
  9. data/.rubocop_todo.yml +224 -43
  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 +51 -0
  21. data/docs/_guides/modifying-xml.adoc +292 -0
  22. data/docs/_guides/parsing-xml.adoc +230 -0
  23. data/docs/_guides/sax-parsing.adoc +603 -0
  24. data/docs/_guides/working-with-documents.adoc +118 -0
  25. data/docs/_guides/xml-declaration.adoc +450 -0
  26. data/docs/_pages/adapter-compatibility.adoc +369 -0
  27. data/docs/_pages/adapters/headed-ox.adoc +237 -0
  28. data/docs/_pages/adapters/index.adoc +97 -0
  29. data/docs/_pages/adapters/libxml.adoc +285 -0
  30. data/docs/_pages/adapters/nokogiri.adoc +251 -0
  31. data/docs/_pages/adapters/oga.adoc +291 -0
  32. data/docs/_pages/adapters/ox.adoc +56 -0
  33. data/docs/_pages/adapters/rexml.adoc +292 -0
  34. data/docs/_pages/best-practices.adoc +429 -0
  35. data/docs/_pages/compatibility.adoc +467 -0
  36. data/docs/_pages/configuration.adoc +250 -0
  37. data/docs/_pages/error-handling.adoc +349 -0
  38. data/docs/_pages/headed-ox-limitations.adoc +574 -0
  39. data/docs/_pages/headed-ox.adoc +1025 -0
  40. data/docs/_pages/index.adoc +35 -0
  41. data/docs/_pages/installation.adoc +140 -0
  42. data/docs/_pages/node-api-reference.adoc +49 -0
  43. data/docs/_pages/performance.adoc +35 -0
  44. data/docs/_pages/quick-start.adoc +243 -0
  45. data/docs/_pages/thread-safety.adoc +28 -0
  46. data/docs/_references/document-api.adoc +407 -0
  47. data/docs/_references/index.adoc +48 -0
  48. data/docs/_tutorials/basic-usage.adoc +267 -0
  49. data/docs/_tutorials/builder-pattern.adoc +342 -0
  50. data/docs/_tutorials/index.adoc +33 -0
  51. data/docs/_tutorials/namespace-handling.adoc +324 -0
  52. data/docs/_tutorials/xpath-queries.adoc +358 -0
  53. data/docs/index.adoc +122 -0
  54. data/examples/README.md +124 -0
  55. data/examples/api_client/README.md +424 -0
  56. data/examples/api_client/api_client.rb +394 -0
  57. data/examples/api_client/example_response.xml +48 -0
  58. data/examples/headed_ox_example/README.md +90 -0
  59. data/examples/headed_ox_example/headed_ox_demo.rb +71 -0
  60. data/examples/rss_parser/README.md +194 -0
  61. data/examples/rss_parser/example_feed.xml +93 -0
  62. data/examples/rss_parser/rss_parser.rb +189 -0
  63. data/examples/sax_parsing/README.md +50 -0
  64. data/examples/sax_parsing/data_extractor.rb +75 -0
  65. data/examples/sax_parsing/example.xml +21 -0
  66. data/examples/sax_parsing/large_file.rb +78 -0
  67. data/examples/sax_parsing/simple_parser.rb +55 -0
  68. data/examples/web_scraper/README.md +352 -0
  69. data/examples/web_scraper/example_page.html +201 -0
  70. data/examples/web_scraper/web_scraper.rb +312 -0
  71. data/lib/moxml/adapter/base.rb +107 -28
  72. data/lib/moxml/adapter/customized_libxml/cdata.rb +28 -0
  73. data/lib/moxml/adapter/customized_libxml/comment.rb +24 -0
  74. data/lib/moxml/adapter/customized_libxml/declaration.rb +85 -0
  75. data/lib/moxml/adapter/customized_libxml/element.rb +39 -0
  76. data/lib/moxml/adapter/customized_libxml/node.rb +44 -0
  77. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +31 -0
  78. data/lib/moxml/adapter/customized_libxml/text.rb +27 -0
  79. data/lib/moxml/adapter/customized_oga/xml_generator.rb +1 -1
  80. data/lib/moxml/adapter/customized_ox/attribute.rb +28 -1
  81. data/lib/moxml/adapter/customized_rexml/formatter.rb +13 -8
  82. data/lib/moxml/adapter/headed_ox.rb +161 -0
  83. data/lib/moxml/adapter/libxml.rb +1564 -0
  84. data/lib/moxml/adapter/nokogiri.rb +156 -9
  85. data/lib/moxml/adapter/oga.rb +190 -15
  86. data/lib/moxml/adapter/ox.rb +322 -28
  87. data/lib/moxml/adapter/rexml.rb +157 -28
  88. data/lib/moxml/adapter.rb +21 -4
  89. data/lib/moxml/attribute.rb +6 -0
  90. data/lib/moxml/builder.rb +40 -4
  91. data/lib/moxml/config.rb +8 -3
  92. data/lib/moxml/context.rb +57 -2
  93. data/lib/moxml/declaration.rb +9 -0
  94. data/lib/moxml/doctype.rb +13 -1
  95. data/lib/moxml/document.rb +53 -6
  96. data/lib/moxml/document_builder.rb +34 -5
  97. data/lib/moxml/element.rb +71 -2
  98. data/lib/moxml/error.rb +175 -6
  99. data/lib/moxml/node.rb +155 -4
  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 +1770 -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 -5
  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_preservation_spec.rb +217 -0
  164. data/spec/moxml/declaration_spec.rb +36 -0
  165. data/spec/moxml/doctype_spec.rb +33 -0
  166. data/spec/moxml/document_builder_spec.rb +30 -0
  167. data/spec/moxml/document_spec.rb +105 -0
  168. data/spec/moxml/element_spec.rb +143 -0
  169. data/spec/moxml/error_spec.rb +266 -22
  170. data/spec/{moxml_spec.rb → moxml/moxml_spec.rb} +9 -9
  171. data/spec/moxml/namespace_spec.rb +32 -0
  172. data/spec/moxml/node_set_spec.rb +39 -0
  173. data/spec/moxml/node_spec.rb +37 -0
  174. data/spec/moxml/processing_instruction_spec.rb +34 -0
  175. data/spec/moxml/sax_spec.rb +1067 -0
  176. data/spec/moxml/text_spec.rb +31 -0
  177. data/spec/moxml/version_spec.rb +14 -0
  178. data/spec/moxml/xml_utils/.gitkeep +0 -0
  179. data/spec/moxml/xml_utils/encoder_spec.rb +27 -0
  180. data/spec/moxml/xml_utils_spec.rb +49 -0
  181. data/spec/moxml/xpath/ast/node_spec.rb +83 -0
  182. data/spec/moxml/xpath/axes_spec.rb +296 -0
  183. data/spec/moxml/xpath/cache_spec.rb +358 -0
  184. data/spec/moxml/xpath/compiler_spec.rb +406 -0
  185. data/spec/moxml/xpath/context_spec.rb +210 -0
  186. data/spec/moxml/xpath/conversion_spec.rb +365 -0
  187. data/spec/moxml/xpath/fixtures/sample.xml +25 -0
  188. data/spec/moxml/xpath/functions/boolean_functions_spec.rb +114 -0
  189. data/spec/moxml/xpath/functions/node_functions_spec.rb +145 -0
  190. data/spec/moxml/xpath/functions/numeric_functions_spec.rb +164 -0
  191. data/spec/moxml/xpath/functions/position_functions_spec.rb +93 -0
  192. data/spec/moxml/xpath/functions/special_functions_spec.rb +89 -0
  193. data/spec/moxml/xpath/functions/string_functions_spec.rb +381 -0
  194. data/spec/moxml/xpath/lexer_spec.rb +488 -0
  195. data/spec/moxml/xpath/parser_integration_spec.rb +210 -0
  196. data/spec/moxml/xpath/parser_spec.rb +364 -0
  197. data/spec/moxml/xpath/ruby/generator_spec.rb +421 -0
  198. data/spec/moxml/xpath/ruby/node_spec.rb +291 -0
  199. data/spec/moxml/xpath_capabilities_spec.rb +199 -0
  200. data/spec/moxml/xpath_spec.rb +77 -0
  201. data/spec/performance/README.md +83 -0
  202. data/spec/performance/benchmark_spec.rb +64 -0
  203. data/spec/{support/shared_examples/examples/memory.rb → performance/memory_usage_spec.rb} +4 -1
  204. data/spec/{support/shared_examples/examples/thread_safety.rb → performance/thread_safety_spec.rb} +3 -1
  205. data/spec/performance/xpath_benchmark_spec.rb +259 -0
  206. data/spec/spec_helper.rb +58 -1
  207. data/spec/support/xml_matchers.rb +1 -1
  208. metadata +178 -34
  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,1770 @@
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
+ # rubocop:disable Style/RedundantSum, Performance/Sum
980
+ concatted = assigns.inject(:followed_by)
981
+ .followed_by(conversions.inject(:+))
982
+ # rubocop:enable Style/RedundantSum, Performance/Sum
983
+
984
+ block_given? ? concatted.empty?.if_false { yield concatted } : concatted
985
+ end
986
+
987
+ # 3. starts-with() - Check string prefix
988
+ def on_call_starts_with(input, haystack, needle)
989
+ haystack_var = unique_literal(:haystack)
990
+ needle_var = unique_literal(:needle)
991
+ conversion = literal(Moxml::XPath::Conversion)
992
+
993
+ haystack_var.assign(try_match_first_node(haystack, input))
994
+ .followed_by do
995
+ needle_var.assign(try_match_first_node(needle, input))
996
+ end
997
+ .followed_by do
998
+ haystack_var.assign(conversion.to_string(haystack_var))
999
+ .followed_by do
1000
+ needle_var.assign(conversion.to_string(needle_var))
1001
+ end
1002
+ .followed_by do
1003
+ equal = needle_var.empty?
1004
+ .or(haystack_var.start_with?(needle_var))
1005
+
1006
+ block_given? ? equal.if_true { yield equal } : equal
1007
+ end
1008
+ end
1009
+ end
1010
+
1011
+ # 4. contains() - Check substring
1012
+ def on_call_contains(input, haystack, needle)
1013
+ haystack_lit = unique_literal(:haystack)
1014
+ needle_lit = unique_literal(:needle)
1015
+ conversion = literal(Moxml::XPath::Conversion)
1016
+
1017
+ haystack_lit.assign(try_match_first_node(haystack, input))
1018
+ .followed_by do
1019
+ needle_lit.assign(try_match_first_node(needle, input))
1020
+ end
1021
+ .followed_by do
1022
+ converted = conversion.to_string(haystack_lit)
1023
+ .include?(conversion.to_string(needle_lit))
1024
+
1025
+ block_given? ? converted.if_true { yield converted } : converted
1026
+ end
1027
+ end
1028
+
1029
+ # 5. substring-before() - Get part before separator
1030
+ def on_call_substring_before(input, haystack, needle)
1031
+ haystack_var = unique_literal(:haystack)
1032
+ needle_var = unique_literal(:needle)
1033
+ conversion = literal(Moxml::XPath::Conversion)
1034
+
1035
+ before = unique_literal(:before)
1036
+ sep = unique_literal(:sep)
1037
+ after = unique_literal(:after)
1038
+
1039
+ haystack_var.assign(try_match_first_node(haystack, input))
1040
+ .followed_by do
1041
+ needle_var.assign(try_match_first_node(needle, input))
1042
+ end
1043
+ .followed_by do
1044
+ converted = conversion.to_string(haystack_var)
1045
+ .partition(conversion.to_string(needle_var))
1046
+
1047
+ mass_assign([before, sep, after], converted).followed_by do
1048
+ sep.empty?
1049
+ .if_true { sep }
1050
+ .else { block_given? ? yield : before }
1051
+ end
1052
+ end
1053
+ end
1054
+
1055
+ # 6. substring-after() - Get part after separator
1056
+ def on_call_substring_after(input, haystack, needle)
1057
+ haystack_var = unique_literal(:haystack)
1058
+ needle_var = unique_literal(:needle)
1059
+ conversion = literal(Moxml::XPath::Conversion)
1060
+
1061
+ before = unique_literal(:before)
1062
+ sep = unique_literal(:sep)
1063
+ after = unique_literal(:after)
1064
+
1065
+ haystack_var.assign(try_match_first_node(haystack, input))
1066
+ .followed_by do
1067
+ needle_var.assign(try_match_first_node(needle, input))
1068
+ end
1069
+ .followed_by do
1070
+ converted = conversion.to_string(haystack_var)
1071
+ .partition(conversion.to_string(needle_var))
1072
+
1073
+ mass_assign([before, sep, after], converted).followed_by do
1074
+ sep.empty?
1075
+ .if_true { sep }
1076
+ .else { block_given? ? yield : after }
1077
+ end
1078
+ end
1079
+ end
1080
+
1081
+ # 7. substring() - Extract substring
1082
+ def on_call_substring(input, haystack, start, length = nil)
1083
+ haystack_var = unique_literal(:haystack)
1084
+ start_var = unique_literal(:start)
1085
+ length_var = unique_literal(:length)
1086
+ result_var = unique_literal(:result)
1087
+ ruby_start = unique_literal(:ruby_start)
1088
+ effective_length = unique_literal(:effective_length)
1089
+ conversion = literal(Moxml::XPath::Conversion)
1090
+
1091
+ haystack_var.assign(try_match_first_node(haystack, input))
1092
+ .followed_by do
1093
+ haystack_var.assign(conversion.to_string(haystack_var))
1094
+ end
1095
+ .followed_by do
1096
+ start_var.assign(try_match_first_node(start, input))
1097
+ .followed_by do
1098
+ # Round the start position first (XPath 1.0 spec requires rounding)
1099
+ start_var.assign(conversion.to_float(start_var).round.to_i)
1100
+ end
1101
+ end
1102
+ .followed_by do
1103
+ if length
1104
+ length_var.assign(try_match_first_node(length, input))
1105
+ .followed_by do
1106
+ # Round the length (XPath 1.0 spec requires rounding)
1107
+ length_var.assign(conversion.to_float(length_var).round.to_i)
1108
+ end
1109
+ .followed_by do
1110
+ # XPath 1.0 algorithm:
1111
+ # If start < 1, some positions fall before the string
1112
+ # We need to adjust the effective length accordingly
1113
+ # effective_length = (start + length) - max(start, 1)
1114
+ # lua_start = max(start, 1) - 1 (since we start from position 1)
1115
+
1116
+ # Calculate how many positions to skip before position 1
1117
+ # If start is 0, we lose 1 position; if -2, we lose 3 positions
1118
+ ruby_start.assign(
1119
+ (start_var < literal(1))
1120
+ .if_true { literal(0) }
1121
+ .else { start_var - literal(1) },
1122
+ )
1123
+ end
1124
+ .followed_by do
1125
+ # Calculate effective length accounting for positions before string
1126
+ effective_length.assign(
1127
+ (start_var < literal(1))
1128
+ .if_true do
1129
+ # Some positions are before position 1
1130
+ # end_pos = start + length
1131
+ # effective = end_pos - 1 (since we start from position 1)
1132
+ # But clamp to 0 if entirely before string
1133
+ ((start_var + length_var) - literal(1))
1134
+ .if_true { (start_var + length_var) - literal(1) }
1135
+ .else { literal(0) }
1136
+ end
1137
+ .else { length_var },
1138
+ )
1139
+ end
1140
+ .followed_by do
1141
+ # Clamp effective length to non-negative
1142
+ effective_length.assign(
1143
+ (effective_length < literal(0))
1144
+ .if_true { literal(0) }
1145
+ .else { effective_length },
1146
+ )
1147
+ end
1148
+ .followed_by do
1149
+ # Extract substring with effective length
1150
+ result_var.assign(haystack_var[ruby_start, effective_length])
1151
+ .followed_by do
1152
+ # Ensure we return empty string instead of nil
1153
+ result_var.assign(result_var.if_true do
1154
+ result_var
1155
+ end.else { string("") })
1156
+ end
1157
+ end
1158
+ .followed_by do
1159
+ if block_given?
1160
+ result_var.empty?.if_false do
1161
+ yield result_var
1162
+ end
1163
+ else
1164
+ result_var
1165
+ end
1166
+ end
1167
+ else
1168
+ # No length specified - go to end of string
1169
+ # Convert to 0-based index, clamping to 0
1170
+ ruby_start.assign(
1171
+ (start_var < literal(1))
1172
+ .if_true { literal(0) }
1173
+ .else { start_var - literal(1) },
1174
+ ).followed_by do
1175
+ # Extract from start to end
1176
+ result_var.assign(haystack_var[range(ruby_start, literal(-1))])
1177
+ .followed_by do
1178
+ # Ensure we return empty string instead of nil
1179
+ result_var.assign(result_var.if_true do
1180
+ result_var
1181
+ end.else { string("") })
1182
+ end
1183
+ end
1184
+ .followed_by do
1185
+ if block_given?
1186
+ result_var.empty?.if_false do
1187
+ yield result_var
1188
+ end
1189
+ else
1190
+ result_var
1191
+ end
1192
+ end
1193
+ end
1194
+ end
1195
+ end
1196
+
1197
+ # 8. string-length() - Get string length
1198
+ def on_call_string_length(input, arg = nil)
1199
+ convert_var = unique_literal(:convert)
1200
+ conversion = literal(Moxml::XPath::Conversion)
1201
+
1202
+ argument_or_first_node(input, arg) do |arg_var|
1203
+ convert_var.assign(conversion.to_string(arg_var).length)
1204
+ .followed_by do
1205
+ if block_given?
1206
+ convert_var.zero?.if_false { yield convert_var }
1207
+ else
1208
+ convert_var.to_f
1209
+ end
1210
+ end
1211
+ end
1212
+ end
1213
+
1214
+ # 9. normalize-space() - Normalize whitespace
1215
+ def on_call_normalize_space(input, arg = nil)
1216
+ conversion = literal(Moxml::XPath::Conversion)
1217
+ norm_var = unique_literal(:normalized)
1218
+
1219
+ # Create regex for matching whitespace sequences
1220
+ # Use Regexp.new to create /\s+/ pattern at runtime
1221
+ regexp_class = const_ref("Regexp")
1222
+ whitespace_pattern = string('\\s+')
1223
+ whitespace_regex = Ruby::Node.new(:send,
1224
+ [regexp_class, "new",
1225
+ whitespace_pattern])
1226
+ replace = string(" ")
1227
+
1228
+ argument_or_first_node(input, arg) do |arg_var|
1229
+ norm_var
1230
+ .assign(conversion.to_string(arg_var).strip.gsub(whitespace_regex,
1231
+ replace))
1232
+ .followed_by do
1233
+ norm_var.empty?
1234
+ .if_true { string("") }
1235
+ .else { block_given? ? yield : norm_var }
1236
+ end
1237
+ end
1238
+ end
1239
+
1240
+ # 10. translate() - Character replacement
1241
+ def on_call_translate(input, source, find, replace)
1242
+ source_var = unique_literal(:source)
1243
+ find_var = unique_literal(:find)
1244
+ replace_var = unique_literal(:replace)
1245
+ replaced_var = unique_literal(:replaced)
1246
+ conversion = literal(Moxml::XPath::Conversion)
1247
+
1248
+ char = unique_literal(:char)
1249
+ index = unique_literal(:index)
1250
+
1251
+ source_var.assign(try_match_first_node(source, input))
1252
+ .followed_by do
1253
+ replaced_var.assign(conversion.to_string(source_var))
1254
+ end
1255
+ .followed_by do
1256
+ find_var.assign(try_match_first_node(find, input))
1257
+ end
1258
+ .followed_by do
1259
+ find_var.assign(conversion.to_string(find_var).chars.to_array)
1260
+ end
1261
+ .followed_by do
1262
+ replace_var.assign(try_match_first_node(replace, input))
1263
+ end
1264
+ .followed_by do
1265
+ replace_var.assign(conversion.to_string(replace_var).chars.to_array)
1266
+ end
1267
+ .followed_by do
1268
+ find_var.each_with_index.add_block(char, index) do
1269
+ replace_with = replace_var[index]
1270
+ .if_true { replace_var[index] }
1271
+ .else { string("") }
1272
+
1273
+ replaced_var.assign(replaced_var.gsub(char, replace_with))
1274
+ end
1275
+ end
1276
+ .followed_by { replaced_var }
1277
+ end
1278
+
1279
+ # ===== NUMERIC FUNCTIONS =====
1280
+
1281
+ # 1. number() - Convert to number
1282
+ def on_call_number(input, arg = nil, &block)
1283
+ convert_var = unique_literal(:convert)
1284
+ conversion = literal(Moxml::XPath::Conversion)
1285
+
1286
+ argument_or_first_node(input, arg) do |arg_var|
1287
+ convert_var.assign(conversion.to_float(arg_var)).followed_by do
1288
+ if block
1289
+ convert_var.zero?.if_false(&block)
1290
+ else
1291
+ convert_var
1292
+ end
1293
+ end
1294
+ end
1295
+ end
1296
+
1297
+ # 2. sum() - Sum node values
1298
+ def on_call_sum(input, arg, &block)
1299
+ unless return_nodeset?(arg)
1300
+ raise TypeError, "sum() can only operate on a path, axis or predicate"
1301
+ end
1302
+
1303
+ sum_var = unique_literal(:sum)
1304
+ conversion = literal(Moxml::XPath::Conversion)
1305
+
1306
+ sum_var.assign(literal(0.0))
1307
+ .followed_by do
1308
+ process(arg, input) do |matched_node|
1309
+ sum_var.assign(sum_var + conversion.to_float(matched_node.text))
1310
+ end
1311
+ end
1312
+ .followed_by do
1313
+ block ? sum_var.zero?.if_false(&block) : sum_var
1314
+ end
1315
+ end
1316
+
1317
+ # 3. count() - Count nodes
1318
+ def on_call_count(input, arg, &block)
1319
+ count = unique_literal(:count)
1320
+
1321
+ unless return_nodeset?(arg)
1322
+ raise TypeError, "count() can only operate on NodeSet instances"
1323
+ end
1324
+
1325
+ count.assign(literal(0.0))
1326
+ .followed_by do
1327
+ process(arg, input) { count.assign(count + literal(1)) }
1328
+ end
1329
+ .followed_by do
1330
+ block ? count.zero?.if_false(&block) : count
1331
+ end
1332
+ end
1333
+
1334
+ # 4. floor() - Round down
1335
+ def on_call_floor(input, arg)
1336
+ arg_ast = try_match_first_node(arg, input)
1337
+ call_arg = unique_literal(:call_arg)
1338
+ conversion = literal(Moxml::XPath::Conversion)
1339
+
1340
+ call_arg.assign(arg_ast)
1341
+ .followed_by do
1342
+ call_arg.assign(conversion.to_float(call_arg))
1343
+ end
1344
+ .followed_by do
1345
+ call_arg.nan?
1346
+ .if_true { call_arg }
1347
+ .else { block_given? ? yield : call_arg.floor.to_f }
1348
+ end
1349
+ end
1350
+
1351
+ # 5. ceiling() - Round up
1352
+ def on_call_ceiling(input, arg)
1353
+ arg_ast = try_match_first_node(arg, input)
1354
+ call_arg = unique_literal(:call_arg)
1355
+ conversion = literal(Moxml::XPath::Conversion)
1356
+
1357
+ call_arg.assign(arg_ast)
1358
+ .followed_by do
1359
+ call_arg.assign(conversion.to_float(call_arg))
1360
+ end
1361
+ .followed_by do
1362
+ call_arg.nan?
1363
+ .if_true { call_arg }
1364
+ .else { block_given? ? yield : call_arg.ceil.to_f }
1365
+ end
1366
+ end
1367
+
1368
+ # 6. round() - Round to nearest
1369
+ def on_call_round(input, arg)
1370
+ arg_ast = try_match_first_node(arg, input)
1371
+ call_arg = unique_literal(:call_arg)
1372
+ conversion = literal(Moxml::XPath::Conversion)
1373
+
1374
+ call_arg.assign(arg_ast)
1375
+ .followed_by do
1376
+ call_arg.assign(conversion.to_float(call_arg))
1377
+ end
1378
+ .followed_by do
1379
+ call_arg.nan?
1380
+ .if_true { call_arg }
1381
+ .else { block_given? ? yield : call_arg.round.to_f }
1382
+ end
1383
+ end
1384
+
1385
+ # ===== BOOLEAN FUNCTIONS =====
1386
+
1387
+ # 1. boolean() - Convert to boolean
1388
+ def on_call_boolean(input, arg, &block)
1389
+ arg_ast = try_match_first_node(arg, input)
1390
+ call_arg = unique_literal(:call_arg)
1391
+ conversion = literal(Moxml::XPath::Conversion)
1392
+
1393
+ call_arg.assign(arg_ast).followed_by do
1394
+ converted = conversion.to_boolean(call_arg)
1395
+
1396
+ block ? converted.if_true(&block) : converted
1397
+ end
1398
+ end
1399
+
1400
+ # 2. not() - Negate boolean
1401
+ def on_call_not(input, arg, &block)
1402
+ arg_ast = try_match_first_node(arg, input)
1403
+ call_arg = unique_literal(:call_arg)
1404
+ conversion = literal(Moxml::XPath::Conversion)
1405
+
1406
+ call_arg.assign(arg_ast).followed_by do
1407
+ converted = conversion.to_boolean(call_arg).not
1408
+
1409
+ block ? converted.if_true(&block) : converted
1410
+ end
1411
+ end
1412
+
1413
+ # 3. true() - Return true
1414
+ def on_call_true(*)
1415
+ block_given? ? yield : self_true
1416
+ end
1417
+
1418
+ # 4. false() - Return false
1419
+ def on_call_false(*)
1420
+ self_false
1421
+ end
1422
+
1423
+ # ===== NODE FUNCTIONS =====
1424
+
1425
+ # 1. local-name() - Get local name without namespace prefix
1426
+ def on_call_local_name(input, arg = nil)
1427
+ argument_or_first_node(input, arg) do |arg_var|
1428
+ arg_var
1429
+ .if_true do
1430
+ ensure_element_or_attribute(arg_var)
1431
+ .followed_by { block_given? ? yield : arg_var.name }
1432
+ end
1433
+ .else { string("") }
1434
+ end
1435
+ end
1436
+
1437
+ # 2. name() - Get expanded/qualified name with namespace
1438
+ def on_call_name(input, arg = nil)
1439
+ argument_or_first_node(input, arg) do |arg_var|
1440
+ arg_var
1441
+ .if_true do
1442
+ ensure_element_or_attribute(arg_var)
1443
+ .followed_by { block_given? ? yield : arg_var.expanded_name }
1444
+ end
1445
+ .else { string("") }
1446
+ end
1447
+ end
1448
+
1449
+ # 3. namespace-uri() - Get namespace URI
1450
+ def on_call_namespace_uri(input, arg = nil)
1451
+ default = string("")
1452
+
1453
+ argument_or_first_node(input, arg) do |arg_var|
1454
+ arg_var
1455
+ .if_true do
1456
+ ensure_element_or_attribute(arg_var).followed_by do
1457
+ arg_var.namespace
1458
+ .if_true { block_given? ? yield : arg_var.namespace.uri }
1459
+ .else { default }
1460
+ end
1461
+ end
1462
+ .else { default }
1463
+ end
1464
+ end
1465
+
1466
+ # 4. lang() - Check xml:lang attribute
1467
+ def on_call_lang(input, arg)
1468
+ lang_var = unique_literal("lang")
1469
+ node = unique_literal("node")
1470
+ found = unique_literal("found")
1471
+ xml_lang = unique_literal("xml_lang")
1472
+ matched = unique_literal("matched")
1473
+
1474
+ conversion = literal(Moxml::XPath::Conversion)
1475
+
1476
+ ast = lang_var.assign(try_match_first_node(arg, input))
1477
+ .followed_by do
1478
+ lang_var.assign(conversion.to_string(lang_var))
1479
+ end
1480
+ .followed_by do
1481
+ matched.assign(self_false)
1482
+ end
1483
+ .followed_by do
1484
+ node.assign(input)
1485
+ end
1486
+ .followed_by do
1487
+ xml_lang.assign(string("xml:lang"))
1488
+ end
1489
+ .followed_by do
1490
+ node.respond_to?(symbol(:attribute)).while_true do
1491
+ found.assign(node.get(xml_lang))
1492
+ .followed_by do
1493
+ found.if_true do
1494
+ found.eq(lang_var)
1495
+ .if_true do
1496
+ if block_given?
1497
+ yield
1498
+ else
1499
+ matched.assign(self_true).followed_by(break_loop)
1500
+ end
1501
+ end
1502
+ .else { break_loop }
1503
+ end
1504
+ end
1505
+ .followed_by(node.assign(node.parent))
1506
+ end
1507
+ end
1508
+
1509
+ block_given? ? ast : ast.followed_by(matched)
1510
+ end
1511
+
1512
+ # ===== POSITION FUNCTIONS =====
1513
+
1514
+ # 1. position() - Current position in predicate context
1515
+ def on_call_position(*)
1516
+ index = predicate_index
1517
+
1518
+ unless index
1519
+ raise InvalidContextError.new(
1520
+ "position() requires a predicate context. " \
1521
+ "Use position() within a predicate like: //item[position() = 1]",
1522
+ function_name: "position()",
1523
+ required_context: "predicate",
1524
+ )
1525
+ end
1526
+
1527
+ index.to_f
1528
+ end
1529
+
1530
+ # 2. last() - Size of current predicate context
1531
+ def on_call_last(*)
1532
+ set = predicate_nodeset
1533
+
1534
+ unless set
1535
+ raise InvalidContextError.new(
1536
+ "last() requires a predicate context. " \
1537
+ "Use last() within a predicate like: //item[position() = last()]",
1538
+ function_name: "last()",
1539
+ required_context: "predicate",
1540
+ )
1541
+ end
1542
+
1543
+ set.length.to_f
1544
+ end
1545
+
1546
+ # ===== SPECIAL FUNCTIONS =====
1547
+
1548
+ # 1. id() - Find nodes by ID attribute
1549
+ def on_call_id(input, arg)
1550
+ orig_input = original_input_literal
1551
+ node = unique_literal(:node)
1552
+ ids_var = unique_literal("ids")
1553
+ matched = unique_literal("id_matched")
1554
+ id_str_var = unique_literal("id_string")
1555
+ attr_var = unique_literal("attr")
1556
+
1557
+ nodeset_class = const_ref("Moxml", "NodeSet")
1558
+ context_var = context_literal
1559
+ empty_array = Ruby::Node.new(:array, [])
1560
+
1561
+ matched.assign(Ruby::Node.new(:send,
1562
+ [nodeset_class, "new", empty_array,
1563
+ context_var]))
1564
+ .followed_by do
1565
+ # When using a path, get text of all matched nodes
1566
+ if return_nodeset?(arg)
1567
+ empty_ids = Ruby::Node.new(:array, [])
1568
+ ids_var.assign(empty_ids).followed_by do
1569
+ process(arg, input) { |element| ids_var << element.text }
1570
+ end
1571
+ # Otherwise cast to string and split on spaces
1572
+ else
1573
+ conversion = literal(Moxml::XPath::Conversion)
1574
+ ids_var.assign(process(arg, input))
1575
+ .followed_by do
1576
+ ids_var.assign(conversion.to_string(ids_var).split(string(" ")))
1577
+ end
1578
+ end
1579
+ end
1580
+ .followed_by do
1581
+ id_str_var.assign(string("id"))
1582
+ end
1583
+ .followed_by do
1584
+ orig_input.each_node.add_block(node) do
1585
+ node.is_a?(const_ref("Moxml", "Element")).if_true do
1586
+ attr_var.assign(node.attribute(id_str_var)).followed_by do
1587
+ attr_var.and(ids_var.include?(attr_var.value))
1588
+ .if_true { block_given? ? yield : matched << node }
1589
+ end
1590
+ end
1591
+ end
1592
+ end
1593
+ .followed_by(matched)
1594
+ end
1595
+
1596
+ # Helper methods
1597
+
1598
+ # Helper: Get argument or use current node's first child
1599
+ def argument_or_first_node(input, arg = nil)
1600
+ arg_ast = arg ? try_match_first_node(arg, input) : input
1601
+ arg_var = unique_literal(:argument_or_first_node)
1602
+
1603
+ arg_var.assign(arg_ast).followed_by { yield arg_var }
1604
+ end
1605
+
1606
+ # Helper: Try to match first node v1
1607
+ def try_match_first_node_v1(ast, input, optimize_first = true)
1608
+ if return_nodeset?(ast) && optimize_first
1609
+ matched_set = unique_literal(:matched_set)
1610
+ first_node = unique_literal(:first_node)
1611
+ context_var = context_literal
1612
+
1613
+ # Create NodeSet for results
1614
+ nodeset_class = const_ref("Moxml", "NodeSet")
1615
+ empty_array = Ruby::Node.new(:array, [])
1616
+ nodeset_new = Ruby::Node.new(:send,
1617
+ [nodeset_class, "new", empty_array,
1618
+ context_var])
1619
+
1620
+ matched_set.assign(nodeset_new)
1621
+ .followed_by do
1622
+ # Process with block to accumulate results
1623
+ process(ast, input) { |node| matched_set.push(node) }
1624
+ end
1625
+ .followed_by do
1626
+ first_node.assign(matched_set[literal(0)])
1627
+ end
1628
+ .followed_by do
1629
+ first_node.if_true { first_node }.else { string("") }
1630
+ end
1631
+ else
1632
+ process(ast, input)
1633
+ end
1634
+ end
1635
+
1636
+ # Helper: Create mass assignment node
1637
+ def mass_assign(vars, value)
1638
+ Ruby::Node.new(:massign, [vars, value])
1639
+ end
1640
+
1641
+ # Helper: Create range node for Ruby AST
1642
+ def range(start, stop)
1643
+ Ruby::Node.new(:range, [start, stop])
1644
+ end
1645
+
1646
+ # Helper: Ensure node is Element or Attribute
1647
+ def ensure_element_or_attribute(input)
1648
+ element_or_attribute(input).if_false do
1649
+ raise_message(TypeError, "argument is not an Element or Attribute")
1650
+ end
1651
+ end
1652
+
1653
+ # Helper: Raise an error with message
1654
+ def raise_message(klass, message)
1655
+ send_message(:raise, literal(klass), string(message))
1656
+ end
1657
+
1658
+ # Helper: Send a message (for method calls like raise, break)
1659
+ def send_message(name, *args)
1660
+ Ruby::Node.new(:send, [nil, name.to_s] + args)
1661
+ end
1662
+
1663
+ # Helper: Break statement
1664
+ def break_loop
1665
+ send_message(:break)
1666
+ end
1667
+
1668
+ # Helper: Get current predicate index
1669
+ def predicate_index
1670
+ @predicate_indexes.last
1671
+ end
1672
+
1673
+ # Helper: Get current predicate nodeset
1674
+ def predicate_nodeset
1675
+ @predicate_nodesets.last
1676
+ end
1677
+
1678
+ # Helper: Get original input literal for traversal
1679
+ def original_input_literal
1680
+ literal(:node)
1681
+ end
1682
+
1683
+ # Helper: Generate code for an operator
1684
+ #
1685
+ # Processes left and right operands, optimizing to match only first node
1686
+ # when appropriate (path, axis, predicate)
1687
+ def operator(ast, input, optimize_first = true)
1688
+ left, right = ast.children
1689
+
1690
+ left_var = unique_literal(:op_left)
1691
+ right_var = unique_literal(:op_right)
1692
+
1693
+ left_ast = try_match_first_node(left, input, optimize_first)
1694
+ right_ast = try_match_first_node(right, input, optimize_first)
1695
+
1696
+ left_var.assign(left_ast)
1697
+ .followed_by(right_var.assign(right_ast))
1698
+ .followed_by { yield left_var, right_var }
1699
+ end
1700
+
1701
+ # Helper: Try to match first node in a set, otherwise process as usual
1702
+ def try_match_first_node(ast, input, optimize_first = true)
1703
+ if return_nodeset?(ast) && optimize_first
1704
+ matched_set = unique_literal(:matched_set)
1705
+ first_node = unique_literal(:first_node)
1706
+ context_var = context_literal
1707
+
1708
+ # Create NodeSet for results
1709
+ nodeset_class = const_ref("Moxml", "NodeSet")
1710
+ empty_array = Ruby::Node.new(:array, [])
1711
+ nodeset_new = Ruby::Node.new(:send,
1712
+ [nodeset_class, "new", empty_array,
1713
+ context_var])
1714
+
1715
+ matched_set.assign(nodeset_new)
1716
+ .followed_by do
1717
+ # Process with block to accumulate results
1718
+ process(ast, input) { |node| matched_set.push(node) }
1719
+ end
1720
+ .followed_by do
1721
+ first_node.assign(matched_set[literal(0)])
1722
+ end
1723
+ .followed_by { first_node }
1724
+ else
1725
+ process(ast, input)
1726
+ end
1727
+ end
1728
+
1729
+ # Helper: Check if AST node is a number
1730
+ def number?(ast)
1731
+ %i[int float number].include?(ast.type)
1732
+ end
1733
+
1734
+ # Helper: Check if AST contains a call node with given name
1735
+ def has_call_node?(ast, name)
1736
+ visit = [ast]
1737
+
1738
+ until visit.empty?
1739
+ current = visit.pop
1740
+
1741
+ return true if current.type == :call && current.children[0] == name
1742
+
1743
+ current.children.each do |child|
1744
+ visit << child if child.is_a?(AST::Node)
1745
+ end
1746
+ end
1747
+
1748
+ false
1749
+ end
1750
+
1751
+ # Helper: Catch a message (for early returns)
1752
+ def catch_message(name)
1753
+ send_message(:catch, symbol(name)).add_block do
1754
+ # Ensure catch only returns value when throw is invoked
1755
+ yield.followed_by(self_nil)
1756
+ end
1757
+ end
1758
+
1759
+ # Helper: Throw a message with optional arguments
1760
+ def throw_message(name, *args)
1761
+ send_message(:throw, symbol(name), *args)
1762
+ end
1763
+
1764
+ # Helper: Variables literal for variable support
1765
+ def variables_literal
1766
+ literal(:variables)
1767
+ end
1768
+ end
1769
+ end
1770
+ end