leptris 1.9.199.0-arm-linux

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 (124) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3890 -0
  3. data/CLAUDE.md +115 -0
  4. data/CONTEXT.md +39 -0
  5. data/LICENSE.md +33 -0
  6. data/README.adoc +755 -0
  7. data/Rakefile +407 -0
  8. data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
  9. data/TODO.perf/02-auto-enable-native-layer.md +19 -0
  10. data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
  11. data/TODO.perf/04-ext-bound-serialization.md +15 -0
  12. data/TODO.perf/05-native-surface-completion.md +19 -0
  13. data/TODO.perf/06-perf-battery-native-legs.md +16 -0
  14. data/TODO.perf/07-native-read-floor.md +31 -0
  15. data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
  16. data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
  17. data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
  18. data/TODO.perf/11-moxml-gap-battery.md +18 -0
  19. data/TODO.perf/12-document-lifetime-in-c.md +47 -0
  20. data/TODO.perf/13-structural-memos.md +49 -0
  21. data/TODO.perf/14-insert-family-c-bound.md +31 -0
  22. data/TODO.perf/15-compiled-expression-cache.md +35 -0
  23. data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
  24. data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
  25. data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
  26. data/TODO.perf/19-lazy-node-pointer.md +39 -0
  27. data/TODO.perf/20-css-translation-cache.md +24 -0
  28. data/TODO.perf/21-key-memo-consult.md +20 -0
  29. data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
  30. data/TODO.perf/23-cbound-value-mutations.md +27 -0
  31. data/TODO.perf/24-immutable-read-lanes.md +22 -0
  32. data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
  33. data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
  34. data/TODO.perf/27-c-yield-traversal.md +38 -0
  35. data/TODO.perf/28-address-fills.md +25 -0
  36. data/TODO.perf/29-ns-xpath-compiled.md +23 -0
  37. data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
  38. data/TODO.perf/31-battery-rounds-4-8.md +23 -0
  39. data/TODO.perf/32-clean-host-floor-table.md +29 -0
  40. data/TODO.perf/33-cbound-root-set.md +29 -0
  41. data/TODO.perf/34-fragment-fast-lane.md +41 -0
  42. data/TODO.perf/35-parse-default-c-face.md +24 -0
  43. data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
  44. data/TODO.perf/37-document-lazy-pointer.md +28 -0
  45. data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
  46. data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
  47. data/TODO.restructure/02-deep-copy-seam.md +21 -0
  48. data/TODO.restructure/03-evaluation-context-seam.md +18 -0
  49. data/TODO.restructure/04-spec-mece-restructure.md +20 -0
  50. data/TODO.restructure/05-memory-documentation.md +13 -0
  51. data/TODO.restructure/06-architecture-map-refresh.md +12 -0
  52. data/TODO.restructure/07-external-gates.md +16 -0
  53. data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
  54. data/TODO.restructure/09-iteration-scope.md +29 -0
  55. data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
  56. data/TODO.restructure/11-construction-factory-diet.md +16 -0
  57. data/TODO.restructure/12-resultattr-merge.md +12 -0
  58. data/TODO.restructure/13-spec-mece-followup.md +9 -0
  59. data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
  60. data/TODO.restructure/15-readme-feature-documentation.md +10 -0
  61. data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
  62. data/TODO.restructure/17-perf-drift-record.md +12 -0
  63. data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
  64. data/TODO.restructure/19-gitignore-hygiene.md +10 -0
  65. data/TODO.restructure/20-utf8proc-enablement.md +34 -0
  66. data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
  67. data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
  68. data/docs/adr/0001-lockstep-mirror.md +17 -0
  69. data/docs/adr/0002-utf8-at-the-seam.md +19 -0
  70. data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
  71. data/docs/adr/0004-lifetime-guard.md +21 -0
  72. data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
  73. data/docs/adr/0006-ruby-variant-policy.md +25 -0
  74. data/ext/build_windows_native.rb +42 -0
  75. data/ext/leptris/native/extconf.rb +55 -0
  76. data/ext/leptris/native/native.c +2528 -0
  77. data/leptris.gemspec +43 -0
  78. data/lib/leptris/version.rb +5 -0
  79. data/lib/leptris/xml/attr.rb +76 -0
  80. data/lib/leptris/xml/c_string_array.rb +37 -0
  81. data/lib/leptris/xml/cdata.rb +27 -0
  82. data/lib/leptris/xml/comment.rb +27 -0
  83. data/lib/leptris/xml/css_to_xpath.rb +192 -0
  84. data/lib/leptris/xml/descriptor.rb +214 -0
  85. data/lib/leptris/xml/diff.rb +74 -0
  86. data/lib/leptris/xml/doc_type.rb +54 -0
  87. data/lib/leptris/xml/document.rb +716 -0
  88. data/lib/leptris/xml/document_fragment.rb +63 -0
  89. data/lib/leptris/xml/element.rb +771 -0
  90. data/lib/leptris/xml/entity_reference.rb +13 -0
  91. data/lib/leptris/xml/evaluation_context.rb +32 -0
  92. data/lib/leptris/xml/ffi.rb +1527 -0
  93. data/lib/leptris/xml/iteration_scope.rb +56 -0
  94. data/lib/leptris/xml/iterparse.rb +144 -0
  95. data/lib/leptris/xml/namespace.rb +43 -0
  96. data/lib/leptris/xml/native.so +0 -0
  97. data/lib/leptris/xml/native_layer.rb +92 -0
  98. data/lib/leptris/xml/node.rb +686 -0
  99. data/lib/leptris/xml/node_set.rb +212 -0
  100. data/lib/leptris/xml/parse_options.rb +97 -0
  101. data/lib/leptris/xml/plan_value.rb +121 -0
  102. data/lib/leptris/xml/processing_instruction.rb +90 -0
  103. data/lib/leptris/xml/pull.rb +212 -0
  104. data/lib/leptris/xml/relaxng.rb +126 -0
  105. data/lib/leptris/xml/result_attr.rb +39 -0
  106. data/lib/leptris/xml/result_text.rb +36 -0
  107. data/lib/leptris/xml/sax/document.rb +45 -0
  108. data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
  109. data/lib/leptris/xml/sax/parser.rb +326 -0
  110. data/lib/leptris/xml/sax/recorder.rb +374 -0
  111. data/lib/leptris/xml/sax.rb +14 -0
  112. data/lib/leptris/xml/schematron.rb +88 -0
  113. data/lib/leptris/xml/searchable.rb +361 -0
  114. data/lib/leptris/xml/serialization.rb +180 -0
  115. data/lib/leptris/xml/text.rb +32 -0
  116. data/lib/leptris/xml/xpath.rb +89 -0
  117. data/lib/leptris/xml/xquery.rb +77 -0
  118. data/lib/leptris/xml/xslt.rb +85 -0
  119. data/lib/leptris/xml.rb +108 -0
  120. data/lib/leptris.rb +53 -0
  121. data/lib/libleptris.so +0 -0
  122. data/lib/libutf8proc.so.3 +0 -0
  123. data/scripts/gem_smoke.rb +36 -0
  124. metadata +214 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module Leptris
