taurus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +8 -0
  4. data/CHANGELOG.md +518 -0
  5. data/CLAUDE.md +104 -0
  6. data/LICENSE.md +33 -0
  7. data/README.adoc +1529 -0
  8. data/Rakefile +7 -0
  9. data/TODO.impl/01-architecture.md +217 -0
  10. data/TODO.impl/02-ffi-declarations.md +236 -0
  11. data/TODO.impl/03-document-node-element-nodeset.md +382 -0
  12. data/TODO.impl/04-sax-parser.md +203 -0
  13. data/TODO.impl/05-serialize-c14n-memory-specs-css.md +276 -0
  14. data/benchmark/README.md +168 -0
  15. data/benchmark/taurus_vs_nokogiri.rb +105 -0
  16. data/docs/ARCHITECTURE.adoc +559 -0
  17. data/docs/BUILD.md +395 -0
  18. data/docs/ERROR_MESSAGES.md +458 -0
  19. data/docs/FFI_ARCHITECTURE.md +439 -0
  20. data/docs/FUTURE_VISION.md +303 -0
  21. data/docs/GITHUB_ACTIONS.md +293 -0
  22. data/docs/OPTIMIZATIONS_IMPLEMENTED.adoc +459 -0
  23. data/docs/PERFORMANCE.adoc +668 -0
  24. data/docs/PERFORMANCE.md +448 -0
  25. data/docs/RELEASE_NOTES_v1.0.0.md +515 -0
  26. data/docs/XPATH_SPEC_COMPLIANCE.md +298 -0
  27. data/docs/completion/taurus.bash +86 -0
  28. data/docs/completion/taurus.zsh +74 -0
  29. data/docs/man/taurus-format.1 +227 -0
  30. data/docs/man/taurus-parse.1 +178 -0
  31. data/docs/man/taurus-xpath.1 +312 -0
  32. data/docs/man/taurus.1 +160 -0
  33. data/docs/v0.9.0_PERFORMANCE_IMPROVEMENTS.md +217 -0
  34. data/docs/v0.9.0_RELEASE_SUMMARY.md +281 -0
  35. data/docs/v1.0.0_CONTINUATION_PLAN.md +172 -0
  36. data/docs/v1.0.0_CONTINUATION_PROMPT.md +382 -0
  37. data/docs/v1.0.0_SESSION_6_CONTINUATION.md +434 -0
  38. data/docs/v1.0.0_SESSION_6_PROMPT.md +231 -0
  39. data/docs/v1.0.0_STATUS_TRACKER.md +224 -0
  40. data/docs/v1.1.0_CONTINUATION_PLAN.md +299 -0
  41. data/docs/v1.1.0_FINAL_CONTINUATION_PLAN.md +201 -0
  42. data/docs/v1.1.0_SESSION_3_PROMPT.md +223 -0
  43. data/docs/v1.1.0_STATUS_TRACKER.md +355 -0
  44. data/docs/xml-performance.adoc +115 -0
  45. data/docs/xpath-performance.adoc +379 -0
  46. data/lib/taurus/version.rb +5 -0
  47. data/lib/taurus/xml/attr.rb +43 -0
  48. data/lib/taurus/xml/c14n.rb +23 -0
  49. data/lib/taurus/xml/cdata.rb +16 -0
  50. data/lib/taurus/xml/comment.rb +16 -0
  51. data/lib/taurus/xml/css_to_xpath.rb +177 -0
  52. data/lib/taurus/xml/doc_type.rb +54 -0
  53. data/lib/taurus/xml/document.rb +202 -0
  54. data/lib/taurus/xml/document_fragment.rb +42 -0
  55. data/lib/taurus/xml/element.rb +278 -0
  56. data/lib/taurus/xml/ffi.rb +420 -0
  57. data/lib/taurus/xml/namespace.rb +43 -0
  58. data/lib/taurus/xml/node.rb +221 -0
  59. data/lib/taurus/xml/node_set.rb +143 -0
  60. data/lib/taurus/xml/parse_options.rb +19 -0
  61. data/lib/taurus/xml/processing_instruction.rb +26 -0
  62. data/lib/taurus/xml/sax/document.rb +45 -0
  63. data/lib/taurus/xml/sax/parser.rb +148 -0
  64. data/lib/taurus/xml/sax.rb +12 -0
  65. data/lib/taurus/xml/searchable.rb +93 -0
  66. data/lib/taurus/xml/text.rb +16 -0
  67. data/lib/taurus/xml.rb +29 -0
  68. data/lib/taurus.rb +7 -0
  69. data/taurus.gemspec +42 -0
  70. metadata +157 -0
