leptris 1.9.197.2-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 +2646 -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
data/leptris.gemspec ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/leptris/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "leptris"
7
+ spec.version = Leptris::VERSION
8
+ spec.authors = ["Ribose Inc."]
9
+ spec.email = ["open.source@ribose.com"]
10
+
11
+ spec.summary = "Nokogiri-compatible Ruby binding for libleptris (XML 1.0, XPath 1.0, SAX)"
12
+ spec.description = <<~DESC
13
+ Leptris is a Nokogiri-compatible Ruby binding for libleptris, a pure-C99 XML
14
+ 1.0 parser with full XPath 1.0 and SAX support. The C DOM is the single
15
+ source of truth; Ruby objects are thin FFI wrappers (one Ruby method =
16
+ one FFI call).
17
+ DESC
18
+ spec.homepage = "https://github.com/leptris/leptris-ruby"
19
+ spec.license = "MIT"
20
+ spec.required_ruby_version = ">= 3.0.0"
21
+
22
+ spec.metadata = {
23
+ "homepage_uri" => spec.homepage,
24
+ "source_code_uri" => "https://github.com/leptris/leptris-ruby",
25
+ "changelog_uri" => "https://github.com/leptris/leptris-ruby/blob/main/CHANGELOG.md",
26
+ }
27
+
28
+ spec.files = Dir.chdir(__dir__) do
29
+ `git ls-files -z`.split("\x0").reject do |f|
30
+ (File.expand_path(f) == __FILE__) ||
31
+ f.start_with?(*%w[bin/ test/ spec/ features/ benchmark/ .git .github
32
+ appveyor Gemfile .rspec .rubocop.yml])
33
+ end
34
+ end
35
+ spec.bindir = "bin"
36
+ spec.executables = []
37
+ spec.require_paths = ["lib"]
38
+
39
+ spec.add_dependency "ffi", "~> 1.15"
40
+
41
+ spec.add_development_dependency "rake"
42
+ spec.add_development_dependency "rspec"
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leptris
4
+ VERSION = "1.9.197.2"
5
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lightweight attribute wrapper: a (name, value, parent_element)
4
+ # triple materialized from the attribute-iteration face, carrying
5
+ # the C attribute handle for the per-attribute namespace accessors.
6
+ # Mutation goes through the parent element via #[].
7
+
8
+ class Leptris::XML::Attr
9
+ attr_reader :name, :value, :element
10
+
11
+ # c_handle is the C LeptrisAttribute pointer from the owning
12
+ # element's iteration face — document-owned and borrowed. Absent
13
+ # only when an Attr is constructed by hand.
14
+ def initialize(name, value, element, c_handle: nil)
15
+ @name = name
16
+ @value = value
17
+ @element = element
18
+ @c_handle = c_handle
19
+ end
20
+
21
+ def value=(new_value)
22
+ @element[@name] = new_value
23
+ @value = new_value
24
+ end
25
+
26
+ # The prefix as written in the qualified name (bytes before the
27
+ # colon), nil for a no-namespace attribute. Name-derived and
28
+ # immutable — the same semantics as leptris_attribute_prefix, so
29
+ # no declaration lookup is involved.
30
+ def prefix
31
+ i = @name.index(":")
32
+ i ? @name[0, i] : nil
33
+ end
34
+
35
+ # The attribute's namespace URI, resolved through the OWNING
36
+ # element's in-scope declarations at read time (libleptris 1.8.0).
37
+ # nil for a no-namespace attribute or an undeclared prefix; xml
38
+ # is prebound to http://www.w3.org/XML/1998/namespace.
39
+ def namespace_uri
40
+ return nil if @c_handle.nil?
41
+ Leptris::XML::FFI.leptris_attribute_namespace_uri(@c_handle)
42
+ end
43
+
44
+ def namespace
45
+ namespace_uri
46
+ end
47
+
48
+ def remove
49
+ @element.remove_attribute(@name)
50
+ end
51
+
52
+ def to_s; @value; end
53
+ def to_str; @value; end
54
+
55
+ # Serialized attribute form: name="value" with the five XML special
56
+ # characters escaped in the value — single pass, no chained gsubs.
57
+ ESCAPE_TABLE = {
58
+ "&" => "&amp;", "<" => "&lt;", ">" => "&gt;",
59
+ '"' => "&quot;", "'" => "&apos;",
60
+ }.freeze
61
+ private_constant :ESCAPE_TABLE
62
+
63
+ def to_xml
64
+ "#{@name}=\"#{@value.to_s.gsub(/[&<>"']/, ESCAPE_TABLE)}\""
65
+ end
66
+
67
+ def ==(other)
68
+ other.is_a?(Leptris::XML::Attr) &&
69
+ @name == other.name && @value == other.value &&
70
+ @element == other.element
71
+ end
72
+
73
+ def inspect
74
+ "#<#{self.class.name} name=#{@name.inspect} value=#{@value.inspect}>"
75
+ end
76
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Adapter for libleptris's NULL-terminated char** wire format. Both
4
+ # directions of the conversion live here so the pointer arithmetic has
5
+ # one home (previously c14n built the array by hand and SAX walked it
6
+ # by hand).
7
+ module Leptris::XML::CStringArray
8
+ # Ruby strings -> NULL-terminated char**. Returns [buffer, anchors];
9
+ # the caller must keep both referenced for the duration of the FFI
10
+ # call (e.g. via local variables).
11
+ def self.to_c(strings)
12
+ strs = Array(strings).map(&:to_s)
13
+ return [nil, nil] if strs.empty?
14
+
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
+
23
+ # NULL-terminated char** -> Ruby strings. Stops at the first NULL
24
+ # entry. Returns [] for a NULL pointer.
25
+ def self.to_ruby(ptr)
26
+ return [] if ptr.nil? || ptr.null?
27
+
28
+ ptr_size = ::FFI::type_size(:pointer)
29
+ result = []
30
+ offset = 0
31
+ while (entry = ptr.get_pointer(offset)) && !entry.null?
32
+ result << entry.read_string.force_encoding(Encoding::UTF_8)
33
+ offset += ptr_size
34
+ end
35
+ result
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Leptris::XML::CDATA < Leptris::XML::Text
4
+ def name; "#cdata-section"; 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_cdata_content(@c_address)
11
+ else
12
+ Leptris::XML::FFI.leptris_cdata_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
+ ensure_writable!
23
+ Leptris::XML::FFI.check_status(
24
+ Leptris::XML::FFI.leptris_cdata_node_set_content(c_ptr, new_content.to_s))
25
+ new_content
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Leptris::XML::Comment < Leptris::XML::Node
4
+ def name; "comment"; 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_comment_content(@c_address)
11
+ else
12
+ Leptris::XML::FFI.leptris_comment_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
+ ensure_writable!
23
+ Leptris::XML::FFI.check_status(
24
+ Leptris::XML::FFI.leptris_comment_node_set_content(c_ptr, new_content.to_s))
25
+ new_content
26
+ end
27
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Leptris
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
+ # Translation is a pure function of the rule string AND the
39
+ # context prefix, and real workloads repeat a small selector
40
+ # vocabulary in loops — memoize. Failed translations raise
41
+ # before caching. +prefix+ scopes the result: "//" for
42
+ # document receivers, ".//" for elements and fragments
43
+ # (Nokogiri receiver-relative semantics).
44
+ CACHE = {}
45
+ private_constant :CACHE
46
+
47
+ def convert(rule, prefix: "//")
48
+ key = "#{prefix}\t#{rule}"
49
+ CACHE.fetch(key) { CACHE[key] = convert_rule(rule, prefix) }
50
+ end
51
+
52
+ def convert_rule(rule, prefix)
53
+ rule.split(COMMA_SPLIT)
54
+ .map { |r| convert_one(r.strip, prefix) }.join(" | ")
55
+ end
56
+
57
+ def convert_one(rule, prefix)
58
+ return "#{prefix}*" if rule == "*"
59
+
60
+ # Tokenize chain first (handles > and whitespace)
61
+ if rule =~ /\s/ || rule.include?(">")
62
+ return convert_chain(rule, prefix)
63
+ end
64
+
65
+ convert_simple(rule, prefix: prefix)
66
+ end
67
+
68
+ # Parse a single simple selector into (tag, predicates) where
69
+ # predicates is an array of XPath fragments to be joined via [p1][p2]...
70
+ def parse_simple(part)
71
+ tag = "*"
72
+ preds = []
73
+
74
+ s = part.strip
75
+ if s =~ TAG_RE
76
+ tag = $1
77
+ s = $'
78
+ end
79
+
80
+ until s.empty?
81
+ case s
82
+ when DOT_CLASS_RE
83
+ preds << "contains(concat(' ',normalize-space(@class),' '),' #{$1} ')"
84
+ s = $'
85
+ when HASH_ID_RE
86
+ preds << "@id='#{$1}'"
87
+ s = $'
88
+ when BRACKET_RE
89
+ preds << convert_attrs($1)
90
+ s = $'
91
+ when PSEUDO_RE
92
+ preds << convert_pseudo($1)
93
+ s = $'
94
+ else
95
+ raise ArgumentError,
96
+ "unsupported CSS selector at #{s.inspect} (in #{part.inspect})"
97
+ end
98
+ end
99
+
100
+ [tag, preds]
101
+ end
102
+
103
+ def convert_simple(part, prefix:)
104
+ tag, preds = parse_simple(part)
105
+ "#{prefix}#{tag}#{preds.map { |p| "[#{p}]" }.join}"
106
+ end
107
+ private_class_method :convert_simple
108
+
109
+ def convert_attrs(clause)
110
+ case clause.strip
111
+ when /\A(\w+)\z/
112
+ "@#{$1}"
113
+ when /\A(\w+)=['"]?([^'"\]]+)['"]?\z/
114
+ "@#{$1}='#{$2}'"
115
+ when /\A(\w+)~=['"]?([^'"\]]+)['"]?\z/
116
+ "contains(concat(' ',normalize-space(@#{$1}),' '),' #{$2} ')"
117
+ when /\A(\w+)\^=['"]?([^'"\]]+)['"]?\z/
118
+ "starts-with(@#{$1}, '#{$2}')"
119
+ when /\A(\w+)\$=['"]?([^'"\]]+)['"]?\z/
120
+ "substring(@#{$1}, string-length(@#{$1}) - string-length('#{$2}') + 1) = '#{$2}'"
121
+ when /\A(\w+)\*=['"]?([^'"\]]+)['"]?\z/
122
+ "contains(@#{$1}, '#{$2}')"
123
+ else
124
+ raise ArgumentError, "unsupported attribute selector: [#{clause}]"
125
+ end
126
+ end
127
+ private_class_method :convert_attrs
128
+
129
+ def convert_pseudo(pseudo)
130
+ case pseudo
131
+ when "first-child" then "not(preceding-sibling::*)"
132
+ when "last-child" then "not(following-sibling::*)"
133
+ when "only-child" then "not(preceding-sibling::* or following-sibling::*)"
134
+ when "empty" then "not(node())"
135
+ when "root" then "not(parent::*)"
136
+ when /\Anot\((.+)\)\z/
137
+ inner_tag, _ = parse_simple($1)
138
+ "not(self::#{inner_tag})"
139
+ else
140
+ raise ArgumentError, "unsupported pseudo-class: :#{pseudo}"
141
+ end
142
+ end
143
+ private_class_method :convert_pseudo
144
+
145
+ # Tokenize chain into [sel, op, sel, op, sel, ...] where op is :child or
146
+ # :descendant. Then build XPath.
147
+ def convert_chain(rule, prefix)
148
+ tokens = tokenize_chain(rule)
149
+ build_chain_xpath(tokens, prefix)
150
+ end
151
+ private_class_method :convert_chain
152
+
153
+ def tokenize_chain(rule)
154
+ tokens = []
155
+ s = rule.strip
156
+ until s.empty?
157
+ if s.sub!(/\A\s*>\s*/, "")
158
+ tokens << :child
159
+ elsif s.sub!(/\A\s+/, "")
160
+ # whitespace descendant only counts if previous token is a string
161
+ tokens << :descendant if tokens.last.is_a?(String)
162
+ else
163
+ # Read one simple selector — keep going until whitespace or > or end
164
+ m = s.match(/\A[^>\s]+/)
165
+ raise ArgumentError, "couldn't parse CSS chain at: #{s.inspect}" unless m
166
+ tokens << m[0]
167
+ s = m.post_match
168
+ end
169
+ end
170
+ tokens
171
+ end
172
+ private_class_method :tokenize_chain
173
+
174
+ def build_chain_xpath(tokens, prefix)
175
+ first = tokens.shift
176
+ raise ArgumentError, "empty CSS chain" unless first.is_a?(String)
177
+
178
+ xpath = convert_simple(first, prefix: prefix)
179
+ until tokens.empty?
180
+ op = tokens.shift
181
+ sel = tokens.shift
182
+ raise ArgumentError, "malformed CSS chain" unless sel.is_a?(String)
183
+ inner_tag, preds = parse_simple(sel)
184
+ connector = op == :child ? "/" : "//"
185
+ xpath = "#{xpath}#{connector}#{inner_tag}#{preds.map { |p| "[#{p}]" }.join}"
186
+ end
187
+ xpath
188
+ end
189
+ private_class_method :build_chain_xpath
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ # Tree-shaped schema-descriptor materialization (libleptris 1.9.162,
6
+ # upstream #1039): compile a plan tree once, then materialize a whole
7
+ # subtree against it in ONE native pass — no per-element Ruby calls
8
+ # (#185's class of cost). Children the plan does not describe are
9
+ # skipped; matches within a row keep document order.
10
+ #
11
+ # descriptor = Leptris::XML::Descriptor.build(
12
+ # name: "catalog",
13
+ # attributes: [{ name: "version", kind: :scalar }],
14
+ # children: [
15
+ # { name: "item", kind: :nested, plan: {
16
+ # name: "item",
17
+ # attributes: [{ name: "id", kind: :scalar }],
18
+ # children: [
19
+ # { name: "name", kind: :scalar },
20
+ # { name: "price", kind: :scalar },
21
+ # ] } },
22
+ # ])
23
+ # descriptor.walk(doc.root).to_ruby
24
+ #
25
+ # Plan rows: :scalar, :collection (repeated matches), :nested (recurse
26
+ # via +plan:+), :raw (serialized subtree), :content (mixed-content
27
+ # text runs), :callback (raw value + byte position + type_tag echo).
28
+ # Namespace binding per plan: +ns:+ is :none (default), :any, or
29
+ # { exact: "urn:..." }; +flags: [:mixed_content, :ordered, :cdata,
30
+ # :ns_lenient].
31
+ class Leptris::XML::Descriptor
32
+ class Handle < ::FFI::AutoPointer
33
+ def self.release(ptr)
34
+ Leptris::XML::FFI.leptris_plan_free(ptr)
35
+ end
36
+ end
37
+
38
+ class ResultHandle < ::FFI::AutoPointer
39
+ def self.release(ptr)
40
+ Leptris::XML::FFI.leptris_plan_result_free(ptr)
41
+ end
42
+ end
43
+
44
+ KINDS = {
45
+ scalar: Leptris::XML::FFI::PLAN_KIND_SCALAR,
46
+ collection: Leptris::XML::FFI::PLAN_KIND_COLLECTION,
47
+ nested: Leptris::XML::FFI::PLAN_KIND_NESTED,
48
+ raw: Leptris::XML::FFI::PLAN_KIND_RAW,
49
+ content: Leptris::XML::FFI::PLAN_KIND_CONTENT,
50
+ callback: Leptris::XML::FFI::PLAN_KIND_CALLBACK,
51
+ }.freeze
52
+ private_constant :KINDS
53
+
54
+ FLAGS = {
55
+ mixed_content: Leptris::XML::FFI::PLAN_FLAG_MIXED_CONTENT,
56
+ ordered: Leptris::XML::FFI::PLAN_FLAG_ORDERED,
57
+ cdata: Leptris::XML::FFI::PLAN_FLAG_CDATA,
58
+ ns_lenient: Leptris::XML::FFI::PLAN_FLAG_NS_LENIENT,
59
+ }.freeze
60
+ private_constant :FLAGS
61
+
62
+ attr_reader :root
63
+
64
+ # +tree+ is the root plan (the hash shape documented on the
65
+ # class). Returns the compiled, engine-owned descriptor; the
66
+ # Ruby-side spec memory is transient (the engine deep-copies).
67
+ def self.build(tree)
68
+ abi = Leptris::XML::FFI.leptris_plan_abi_version
69
+ if abi != Leptris::XML::FFI::PLAN_ABI_VERSION
70
+ raise Leptris::XML::Error,
71
+ "descriptor ABI v#{abi} != binding v#{Leptris::XML::FFI::PLAN_ABI_VERSION}"
72
+ end
73
+ plans = []
74
+ root_index = flatten_plan(tree, plans)
75
+ spec, plans_pointer, anchors = pack_plans(plans)
76
+ spec[:abi_version] = abi
77
+ spec[:plan_count] = plans.size
78
+ spec[:plans] = plans_pointer
79
+ status = ::FFI::MemoryPointer.new(:int)
80
+ raw = Leptris::XML::FFI.leptris_plan_build(spec.pointer, status)
81
+ if raw.null?
82
+ raise Leptris::XML::Error,
83
+ "plan build failed (status=#{status.read_int})"
84
+ end
85
+ new(Handle.new(raw), plans, root_index)
86
+ end
87
+
88
+ # Flattens the nested hash tree into the engine's flat plans
89
+ # array (child rows reference plans by index). Returns the root
90
+ # plan's index — always 0 for a single root.
91
+ def self.flatten_plan(tree, plans)
92
+ tree = tree.dup
93
+ plans << tree
94
+ tree_index = plans.size - 1
95
+ tree[:children] = (tree[:children] || []).map do |row|
96
+ if row[:kind] == :nested
97
+ row.merge(child_plan_index: flatten_plan(row.fetch(:plan), plans))
98
+ else
99
+ row
100
+ end
101
+ end
102
+ tree_index
103
+ end
104
+ private_class_method :flatten_plan
105
+
106
+ # Packs the flattened plans into FFI memory: one ElementPlan per
107
+ # plan plus one AttrPlan/ChildPlan row per attribute/child. Every
108
+ # C string and row array is anchored in Ruby until the build call
109
+ # returns (the engine deep-copies).
110
+ def self.pack_plans(plans)
111
+ anchors = [nil]
112
+ plan_memory = ::FFI::MemoryPointer.new(
113
+ Leptris::XML::FFI::ElementPlan, plans.size)
114
+ anchors[0] = plan_memory
115
+ plans.each_with_index do |plan, i|
116
+ ep = Leptris::XML::FFI::ElementPlan.new(plan_memory[i])
117
+ ep[:element_name] = anchor_string(anchors, plan.fetch(:name))
118
+ ns = plan[:ns]
119
+ ns_form, ns_uri =
120
+ case ns
121
+ when nil, :none then [Leptris::XML::FFI::PLAN_NS_NONE, nil]
122
+ when :any then [Leptris::XML::FFI::PLAN_NS_ANY, nil]
123
+ when Hash
124
+ [Leptris::XML::FFI::PLAN_NS_EXACT,
125
+ anchor_string(anchors, ns.fetch(:exact))]
126
+ else
127
+ raise ArgumentError, "ns must be :none, :any, or {exact: uri}"
128
+ end
129
+ ep[:ns_form] = ns_form
130
+ ep[:ns_uri] = ns_uri
131
+ attrs = plan[:attributes] || []
132
+ attr_memory = attrs.empty? ? nil :
133
+ ::FFI::MemoryPointer.new(Leptris::XML::FFI::AttrPlan, attrs.size)
134
+ anchors << attr_memory
135
+ attrs.each_with_index do |row, j|
136
+ ap = Leptris::XML::FFI::AttrPlan.new(attr_memory[j])
137
+ ap[:wire_name] = anchor_string(anchors, row.fetch(:name))
138
+ ap[:kind] = kind_code(row[:kind] || :scalar)
139
+ ap[:type_tag] = row[:type_tag] || 0
140
+ end
141
+ ep[:attribute_count] = attrs.size
142
+ ep[:attribute_plans] = attr_memory
143
+ children = plan[:children] || []
144
+ child_memory = children.empty? ? nil :
145
+ ::FFI::MemoryPointer.new(Leptris::XML::FFI::ChildPlan, children.size)
146
+ anchors << child_memory
147
+ children.each_with_index do |row, j|
148
+ cp = Leptris::XML::FFI::ChildPlan.new(child_memory[j])
149
+ cp[:wire_name] = anchor_string(anchors, row.fetch(:name))
150
+ cp[:kind] = kind_code(row[:kind] || :scalar)
151
+ cp[:type_tag] = row[:type_tag] || 0
152
+ cp[:child_plan_index] = row[:child_plan_index] || -1
153
+ # Rule-level ns form (libleptris 1.9.178, #1115): siblings
154
+ # under one parent can require different URIs when set;
155
+ # defaults to NONE for backward compatibility.
156
+ cp[:ns_form] =
157
+ case row[:ns]
158
+ when :any then Leptris::XML::FFI::PLAN_NS_ANY
159
+ when Hash then Leptris::XML::FFI::PLAN_NS_EXACT
160
+ else Leptris::XML::FFI::PLAN_NS_NONE
161
+ end
162
+ cp[:ns_uri] =
163
+ row[:ns].is_a?(Hash) ?
164
+ anchor_string(anchors, row[:ns].fetch(:exact)) : nil
165
+ end
166
+ ep[:child_count] = children.size
167
+ ep[:child_plans] = child_memory
168
+ ep[:flags] = (plan[:flags] || []).map { |f| FLAGS.fetch(f) }.sum
169
+ end
170
+ [Leptris::XML::FFI::PlanSpec.new, plan_memory, anchors]
171
+ end
172
+ private_class_method :pack_plans
173
+
174
+ # Anchors the NUL-terminated copy in +anchors+ — the engine
175
+ # deep-copies at build, but the source bytes must live until the
176
+ # leptris_plan_build call returns.
177
+ def self.anchor_string(anchors, ruby_string)
178
+ ptr = ::FFI::MemoryPointer.from_string(ruby_string.to_s)
179
+ anchors << ptr
180
+ ptr
181
+ end
182
+ private_class_method :anchor_string
183
+
184
+ def self.kind_code(kind)
185
+ KINDS.fetch(kind) do
186
+ raise ArgumentError,
187
+ "kind must be one of #{KINDS.keys.inspect}, got #{kind.inspect}"
188
+ end
189
+ end
190
+ private_class_method :kind_code
191
+
192
+ def initialize(handle, plans, root_index)
193
+ @handle = handle
194
+ @plans = plans
195
+ @root_index = root_index
196
+ end
197
+
198
+ # Materializes the subtree rooted at +element_or_document+ (the
199
+ # root plan applies to the context node itself) in one native
200
+ # pass. Returns the lazy PlanValue tree — a standalone result
201
+ # that outlives the document.
202
+ def walk(element_or_document)
203
+ context = Leptris::XML::EvaluationContext.of(element_or_document)
204
+ status = ::FFI::MemoryPointer.new(:int)
205
+ raw = Leptris::XML::FFI.leptris_plan_walk(
206
+ context.document.c_ptr, context.context_node_ptr, @handle, status)
207
+ if raw.null?
208
+ raise Leptris::XML::Error,
209
+ "plan walk failed (status=#{status.read_int})"
210
+ end
211
+ Leptris::XML::PlanValue.new(ResultHandle.new(raw),
212
+ owner: true, plans: @plans, plan: @plans[0])
213
+ end
214
+ end