6
+ module XML
7
+ module SAX
8
+ autoload :Document, "leptris/xml/sax/document"
9
+ autoload :Parser, "leptris/xml/sax/parser"
10
+ autoload :Recorder, "leptris/xml/sax/recorder"
11
+ autoload :DomDispatch, "leptris/xml/sax/dom_dispatch"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module Leptris::XML::Schematron
6
+ module_function
7
+
8
+ def parse(schema_xml, phase: nil)
9
+ Schema.parse(schema_xml, phase: phase)
10
+ end
11
+
12
+ def parse_file(path, phase: nil)
13
+ Schema.parse_file(path, phase: phase)
14
+ end
15
+
16
+ # A compiled Schematron schema (libleptris >= 1.9.126): parse
17
+ # once — optionally selecting a PHASE, which activates only its
18
+ # patterns — then validate any number of documents. #validate
19
+ # returns the SVRL report as a queryable Document; #valid?
20
+ # answers the boolean.
21
+ #
22
+ # sch = Leptris::XML::Schematron.parse(schema_xml)
23
+ # sch.valid?(doc) # => true/false
24
+ # svrl = sch.validate(doc) # => Document (svrl:schema root)
25
+ # sch = Leptris::XML::Schematron.parse(schema_xml, phase: "Basic")
26
+ #
27
+ class Schema
28
+ class Handle < ::FFI::AutoPointer
29
+ def self.release(ptr)
30
+ Leptris::XML::FFI.leptris_schematron_free(ptr)
31
+ end
32
+ end
33
+
34
+ def self.parse(schema_xml, phase: nil)
35
+ xml = schema_xml.to_s
36
+ raw =
37
+ if phase
38
+ Leptris::XML::FFI.leptris_schematron_parse_phase(
39
+ xml, xml.bytesize, phase.to_s, nil)
40
+ else
41
+ Leptris::XML::FFI.leptris_schematron_parse(xml, xml.bytesize, nil)
42
+ end
43
+ if raw.null?
44
+ raise Leptris::XML::Error,
45
+ "schema parse failed: #{Leptris::XML::FFI.leptris_last_error}"
46
+ end
47
+ new(Handle.new(raw))
48
+ end
49
+
50
+ def self.parse_file(path, phase: nil)
51
+ raw =
52
+ if phase
53
+ xml = File.read(path)
54
+ Leptris::XML::FFI.leptris_schematron_parse_phase(
55
+ xml, xml.bytesize, phase.to_s, nil)
56
+ else
57
+ Leptris::XML::FFI.leptris_schematron_parse_file(path.to_s, nil)
58
+ end
59
+ if raw.null?
60
+ raise Leptris::XML::Error,
61
+ "schema parse failed: #{Leptris::XML::FFI.leptris_last_error}"
62
+ end
63
+ new(Handle.new(raw))
64
+ end
65
+
66
+ def initialize(handle)
67
+ @handle = handle
68
+ end
69
+
70
+ # The SVRL report as a Document (svrl:schema root — query
71
+ # failed-assert / successful-report elements like any other
72
+ # tree), or the schema-level error text.
73
+ def validate(document)
74
+ report = Leptris::XML::FFI.leptris_schematron_validate(
75
+ @handle, document.c_ptr)
76
+ if report.null?
77
+ Leptris::XML::FFI.leptris_schematron_error(@handle)
78
+ else
79
+ Leptris::XML::Document.wrap(report)
80
+ end
81
+ end
82
+
83
+ def valid?(document)
84
+ Leptris::XML::FFI.leptris_schematron_valid(
85
+ @handle, document.c_ptr) != 0
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leptris::XML::Searchable
4
+ # Values that make a :version key the version channel (#183,
5
+ # mirroring leptris-py#105); anything else is a namespace prefix.
6
+ VERSION_SELECTORS = ["1.0", "3.1", :xpath10, :xpath31].freeze
7
+
8
+ # Compiled-expression cache (TODO.perf/15): the engine caches
9
+ # compiled strings internally, but the string path still pays
10
+ # lookup + hashing per call — a direct handle eval measured 34%
11
+ # under it on repeat expressions. Bounded LRU keyed on the
12
+ # expression string; only successful compiles cache; GVL makes
13
+ # the Hash operations safe. Version-pinned and namespace-bound
14
+ # evaluations keep their dedicated string entries.
15
+ COMPILED_CACHE_LIMIT = 64
16
+
17
+ def self.compiled_expression(expr)
18
+ cache = (@compiled_expressions ||= {})
19
+ if (hit = cache[expr])
20
+ cache.delete(expr)
21
+ cache[expr] = hit # LRU refresh
22
+ return hit
23
+ end
24
+ compiled = Leptris::XML::XPath.compile(expr)
25
+ cache.shift while cache.size >= COMPILED_CACHE_LIMIT
26
+ cache[expr] = compiled
27
+ compiled
28
+ rescue Leptris::XML::XPathError
29
+ nil # fall back to the string path — its error surface is the contract
30
+ end
31
+
32
+ # CSS translation cache (TODO.perf/20): the selector -> XPath
33
+ # translation is a pure function of (selector, prefix); without
34
+ # it every css call re-ran the regex machinery before the
35
+ # compiled-expression cache could hit. Same bounded-LRU shape.
36
+ def self.css_expression(selector, prefix)
37
+ cache = (@css_expressions ||= {})
38
+ key = "#{prefix}#{selector}"
39
+ if (hit = cache[key])
40
+ cache.delete(key)
41
+ cache[key] = hit # LRU refresh
42
+ return hit
43
+ end
44
+ expr = Leptris::XML::CssToXPath.convert(selector, prefix: prefix)
45
+ cache.shift while cache.size >= COMPILED_CACHE_LIMIT
46
+ cache[key] = expr
47
+ expr
48
+ end
49
+
50
+ def xpath(*paths)
51
+ handler, ns, version = parse_search_args(paths)
52
+ raise ArgumentError, "custom XPath handlers not supported" if handler
53
+ # Single-String shape (the common call): join would mint a
54
+ # fresh String per call even for one path.
55
+ expr = paths.one? && paths.first.is_a?(String) ? paths.first
56
+ : paths.join(" | ")
57
+
58
+ # TODO.perf/37: the document's Pointer is lazy (materializes
59
+ # once per document lifetime, not per parse); the ffi gem
60
+ # does NOT convert Integers for typedef'd pointer params, so
61
+ # the Pointer object is still what crosses here.
62
+ doc_ptr = is_a?(Leptris::XML::Document) ? c_ptr : document.c_ptr
63
+ context_ptr = is_a?(Leptris::XML::Document) ? nil : c_ptr
64
+
65
+ result_ptr =
66
+ if version
67
+ if ns && !ns.empty?
68
+ raise ArgumentError,
69
+ "version: cannot be combined with namespace bindings " \
70
+ "(the versioned engine entry takes no ns set yet)"
71
+ end
72
+ Leptris::XML::FFI.leptris_xpath_eval_versioned(
73
+ doc_ptr, context_ptr, expr,
74
+ Leptris::XML::Searchable.xpath_version_code(version), nil)
75
+ elsif ns && !ns.empty?
76
+ # TODO.perf/29: the compiled handle is ns-independent —
77
+ # ride the cache and the cached ns set instead of the
78
+ # engine's string parse per call.
79
+ if (compiled = Leptris::XML::Searchable.compiled_expression(expr))
80
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
81
+ compiled.eval_ns_ptrs(doc_ptr, context_ptr, set)
82
+ end
83
+ else
84
+ xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
85
+ end
86
+ elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
87
+ compiled.eval_ptrs(doc_ptr, context_ptr)
88
+ else
89
+ Leptris::XML::FFI.leptris_xpath_eval(doc_ptr, context_ptr, expr)
90
+ end
91
+ if result_ptr.null?
92
+ raise Leptris::XML::XPathError,
93
+ Leptris::XML::FFI.leptris_last_error.to_s
94
+ end
95
+
96
+ Leptris::XML::Searchable.wrap_xpath_result(document, result_ptr)
97
+ end
98
+
99
+ def at_xpath(*paths)
100
+ handler, ns, version = parse_search_args(paths)
101
+ raise ArgumentError, "custom XPath handlers not supported" if handler
102
+ # Single-String shape (the common call): join would mint a
103
+ # fresh String per call even for one path.
104
+ expr = paths.one? && paths.first.is_a?(String) ? paths.first
105
+ : paths.join(" | ")
106
+
107
+ # TODO.perf/37: the document's Pointer is lazy (materializes
108
+ # once per document lifetime, not per parse); the ffi gem
109
+ # does NOT convert Integers for typedef'd pointer params, so
110
+ # the Pointer object is still what crosses here.
111
+ doc_ptr = is_a?(Leptris::XML::Document) ? c_ptr : document.c_ptr
112
+ context_ptr = is_a?(Leptris::XML::Document) ? nil : c_ptr
113
+
114
+ result_ptr =
115
+ if version
116
+ if ns && !ns.empty?
117
+ raise ArgumentError,
118
+ "version: cannot be combined with namespace bindings " \
119
+ "(the versioned engine entry takes no ns set yet)"
120
+ end
121
+ Leptris::XML::FFI.leptris_xpath_eval_versioned(
122
+ doc_ptr, context_ptr, expr,
123
+ Leptris::XML::Searchable.xpath_version_code(version), nil)
124
+ elsif ns && !ns.empty?
125
+ # TODO.perf/29: the compiled handle is ns-independent —
126
+ # ride the cache and the cached ns set instead of the
127
+ # engine's string parse per call.
128
+ if (compiled = Leptris::XML::Searchable.compiled_expression(expr))
129
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
130
+ compiled.eval_ns_ptrs(doc_ptr, context_ptr, set)
131
+ end
132
+ else
133
+ xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
134
+ end
135
+ elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
136
+ compiled.eval_ptrs(doc_ptr, context_ptr)
137
+ else
138
+ Leptris::XML::FFI.leptris_xpath_eval(doc_ptr, context_ptr, expr)
139
+ end
140
+ if result_ptr.null?
141
+ raise Leptris::XML::XPathError,
142
+ Leptris::XML::FFI.leptris_last_error.to_s
143
+ end
144
+ Leptris::XML::Searchable.wrap_xpath_first_result(document, result_ptr)
145
+ end
146
+
147
+ def search(*args)
148
+ paths = args.first.is_a?(Array) ? args.first : [args.first]
149
+ paths.map(&:to_s).all? { |p| looks_like_xpath?(p) } ? xpath(*paths) : css(*paths)
150
+ end
151
+ alias_method :/, :search
152
+
153
+ # Single-node seam (round XIV): dispatch on syntax like #search,
154
+ # then call the at_* fast paths directly — no NodeSet container,
155
+ # no result handle, one fewer dispatch than search().first.
156
+ def at(*args)
157
+ paths = args.first.is_a?(Array) ? args.first : [args.first]
158
+ if paths.map(&:to_s).all? { |p| looks_like_xpath?(p) }
159
+ at_xpath(*paths)
160
+ else
161
+ at_css(*args)
162
+ end
163
+ end
164
+ alias_method :%, :at
165
+
166
+ def css(*args)
167
+ handler, ns, version = parse_search_args(args)
168
+ raise ArgumentError, "namespace bindings not supported in css" if ns && !ns.empty?
169
+ raise ArgumentError, "version: is XPath-only" if version
170
+ raise ArgumentError, "custom CSS handlers not supported" if handler
171
+ # Nokogiri semantics: css is receiver-relative — absolute "//"
172
+ # from a Document, descendant ".//" from elements and fragments.
173
+ prefix = is_a?(Leptris::XML::Document) ? "//" : ".//"
174
+ expr = args.map { |r| Leptris::XML::Searchable.css_expression(r, prefix) }
175
+ .join(" | ")
176
+ xpath(expr)
177
+ end
178
+
179
+ def at_css(*args)
180
+ handler, ns, version = parse_search_args(args)
181
+ raise ArgumentError, "namespace bindings not supported in css" if ns && !ns.empty?
182
+ raise ArgumentError, "version: is XPath-only" if version
183
+ raise ArgumentError, "custom CSS handlers not supported" if handler
184
+ prefix = is_a?(Leptris::XML::Document) ? "//" : ".//"
185
+ expr = args.map { |r| Leptris::XML::Searchable.css_expression(r, prefix) }
186
+ .join(" | ")
187
+ at_xpath(expr)
188
+ end
189
+
190
+ # Version-pinned evaluation (libleptris lane 15,
191
+ # leptris_xpath_eval_versioned): :xpath10 keeps the strict XPath
192
+ # 1.0 surface — 3.x syntax (arrow, bang, lookup, let, inline
193
+ # functions, string templates) raises; :xpath31 evaluates the
194
+ # full grammar (identical to #xpath's default entry). A separate
195
+ # method by design: #xpath's trailing-hash argument is the
196
+ # namespace-binding channel, and a declared keyword would
197
+ # capture it.
198
+ def xpath_versioned(expression, version)
199
+ doc_ptr = is_a?(Leptris::XML::Document) ? c_ptr : document.c_ptr
200
+ context_ptr = is_a?(Leptris::XML::Document) ? nil : c_ptr
201
+ result_ptr = Leptris::XML::FFI.leptris_xpath_eval_versioned(
202
+ doc_ptr, context_ptr, expression,
203
+ Leptris::XML::Searchable.xpath_version_code(version), nil)
204
+ if result_ptr.null?
205
+ raise Leptris::XML::XPathError,
206
+ Leptris::XML::FFI.leptris_last_error.to_s
207
+ end
208
+ Leptris::XML::Searchable.wrap_xpath_result(document, result_ptr)
209
+ end
210
+
211
+ protected
212
+
213
+ # Evaluates against a caller-owned namespace binding set; the
214
+ # build/teardown lifecycle lives at the FFI seam (FFI.with_ns_set).
215
+ def xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
216
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
217
+ Leptris::XML::FFI.leptris_xpath_eval_ns(doc_ptr, context_ptr, expr, set)
218
+ end
219
+ end
220
+
221
+
222
+ # Version pin for xpath_versioned: :xpath10
223
+ # keeps the strict XPath 1.0 surface (3.x syntax — arrow, bang,
224
+ # lookup, let, inline functions — raises); :xpath31 evaluates the
225
+ # full grammar (identical to the default entry).
226
+ def self.xpath_version_code(version)
227
+ case version
228
+ when :xpath10, "1.0" then Leptris::XML::FFI::XPATH_10
229
+ when :xpath31, "3.1" then Leptris::XML::FFI::XPATH_31
230
+ else
231
+ raise ArgumentError,
232
+ "version must be :xpath10 or :xpath31, got #{version.inspect}"
233
+ end
234
+ end
235
+
236
+ # Trailing-argument contract (#183): a String-keyed hash is the
237
+ # namespace-binding channel — as is a Symbol-keyed hash (the
238
+ # legacy prefix vocabulary, {x: "urn:x"}); a :version key whose
239
+ # VALUE is a version selector is the version-selection channel
240
+ # (xpath("//b", version: "1.0"), mirroring leptris-py#105). A
241
+ # DECLARED keyword cannot carry this: the moment #xpath declares
242
+ # one, Ruby converts the trailing namespace hash into keywords
243
+ # (unknown keyword: "p") — so both channels stay positional and
244
+ # dispatch on key/value here.
245
+ def parse_search_args(args)
246
+ handler = args.find { |a| !a.is_a?(String) && !a.is_a?(Hash) && !a.is_a?(Symbol) }
247
+ args = args - [handler] if handler
248
+ hashes = []
249
+ while args.last.is_a?(Hash) || args.last.nil?
250
+ hashes << args.pop
251
+ break if args.empty?
252
+ end
253
+ ns, vars = hashes.reverse
254
+ unless vars.nil? || vars.empty?
255
+ raise ArgumentError, "XPath variable bindings are not supported"
256
+ end
257
+ # Value-based disambiguation is load-bearing: Symbol keys are
258
+ # also the namespace vocabulary, and bare keywords FUSE into a
259
+ # preceding String-keyed hash at the call site ({"p" => uri,
260
+ # version: "1.0"} arrives as ONE hash). A :version key with any
261
+ # other value stays a prefix binding.
262
+ version = nil
263
+ if ns.is_a?(Hash)
264
+ v = ns[:version]
265
+ if VERSION_SELECTORS.include?(v)
266
+ version = ns.delete(:version)
267
+ ns = nil if ns.empty?
268
+ elsif v.is_a?(String) && v.match?(/\A\d+\.\d+\z/)
269
+ # A dotted-number :version value is version INTENT — raise
270
+ # with the valid values rather than silently binding a
271
+ # prefix literally. Genuine prefix bindings carry URIs.
272
+ raise ArgumentError,
273
+ "unknown XPath version #{v.inspect} " \
274
+ "(valid: \"1.0\"/:xpath10, \"3.1\"/:xpath31)"
275
+ end
276
+ end
277
+ [handler, ns, version]
278
+ end
279
+
280
+ def looks_like_xpath?(str)
281
+ %r{\A(\./|/|\.\.|\.)}.match?(str)
282
+ end
283
+
284
+ # Single-node seam beside wrap_xpath_result: a nodeset answers
285
+ # via result_get_node(0) + wrap + free — no NodeSet container, no
286
+ # AutoPointer, one fewer FFI than xpath().first; scalars keep the
287
+ # full-wrapper semantics.
288
+ def self.wrap_xpath_first_result(document, result_ptr)
289
+ # C seam (TODO.perf/16): nodesets materialize entry 0, free
290
+ # the handle, and return in one dispatch. The module object
291
+ # back means non-nodeset — the scalar path below owns it.
292
+ if defined?(Leptris::XML::NATIVE_FAST)
293
+ first = Leptris::XML::Native.at_xpath_first(
294
+ document, result_ptr.address)
295
+ return first unless first.equal?(Leptris::XML::Native)
296
+ end
297
+ type = Leptris::XML::FFI.leptris_xpath_result_type(result_ptr)
298
+ if type == Leptris::XML::FFI::XPATH_NODESET
299
+ ptr = Leptris::XML::FFI.leptris_xpath_result_get_node(result_ptr, 0)
300
+ if ptr.null?
301
+ node = nil
302
+ else
303
+ kind = Leptris::XML::FFI.leptris_xpath_result_node_kind(result_ptr, 0)
304
+ node = if kind == Leptris::XML::FFI::XPATH_NODE_ATTRIBUTE
305
+ Leptris::XML::ResultAttr.new(
306
+ ptr, document,
307
+ Leptris::XML::FFI.leptris_xpath_result_node_name(result_ptr, 0),
308
+ Leptris::XML::FFI.leptris_xpath_result_node_value(result_ptr, 0))
309
+ else
310
+ Leptris::XML::Node.wrap(ptr, document)
311
+ end
312
+ end
313
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
314
+ node
315
+ else
316
+ wrap_xpath_result(document, result_ptr)
317
+ end
318
+ end
319
+
320
+ # Shared by Searchable#xpath and Leptris::XML::XPath (compiled
321
+ # expressions): wraps a raw XPathResult pointer into the
322
+ # Ruby-typed result and frees the C handle.
323
+ def self.wrap_xpath_result(document, result_ptr)
324
+ type = Leptris::XML::FFI.leptris_xpath_result_type(result_ptr)
325
+ case type
326
+ when Leptris::XML::FFI::XPATH_NODESET
327
+ # TODO.perf/22: materialize AND free in C — no Ruby
328
+ # AutoPointer + finalizer per xpath call. Qnil means an
329
+ # exotic kind needs the Ruby fallback per entry; the lazy
330
+ # path below keeps the handle.
331
+ if defined?(Leptris::XML::NATIVE_FAST)
332
+ nodes = Leptris::XML::Native.materialize_xpath(
333
+ document, result_ptr.address)
334
+ return Leptris::XML::NodeSet.new(document, nodes) if nodes
335
+ end
336
+ Leptris::XML::NodeSet.from_result(document, result_ptr)
337
+ when Leptris::XML::FFI::XPATH_BOOLEAN
338
+ v = Leptris::XML::FFI.leptris_xpath_result_boolean(result_ptr) != 0
339
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
340
+ v
341
+ when Leptris::XML::FFI::XPATH_NUMBER
342
+ v = Leptris::XML::FFI.leptris_xpath_result_number(result_ptr)
343
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
344
+ v
345
+ when Leptris::XML::FFI::XPATH_STRING
346
+ str_ptr = Leptris::XML::FFI.leptris_xpath_result_string(result_ptr)
347
+ v = Leptris::XML::FFI.read_owned_string(str_ptr)
348
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
349
+ v
350
+ when Leptris::XML::FFI::XPATH_FUNCTION
351
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
352
+ raise Leptris::XML::XPathError,
353
+ 'XPath function items cannot cross the FFI boundary yet ' \
354
+ '(libleptris TODO 07; call them inside the expression, ' \
355
+ 'e.g. for-each((1,2), function($n){$n+1}))'
356
+ else
357
+ Leptris::XML::FFI.leptris_xpath_result_free(result_ptr)
358
+ raise Leptris::XML::XPathError, "unknown xpath result type #{type}"
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Owns the SerializeOptions lifecycle and the owned-string read for
4
+ # document and subtree serialization/canonicalization. Document and
5
+ # Element expose one-line methods over this module — the options
6
+ # struct, the encoding anchor, the C call, and the read-and-free all
7
+ # live here.
8
+ module Leptris::XML::Serialization
9
+ # The default options struct (indent 0, declaration on, no encoding
10
+ # override) is constant: build it once and reuse its pointer on the
11
+ # fast path instead of allocating a fresh struct per call.
12
+ DEFAULT_OPTIONS, _default_anchor = begin
13
+ opts = Leptris::XML::FFI::SerializeOptions.new
14
+ opts[:indent] = 0
15
+ opts[:xml_declaration] = 1
16
+ opts[:encoding] = nil
17
+ [opts, nil]
18
+ end
19
+ private_constant :DEFAULT_OPTIONS
20
+
21
+ # Bound serializer entries, hoisted: Element#to_xml and the
22
+ # inner_html per-child loop passed FFI.method(:...) — one Method
23
+ # allocation per serialized child.
24
+ ELEMENT_SERIALIZE_INTO =
25
+ Leptris::XML::FFI.method(:leptris_element_serialize_into)
26
+ DOCUMENT_SERIALIZE_INTO =
27
+ Leptris::XML::FFI.method(:leptris_document_serialize_into)
28
+ # Public within the library: Element#to_xml and Document#to_xml
29
+ # pass the hoisted bound methods straight through.
30
+
31
+ # +ffi_function+ is a bound _serialize_into function. The buffer
32
+ # cycle lives at the FFI seam (FFI.serialize_into_string); this
33
+ # module owns only options selection and construction.
34
+ def self.to_xml(ffi_function, c_ptr, indent: 0, no_decl: false, encoding: nil)
35
+ # TODO.perf/04+36: the whole sized-buffer cycle in the ext —
36
+ # one C dispatch, no options marshaling, no Ruby buffer
37
+ # management; encoding rides the face (nil -> no override).
38
+ if defined?(Leptris::XML::NATIVE_FAST)
39
+ decl = !no_decl
40
+ return Leptris::XML::Native.fast_element_xml(
41
+ c_ptr.address, indent.to_i, decl, encoding) if
42
+ ffi_function == ELEMENT_SERIALIZE_INTO
43
+ return Leptris::XML::Native.fast_document_xml(
44
+ c_ptr.address, indent.to_i, decl, encoding) if
45
+ ffi_function == DOCUMENT_SERIALIZE_INTO
46
+ end
47
+ opts =
48
+ if indent.to_i.zero? && !no_decl && encoding.nil?
49
+ DEFAULT_OPTIONS
50
+ else
51
+ opts, _encoding_anchor = build_options(
52
+ indent: indent, no_decl: no_decl, encoding: encoding)
53
+ opts
54
+ end
55
+ Leptris::XML::FFI.serialize_into_string(ffi_function, c_ptr, opts.pointer)
56
+ end
57
+
58
+ # Default-options element serialization for inner_html's per-child
59
+ # loop: no kwargs, no options rebuild, no method allocation —
60
+ # one FFI fast-path dispatch per child.
61
+ def self.element_xml_default(c_ptr)
62
+ return Leptris::XML::Native.fast_element_xml(c_ptr.address, 0, true,
63
+ nil) if
64
+ defined?(Leptris::XML::NATIVE_FAST)
65
+ Leptris::XML::FFI.serialize_into_string(
66
+ ELEMENT_SERIALIZE_INTO, c_ptr, DEFAULT_OPTIONS.pointer)
67
+ end
68
+
69
+ # Element serialization with expand_empty (libleptris 1.9.95,
70
+ # #882): empty elements emit <a></a> instead of <a/> — the
71
+ # inverse of libxml2's XML_SAVE_NO_EMPTY, through the element
72
+ # ext entry (the sized form keeps older ext allocations safe).
73
+ EXPAND_EMPTY_EXT = begin
74
+ ext = Leptris::XML::FFI::SerializeExtStruct.new
75
+ ext[:indent_text] = 0
76
+ ext[:indent_unit] = nil
77
+ ext[:expand_empty] = 1
78
+ ext
79
+ end
80
+ private_constant :EXPAND_EMPTY_EXT
81
+
82
+ def self.element_xml_expand_empty(c_ptr)
83
+ str_ptr = Leptris::XML::FFI.leptris_element_serialize_ext_sized(
84
+ c_ptr, DEFAULT_OPTIONS.pointer,
85
+ EXPAND_EMPTY_EXT.pointer, EXPAND_EMPTY_EXT.size)
86
+ Leptris::XML::FFI.read_owned_string(str_ptr)
87
+ end
88
+
89
+ # Display-form document serialization (libleptris 1.9.9, #129):
90
+ # the ext struct's indent_text hands ALL whitespace to the
91
+ # formatter — text and mixed content indent too. Display-oriented:
92
+ # the output is NOT guaranteed to round-trip byte-exactly.
93
+ INDENT_TEXT_EXT = begin
94
+ ext = Leptris::XML::FFI::SerializeExtStruct.new
95
+ ext[:indent_text] = 1
96
+ ext
97
+ end
98
+ private_constant :INDENT_TEXT_EXT
99
+
100
+ def self.to_xml_display(c_ptr, indent: 0, no_decl: false, encoding: nil)
101
+ opts, encoding_anchor = build_options(
102
+ indent: indent, no_decl: no_decl, encoding: encoding)
103
+ str_ptr = Leptris::XML::FFI.leptris_document_serialize_ext_sized(
104
+ c_ptr, opts.pointer, INDENT_TEXT_EXT.pointer, INDENT_TEXT_EXT.size)
105
+ Leptris::XML::FFI.read_owned_string(str_ptr)
106
+ end
107
+
108
+ # Indent-unit serialization (libleptris 1.9.22, #633 —
109
+ # leptris-ruby#109): Nokogiri's indent_text semantics — the unit
110
+ # string replaces the default spaces, repeated `indent` times per
111
+ # depth level, standard layout otherwise. The engine emits ONE
112
+ # copy of the unit string per level (options->indent is ignored
113
+ # when a unit is set), so the binding multiplies unit x indent to
114
+ # reach Nokogiri's repeat count.
115
+ def self.to_xml_indent_unit(c_ptr, unit, indent: 0, no_decl: false,
116
+ encoding: nil)
117
+ opts, encoding_anchor = build_options(
118
+ indent: indent, no_decl: no_decl, encoding: encoding)
119
+ ext = Leptris::XML::FFI::SerializeExtStruct.new
120
+ unit_anchor = ::FFI::MemoryPointer.from_string(
121
+ indent.positive? ? unit.to_s * indent : unit.to_s)
122
+ ext[:indent_unit] = unit_anchor
123
+ str_ptr = Leptris::XML::FFI.leptris_document_serialize_ext_sized(
124
+ c_ptr, opts.pointer, ext.pointer, ext.size)
125
+ Leptris::XML::FFI.read_owned_string(str_ptr)
126
+ end
127
+
128
+ # +ffi_function+ is a bound FFI function taking
129
+ # (c_ptr, version, mode, ns_ptr, flags): leptris_c14n_canonicalize_ex
130
+ # or leptris_c14n_canonicalize_subtree_ex.
131
+ def self.canonicalize(ffi_function, c_ptr, version:, mode:,
132
+ inclusive_namespaces: nil, with_comments: false)
133
+ ns_ptr, _ns_anchor = Leptris::XML::CStringArray.to_c(inclusive_namespaces)
134
+ str_ptr = ffi_function.call(c_ptr, version, mode, ns_ptr, with_comments ? 1 : 0)
135
+ Leptris::XML::FFI.read_owned_string(str_ptr)
136
+ end
137
+
138
+ # XML-escapes text content for inner_html: & first, then < >
139
+ # and CR (libxml2 emits &#xD; for a bare carriage return).
140
+ ESCAPE_TEXT = {
141
+ "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", "\r" => "&#xD;",
142
+ }.freeze
143
+ private_constant :ESCAPE_TEXT
144
+ ESCAPE_TEXT_RE = /[&<>\r]/.freeze
145
+ private_constant :ESCAPE_TEXT_RE
146
+
147
+ def self.escape_text(string)
148
+ string.gsub(ESCAPE_TEXT_RE, ESCAPE_TEXT)
149
+ end
150
+
151
+ # Element-face indent unit (leptris-ruby#109 residual 2): no
152
+ # element-level ext-serialize entry exists yet, so the unit path
153
+ # copies the subtree into a fresh document via Document.copy_of
154
+ # (the single copy seam) and serializes that document without a
155
+ # declaration — identical output to the element serializer for
156
+ # the standard layout, with the unit and every child kind intact.
157
+ def self.to_xml_element_unit(element, unit, indent: 0)
158
+ document = Leptris::XML::Document.copy_of(element).document
159
+ begin
160
+ to_xml_indent_unit(document.c_ptr, unit,
161
+ indent: indent, no_decl: true)
162
+ ensure
163
+ document.free
164
+ end
165
+ end
166
+
167
+ # Builds a SerializeOptions struct. Returns [opts, encoding_anchor];
168
+ # the anchor must stay referenced while opts is in an FFI call.
169
+ def self.build_options(indent: 0, no_decl: false, encoding: nil)
170
+ opts = Leptris::XML::FFI::SerializeOptions.new
171
+ opts[:indent] = indent.to_i
172
+ opts[:xml_declaration] = no_decl ? 0 : 1
173
+ encoding_anchor = nil
174
+ if encoding
175
+ encoding_anchor = ::FFI::MemoryPointer.from_string(encoding.to_s)
176
+ opts[:encoding] = encoding_anchor
177
+ end
178
+ [opts, encoding_anchor]
179
+ end
180
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Leptris::XML::Text < Leptris::XML::Node
4
+ def name; "text"; end
5
+
6
+ def content
7
+ return @content if memo_hit?(@content_version)
8
+ ensure_alive!
9
+ result = if @addr_reads_fast
10
+ Leptris::XML::Native.fast_text_content(@c_address)
11
+ else
12
+ Leptris::XML::FFI.leptris_text_node_get_content(c_ptr)
13
+ end
14
+ if @document
15
+ @content = result
16
+ @content_version = @document.version
17
+ end
18
+ result
19
+ end
20
+
21
+ def content=(new_content)
22
+ if @native_fast
23
+ Leptris::XML::Native.set_binding_text(
24
+ @document, @c_address, new_content.to_s)
25
+ return new_content
26
+ end
27
+ ensure_writable!
28
+ Leptris::XML::FFI.check_status(
29
+ Leptris::XML::FFI.leptris_text_node_set_content(c_ptr, new_content.to_s))
30
+ new_content
31
+ end
32
+ end