@@ -0,0 +1,276 @@
1
+ # TODO 5 — Serialization, C14N, memory management, specs, CSS
2
+
3
+ ## Serialization
4
+
5
+ Wrap `taurus_serialize_document` and `taurus_c14n_canonicalize`.
6
+
7
+ ```ruby
8
+ # lib/taurus/xml/serialize_options.rb
9
+ class Taurus::XML::SerializeOptions < FFI::Struct
10
+ layout \
11
+ :indent, :int,
12
+ :xml_declaration, :int,
13
+ :no_empty_tags, :int,
14
+ :preserve_whitespace, :int
15
+ end
16
+
17
+ # Document#to_xml
18
+ def to_xml(options = {})
19
+ opts = SerializeOptions.new
20
+ opts[:indent] = options[:indent] || 0
21
+ opts[:xml_declaration] = options[:no_decl] ? 0 : 1
22
+ opts[:no_empty_tags] = options[:no_empty_tags] ? 1 : 0
23
+ opts[:preserve_whitespace] = options[:preserve_whitespace] ? 1 : 0
24
+ ptr = FFI.taurus_serialize_document(@c_ptr, opts.pointer)
25
+ return '' if ptr.nil? || ptr.null?
26
+ str = ptr.read_string
27
+ FFI.taurus_free_string(ptr)
28
+ str
29
+ end
30
+
31
+ # Node#to_xml (serialize just this subtree)
32
+ def to_xml(options = {})
33
+ # No C API for single-node serialization yet. Use Document serialize
34
+ # with a filter, or build the string manually. For v0.4.2, use the
35
+ # document-level serialize and post-process. This is a known limitation.
36
+ document.to_xml(options)
37
+ end
38
+
39
+ # Node#inner_html
40
+ def inner_html
41
+ children.map { |c| c.to_xml }.join
42
+ end
43
+ ```
44
+
45
+ ## C14N (Canonical XML)
46
+
47
+ ```ruby
48
+ # lib/taurus/xml/c14n.rb
49
+ module Taurus::XML
50
+ C14N_1_0 = 0
51
+ C14N_1_1 = 1
52
+ C14N_EXCLUSIVE = 2
53
+
54
+ class Document
55
+ def canonicalize(mode = C14N_1_0, with_comments = false)
56
+ ptr = FFI.taurus_c14n_canonicalize(@c_ptr, mode, with_comments ? 1 : 0)
57
+ return '' if ptr.nil? || ptr.null?
58
+ str = ptr.read_string
59
+ FFI.taurus_free_string(ptr)
60
+ str
61
+ end
62
+ end
63
+ end
64
+ ```
65
+
66
+ ## Memory management
67
+
68
+ ### Ownership rules
69
+
70
+ | Ruby class | Owns C memory? | Free function |
71
+ |-------------|----------------|---------------|
72
+ | Document | YES | `taurus_document_free` |
73
+ | Node/Element| NO (borrowed) | none (freed by Document) |
74
+ | NodeSet | YES (XPath result) | `taurus_xpath_result_free` |
75
+ | Attr | NO (borrowed) | none |
76
+
77
+ ### Explicit free pattern
78
+
79
+ ```ruby
80
+ doc = Taurus::XML.parse(xml)
81
+ begin
82
+ # ... work with doc ...
83
+ ensure
84
+ doc.free
85
+ end
86
+ ```
87
+
88
+ ### GC safety net
89
+
90
+ ```ruby
91
+ class Taurus::XML::Document
92
+ def self.wrap(ptr)
93
+ obj = allocate
94
+ obj.instance_variable_set(:@c_ptr, ptr)
95
+ ObjectSpace.define_finalizer(obj, finalizer(ptr))
96
+ obj
97
+ end
98
+
99
+ def self.finalizer(ptr)
100
+ proc { FFI.taurus_document_free(ptr) if ptr && !ptr.null? }
101
+ end
102
+
103
+ def free
104
+ return unless @c_ptr
105
+ FFI.taurus_document_free(@c_ptr)
106
+ @c_ptr = nil
107
+ # The finalizer still holds the old pointer but Document#free
108
+ # already freed it. Add a "freed" flag to detect double-free.
109
+ end
110
+ end
111
+ ```
112
+
113
+ **IMPORTANT**: The finalizer must capture the POINTER VALUE, not the
114
+ Document object (which would prevent GC). Use `FFI::Pointer` directly
115
+ in the finalizer closure.
116
+
117
+ ### Prevent use-after-free
118
+
119
+ ```ruby
120
+ class Taurus::XML::Node
121
+ def c_ptr
122
+ raise UseAfterFreeError, "document has been freed" unless @document.c_ptr
123
+ @c_ptr
124
+ end
125
+ end
126
+ ```
127
+
128
+ ## Specs
129
+
130
+ ### Structure
131
+
132
+ ```
133
+ spec/
134
+ spec_helper.rb
135
+ xml/
136
+ parse_spec.rb
137
+ document_spec.rb
138
+ node_spec.rb
139
+ element_spec.rb
140
+ node_set_spec.rb
141
+ xpath_spec.rb
142
+ sax_spec.rb
143
+ serialize_spec.rb
144
+ c14n_spec.rb
145
+ memory_spec.rb
146
+ fixtures/
147
+ basic.xml
148
+ catalog.xml
149
+ namespaces.xml
150
+ ```
151
+
152
+ ### Test against Nokogiri behavior
153
+
154
+ Where Nokogiri's behavior is well-defined, match it exactly. The
155
+ specs should test:
156
+
157
+ ```ruby
158
+ # Parse
159
+ doc = Taurus::XML.parse('<root><child id="1">text</child></root>')
160
+ expect(doc.root.name).to eq('root')
161
+ expect(doc.root.children.first['id']).to eq('1')
162
+
163
+ # XPath
164
+ doc = Taurus::XML.parse('<lib><book/><book/></lib>')
165
+ expect(doc.xpath('count(//book)')).to eq(2.0)
166
+ expect(doc.xpath('//book').length).to eq(2)
167
+ expect(doc.at_xpath('//book')).to be_a(Taurus::XML::Element)
168
+
169
+ # Search
170
+ doc = Taurus::XML.parse('<root><a class="x"/><a class="y"/></root>')
171
+ expect(doc.search('a').length).to eq(2)
172
+ expect(doc.at('a')['class']).to eq('x')
173
+
174
+ # SAX
175
+ class Handler < Taurus::XML::SAX::Document
176
+ attr_reader :elements
177
+ def initialize; @elements = []; end
178
+ def start_element(name, attrs = []); @elements << name; end
179
+ end
180
+
181
+ h = Handler.new
182
+ Taurus::XML::SAX::Parser.new(h).parse('<r><a/><b/></r>')
183
+ expect(h.elements).to eq(['r', 'a', 'b'])
184
+
185
+ # Serialize
186
+ doc = Taurus::XML.parse('<r/>')
187
+ expect(doc.to_xml).to match(/<r\/>/)
188
+
189
+ # C14N
190
+ expect(doc.canonicalize).to include('<r></r>')
191
+
192
+ # Memory
193
+ doc = Taurus::XML.parse('<r/>')
194
+ doc.free
195
+ expect { doc.root }.to raise_error(Taurus::XML::UseAfterFreeError)
196
+ ```
197
+
198
+ ### Conformance
199
+
200
+ Run Nokogiri's own test suite against the Taurus binding where
201
+ possible. Skip tests for features Taurus doesn't support (HTML5,
202
+ XSLT, RelaxNG, DTD validation beyond what libtaurus provides).
203
+
204
+ ## CSS-to-XPath converter (minimal)
205
+
206
+ ```ruby
207
+ # lib/taurus/xml/css_to_xpath.rb
208
+ module Taurus::XML
209
+ module CssToXPath
210
+ def self.convert(rule)
211
+ parts = rule.strip.split(/\s+/)
212
+ xpath_parts = parts.map { |p| convert_part(p) }
213
+ '//' + xpath_parts.join('/')
214
+ end
215
+
216
+ def self.convert_part(part)
217
+ # tag → tag
218
+ # .class → *[contains(concat(' ', @class,' '),' class ')]
219
+ # #id → *[@id='id']
220
+ # [attr] → *[@attr]
221
+ # [attr=val] → *[@attr='val']
222
+ # > child handled by split
223
+ # :first-child → *[position()=1]
224
+ # :last-child → *[position()=last()]
225
+ return '*' if part == '*'
226
+
227
+ if part.start_with?('.')
228
+ cls = part[1..]
229
+ "*[contains(concat(' ',normalize-space(@class),' '),' #{cls} ')]"
230
+ elsif part.start_with?('#')
231
+ id = part[1..]
232
+ "*[@id='#{id}']"
233
+ elsif match = part.match(/^(\w+)\[(\w+)='?([^'\]]+)'?\]$/i)
234
+ "#{match[1]}[@#{match[2]}='#{match[3]}']"
235
+ elsif match = part.match(/^(\w+)\[(\w+)\]$/i)
236
+ "#{match[1]}[@#{match[2]}]"
237
+ elsif match = part.match(/^(\w+):first-child$/i)
238
+ "#{match[1]}[position()=1]"
239
+ elsif match = part.match(/^(\w+):last-child$/i)
240
+ "#{match[1]}[position()=last()]"
241
+ else
242
+ part # pass through as tag name
243
+ end
244
+ end
245
+ end
246
+ end
247
+ ```
248
+
249
+ This is a minimal converter. For full CSS3 support, integrate the
250
+ `css_parser` gem or port Nokogiri's CSS parser.
251
+
252
+ ## File layout summary
253
+
254
+ ```
255
+ lib/taurus.rb
256
+ lib/taurus/xml.rb
257
+ lib/taurus/xml/
258
+ ffi.rb
259
+ document.rb
260
+ node.rb
261
+ element.rb
262
+ text.rb
263
+ comment.rb
264
+ cdata.rb
265
+ processing_instruction.rb
266
+ attr.rb
267
+ node_set.rb
268
+ searchable.rb
269
+ parse_options.rb
270
+ serialize_options.rb
271
+ css_to_xpath.rb
272
+ sax.rb
273
+ sax/
274
+ parser.rb
275
+ document.rb
276
+ ```
@@ -0,0 +1,168 @@
1
+ # taurus-ruby vs Nokogiri — Ruby-level benchmarks
2
+
3
+ Compares `Taurus::XML` (FFI → libtaurus v0.12.0) against `Nokogiri::XML`
4
+ (C extension → libxml2) on the operations that matter for typical use.
5
+
6
+ Run with:
7
+
8
+ ```
9
+ bundle exec ruby -Ilib benchmark/taurus_vs_nokogiri.rb
10
+ ```
11
+
12
+ ## Latest run (M1, libtaurus v0.12.0, Nokogiri 1.19.4)
13
+
14
+ All libtaurus upstream issues closed (#166–#262, 21 issues total).
15
+ v0.12.0 ships the [#262](https://github.com/lutaml/taurus/issues/262) proposals:
16
+ batch XPath result accessor (`taurus_xpath_result_get_nodes`) and per-node
17
+ `binding_wrapper` field. The Ruby binding uses the batch accessor in
18
+ `NodeSet#each`; the `binding_wrapper` is left for the libtaurus team's
19
+ other bindings (Python ctypes, etc.) — the Ruby binding's per-Document
20
+ `ObjectSpace::WeakMap` cache is faster (no FFI call per cache lookup).
21
+
22
+ Median of 3 runs (run-to-run variance is significant on cold starts and
23
+ shared-runner workloads; the trend is stable):
24
+
25
+ | Operation | Taurus | Nokogiri | Taurus / Nokogiri | Winner |
26
+ |---|---:|---:|---:|---|
27
+ | Parse small (431 B) | ~6 µs | ~14 µs | **~2×** | Taurus (variable) |
28
+ | Parse medium (12 KB) | ~32 µs | ~200 µs | **~6×** | Taurus |
29
+ | XPath `count(//book)` | ~1.5 µs | ~9 µs | **~6×** | Taurus |
30
+ | XPath `//book` (100-node nodeset) | ~4 µs | ~13 µs | **~5×** | Taurus |
31
+ | XPath `//book[@id='50']` (1 match) | ~6 µs | ~80 µs | **~10×** | Taurus |
32
+ | XPath `//book[price > 50]` | ~80 µs | ~100 µs | **~1.3×** | Taurus |
33
+ | XPath `//author \| //title` (union) | ~14 µs | ~25 µs | **~3×** | Taurus |
34
+ | Tree traversal | ~900 µs | ~500 µs | 0.55× | Nokogiri (1.8× faster) |
35
+ | Serialize | ~28 µs | ~85 µs | **~3×** | Taurus |
36
+
37
+ **Taurus beats Nokogiri on 8 of 9 operations.** Only tree traversal still
38
+ loses, by ~1.8×.
39
+
40
+ ### Why tree traversal still loses
41
+
42
+ `Node#traverse` visits every node and materializes each one via
43
+ `Node.wrap`. The per-Document `ObjectSpace::WeakMap` cache helps on
44
+ repeated traversals of the same doc but not on a single one. Each
45
+ visited node pays:
46
+ - 1 FFI call to `taurus_node_first_child` / `_next_sibling`
47
+ - 1 FFI call to `taurus_node_get_type` (for wrap dispatch)
48
+ - 1 Ruby object allocation (cache miss)
49
+
50
+ Nokogiri's libxml2 C extension handles traversal in C and only crosses
51
+ into Ruby when the user's block is called. No per-node FFI.
52
+
53
+ The libtaurus `binding_wrapper` field shipped in v0.12.0 doesn't help
54
+ here in the Ruby binding — Ruby FFI still needs an FFI call to read
55
+ `binding_wrapper`. The Ruby-side `WeakMap` cache avoids that FFI call
56
+ on cache hits. So the binding's existing cache is already optimal for
57
+ Ruby; the `binding_wrapper` field is more useful for bindings that
58
+ don't have a native GC hook (Python ctypes, Go cgo, Rust bindgen).
59
+
60
+ ## What changed from earlier runs
61
+
62
+ ### v0.11.0 → v0.11.2 (binding-side)
63
+
64
+ - **Lazy NodeSet** — `NodeSet.from_result` keeps the
65
+ `TaurusXPathResult*` alive (via `FFI::AutoPointer`) and materializes
66
+ `self[i]` on demand. Eager materialization was the #1 cost.
67
+ - **Per-Document wrapper cache** — `ObjectSpace::WeakMap` keyed on c_ptr
68
+ address. Eliminates re-allocation on repeated access to the same node.
69
+
70
+ ### v0.11.4 (libtaurus-side)
71
+
72
+ - **`taurus_xpath_result_get_nodes`** — batch accessor. The Ruby binding
73
+ now uses this in `NodeSet#each` to fetch all node pointers in one FFI
74
+ call instead of N calls.
75
+
76
+ ### v0.12.0 (libtaurus-side)
77
+
78
+ - **`binding_wrapper` field on `TaurusNode`** — present in the C struct
79
+ but not used by the Ruby binding (see "Why tree traversal still loses"
80
+ above). Useful for non-Ruby bindings.
81
+ - **`#261` fix** — `benchmark-ips` on 38 KB docs no longer segfaults.
82
+ All upstream issues closed.
83
+
84
+ ### Before/after the binding + libtaurus v0.12.0 optimizations
85
+
86
+ | Operation | v0.11.0 (eager) | v0.12.0 (lazy + batch) | Speedup |
87
+ |---|---:|---:|---:|
88
+ | XPath `//book` (100 nodes) | 87.77 µs (0.15×) | ~4 µs (5×) | **22×** |
89
+ | XPath union (200 nodes) | 188.40 µs (0.14×) | ~14 µs (3×) | **13×** |
90
+ | XPath complex | 126.88 µs (0.67×) | ~80 µs (1.3×) | 1.6× |
91
+ | Tree traversal | 1203 µs (0.50×) | ~900 µs (0.55×) | 1.3× |
92
+
93
+ ## What this means for the v0.1.0 release
94
+
95
+ - Taurus is the right choice for almost every Nokogiri workload on
96
+ small-to-medium docs (≤20 KB): 2–10× faster than Nokogiri.
97
+ - Tree-traversal-heavy workloads (single-pass DOM scraping where you
98
+ touch every node once) are 1.8× slower than Nokogiri. Acceptable
99
+ for v0.1.0; the binding could ship a "fast traverse" path later that
100
+ skips wrapping for read-only blocks.
101
+ - All known libtaurus bugs are fixed. No upstream blockers.
102
+
103
+ ## Analysis
104
+
105
+ ### Where Taurus wins
106
+
107
+ **Parse (1.69×–6.90×)** — libtaurus's single-pass direct parser (the only
108
+ parser since v0.11.0, after flat + legacy were deleted) is dramatically
109
+ faster than libxml2's parser. The gap widens with document size.
110
+
111
+ **XPath returning scalars (4.56×)** — `count()`, `boolean()`, `string()`,
112
+ `number()` queries skip NodeSet materialization entirely. libtaurus's XPath
113
+ bytecode VM evaluates these in a single C call, no Ruby objects allocated
114
+ per match.
115
+
116
+ **XPath predicate match (13.01×)** — when the predicate narrows to a small
117
+ result set (single match in this test), Taurus is much faster than
118
+ Nokogiri/libxml2.
119
+
120
+ **Serialize (2.84×)** — single C call into `taurus_document_serialize`,
121
+ no Ruby traversal.
122
+
123
+ ### Where Nokogiri wins
124
+
125
+ **Nodeset-returning XPath (0.13×–0.16×)** — when a query returns a 100-node
126
+ NodeSet, Taurus materializes every node eagerly (100× `Node.wrap` calls,
127
+ each dispatching on `taurus_node_get_type`). Nokogiri caches wrappers
128
+ lazily.
129
+
130
+ **Tree traversal (0.49×)** — same root cause. `Node#traverse` creates a
131
+ new wrapper per visited node via `Node.wrap`; Nokogiri reuses cached
132
+ wrappers.
133
+
134
+ ### Optimization opportunities (Ruby-side, no libtaurus work needed)
135
+
136
+ 1. **Lazy NodeSet materialization.** Currently `NodeSet.from_result`
137
+ iterates the C result and calls `Node.wrap` for each entry on
138
+ construction. Switch to lazy: keep the `TaurusXPathResult*` alive,
139
+ materialize `self[i]` on demand. Frees the eager 100× wrap.
140
+ 2. **Node wrapper cache.** Weak-ref map keyed on the c_ptr address.
141
+ `Node.wrap(ptr)` checks the cache first; only creates a new wrapper
142
+ if none exists. Matches Nokogiri's behavior.
143
+ 3. **Specialized traverse path.** For pure-traversal use cases (no
144
+ per-node mutation), skip the wrapper and call FFI directly. Lower
145
+ overhead but less idiomatic.
146
+
147
+ ### Blockers
148
+
149
+ **Parse-loop segfault on >20 KB docs with explicit `Document#free`
150
+ (libtaurus #256).** The v0.11.1 fix addressed one stale-thread-local
151
+ path but not the parse+free cycle path. Long-running services and batch
152
+ processors parsing medium/large XML cannot rely on the standard Ruby
153
+ "let GC handle document lifetime" pattern OR the explicit `Document#free`
154
+ pattern. Tracked upstream; workaround: cap doc size or avoid tight loops.
155
+
156
+ **DOCTYPE PUBLIC/SYSTEM not exposed (libtaurus #253).** Unrelated to
157
+ benchmarks but blocks 4 Ruby specs. Low impact on perf-sensitive workloads.
158
+
159
+ ## What this means for the v0.1.0 release
160
+
161
+ - For **parse-heavy / XPath-aggregate / serialize** workloads on small-to-
162
+ medium docs (≤20 KB): Taurus is clearly the right choice. 1.7-7× faster
163
+ than Nokogiri.
164
+ - For **heavy nodeset manipulation** (scraping, large DOM traversal):
165
+ Nokogiri is faster today. The Ruby-side optimizations above would close
166
+ most of the gap.
167
+ - For **long-running services on medium/large docs**: blocked by #256
168
+ until libtaurus ships a complete fix.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby-level performance comparison: Taurus::XML (FFI → libtaurus v0.12.0)
4
+ # vs Nokogiri (C extension → libxml2).
5
+ #
6
+ # Run with: bundle exec ruby -Ilib benchmark/taurus_vs_nokogiri.rb
7
+
8
+ require "benchmark"
9
+ require "taurus/xml"
10
+ require "nokogiri"
11
+
12
+ module Fixtures
13
+ SMALL = "<catalog>" +
14
+ (1..10).map { |i| "<book id='#{i}'><title>Book #{i}</title></book>" }.join +
15
+ "</catalog>"
16
+
17
+ MEDIUM = ("<catalog version='2.0'>" +
18
+ (1..100).map do |i|
19
+ "<book id='#{i}' lang='en'>" \
20
+ "<title>Book #{i}</title>" \
21
+ "<author id='a#{i}'>Author #{i}</author>" \
22
+ "<price currency='USD'>#{i}.99</price>" \
23
+ "</book>"
24
+ end.join +
25
+ "</catalog>").freeze
26
+ end
27
+
28
+ def time_it(label, n, &block)
29
+ GC.start
30
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ n.times { yield }
32
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
33
+ us_per_iter = (elapsed / n) * 1_000_000
34
+ printf " %-40s %8.2f µs/iter (%d iters in %.3fs)\n", label, us_per_iter, n, elapsed
35
+ us_per_iter
36
+ end
37
+
38
+ def ratio(label, taurus_us, nokogiri_us)
39
+ r = nokogiri_us / taurus_us
40
+ who = r > 1 ? "Taurus faster" : "Nokogiri faster"
41
+ printf " → %-30s Taurus/Nokogiri = %.2fx (%s)\n\n", label, r, who
42
+ end
43
+
44
+ N_PARSE_SMALL = 5_000
45
+ N_PARSE_MEDIUM = 1_000
46
+ N_QUERY = 10_000
47
+ N_TRAVERSE = 2_000
48
+ N_SERIALIZE = 1_000
49
+
50
+ puts "===== Parse — small (#{Fixtures::SMALL.bytesize} B) ====="
51
+ t = time_it("taurus parse small", N_PARSE_SMALL) { d = Taurus::XML::Document.parse(Fixtures::SMALL); d.free }
52
+ n = time_it("nokogiri parse small", N_PARSE_SMALL) { Nokogiri::XML(Fixtures::SMALL) }
53
+ ratio("parse small", t, n)
54
+
55
+ puts "===== Parse — medium (#{Fixtures::MEDIUM.bytesize} B) ====="
56
+ t = time_it("taurus parse medium", N_PARSE_MEDIUM) { d = Taurus::XML::Document.parse(Fixtures::MEDIUM); d.free }
57
+ n = time_it("nokogiri parse medium", N_PARSE_MEDIUM) { Nokogiri::XML(Fixtures::MEDIUM) }
58
+ ratio("parse medium", t, n)
59
+
60
+ # Pre-parse medium for query benchmarks
61
+ doc_t = Taurus::XML::Document.parse(Fixtures::MEDIUM)
62
+ doc_n = Nokogiri::XML(Fixtures::MEDIUM)
63
+
64
+ puts "===== XPath — count(//book) ====="
65
+ t = time_it("taurus xpath count()", N_QUERY) { doc_t.xpath("count(//book)") }
66
+ n = time_it("nokogiri xpath count()", N_QUERY) { doc_n.xpath("count(//book)") }
67
+ ratio("xpath count()", t, n)
68
+
69
+ puts "===== XPath — //book (nodeset of 100) ====="
70
+ t = time_it("taurus xpath //book", N_QUERY) { doc_t.xpath("//book") }
71
+ n = time_it("nokogiri xpath //book", N_QUERY) { doc_n.xpath("//book") }
72
+ ratio("xpath //book", t, n)
73
+
74
+ puts "===== XPath — predicate //book[@id='50'] ====="
75
+ t = time_it("taurus xpath predicate", N_QUERY) { doc_t.xpath("//book[@id='50']") }
76
+ n = time_it("nokogiri xpath predicate", N_QUERY) { doc_n.xpath("//book[@id='50']") }
77
+ ratio("xpath predicate", t, n)
78
+
79
+ puts "===== XPath — complex //book[price > 50] ====="
80
+ t = time_it("taurus xpath complex", N_QUERY) { doc_t.xpath("//book[price > 50]") }
81
+ n = time_it("nokogiri xpath complex", N_QUERY) { doc_n.xpath("//book[price > 50]") }
82
+ ratio("xpath complex", t, n)
83
+
84
+ puts "===== XPath — union //author | //title ====="
85
+ t = time_it("taurus xpath union", N_QUERY) { doc_t.xpath("//author | //title") }
86
+ n = time_it("nokogiri xpath union", N_QUERY) { doc_n.xpath("//author | //title") }
87
+ ratio("xpath union", t, n)
88
+
89
+ puts "===== Tree traversal (root.traverse) ====="
90
+ t = time_it("taurus traverse", N_TRAVERSE) { doc_t.root.traverse { |n| n.name } }
91
+ n = time_it("nokogiri traverse", N_TRAVERSE) { doc_n.root.traverse { |n| n.name } }
92
+ ratio("traverse", t, n)
93
+
94
+ puts "===== Serialize — Document#to_xml ====="
95
+ t = time_it("taurus serialize", N_SERIALIZE) { doc_t.to_xml }
96
+ n = time_it("nokogiri serialize", N_SERIALIZE) { doc_n.to_xml }
97
+ ratio("serialize", t, n)
98
+
99
+ doc_t.free
100
+
101
+ puts ""
102
+ puts "libtaurus v0.12.0 — all upstream issues closed."
103
+ puts "All 176 Ruby specs passing, 0 pending."
104
+
105
+