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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3890 -0
- data/CLAUDE.md +115 -0
- data/CONTEXT.md +39 -0
- data/LICENSE.md +33 -0
- data/README.adoc +755 -0
- data/Rakefile +407 -0
- data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
- data/TODO.perf/02-auto-enable-native-layer.md +19 -0
- data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
- data/TODO.perf/04-ext-bound-serialization.md +15 -0
- data/TODO.perf/05-native-surface-completion.md +19 -0
- data/TODO.perf/06-perf-battery-native-legs.md +16 -0
- data/TODO.perf/07-native-read-floor.md +31 -0
- data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
- data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
- data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
- data/TODO.perf/11-moxml-gap-battery.md +18 -0
- data/TODO.perf/12-document-lifetime-in-c.md +47 -0
- data/TODO.perf/13-structural-memos.md +49 -0
- data/TODO.perf/14-insert-family-c-bound.md +31 -0
- data/TODO.perf/15-compiled-expression-cache.md +35 -0
- data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
- data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
- data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
- data/TODO.perf/19-lazy-node-pointer.md +39 -0
- data/TODO.perf/20-css-translation-cache.md +24 -0
- data/TODO.perf/21-key-memo-consult.md +20 -0
- data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
- data/TODO.perf/23-cbound-value-mutations.md +27 -0
- data/TODO.perf/24-immutable-read-lanes.md +22 -0
- data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
- data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
- data/TODO.perf/27-c-yield-traversal.md +38 -0
- data/TODO.perf/28-address-fills.md +25 -0
- data/TODO.perf/29-ns-xpath-compiled.md +23 -0
- data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
- data/TODO.perf/31-battery-rounds-4-8.md +23 -0
- data/TODO.perf/32-clean-host-floor-table.md +29 -0
- data/TODO.perf/33-cbound-root-set.md +29 -0
- data/TODO.perf/34-fragment-fast-lane.md +41 -0
- data/TODO.perf/35-parse-default-c-face.md +24 -0
- data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
- data/TODO.perf/37-document-lazy-pointer.md +28 -0
- data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
- data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
- data/TODO.restructure/02-deep-copy-seam.md +21 -0
- data/TODO.restructure/03-evaluation-context-seam.md +18 -0
- data/TODO.restructure/04-spec-mece-restructure.md +20 -0
- data/TODO.restructure/05-memory-documentation.md +13 -0
- data/TODO.restructure/06-architecture-map-refresh.md +12 -0
- data/TODO.restructure/07-external-gates.md +16 -0
- data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
- data/TODO.restructure/09-iteration-scope.md +29 -0
- data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
- data/TODO.restructure/11-construction-factory-diet.md +16 -0
- data/TODO.restructure/12-resultattr-merge.md +12 -0
- data/TODO.restructure/13-spec-mece-followup.md +9 -0
- data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
- data/TODO.restructure/15-readme-feature-documentation.md +10 -0
- data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
- data/TODO.restructure/17-perf-drift-record.md +12 -0
- data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
- data/TODO.restructure/19-gitignore-hygiene.md +10 -0
- data/TODO.restructure/20-utf8proc-enablement.md +34 -0
- data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
- data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
- data/docs/adr/0001-lockstep-mirror.md +17 -0
- data/docs/adr/0002-utf8-at-the-seam.md +19 -0
- data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
- data/docs/adr/0004-lifetime-guard.md +21 -0
- data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
- data/docs/adr/0006-ruby-variant-policy.md +25 -0
- data/ext/build_windows_native.rb +42 -0
- data/ext/leptris/native/extconf.rb +55 -0
- data/ext/leptris/native/native.c +2528 -0
- data/leptris.gemspec +43 -0
- data/lib/leptris/version.rb +5 -0
- data/lib/leptris/xml/attr.rb +76 -0
- data/lib/leptris/xml/c_string_array.rb +37 -0
- data/lib/leptris/xml/cdata.rb +27 -0
- data/lib/leptris/xml/comment.rb +27 -0
- data/lib/leptris/xml/css_to_xpath.rb +192 -0
- data/lib/leptris/xml/descriptor.rb +214 -0
- data/lib/leptris/xml/diff.rb +74 -0
- data/lib/leptris/xml/doc_type.rb +54 -0
- data/lib/leptris/xml/document.rb +716 -0
- data/lib/leptris/xml/document_fragment.rb +63 -0
- data/lib/leptris/xml/element.rb +771 -0
- data/lib/leptris/xml/entity_reference.rb +13 -0
- data/lib/leptris/xml/evaluation_context.rb +32 -0
- data/lib/leptris/xml/ffi.rb +1527 -0
- data/lib/leptris/xml/iteration_scope.rb +56 -0
- data/lib/leptris/xml/iterparse.rb +144 -0
- data/lib/leptris/xml/namespace.rb +43 -0
- data/lib/leptris/xml/native.so +0 -0
- data/lib/leptris/xml/native_layer.rb +92 -0
- data/lib/leptris/xml/node.rb +686 -0
- data/lib/leptris/xml/node_set.rb +212 -0
- data/lib/leptris/xml/parse_options.rb +97 -0
- data/lib/leptris/xml/plan_value.rb +121 -0
- data/lib/leptris/xml/processing_instruction.rb +90 -0
- data/lib/leptris/xml/pull.rb +212 -0
- data/lib/leptris/xml/relaxng.rb +126 -0
- data/lib/leptris/xml/result_attr.rb +39 -0
- data/lib/leptris/xml/result_text.rb +36 -0
- data/lib/leptris/xml/sax/document.rb +45 -0
- data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
- data/lib/leptris/xml/sax/parser.rb +326 -0
- data/lib/leptris/xml/sax/recorder.rb +374 -0
- data/lib/leptris/xml/sax.rb +14 -0
- data/lib/leptris/xml/schematron.rb +88 -0
- data/lib/leptris/xml/searchable.rb +361 -0
- data/lib/leptris/xml/serialization.rb +180 -0
- data/lib/leptris/xml/text.rb +32 -0
- data/lib/leptris/xml/xpath.rb +89 -0
- data/lib/leptris/xml/xquery.rb +77 -0
- data/lib/leptris/xml/xslt.rb +85 -0
- data/lib/leptris/xml.rb +108 -0
- data/lib/leptris.rb +53 -0
- data/lib/libleptris.so +0 -0
- data/lib/libutf8proc.so.3 +0 -0
- data/scripts/gem_smoke.rb +36 -0
- 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,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
|
+
"&" => "&", "<" => "<", ">" => ">",
|
|
59
|
+
'"' => """, "'" => "'",
|
|
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
|