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,379 @@
1
+ = XPath Performance Benchmarks
2
+ :toc:
3
+ :toclevels: 3
4
+
5
+ == Overview
6
+
7
+ This document provides comprehensive performance benchmarks, generated from actual benchmark runs using `benchmark-ips`.
8
+
9
+ **IMPORTANT**: This file is auto-generated from benchmark data. Do not edit manually.
10
+ To update, run the appropriate rake task (see footer).
11
+
12
+ == Test Environment
13
+
14
+ * **Ruby Version**: 3.1.1
15
+ * **Platform**: arm64-darwin21
16
+ * **Taurus Version**: 0.1.0
17
+ * **Libraries Available**:
18
+ * **Nokogiri**: Available
19
+ * **Timestamp**: 2025-11-27T18:27:43+0800
20
+ * **Method**: benchmark-ips
21
+
22
+ == Performance Summary
23
+
24
+ **Overall Results** (50 tests, vs Nokogiri):
25
+
26
+ * **Average**: 2.34× slower
27
+ * **Best case**: 3.13× faster
28
+ * **Worst case**: 10.38× slower
29
+ * **Tests faster**: 2/50 (4.0%)
30
+ * **Tests slower**: 48/50 (96.0%)
31
+
32
+ **Verdict**: ❌ **Needs Optimization** - Significantly slower than Nokogiri
33
+
34
+ == axes
35
+
36
+ [cols="["3", "2", "2", "2"]",options="header"]
37
+ |===
38
+ |Test |Taurus |Nokogiri |vs Nokogiri
39
+
40
+ |Child
41
+ |1.39µs
42
+ |4.3µs
43
+ |**3.09× faster** ✅
44
+
45
+ |Descendant
46
+ |59.56µs
47
+ |5.74µs
48
+ |**10.38× slower** ❌
49
+
50
+ |Descendant Or Self
51
+ |17.69µs
52
+ |6.49µs
53
+ |**2.73× slower** ❌
54
+
55
+ |Parent
56
+ |11.5µs
57
+ |4.27µs
58
+ |**2.69× slower** ❌
59
+
60
+ |Ancestor
61
+ |12.5µs
62
+ |4.3µs
63
+ |**2.91× slower** ❌
64
+
65
+ |Ancestor Or Self
66
+ |12.72µs
67
+ |4.36µs
68
+ |**2.92× slower** ❌
69
+
70
+ |Self
71
+ |11.5µs
72
+ |7.53µs
73
+ |**1.53× slower** ❌
74
+
75
+ |Following Sibling
76
+ |11.83µs
77
+ |6.89µs
78
+ |**1.72× slower** ❌
79
+
80
+ |Preceding Sibling
81
+ |11.37µs
82
+ |5.52µs
83
+ |**2.06× slower** ❌
84
+
85
+ |Following
86
+ |17.99µs
87
+ |12.94µs
88
+ |**1.39× slower** ❌
89
+
90
+ |Preceding
91
+ |12.91µs
92
+ |5.3µs
93
+ |**2.44× slower** ❌
94
+
95
+ |Attribute
96
+ |11.29µs
97
+ |5.08µs
98
+ |**2.22× slower** ❌
99
+
100
+ |Namespace
101
+ |14.79µs
102
+ |7.71µs
103
+ |**1.92× slower** ❌
104
+
105
+ |===
106
+
107
+ == simple_paths
108
+
109
+ [cols="["3", "2", "2", "2"]",options="header"]
110
+ |===
111
+ |Test |Taurus |Nokogiri |vs Nokogiri
112
+
113
+ |Absolute Path
114
+ |6.01µs
115
+ |4.99µs
116
+ |**1.2× slower** ❌
117
+
118
+ |Relative Path
119
+ |1.65µs
120
+ |4.66µs
121
+ |**2.82× faster** ✅
122
+
123
+ |Descendant Path
124
+ |12.73µs
125
+ |4.48µs
126
+ |**2.84× slower** ❌
127
+
128
+ |Wildcard
129
+ |10.55µs
130
+ |4.51µs
131
+ |**2.34× slower** ❌
132
+
133
+ |Attribute Access
134
+ |25.37µs
135
+ |4.76µs
136
+ |**5.33× slower** ❌
137
+
138
+ |Text Nodes
139
+ |12.85µs
140
+ |4.67µs
141
+ |**2.75× slower** ❌
142
+
143
+ |===
144
+
145
+ == position_predicates
146
+
147
+ [cols="["3", "2", "2", "2"]",options="header"]
148
+ |===
149
+ |Test |Taurus |Nokogiri |vs Nokogiri
150
+
151
+ |First
152
+ |38.24µs
153
+ |5.95µs
154
+ |**6.43× slower** ❌
155
+
156
+ |Last
157
+ |14.06µs
158
+ |5.86µs
159
+ |**2.4× slower** ❌
160
+
161
+ |Position Eq
162
+ |11.34µs
163
+ |5.6µs
164
+ |**2.03× slower** ❌
165
+
166
+ |Position Range
167
+ |11.83µs
168
+ |5.56µs
169
+ |**2.13× slower** ❌
170
+
171
+ |===
172
+
173
+ == boolean_predicates
174
+
175
+ [cols="["3", "2", "2", "2"]",options="header"]
176
+ |===
177
+ |Test |Taurus |Nokogiri |vs Nokogiri
178
+
179
+ |Attribute Exists
180
+ |11.91µs
181
+ |5.77µs
182
+ |**2.06× slower** ❌
183
+
184
+ |Attribute Value
185
+ |12.15µs
186
+ |7.24µs
187
+ |**1.68× slower** ❌
188
+
189
+ |Element Exists
190
+ |13.13µs
191
+ |7.57µs
192
+ |**1.73× slower** ❌
193
+
194
+ |Negation
195
+ |11.87µs
196
+ |6.21µs
197
+ |**1.91× slower** ❌
198
+
199
+ |===
200
+
201
+ == multiple_predicates
202
+
203
+ [cols="["3", "2", "2", "2"]",options="header"]
204
+ |===
205
+ |Test |Taurus |Nokogiri |vs Nokogiri
206
+
207
+ |Two Predicates
208
+ |16.65µs
209
+ |6.86µs
210
+ |**2.43× slower** ❌
211
+
212
+ |Position And Boolean
213
+ |11.52µs
214
+ |11.17µs
215
+ |**1.03× slower** ❌
216
+
217
+ |Three Predicates
218
+ |16.73µs
219
+ |11.11µs
220
+ |**1.51× slower** ❌
221
+
222
+ |===
223
+
224
+ == nodeset_functions
225
+
226
+ [cols="["3", "2", "2", "2"]",options="header"]
227
+ |===
228
+ |Test |Taurus |Nokogiri |vs Nokogiri
229
+
230
+ |Count
231
+ |10.64µs
232
+ |3.69µs
233
+ |**2.88× slower** ❌
234
+
235
+ |Last Func
236
+ |17.68µs
237
+ |5.06µs
238
+ |**3.49× slower** ❌
239
+
240
+ |Position Func
241
+ |12.61µs
242
+ |8.05µs
243
+ |**1.57× slower** ❌
244
+
245
+ |Local Name
246
+ |19.11µs
247
+ |13.09µs
248
+ |**1.46× slower** ❌
249
+
250
+ |Name
251
+ |15.31µs
252
+ |13.7µs
253
+ |**1.12× slower** ❌
254
+
255
+ |===
256
+
257
+ == string_functions
258
+
259
+ [cols="["3", "2", "2", "2"]",options="header"]
260
+ |===
261
+ |Test |Taurus |Nokogiri |vs Nokogiri
262
+
263
+ |String
264
+ |13.64µs
265
+ |11.58µs
266
+ |**1.18× slower** ❌
267
+
268
+ |Concat
269
+ |43.93µs
270
+ |12.81µs
271
+ |**3.43× slower** ❌
272
+
273
+ |Starts With
274
+ |20.35µs
275
+ |7.64µs
276
+ |**2.66× slower** ❌
277
+
278
+ |Contains
279
+ |14.53µs
280
+ |9.3µs
281
+ |**1.56× slower** ❌
282
+
283
+ |String Length
284
+ |15.21µs
285
+ |8.94µs
286
+ |**1.7× slower** ❌
287
+
288
+ |===
289
+
290
+ == boolean_functions
291
+
292
+ [cols="["3", "2", "2", "2"]",options="header"]
293
+ |===
294
+ |Test |Taurus |Nokogiri |vs Nokogiri
295
+
296
+ |Boolean
297
+ |10.76µs
298
+ |4.56µs
299
+ |**2.36× slower** ❌
300
+
301
+ |Not
302
+ |12.34µs
303
+ |6.8µs
304
+ |**1.81× slower** ❌
305
+
306
+ |True
307
+ |11.11µs
308
+ |5.15µs
309
+ |**2.16× slower** ❌
310
+
311
+ |===
312
+
313
+ == number_functions
314
+
315
+ [cols="["3", "2", "2", "2"]",options="header"]
316
+ |===
317
+ |Test |Taurus |Nokogiri |vs Nokogiri
318
+
319
+ |Number
320
+ |12.6µs
321
+ |7.06µs
322
+ |**1.78× slower** ❌
323
+
324
+ |Sum
325
+ |14.71µs
326
+ |4.51µs
327
+ |**3.26× slower** ❌
328
+
329
+ |Floor
330
+ |18.48µs
331
+ |11.28µs
332
+ |**1.64× slower** ❌
333
+
334
+ |Ceiling
335
+ |22.09µs
336
+ |10.11µs
337
+ |**2.18× slower** ❌
338
+
339
+ |===
340
+
341
+ == complex_queries
342
+
343
+ [cols="["3", "2", "2", "2"]",options="header"]
344
+ |===
345
+ |Test |Taurus |Nokogiri |vs Nokogiri
346
+
347
+ |Multi Axis
348
+ |12.58µs
349
+ |6.44µs
350
+ |**1.95× slower** ❌
351
+
352
+ |Union
353
+ |24.42µs
354
+ |13.61µs
355
+ |**1.79× slower** ❌
356
+
357
+ |Nested
358
+ |18.96µs
359
+ |12.73µs
360
+ |**1.49× slower** ❌
361
+
362
+ |===
363
+
364
+ == Conclusion
365
+
366
+ Based on 50 comprehensive tests (vs Nokogiri):
367
+
368
+ * **Average Performance**: 2.34× slower
369
+ * **Tests faster**: 2/50 (4.0%)
370
+ * **Tests slower**: 48/50 (96.0%)
371
+ * **Best result**: 0.32×
372
+ * **Worst result**: 10.38×
373
+
374
+ ❌ **Taurus performance needs optimization** - Significantly slower than Nokogiri (2.34× average).
375
+
376
+ ---
377
+
378
+ **Generated**: 2025-11-27T18:27:43+0800
379
+ **Baseline**: Nokogiri
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taurus
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lightweight attribute wrapper. libtaurus v0.4.4 doesn't expose per-attribute
4
+ # pointers (no TaurusAttribute accessors in the public API), so an Attr is a
5
+ # (name, value, parent_element) triple. Mutation goes through the parent
6
+ # element via #[]. Once upstream adds attribute-level accessors, this can
7
+ # become a pointer-backed wrapper.
8
+
9
+ class Taurus::XML::Attr
10
+ attr_reader :name, :value, :element
11
+
12
+ def initialize(name, value, element)
13
+ @name = name
14
+ @value = value
15
+ @element = element
16
+ end
17
+
18
+ def value=(new_value)
19
+ @element[@name] = new_value
20
+ @value = new_value
21
+ end
22
+
23
+ def namespace
24
+ nil # upstream gap: per-attribute namespace not exposed
25
+ end
26
+
27
+ def remove
28
+ @element.remove_attribute(@name)
29
+ end
30
+
31
+ def to_s; @value; end
32
+ def to_str; @value; end
33
+
34
+ def ==(other)
35
+ other.is_a?(Taurus::XML::Attr) &&
36
+ @name == other.name && @value == other.value &&
37
+ @element == other.element
38
+ end
39
+
40
+ def inspect
41
+ "#<#{self.class.name} name=#{@name.inspect} value=#{@value.inspect}>"
42
+ end
43
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taurus
4
+ module XML
5
+ # @api private
6
+ #
7
+ # Builds a NULL-terminated `const char**` from a Ruby array of strings,
8
+ # suitable for passing to taurus_c14n_canonicalize_ex's
9
+ # `inclusive_ns_prefixes` argument. Returns [pointer, anchor] where
10
+ # anchor is the underlying MemoryPointer that the caller must keep
11
+ # alive (e.g. via local variable) for the duration of the FFI call.
12
+ def self.c14n_build_ns_pointer(inclusive_namespaces)
13
+ return [nil, nil] if inclusive_namespaces.nil? || inclusive_namespaces.empty?
14
+ strs = Array(inclusive_namespaces).map(&:to_s)
15
+ ptr_size = ::FFI.type_size(:pointer)
16
+ buffer = ::FFI::MemoryPointer.new(:pointer, strs.size + 1)
17
+ anchors = strs.map { |s| ::FFI::MemoryPointer.from_string(s) }
18
+ anchors.each_with_index { |p, i| buffer.put_pointer(i * ptr_size, p) }
19
+ buffer.put_pointer(strs.size * ptr_size, nil)
20
+ [buffer, anchors]
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Taurus::XML::CDATA < Taurus::XML::Text
4
+ def name; "#cdata-section"; end
5
+
6
+ def content
7
+ Taurus::XML::FFI.taurus_cdata_node_get_content(@c_ptr)
8
+ end
9
+
10
+ def content=(new_content)
11
+ status = Taurus::XML::FFI.taurus_cdata_node_set_content(@c_ptr, new_content.to_s)
12
+ raise Taurus::XML::Error,
13
+ Taurus::XML::FFI.taurus_status_string(status) unless status == Taurus::XML::FFI::TAURUS_OK
14
+ new_content
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Taurus::XML::Comment < Taurus::XML::Node
4
+ def name; "comment"; end
5
+
6
+ def content
7
+ Taurus::XML::FFI.taurus_comment_node_get_content(@c_ptr)
8
+ end
9
+
10
+ def content=(new_content)
11
+ status = Taurus::XML::FFI.taurus_comment_node_set_content(@c_ptr, new_content.to_s)
12
+ raise Taurus::XML::Error,
13
+ Taurus::XML::FFI.taurus_status_string(status) unless status == Taurus::XML::FFI::TAURUS_OK
14
+ new_content
15
+ end
16
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taurus
4
+ module XML
5
+ # Minimal CSS-to-XPath translator covering the common Nokogiri subset:
6
+ #
7
+ # tag → //tag
8
+ # * → //*
9
+ # .class → //*[contains(concat(' ',@class,' '),' class ')]
10
+ # #id → //*[@id='id']
11
+ # [attr] → //*[@attr]
12
+ # [attr=val] → //*[@attr='val']
13
+ # tag[attr] → //tag[@attr]
14
+ # tag[attr=val] → //tag[@attr='val']
15
+ # parent > child → //parent/child
16
+ # ancestor descendant → //ancestor//descendant
17
+ # a, b → //a | //b
18
+ # tag:first-child → //tag[position()=1]
19
+ # tag:last-child → //tag[position()=last()]
20
+ # tag:not(simple) → //tag[not(self::simple)]
21
+ #
22
+ # For anything beyond this subset, raise ArgumentError. Callers can fall
23
+ # back to writing XPath directly via #xpath/#at_xpath.
24
+ module CssToXPath
25
+ COMMA_SPLIT = /\s*,\s*/.freeze
26
+ private_constant :COMMA_SPLIT
27
+
28
+ # Token types in a single simple selector.
29
+ # tag (optional), then zero or more of: .class, #id, [attr...], :pseudo
30
+ TAG_RE = /\A(\*|[\w-]+)/.freeze
31
+ DOT_CLASS_RE = /\A\.([\w-]+)/.freeze
32
+ HASH_ID_RE = /\A#([\w-]+)/.freeze
33
+ BRACKET_RE = /\A\[([^\]]+)\]/.freeze
34
+ PSEUDO_RE = /\A:([\w-]+(?:\([^)]*\))?)/.freeze
35
+
36
+ module_function
37
+
38
+ def convert(rule)
39
+ rule.to_s.split(COMMA_SPLIT).map { |r| convert_one(r.strip) }.join(" | ")
40
+ end
41
+
42
+ def convert_one(rule)
43
+ return "//*" if rule == "*"
44
+
45
+ # Tokenize chain first (handles > and whitespace)
46
+ if rule =~ /\s/ || rule.include?(">")
47
+ return convert_chain(rule)
48
+ end
49
+
50
+ convert_simple(rule, prefix: "//")
51
+ end
52
+
53
+ # Parse a single simple selector into (tag, predicates) where
54
+ # predicates is an array of XPath fragments to be joined via [p1][p2]...
55
+ def parse_simple(part)
56
+ tag = "*"
57
+ preds = []
58
+
59
+ s = part.strip
60
+ if s =~ TAG_RE
61
+ tag = $1
62
+ s = $'
63
+ end
64
+
65
+ until s.empty?
66
+ case s
67
+ when DOT_CLASS_RE
68
+ preds << "contains(concat(' ',normalize-space(@class),' '),' #{$1} ')"
69
+ s = $'
70
+ when HASH_ID_RE
71
+ preds << "@id='#{$1}'"
72
+ s = $'
73
+ when BRACKET_RE
74
+ preds << convert_attrs($1)
75
+ s = $'
76
+ when PSEUDO_RE
77
+ preds << convert_pseudo($1)
78
+ s = $'
79
+ else
80
+ raise ArgumentError,
81
+ "unsupported CSS selector at #{s.inspect} (in #{part.inspect})"
82
+ end
83
+ end
84
+
85
+ [tag, preds]
86
+ end
87
+
88
+ def convert_simple(part, prefix:)
89
+ tag, preds = parse_simple(part)
90
+ "#{prefix}#{tag}#{preds.map { |p| "[#{p}]" }.join}"
91
+ end
92
+ private_class_method :convert_simple
93
+
94
+ def convert_attrs(clause)
95
+ case clause.strip
96
+ when /\A(\w+)\z/
97
+ "@#{$1}"
98
+ when /\A(\w+)=['"]?([^'"\]]+)['"]?\z/
99
+ "@#{$1}='#{$2}'"
100
+ when /\A(\w+)~=['"]?([^'"\]]+)['"]?\z/
101
+ "contains(concat(' ',normalize-space(@#{$1}),' '),' #{$2} ')"
102
+ when /\A(\w+)\^=['"]?([^'"\]]+)['"]?\z/
103
+ "starts-with(@#{$1}, '#{$2}')"
104
+ when /\A(\w+)\$=['"]?([^'"\]]+)['"]?\z/
105
+ "substring(@#{$1}, string-length(@#{$1}) - string-length('#{$2}') + 1) = '#{$2}'"
106
+ when /\A(\w+)\*=['"]?([^'"\]]+)['"]?\z/
107
+ "contains(@#{$1}, '#{$2}')"
108
+ else
109
+ raise ArgumentError, "unsupported attribute selector: [#{clause}]"
110
+ end
111
+ end
112
+ private_class_method :convert_attrs
113
+
114
+ def convert_pseudo(pseudo)
115
+ case pseudo
116
+ when "first-child" then "not(preceding-sibling::*)"
117
+ when "last-child" then "not(following-sibling::*)"
118
+ when "only-child" then "not(preceding-sibling::* or following-sibling::*)"
119
+ when "empty" then "not(node())"
120
+ when "root" then "not(parent::*)"
121
+ when /\Anot\((.+)\)\z/
122
+ inner_tag, _ = parse_simple($1)
123
+ "not(self::#{inner_tag})"
124
+ else
125
+ raise ArgumentError, "unsupported pseudo-class: :#{pseudo}"
126
+ end
127
+ end
128
+ private_class_method :convert_pseudo
129
+
130
+ # Tokenize chain into [sel, op, sel, op, sel, ...] where op is :child or
131
+ # :descendant. Then build XPath.
132
+ def convert_chain(rule)
133
+ tokens = tokenize_chain(rule)
134
+ build_chain_xpath(tokens)
135
+ end
136
+ private_class_method :convert_chain
137
+
138
+ def tokenize_chain(rule)
139
+ tokens = []
140
+ s = rule.strip
141
+ until s.empty?
142
+ if s.sub!(/\A\s*>\s*/, "")
143
+ tokens << :child
144
+ elsif s.sub!(/\A\s+/, "")
145
+ # whitespace descendant only counts if previous token is a string
146
+ tokens << :descendant if tokens.last.is_a?(String)
147
+ else
148
+ # Read one simple selector — keep going until whitespace or > or end
149
+ m = s.match(/\A[^>\s]+/)
150
+ raise ArgumentError, "couldn't parse CSS chain at: #{s.inspect}" unless m
151
+ tokens << m[0]
152
+ s = m.post_match
153
+ end
154
+ end
155
+ tokens
156
+ end
157
+ private_class_method :tokenize_chain
158
+
159
+ def build_chain_xpath(tokens)
160
+ first = tokens.shift
161
+ raise ArgumentError, "empty CSS chain" unless first.is_a?(String)
162
+
163
+ xpath = convert_simple(first, prefix: "//")
164
+ until tokens.empty?
165
+ op = tokens.shift
166
+ sel = tokens.shift
167
+ raise ArgumentError, "malformed CSS chain" unless sel.is_a?(String)
168
+ inner_tag, preds = parse_simple(sel)
169
+ connector = op == :child ? "/" : "//"
170
+ xpath = "#{xpath}#{connector}#{inner_tag}#{preds.map { |p| "[#{p}]" }.join}"
171
+ end
172
+ xpath
173
+ end
174
+ private_class_method :build_chain_xpath
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Taurus::XML::DocType
4
+ attr_reader :c_ptr, :document
5
+
6
+ def initialize(c_ptr, document)
7
+ @c_ptr = c_ptr
8
+ @document = document
9
+ end
10
+
11
+ def name
12
+ Taurus::XML::FFI.taurus_doctype_get_name(@c_ptr)
13
+ end
14
+ alias_method :node_name, :name
15
+
16
+ def root_name
17
+ Taurus::XML::FFI.taurus_doctype_get_root_name(@c_ptr)
18
+ end
19
+
20
+ def public_id
21
+ Taurus::XML::FFI.taurus_doctype_get_public_id(@c_ptr)
22
+ end
23
+
24
+ def system_id
25
+ Taurus::XML::FFI.taurus_doctype_get_system_id(@c_ptr)
26
+ end
27
+
28
+ def internal_subset
29
+ Taurus::XML::FFI.taurus_doctype_get_internal_subset(@c_ptr)
30
+ end
31
+
32
+ def external_id
33
+ pub = public_id
34
+ sys = system_id
35
+ return nil if pub.nil? && sys.nil?
36
+ parts = []
37
+ parts << "PUBLIC \"#{pub}\"" if pub
38
+ parts << "SYSTEM \"#{sys}\"" if sys && pub.nil?
39
+ parts << "\"#{sys}\"" if pub && sys
40
+ parts.join(" ")
41
+ end
42
+
43
+ def to_s
44
+ inner = internal_subset
45
+ parts = ["<!DOCTYPE #{name}"]
46
+ parts << external_id if external_id
47
+ parts << "[#{inner}]" if inner && !inner.empty?
48
+ parts.join(" ") + ">"
49
+ end
50
+
51
+ def inspect
52
+ "#<#{self.class.name} name=#{name.inspect} public=#{public_id.inspect} system=#{system_id.inspect}>"
53
+ end
54
+ end