moxml 0.5.148 → 0.5.149
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 +4 -4
- data/.github/workflows/nightly-fuzz.yml +80 -0
- data/docs/_pages/divergences.adoc +58 -0
- data/docs/_references/processing-model.adoc +30 -3
- data/lib/compat/opal/moxml_boot.rb +4 -0
- data/lib/moxml/adapter/leptris.rb +110 -19
- data/lib/moxml/adapter/libxml.rb +145 -23
- data/lib/moxml/adapter/oga.rb +33 -3
- data/lib/moxml/adapter/ox.rb +26 -2
- data/lib/moxml/adapter/rexml.rb +65 -15
- data/lib/moxml/context.rb +5 -1
- data/lib/moxml/document.rb +6 -6
- data/lib/moxml/node.rb +22 -6
- data/lib/moxml/node_set.rb +11 -0
- data/lib/moxml/reference/normalizer.rb +63 -0
- data/lib/moxml/reference/parser.rb +417 -0
- data/lib/moxml/reference/writer.rb +121 -0
- data/lib/moxml/reference.rb +205 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xpath/compiler.rb +17 -2
- data/lib/moxml/xpath/ordering.rb +97 -0
- data/lib/moxml/xpath.rb +1 -0
- data/lib/moxml.rb +1 -0
- data/spec/consistency/adapter_parity_spec.rb +2 -0
- data/spec/consistency/differential_taxonomy_spec.rb +117 -0
- data/spec/consistency/divergence_register_spec.rb +9 -1
- data/spec/consistency/eol_normalization_spec.rb +2 -0
- data/spec/consistency/loud_degradation_spec.rb +2 -0
- data/spec/consistency/metamorphic_properties_spec.rb +195 -0
- data/spec/consistency/metamorphic_spec.rb +138 -0
- data/spec/consistency/model_coverage_audit_spec.rb +108 -0
- data/spec/consistency/reference_differential_spec.rb +186 -0
- data/spec/consistency/xpath_idioms_spec.rb +11 -0
- data/spec/consistency/xslt_spec.rb +2 -0
- data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
- data/spec/moxml/adapter/ox_spec.rb +1 -1
- data/spec/moxml/attribute_resolver_spec.rb +2 -0
- data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
- data/spec/moxml/doctype_spec.rb +7 -0
- data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
- data/spec/moxml/entity_spec.rb +2 -0
- data/spec/moxml/plan_conformance_spec.rb +2 -0
- data/spec/moxml/processing_instruction_spec.rb +9 -0
- data/spec/moxml/reference_opal_spec.rb +29 -0
- data/spec/moxml/reference_spec.rb +162 -0
- data/spec/moxml/sax_spec.rb +2 -0
- data/spec/moxml/text_spec.rb +2 -0
- data/spec/moxml/to_html_spec.rb +2 -0
- data/spec/moxml/xhtml_output_spec.rb +2 -0
- data/spec/moxml/xpath/axes_spec.rb +2 -2
- data/spec/moxml/xsd_spec.rb +38 -0
- data/spec/support/input_generator.rb +154 -0
- data/spec/support/input_taxonomy.rb +212 -0
- data/spec/support/xml_generator.rb +54 -0
- metadata +16 -1
data/lib/moxml/adapter/libxml.rb
CHANGED
|
@@ -7,23 +7,26 @@ require "libxml-ruby"
|
|
|
7
7
|
module Moxml
|
|
8
8
|
module Adapter
|
|
9
9
|
class Libxml < Base
|
|
10
|
-
# DOCTYPE prolog
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
# DOCTYPE prolog scanning is manual (index/char scan): a regex
|
|
11
|
+
# over hostile prolog text backtracks polynomially on inputs
|
|
12
|
+
# with repeated <!DOCTYPE prefixes or unterminated subsets
|
|
13
|
+
# (CodeQL rb/polynomial-redos). See doctype_parts.
|
|
14
|
+
DOCTYPE_OPEN_SIZE = 9
|
|
15
|
+
QUOTES = ['"', "'"].freeze
|
|
14
16
|
|
|
15
17
|
autoload :EntityRefRegistry, "moxml/adapter/libxml/entity_ref_registry"
|
|
16
18
|
|
|
17
19
|
# Wrapper class to store DOCTYPE information
|
|
18
20
|
class DoctypeWrapper
|
|
19
21
|
attr_reader :native_doc
|
|
20
|
-
attr_accessor :name, :external_id, :system_id
|
|
22
|
+
attr_accessor :name, :external_id, :system_id, :internal_subset
|
|
21
23
|
|
|
22
|
-
def initialize(doc, name, external_id, system_id)
|
|
24
|
+
def initialize(doc, name, external_id, system_id, internal_subset = nil)
|
|
23
25
|
@native_doc = doc
|
|
24
26
|
@name = name
|
|
25
27
|
@external_id = external_id
|
|
26
28
|
@system_id = system_id
|
|
29
|
+
@internal_subset = internal_subset
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
# Provide native method to match adapter pattern
|
|
@@ -32,7 +35,8 @@ module Moxml
|
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
def to_xml
|
|
35
|
-
XmlEmitter.doctype_xml(@name, @external_id, @system_id
|
|
38
|
+
XmlEmitter.doctype_xml(@name, @external_id, @system_id,
|
|
39
|
+
@internal_subset)
|
|
36
40
|
end
|
|
37
41
|
end
|
|
38
42
|
|
|
@@ -83,6 +87,104 @@ module Moxml
|
|
|
83
87
|
false
|
|
84
88
|
end
|
|
85
89
|
|
|
90
|
+
# Linear DOCTYPE prolog scanner. Returns
|
|
91
|
+
# {name:, external_id:, system_id:, internal_subset:} or nil.
|
|
92
|
+
# Single pass, quote- and bracket-aware: no backtracking, so
|
|
93
|
+
# hostile prolog text cannot make parsing superlinear.
|
|
94
|
+
def doctype_parts(source, from = 0)
|
|
95
|
+
start = source.index("<!DOCTYPE", from)
|
|
96
|
+
return unless start
|
|
97
|
+
|
|
98
|
+
pos = skip_doctype_space(source, start + DOCTYPE_OPEN_SIZE)
|
|
99
|
+
name_start = pos
|
|
100
|
+
pos += 1 while pos < source.length &&
|
|
101
|
+
!" \t\r\n>".include?(source[pos])
|
|
102
|
+
name = source[name_start...pos]
|
|
103
|
+
return if name.empty?
|
|
104
|
+
|
|
105
|
+
pos = skip_doctype_space(source, pos)
|
|
106
|
+
external_id = nil
|
|
107
|
+
system_id = nil
|
|
108
|
+
keyword = source[pos, 6].to_s.upcase
|
|
109
|
+
if keyword == "PUBLIC"
|
|
110
|
+
pos = skip_doctype_space(source, pos + 6)
|
|
111
|
+
pair = scan_doctype_quoted(source, pos)
|
|
112
|
+
return unless pair
|
|
113
|
+
|
|
114
|
+
external_id, pos = pair
|
|
115
|
+
pos = skip_doctype_space(source, pos)
|
|
116
|
+
pair = scan_doctype_quoted(source, pos)
|
|
117
|
+
return unless pair
|
|
118
|
+
|
|
119
|
+
system_id, pos = pair
|
|
120
|
+
elsif keyword == "SYSTEM"
|
|
121
|
+
pos = skip_doctype_space(source, pos + 6)
|
|
122
|
+
pair = scan_doctype_quoted(source, pos)
|
|
123
|
+
return unless pair
|
|
124
|
+
|
|
125
|
+
system_id, pos = pair
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
pos = skip_doctype_space(source, pos)
|
|
129
|
+
internal_subset = nil
|
|
130
|
+
if source[pos] == "["
|
|
131
|
+
subset_start = pos + 1
|
|
132
|
+
pos = matching_subset_end(source, pos + 1)
|
|
133
|
+
return unless pos
|
|
134
|
+
|
|
135
|
+
internal_subset = source[subset_start...pos]
|
|
136
|
+
pos += 1
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
pos = skip_doctype_space(source, pos)
|
|
140
|
+
return unless source[pos] == ">"
|
|
141
|
+
|
|
142
|
+
{
|
|
143
|
+
name: name,
|
|
144
|
+
external_id: external_id,
|
|
145
|
+
system_id: system_id,
|
|
146
|
+
internal_subset: internal_subset,
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def skip_doctype_space(source, pos)
|
|
151
|
+
pos += 1 while pos < source.length &&
|
|
152
|
+
" \t\r\n".include?(source[pos])
|
|
153
|
+
pos
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def scan_doctype_quoted(source, pos)
|
|
157
|
+
quote = source[pos]
|
|
158
|
+
return unless QUOTES.include?(quote)
|
|
159
|
+
|
|
160
|
+
stop = source.index(quote, pos + 1)
|
|
161
|
+
return unless stop
|
|
162
|
+
|
|
163
|
+
[source[(pos + 1)...stop], stop + 1]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Index just past the closing bracket at nesting depth zero,
|
|
167
|
+
# or nil when the subset is unterminated.
|
|
168
|
+
def matching_subset_end(source, pos)
|
|
169
|
+
depth = 1
|
|
170
|
+
quote = nil
|
|
171
|
+
while pos < source.length
|
|
172
|
+
char = source[pos]
|
|
173
|
+
if quote
|
|
174
|
+
quote = nil if char == quote
|
|
175
|
+
elsif QUOTES.include?(char)
|
|
176
|
+
quote = char
|
|
177
|
+
elsif char == "["
|
|
178
|
+
depth += 1
|
|
179
|
+
elsif char == "]"
|
|
180
|
+
depth -= 1
|
|
181
|
+
return pos if depth.zero?
|
|
182
|
+
end
|
|
183
|
+
pos += 1
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
86
188
|
def parse(xml, options = {}, _context = nil)
|
|
87
189
|
# LibXML doesn't preserve DOCTYPE during parsing, so we need to extract it manually
|
|
88
190
|
xml_string = if xml.is_a?(String)
|
|
@@ -99,19 +201,24 @@ module Moxml
|
|
|
99
201
|
xml_string = preprocess_entities(xml_string)
|
|
100
202
|
|
|
101
203
|
# Extract DOCTYPE before parsing. Locate the literal first:
|
|
102
|
-
# a plain memscan
|
|
103
|
-
#
|
|
104
|
-
# and matching from the found offset keeps captures
|
|
105
|
-
# identical.
|
|
204
|
+
# a plain memscan keeps doctype-less documents (the common
|
|
205
|
+
# case) off the scanner path; the scan itself is linear.
|
|
106
206
|
doctype_idx = xml_string.index("<!DOCTYPE")
|
|
107
|
-
|
|
108
|
-
xml_string.match(DOCTYPE_RE, doctype_idx)
|
|
109
|
-
end
|
|
207
|
+
doctype = doctype_idx && doctype_parts(xml_string, doctype_idx)
|
|
110
208
|
|
|
111
209
|
native_doc = begin
|
|
112
210
|
# Handle both string and file inputs
|
|
113
211
|
parser = ::LibXML::XML::Parser.string(xml_string)
|
|
114
212
|
parser.parse
|
|
213
|
+
rescue ::ArgumentError => e
|
|
214
|
+
# Invalid byte sequences raise ArgumentError from Ruby's
|
|
215
|
+
# encoding checks before libxml sees the bytes; the
|
|
216
|
+
# facade names them (taxonomy differential, malformed
|
|
217
|
+
# class).
|
|
218
|
+
raise Moxml::ParseError.new(
|
|
219
|
+
e.message,
|
|
220
|
+
source: xml.is_a?(String) ? xml[0..100] : nil,
|
|
221
|
+
)
|
|
115
222
|
rescue ::LibXML::XML::Error => e
|
|
116
223
|
if options[:strict]
|
|
117
224
|
line = e.line
|
|
@@ -127,16 +234,17 @@ module Moxml
|
|
|
127
234
|
end
|
|
128
235
|
|
|
129
236
|
# Store DOCTYPE if found
|
|
130
|
-
if
|
|
131
|
-
name =
|
|
132
|
-
external_id =
|
|
133
|
-
system_id =
|
|
237
|
+
if doctype
|
|
238
|
+
name = doctype[:name]
|
|
239
|
+
external_id = doctype[:external_id]
|
|
240
|
+
system_id = doctype[:system_id]
|
|
134
241
|
|
|
135
242
|
doctype_wrapper = DoctypeWrapper.new(
|
|
136
243
|
native_doc,
|
|
137
244
|
name,
|
|
138
245
|
external_id,
|
|
139
246
|
system_id,
|
|
247
|
+
doctype[:internal_subset],
|
|
140
248
|
)
|
|
141
249
|
attachments.set(native_doc, :doctype, doctype_wrapper)
|
|
142
250
|
end
|
|
@@ -855,7 +963,8 @@ module Moxml
|
|
|
855
963
|
|
|
856
964
|
def processing_instruction_content(node)
|
|
857
965
|
# XML 1.0 §2.6: PI content is verbatim — no entity resolution.
|
|
858
|
-
|
|
966
|
+
# Empty data is the empty string, never nil (uniform Kind face).
|
|
967
|
+
unpatch_node(node)&.content.to_s
|
|
859
968
|
end
|
|
860
969
|
|
|
861
970
|
def set_processing_instruction_content(node, content)
|
|
@@ -936,12 +1045,12 @@ module Moxml
|
|
|
936
1045
|
|
|
937
1046
|
# libxml-ruby 5.x exposes no external/system ID accessor on
|
|
938
1047
|
# the DTD node — the DOCTYPE source text is the only source,
|
|
939
|
-
# so re-derive from it (same
|
|
1048
|
+
# so re-derive from it (same linear scanner as the parse path).
|
|
940
1049
|
def doctype_external_id(native)
|
|
941
1050
|
if native.is_a?(DoctypeWrapper)
|
|
942
1051
|
native.external_id
|
|
943
1052
|
else
|
|
944
|
-
native.to_s
|
|
1053
|
+
Libxml.doctype_parts(native.to_s)&.dig(:external_id)
|
|
945
1054
|
end
|
|
946
1055
|
end
|
|
947
1056
|
|
|
@@ -949,7 +1058,7 @@ module Moxml
|
|
|
949
1058
|
if native.is_a?(DoctypeWrapper)
|
|
950
1059
|
native.system_id
|
|
951
1060
|
else
|
|
952
|
-
|
|
1061
|
+
Libxml.doctype_parts(native.to_s)&.dig(:system_id)
|
|
953
1062
|
end
|
|
954
1063
|
end
|
|
955
1064
|
|
|
@@ -962,8 +1071,10 @@ module Moxml
|
|
|
962
1071
|
# expression from the document node is equivalent to the
|
|
963
1072
|
# absolute form — the document's only name-testable child
|
|
964
1073
|
# is the root — so rewrite instead of losing the match.
|
|
1074
|
+
# Function calls and unions are not name-test paths: a
|
|
1075
|
+
# "/count(...)" rewrite is invalid XPath (scalar arm).
|
|
965
1076
|
if native_node.is_a?(::LibXML::XML::Document) &&
|
|
966
|
-
|
|
1077
|
+
relative_name_path?(expression)
|
|
967
1078
|
expression = "/#{expression}"
|
|
968
1079
|
end
|
|
969
1080
|
|
|
@@ -997,6 +1108,16 @@ module Moxml
|
|
|
997
1108
|
results&.first
|
|
998
1109
|
end
|
|
999
1110
|
|
|
1111
|
+
# A relative name-test path (the only shape the document-context
|
|
1112
|
+
# rewrite is for): a name/`*`/`@` start, no function call (the
|
|
1113
|
+
# scalar arm), no union.
|
|
1114
|
+
def relative_name_path?(expression)
|
|
1115
|
+
return false unless expression.match?(/\A[A-Za-z_*@]/)
|
|
1116
|
+
return false if expression.match?(/\A[A-Za-z][\w.-]*\s*\(/)
|
|
1117
|
+
|
|
1118
|
+
!expression.include?("|")
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1000
1121
|
# Shallow duplication: copies the node itself (name, attrs, namespaces)
|
|
1001
1122
|
# but NOT its descendants (deep duplication composes this).
|
|
1002
1123
|
# walks the source tree and re-adds children one at a time via
|
|
@@ -1019,6 +1140,7 @@ module Moxml
|
|
|
1019
1140
|
node.name,
|
|
1020
1141
|
node.external_id,
|
|
1021
1142
|
node.system_id,
|
|
1143
|
+
node.internal_subset,
|
|
1022
1144
|
)
|
|
1023
1145
|
else
|
|
1024
1146
|
node
|
data/lib/moxml/adapter/oga.rb
CHANGED
|
@@ -40,6 +40,18 @@ module Moxml
|
|
|
40
40
|
)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Rootless parses (empty or declaration-only input) are
|
|
44
|
+
# malformed: XML requires exactly one root. Oga returns a
|
|
45
|
+
# bare document where nokogiri/leptris raise — the facade
|
|
46
|
+
# names it instead (loudness differential, TODO.sota/01).
|
|
47
|
+
if options[:strict] && native_doc.respond_to?(:root_node) &&
|
|
48
|
+
native_doc.children.none?(::Oga::XML::Element)
|
|
49
|
+
raise Moxml::ParseError.new(
|
|
50
|
+
"document has no root element",
|
|
51
|
+
source: xml.is_a?(String) ? xml[0..100] : nil,
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
43
55
|
# Oga parses multiple root elements leniently; the moxml
|
|
44
56
|
# contract requires a single root. to_a because the vendored
|
|
45
57
|
# fork's NodeSet#count ignores its block.
|
|
@@ -499,7 +511,13 @@ module Moxml
|
|
|
499
511
|
end
|
|
500
512
|
|
|
501
513
|
def processing_instruction_content(node)
|
|
502
|
-
|
|
514
|
+
# Oga keeps the target/content separator space inside the
|
|
515
|
+
# PI's text; the model's content is the data AFTER the
|
|
516
|
+
# separator (D-04). Separator whitespace is parser syntax, and
|
|
517
|
+
# leaving it in makes canonicalization non-idempotent (the
|
|
518
|
+
# serializer re-adds the separator each round — TODO.sota/02,
|
|
519
|
+
# M2).
|
|
520
|
+
node.text.to_s.sub(/\A\s+/, "")
|
|
503
521
|
end
|
|
504
522
|
|
|
505
523
|
def set_processing_instruction_content(node, content)
|
|
@@ -549,8 +567,20 @@ module Moxml
|
|
|
549
567
|
def xpath(node, expression, namespaces = nil)
|
|
550
568
|
result = node.xpath(expression, {},
|
|
551
569
|
namespaces: namespaces&.transform_keys(&:to_s))
|
|
552
|
-
# Adapter contract: Array<native> | scalar
|
|
553
|
-
|
|
570
|
+
# Adapter contract: Array<native> | scalar. Oga's engine
|
|
571
|
+
# answers node-sets in evaluation order, not document order
|
|
572
|
+
# (//* on <r><a><b/></a><c/></r> answers a,c,b); path results
|
|
573
|
+
# are document-ordered by the model — re-order here
|
|
574
|
+
# (TODO.sota/02, M3).
|
|
575
|
+
if result.is_a?(Enumerable)
|
|
576
|
+
nodes = result.to_a
|
|
577
|
+
ctx = Context.new(:oga)
|
|
578
|
+
wrapped = nodes.map { |n| Moxml::Node.wrap(n, ctx) }
|
|
579
|
+
ordered = Moxml::XPath::Ordering.document_order(wrapped)
|
|
580
|
+
ordered.map(&:native)
|
|
581
|
+
else
|
|
582
|
+
result
|
|
583
|
+
end
|
|
554
584
|
rescue ::LL::ParserError => e
|
|
555
585
|
raise Moxml::XPathError.new(
|
|
556
586
|
e.message,
|
data/lib/moxml/adapter/ox.rb
CHANGED
|
@@ -60,13 +60,28 @@ module Moxml
|
|
|
60
60
|
assign_parents(doc)
|
|
61
61
|
doc
|
|
62
62
|
end
|
|
63
|
-
rescue ::Ox::ParseError => e
|
|
63
|
+
rescue ::Ox::ParseError, ::RuntimeError => e
|
|
64
|
+
# Ox leaks raw RuntimeErrors from some malformed shapes
|
|
65
|
+
# (e.g. declaration-only input reaches Element#<<); the
|
|
66
|
+
# facade names every parse-time engine failure.
|
|
64
67
|
raise Moxml::ParseError.new(
|
|
65
68
|
e.message,
|
|
66
69
|
source: xml.is_a?(String) ? xml[0..100] : nil,
|
|
67
70
|
)
|
|
68
71
|
end
|
|
69
72
|
|
|
73
|
+
# Rootless parses (empty or declaration-only input) are
|
|
74
|
+
# malformed: XML requires exactly one root. Ox answers a
|
|
75
|
+
# bare document — the facade names it (uniform with the
|
|
76
|
+
# rexml/oga arms; loudness differential, TODO.sota/01).
|
|
77
|
+
if options[:strict] && native_doc.is_a?(::Ox::Document) &&
|
|
78
|
+
native_doc.nodes.none?(::Ox::Element)
|
|
79
|
+
raise Moxml::ParseError.new(
|
|
80
|
+
"document has no root element",
|
|
81
|
+
source: xml.is_a?(String) ? xml[0..100] : nil,
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
70
85
|
ctx = _context || Context.new(context_adapter_name)
|
|
71
86
|
Wrappers::Document.new(native_doc, ctx)
|
|
72
87
|
end
|
|
@@ -627,7 +642,11 @@ module Moxml
|
|
|
627
642
|
end
|
|
628
643
|
|
|
629
644
|
def processing_instruction_content(node)
|
|
630
|
-
|
|
645
|
+
# Ox keeps the target/content separator space inside the
|
|
646
|
+
# PI content; the model's content is the data AFTER the
|
|
647
|
+
# separator. Leaving it in makes canonicalization
|
|
648
|
+
# non-idempotent (TODO.sota/02, M2).
|
|
649
|
+
node.content.to_s.sub(/\A\s+/, "")
|
|
631
650
|
end
|
|
632
651
|
|
|
633
652
|
def set_processing_instruction_content(node, content)
|
|
@@ -708,6 +727,11 @@ module Moxml
|
|
|
708
727
|
end)
|
|
709
728
|
when NodeSet
|
|
710
729
|
dedupe_natives(result.to_a.map(&:native))
|
|
730
|
+
when Moxml::Node
|
|
731
|
+
# The engine answers a lone node for the context node
|
|
732
|
+
# (`.`); the adapter contract is a node-set for node
|
|
733
|
+
# results.
|
|
734
|
+
[result.native]
|
|
711
735
|
else
|
|
712
736
|
result
|
|
713
737
|
end
|
data/lib/moxml/adapter/rexml.rb
CHANGED
|
@@ -46,6 +46,29 @@ module Moxml
|
|
|
46
46
|
)
|
|
47
47
|
end
|
|
48
48
|
create_document
|
|
49
|
+
rescue ::RuntimeError => e
|
|
50
|
+
# REXML's entity-expansion cap (and any engine-level
|
|
51
|
+
# guard) surfaces as a bare RuntimeError; the facade
|
|
52
|
+
# names it (issue: taxonomy differential, huge-entity
|
|
53
|
+
# class; the cap itself is D-16).
|
|
54
|
+
raise Moxml::ParseError.new(
|
|
55
|
+
e.message,
|
|
56
|
+
source: xml.is_a?(String) ? xml[0..100] : nil,
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Rootless parses (empty or declaration-only input) are
|
|
61
|
+
# malformed: XML requires exactly one root. Nokogiri and
|
|
62
|
+
# leptris raise at the engine; rexml returns a bare
|
|
63
|
+
# document — the facade names it instead (loudness
|
|
64
|
+
# differential, TODO.sota/01). Recover mode keeps the
|
|
65
|
+
# rootless-document fallback with its diagnostics (#147).
|
|
66
|
+
if options[:strict] && native_doc.respond_to?(:root) &&
|
|
67
|
+
native_doc.root.nil?
|
|
68
|
+
raise Moxml::ParseError.new(
|
|
69
|
+
"document has no root element",
|
|
70
|
+
source: xml.is_a?(String) ? xml[0..100] : nil,
|
|
71
|
+
)
|
|
49
72
|
end
|
|
50
73
|
|
|
51
74
|
ctx = _context || Context.new(:rexml)
|
|
@@ -435,7 +458,7 @@ module Moxml
|
|
|
435
458
|
end
|
|
436
459
|
|
|
437
460
|
def processing_instruction_content(node)
|
|
438
|
-
node.content
|
|
461
|
+
node.content.to_s
|
|
439
462
|
end
|
|
440
463
|
|
|
441
464
|
def set_processing_instruction_content(node, content)
|
|
@@ -458,17 +481,26 @@ module Moxml
|
|
|
458
481
|
return "" unless element
|
|
459
482
|
|
|
460
483
|
text = ""
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
484
|
+
begin
|
|
485
|
+
element.children.each do |child|
|
|
486
|
+
case child
|
|
487
|
+
when ::REXML::Text
|
|
488
|
+
# Preserve original spacing from text nodes exactly including newlines and all whitespace
|
|
489
|
+
text += child.value
|
|
490
|
+
when ::REXML::Element
|
|
491
|
+
# Extract text recursively from child element
|
|
492
|
+
child_text = extract_text_recursively(child)
|
|
493
|
+
# Concatenate directly like other adapters - NO SPACE INSERTION
|
|
494
|
+
text += child_text
|
|
495
|
+
end
|
|
471
496
|
end
|
|
497
|
+
rescue ::RuntimeError => e
|
|
498
|
+
# rexml expands entities lazily: the parse accepts, the
|
|
499
|
+
# read hits the expansion guard (D-16). The facade names
|
|
500
|
+
# it instead of leaking the bare RuntimeError.
|
|
501
|
+
raise Moxml::Error,
|
|
502
|
+
"entity expansion limit exceeded (rexml guard, D-16): " \
|
|
503
|
+
"#{e.message}"
|
|
472
504
|
end
|
|
473
505
|
# Don't strip - preserve original spacing including newlines
|
|
474
506
|
text
|
|
@@ -612,11 +644,29 @@ module Moxml
|
|
|
612
644
|
# namespaced elements never match (issue #395).
|
|
613
645
|
namespaces = namespaces.transform_keys(&:to_s) if
|
|
614
646
|
namespaces.is_a?(Hash)
|
|
615
|
-
if namespaces && !namespaces.empty?
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
647
|
+
results = if namespaces && !namespaces.empty?
|
|
648
|
+
::REXML::XPath.match(node, expression, namespaces).to_a
|
|
649
|
+
else
|
|
650
|
+
::REXML::XPath.match(node, expression).to_a
|
|
651
|
+
end
|
|
652
|
+
# REXML answers unions in expression order (//a | //b on
|
|
653
|
+
# <r><b/><a/></r> answers a,a,b); path results are
|
|
654
|
+
# document-ordered by the model (TODO.sota/02, M3) —
|
|
655
|
+
# re-order here, the oga lane's pattern.
|
|
656
|
+
return results if results.empty?
|
|
657
|
+
|
|
658
|
+
# Scalar arm: count()/string()/boolean()/arithmetic answer a
|
|
659
|
+
# one-element array holding the value; the adapter contract
|
|
660
|
+
# passes scalars through unwrapped (numbers as Float,
|
|
661
|
+
# uniform with the other adapters).
|
|
662
|
+
unless results.first.is_a?(::REXML::Node)
|
|
663
|
+
value = results.first
|
|
664
|
+
return value.is_a?(Numeric) ? value.to_f : value
|
|
619
665
|
end
|
|
666
|
+
|
|
667
|
+
ctx = Context.new(:rexml)
|
|
668
|
+
wrapped = results.map { |n| Moxml::Node.wrap(n, ctx) }
|
|
669
|
+
Moxml::XPath::Ordering.document_order(wrapped).map(&:native)
|
|
620
670
|
rescue ::REXML::ParseException => e
|
|
621
671
|
raise Moxml::XPathError.new(
|
|
622
672
|
e.message,
|
data/lib/moxml/context.rb
CHANGED
|
@@ -119,7 +119,11 @@ module Moxml
|
|
|
119
119
|
end
|
|
120
120
|
# Allocation-free declaration sniff: String#strip would copy the
|
|
121
121
|
# whole buffer just to test its prefix.
|
|
122
|
-
|
|
122
|
+
# Invalid byte sequences would raise from the regex before any
|
|
123
|
+
# adapter sees them; the adapters' named parse errors own
|
|
124
|
+
# malformed input (taxonomy differential, malformed class).
|
|
125
|
+
has_declaration = xml_string.valid_encoding? &&
|
|
126
|
+
xml_string.match?(/\A[ \t\r\n\f\v\0]*<\?xml/)
|
|
123
127
|
|
|
124
128
|
# Parse with adapter, passing self (context) so adapter can use our config
|
|
125
129
|
parsed_options = default_options.merge(options)
|
data/lib/moxml/document.rb
CHANGED
|
@@ -272,7 +272,7 @@ module Moxml
|
|
|
272
272
|
# - NodeSet: already wrapped, return directly
|
|
273
273
|
# - Array / LazyNodeSet: wrap in NodeSet
|
|
274
274
|
case result
|
|
275
|
-
when NodeSet,
|
|
275
|
+
when NodeSet, Numeric, String, TrueClass, FalseClass, NilClass
|
|
276
276
|
result
|
|
277
277
|
when Array, LazyNodeSet
|
|
278
278
|
NodeSet.new(result, context)
|
|
@@ -282,12 +282,12 @@ module Moxml
|
|
|
282
282
|
end
|
|
283
283
|
end
|
|
284
284
|
|
|
285
|
+
# Sugar over xpath: the first node of a node-set; scalar results
|
|
286
|
+
# (count(), string(), boolean()) pass through unwrapped and
|
|
287
|
+
# uniform across adapters.
|
|
285
288
|
def at_xpath(expression, namespaces = nil)
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (native_node = adapter.at_xpath(@native, expression, namespaces))
|
|
289
|
-
Moxml::Node.wrap(native_node, context)
|
|
290
|
-
end
|
|
289
|
+
result = xpath(expression, namespaces)
|
|
290
|
+
result.is_a?(NodeSet) ? result.first : result
|
|
291
291
|
end
|
|
292
292
|
|
|
293
293
|
# Quick element creation and addition
|
data/lib/moxml/node.rb
CHANGED
|
@@ -367,9 +367,12 @@ module Moxml
|
|
|
367
367
|
Materializer.materialize_fields(self, &block)
|
|
368
368
|
end
|
|
369
369
|
|
|
370
|
+
# Sugar over xpath: the first node of a node-set; scalar results
|
|
371
|
+
# (count(), string(), boolean()) pass through unwrapped and
|
|
372
|
+
# uniform across adapters.
|
|
370
373
|
def at_xpath(expression, namespaces = {})
|
|
371
|
-
|
|
372
|
-
|
|
374
|
+
result = xpath(expression, namespaces)
|
|
375
|
+
result.is_a?(NodeSet) ? result.first : result
|
|
373
376
|
end
|
|
374
377
|
|
|
375
378
|
# Nokogiri-compatible sugar: at/search/at_css/css. Nokogiri's
|
|
@@ -410,10 +413,16 @@ module Moxml
|
|
|
410
413
|
end
|
|
411
414
|
|
|
412
415
|
# Nokogiri-compatible depth-first traversal: yields self, then
|
|
413
|
-
# children recursively (document-order visitor).
|
|
416
|
+
# children recursively (document-order visitor). Document-level
|
|
417
|
+
# parts (Declaration, Doctype) are state, not nodes: they are
|
|
418
|
+
# never yielded, and their (adapter-specific, sometimes missing)
|
|
419
|
+
# child faces are never consulted (issue-free by the model's
|
|
420
|
+
# object-model rule; found by the taxonomy differential).
|
|
414
421
|
def traverse(&block)
|
|
415
422
|
return to_enum(:traverse) unless block
|
|
416
423
|
|
|
424
|
+
return self if is_a?(Moxml::Declaration) || is_a?(Moxml::Doctype)
|
|
425
|
+
|
|
417
426
|
yield self
|
|
418
427
|
children.each { |child| child.traverse(&block) }
|
|
419
428
|
self
|
|
@@ -682,9 +691,16 @@ module Moxml
|
|
|
682
691
|
end
|
|
683
692
|
end
|
|
684
693
|
|
|
685
|
-
# The
|
|
686
|
-
#
|
|
687
|
-
#
|
|
694
|
+
# The node's kind (the model's Kind face): :document, :element,
|
|
695
|
+
# :text, :comment, :cdata, :processing_instruction, :doctype,
|
|
696
|
+
# :declaration, :entity_reference, :namespace, :attribute. The
|
|
697
|
+
# adapter's type probe is an FFI call per invocation; the type is
|
|
698
|
+
# fixed for a node's lifetime, so the first answer is memoized on
|
|
699
|
+
# the wrapper.
|
|
700
|
+
def node_type
|
|
701
|
+
node_type_cached
|
|
702
|
+
end
|
|
703
|
+
|
|
688
704
|
def node_type_cached
|
|
689
705
|
@node_type_cached ||= adapter.node_type(@native)
|
|
690
706
|
end
|
data/lib/moxml/node_set.rb
CHANGED
|
@@ -119,6 +119,17 @@ module Moxml
|
|
|
119
119
|
|
|
120
120
|
# Nokogiri-compatible: Array#flatten and multiple-assignment
|
|
121
121
|
# treat a NodeSet as its wrapped-element array via to_ary.
|
|
122
|
+
# The same node-set in document order (XPath path results are
|
|
123
|
+
# conventionally document-ordered; the compiler batches multi-step
|
|
124
|
+
# evaluation and must re-order). Wrappers are ordered, then the
|
|
125
|
+
# internal natives are re-paired — no re-wrapping.
|
|
126
|
+
def document_order
|
|
127
|
+
return self if native_nodes.size < 2
|
|
128
|
+
|
|
129
|
+
ordered = Moxml::XPath::Ordering.document_order(to_a)
|
|
130
|
+
NodeSet.new(ordered.map(&:native), @context, @parent_node)
|
|
131
|
+
end
|
|
132
|
+
|
|
122
133
|
def to_ary
|
|
123
134
|
to_a
|
|
124
135
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module Reference
|
|
5
|
+
# Pure-Ruby normalization faces: XML 1.0 §2.11 line endings and
|
|
6
|
+
# entity expansion. These are the model's normative answers with
|
|
7
|
+
# no engine involved; adapters are differentially tested against
|
|
8
|
+
# them.
|
|
9
|
+
module Normalizer
|
|
10
|
+
BUILTIN_ENTITIES = {
|
|
11
|
+
"amp" => "&", "lt" => "<", "gt" => ">",
|
|
12
|
+
"quot" => '"', "apos" => "'"
|
|
13
|
+
}.freeze
|
|
14
|
+
ENTITY_REFERENCE = /&([^;&<>]*);/
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# CRLF → LF; bare CR → LF. Character references are untouched
|
|
19
|
+
# (
 keeps a literal CR).
|
|
20
|
+
def normalize_eol(text)
|
|
21
|
+
text.to_s.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Attribute-value normalization: literal tab/newline/CR become a
|
|
25
|
+
# space (character references keep their value).
|
|
26
|
+
def normalize_attribute_literal(text)
|
|
27
|
+
text.to_s.gsub(/[\t\n\r]/, " ")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Expand built-in and numeric references; custom entities come
|
|
31
|
+
# from the provided table. An undefined reference raises a named
|
|
32
|
+
# ParseError (the model has no silently unresolved entities).
|
|
33
|
+
def expand_entities(text, entities = {})
|
|
34
|
+
text.to_s.gsub(ENTITY_REFERENCE) do
|
|
35
|
+
reference = Regexp.last_match(1)
|
|
36
|
+
resolve(reference, BUILTIN_ENTITIES.merge(entities))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve(reference, entities)
|
|
41
|
+
if reference.start_with?("#x")
|
|
42
|
+
numeric(reference[2..], 16)
|
|
43
|
+
elsif reference.start_with?("#")
|
|
44
|
+
numeric(reference[1..], 10)
|
|
45
|
+
elsif BUILTIN_ENTITIES.key?(reference)
|
|
46
|
+
BUILTIN_ENTITIES[reference]
|
|
47
|
+
else
|
|
48
|
+
value = entities[reference]
|
|
49
|
+
raise ParseError, "undefined entity &#{reference};" unless value
|
|
50
|
+
|
|
51
|
+
expand_entities(value, entities)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def numeric(digits, base)
|
|
56
|
+
pattern = base == 16 ? /\A[0-9A-Fa-f]+\z/ : /\A[0-9]+\z/
|
|
57
|
+
raise ParseError, "malformed character reference" unless digits.match?(pattern)
|
|
58
|
+
|
|
59
|
+
[digits.to_i(base)].pack("U")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|