moxml 0.5.166 → 0.5.168

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.
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module XSD
5
+ # Facade-owned lexical validation for the XSD built-in simple
6
+ # types (Datatypes Part 2). One committed answer for every
7
+ # adapter: engines with their own tables converge to this one,
8
+ # never the reverse (#482). The check is lexical only — pattern,
9
+ # enumeration and other constraining facets belong to the
10
+ # schema, not to the built-in's own space.
11
+ module BuiltinLexical
12
+ module_function
13
+
14
+ # Answers for the "xs:NAME" reference spelling. Unknown names
15
+ # raise ArgumentError — the facade never guesses.
16
+ def valid?(builtin, lexical)
17
+ checker = CHECKERS.fetch(builtin) do
18
+ raise ArgumentError, "unknown built-in type #{builtin.inspect}"
19
+ end
20
+
21
+ checker.call(lexical.to_s)
22
+ end
23
+
24
+ # XML 1.0 5th ed. name productions. NCName excludes ":";
25
+ # xs:Name and xs:NMTOKEN admit it.
26
+ NCNAME_START_CLASS =
27
+ "A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}" \
28
+ "\u{37F}-\u{1FFF}\u{200C}-\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}" \
29
+ "\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}"
30
+ NCNAME_CHAR_CLASS =
31
+ "#{NCNAME_START_CLASS}0-9.\\-\u{B7}\u{300}-\u{36F}\u{203F}-\u{2040}".freeze
32
+
33
+ NCNAME = /\A[#{NCNAME_START_CLASS}][#{NCNAME_CHAR_CLASS}]*\z/
34
+ NAME = /\A[:#{NCNAME_START_CLASS}][:#{NCNAME_CHAR_CLASS}]*\z/
35
+ NMTOKEN = /\A[:#{NCNAME_CHAR_CLASS}]+\z/
36
+
37
+ INTEGER_LEXICAL = /\A[+-]?[0-9]+\z/
38
+ DECIMAL_LEXICAL = /\A[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)\z/
39
+ FLOAT_LEXICAL = /\A(?:[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?|[+-]?INF|NaN)\z/
40
+ BOOLEAN_LEXICAL = /\A(?:true|false|0|1)\z/
41
+ LANGUAGE_LEXICAL = /\A[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*\z/
42
+ HEX_LEXICAL = /\A(?:[0-9A-Fa-f]{2})*\z/
43
+ BASE64_LEXICAL = /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/
44
+
45
+ DATE_PART = /-?(?:[1-9][0-9]{3,}|0[0-9]{3})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])/
46
+ TIME_PART = /(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?/
47
+ ZONE = /(?:Z|[+-](?:0[0-9]|1[0-3]):[0-5][0-9]|14:00)/
48
+ DATE_LEXICAL = /\A#{DATE_PART}#{ZONE}?\z/
49
+ TIME_LEXICAL = /\A#{TIME_PART}#{ZONE}?\z/
50
+ DATE_TIME_LEXICAL = /\A#{DATE_PART}T#{TIME_PART}#{ZONE}?\z/
51
+ DATE_TIME_STAMP_LEXICAL = /\A#{DATE_PART}T#{TIME_PART}#{ZONE}\z/
52
+ G_YEAR_LEXICAL = /\A-?(?:[1-9][0-9]{3,}|0[0-9]{3})#{ZONE}?\z/
53
+ G_YEAR_MONTH_LEXICAL = /\A-?(?:[1-9][0-9]{3,}|0[0-9]{3})-(?:0[1-9]|1[0-2])#{ZONE}?\z/
54
+ G_MONTH_LEXICAL = /\A--(?:0[1-9]|1[0-2])#{ZONE}?\z/
55
+ G_DAY_LEXICAL = /\A---(?:0[1-9]|[12][0-9]|3[01])#{ZONE}?\z/
56
+ G_MONTH_DAY_LEXICAL = /\A--(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])#{ZONE}?\z/
57
+ DURATION_LEXICAL = /\A-?P(?:[0-9]+Y)?(?:[0-9]+M)?(?:[0-9]+D)?(?:T(?:[0-9]+H)?(?:[0-9]+M)?(?:[0-9]+(?:\.[0-9]+)?S)?)?\z/
58
+
59
+ MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
60
+
61
+ private_constant :NCNAME_START_CLASS, :NCNAME_CHAR_CLASS, :NCNAME,
62
+ :NAME, :NMTOKEN, :INTEGER_LEXICAL, :DECIMAL_LEXICAL,
63
+ :FLOAT_LEXICAL, :BOOLEAN_LEXICAL, :LANGUAGE_LEXICAL,
64
+ :HEX_LEXICAL, :BASE64_LEXICAL, :DATE_PART,
65
+ :TIME_PART, :ZONE, :DATE_LEXICAL, :TIME_LEXICAL,
66
+ :DATE_TIME_LEXICAL, :DATE_TIME_STAMP_LEXICAL,
67
+ :G_YEAR_LEXICAL, :G_YEAR_MONTH_LEXICAL,
68
+ :G_MONTH_LEXICAL, :G_DAY_LEXICAL,
69
+ :G_MONTH_DAY_LEXICAL, :DURATION_LEXICAL, :MONTH_DAYS
70
+
71
+ # whiteSpace=collapse, shared by token and its derivatives.
72
+ def collapsed(lexical)
73
+ lexical.gsub(/[ \t\r\n]+/, " ").strip
74
+ end
75
+
76
+ def ncname?(lexical)
77
+ NCNAME.match?(lexical)
78
+ end
79
+
80
+ # A QName is an optional NCName prefix, a colon, and an NCName
81
+ # local part. The "xmlns" prefix is reserved (Namespaces in
82
+ # XML) and never a legal QName prefix.
83
+ def qname?(lexical)
84
+ prefix, colon, local = lexical.partition(":")
85
+ return ncname?(lexical) if colon.empty?
86
+
87
+ return false if prefix == "xmlns"
88
+
89
+ ncname?(prefix) && ncname?(local)
90
+ end
91
+
92
+ # List lexical spaces are whitespace-separated items, at least
93
+ # one; every item must be a valid item-type lexical.
94
+ def list?(lexical, &block)
95
+ items = lexical.split(/[ \t\r\n]+/).reject(&:empty?)
96
+ !items.empty? && items.all?(&block)
97
+ end
98
+
99
+ # The grammar admits a bare "T" and the empty body; the
100
+ # lexical space requires at least one component, and a "T"
101
+ # must be followed by one.
102
+ def duration?(lexical)
103
+ return false unless DURATION_LEXICAL.match?(lexical)
104
+
105
+ body = lexical.sub(/\A-?P/, "")
106
+ if (time_at = body.index("T"))
107
+ /[0-9]/.match?(body[(time_at + 1)..].to_s)
108
+ else
109
+ /[0-9]/.match?(body)
110
+ end
111
+ end
112
+
113
+ def day_time_duration?(lexical)
114
+ return false unless duration?(lexical)
115
+
116
+ head = lexical.split("T", 2).first.to_s
117
+ !/[0-9]+[YM]/.match?(head)
118
+ end
119
+
120
+ def year_month_duration?(lexical)
121
+ duration?(lexical) && !/[0-9]+D/.match?(lexical) &&
122
+ !lexical.include?("T")
123
+ end
124
+
125
+ def leap_year?(year)
126
+ ((year % 4).zero? && !(year % 100).zero?) || (year % 400).zero?
127
+ end
128
+
129
+ # Dates carry calendar validity in their lexical space: the
130
+ # day must exist for the month and year (Gregorian, leap
131
+ # rules on the absolute year).
132
+ def calendar_date?(year, month, day)
133
+ return false unless month.between?(1, 12)
134
+
135
+ limit = MONTH_DAYS[month - 1]
136
+ limit += 1 if month == 2 && leap_year?(year.abs)
137
+ day.between?(1, limit)
138
+ end
139
+
140
+ def date_fields(lexical)
141
+ match = /\A(-?[0-9]{4,})-([0-9]{2})-([0-9]{2})/.match(lexical)
142
+ return unless match
143
+
144
+ match.captures.map { |field| Integer(field) }
145
+ end
146
+
147
+ def with_date_fields(lexical)
148
+ year, month, day = date_fields(lexical)
149
+ year && yield(year, month, day)
150
+ end
151
+
152
+ def integer?(lexical, range: nil)
153
+ return false unless INTEGER_LEXICAL.match?(lexical)
154
+
155
+ range.nil? || range.cover?(Integer(lexical))
156
+ end
157
+
158
+ def signed(range)
159
+ ->(lexical) { integer?(lexical, range: range) }
160
+ end
161
+
162
+ private_class_method :collapsed, :ncname?, :qname?, :list?,
163
+ :duration?, :day_time_duration?,
164
+ :year_month_duration?, :leap_year?,
165
+ :calendar_date?, :date_fields,
166
+ :with_date_fields, :integer?, :signed
167
+
168
+ CHECKERS = {
169
+ "xs:anySimpleType" => ->(_lexical) { true },
170
+ "xs:anyAtomicType" => ->(_lexical) { true },
171
+ "xs:anyType" => ->(_lexical) { true },
172
+ "xs:string" => ->(_lexical) { true },
173
+ "xs:normalizedString" => ->(_lexical) { true },
174
+ "xs:token" => ->(_lexical) { true },
175
+ "xs:language" => ->(lexical) { LANGUAGE_LEXICAL.match?(collapsed(lexical)) },
176
+ "xs:Name" => ->(lexical) { NAME.match?(collapsed(lexical)) },
177
+ "xs:NCName" => ->(lexical) { ncname?(collapsed(lexical)) },
178
+ "xs:ID" => ->(lexical) { ncname?(collapsed(lexical)) },
179
+ "xs:IDREF" => ->(lexical) { ncname?(collapsed(lexical)) },
180
+ "xs:ENTITY" => ->(lexical) { ncname?(collapsed(lexical)) },
181
+ "xs:NMTOKEN" => ->(lexical) { NMTOKEN.match?(collapsed(lexical)) },
182
+ "xs:IDREFS" => ->(lexical) { list?(lexical) { |item| ncname?(item) } },
183
+ "xs:ENTITIES" => ->(lexical) { list?(lexical) { |item| ncname?(item) } },
184
+ "xs:NMTOKENS" => ->(lexical) { list?(lexical) { |item| NMTOKEN.match?(item) } },
185
+ "xs:boolean" => ->(lexical) { BOOLEAN_LEXICAL.match?(lexical) },
186
+ "xs:decimal" => ->(lexical) { DECIMAL_LEXICAL.match?(lexical) },
187
+ "xs:precisionDecimal" => ->(lexical) { FLOAT_LEXICAL.match?(lexical) },
188
+ "xs:float" => ->(lexical) { FLOAT_LEXICAL.match?(lexical) },
189
+ "xs:double" => ->(lexical) { FLOAT_LEXICAL.match?(lexical) },
190
+ "xs:integer" => ->(lexical) { integer?(lexical) },
191
+ "xs:nonPositiveInteger" => signed(-Float::INFINITY..0),
192
+ "xs:negativeInteger" => signed(-Float::INFINITY...0),
193
+ "xs:long" => signed(-(2**63)..((2**63) - 1)),
194
+ "xs:int" => signed(-(2**31)..((2**31) - 1)),
195
+ "xs:short" => signed(-32_768..32_767),
196
+ "xs:byte" => signed(-128..127),
197
+ "xs:nonNegativeInteger" => signed(0..Float::INFINITY),
198
+ "xs:positiveInteger" => signed(1..Float::INFINITY),
199
+ "xs:unsignedLong" => signed(0..((2**64) - 1)),
200
+ "xs:unsignedInt" => signed(0..((2**32) - 1)),
201
+ "xs:unsignedShort" => signed(0..65_535),
202
+ "xs:unsignedByte" => signed(0..255),
203
+ "xs:duration" => ->(lexical) { duration?(lexical) },
204
+ "xs:dayTimeDuration" => ->(lexical) { day_time_duration?(lexical) },
205
+ "xs:yearMonthDuration" => ->(lexical) { year_month_duration?(lexical) },
206
+ "xs:dateTime" => ->(lexical) do
207
+ with_date_fields(lexical) do |y, m, d|
208
+ DATE_TIME_LEXICAL.match?(lexical) && calendar_date?(y, m, d)
209
+ end
210
+ end,
211
+ "xs:dateTimeStamp" => ->(lexical) do
212
+ with_date_fields(lexical) do |y, m, d|
213
+ DATE_TIME_STAMP_LEXICAL.match?(lexical) && calendar_date?(y, m, d)
214
+ end
215
+ end,
216
+ "xs:time" => ->(lexical) { TIME_LEXICAL.match?(lexical) },
217
+ "xs:date" => ->(lexical) do
218
+ with_date_fields(lexical) do |y, m, d|
219
+ DATE_LEXICAL.match?(lexical) && calendar_date?(y, m, d)
220
+ end
221
+ end,
222
+ "xs:gYear" => ->(lexical) { G_YEAR_LEXICAL.match?(lexical) },
223
+ "xs:gYearMonth" => ->(lexical) do
224
+ G_YEAR_MONTH_LEXICAL.match?(lexical) &&
225
+ with_date_fields("#{lexical.sub(/(?:Z|[+-][0-9]{2}:[0-9]{2})\z/, '')}-01") do |y, m, d|
226
+ calendar_date?(y, m, d)
227
+ end
228
+ end,
229
+ "xs:gMonth" => ->(lexical) { G_MONTH_LEXICAL.match?(lexical) },
230
+ "xs:gDay" => ->(lexical) { G_DAY_LEXICAL.match?(lexical) },
231
+ "xs:gMonthDay" => ->(lexical) do
232
+ match = /\A--([0-9]{2})-([0-9]{2})/.match(lexical)
233
+ next false unless match
234
+
235
+ month = Integer(match[1])
236
+ limit = MONTH_DAYS[month - 1]
237
+ limit += 1 if month == 2
238
+ G_MONTH_DAY_LEXICAL.match?(lexical) &&
239
+ Integer(match[2]).between?(1, limit)
240
+ end,
241
+ "xs:hexBinary" => ->(lexical) { HEX_LEXICAL.match?(lexical) },
242
+ "xs:base64Binary" => ->(lexical) { BASE64_LEXICAL.match?(lexical.delete(" \t\r\n")) },
243
+ "xs:anyURI" => ->(_lexical) { true },
244
+ "xs:QName" => ->(lexical) { qname?(collapsed(lexical)) },
245
+ "xs:NOTATION" => ->(lexical) { qname?(collapsed(lexical)) },
246
+ }.freeze
247
+
248
+ private_constant :CHECKERS
249
+ end
250
+ end
251
+ end
data/lib/moxml/xsd.rb CHANGED
@@ -33,6 +33,7 @@ module Moxml
33
33
  SUPPORTED_ADAPTERS = %i[leptris nokogiri].freeze
34
34
 
35
35
  autoload :SchemaRules, "moxml/xsd/schema_rules"
36
+ autoload :BuiltinLexical, "moxml/xsd/builtin_lexical"
36
37
 
37
38
  # One validation finding, harmonized across adapters (#362.2):
38
39
  # +message+ always present; +line+ / +column+ when the engine
@@ -76,12 +77,12 @@ module Moxml
76
77
  adapter.const_get(:XSD_SUPPORTED)
77
78
  end
78
79
 
79
- # Lexical validation against the built-in type table (the
80
- # "xs:NAME" reference spelling). Raises ArgumentError when the
81
- # name is not in the table; NotImplementedError on adapters
82
- # without an engine.
83
- def builtin_valid?(context, builtin, lexical)
84
- context.config.adapter.xsd_builtin_valid?(builtin, lexical)
80
+ # Lexical validation against the facade-owned built-in type
81
+ # table (the "xs:NAME" reference spelling). One committed
82
+ # answer on every adapter (#482); raises ArgumentError when
83
+ # the name is not in the table.
84
+ def builtin_valid?(_context, builtin, lexical)
85
+ BuiltinLexical.valid?(builtin, lexical)
85
86
  end
86
87
  end
87
88
 
@@ -177,15 +178,49 @@ module Moxml
177
178
  @context.config.adapter.xsd_declarations(@handle)
178
179
  end
179
180
 
180
- # The effective schema version the source declares (#460 item
181
- # 2): "1.1" when the root carries vc:minVersion="1.1", else
182
- # "1.0". Consumers (lutaml-xsd) scope 1.0-only checks with
183
- # it; the engine's application of 1.1 semantics is the
184
- # engine-side program.
181
+ # The effective schema version (#460 item 2): "1.1" or
182
+ # "1.0". The engine's answer (root @version) can only
183
+ # UPGRADE to 1.1 — it never downgrades the source-derived
184
+ # answer, because vc:minVersion="1.1" selects 1.1 semantics
185
+ # even when @version is absent, and @version="1.1" alongside
186
+ # vc:minVersion="1.0" is an authoring contradiction the
187
+ # source annotation wins.
185
188
  def version
189
+ adapter = @context.config.adapter
190
+ engine = if adapter.respond_to?(:xsd_version)
191
+ adapter.xsd_version(@handle)
192
+ end
193
+ return "1.1" if engine == "1.1"
194
+
186
195
  @rules_index ? @rules_index.version : "1.0"
187
196
  end
188
197
 
198
+ # One row per engine finding with the structured fields the
199
+ # #1843 report carries (moxml#460): kind, component, line,
200
+ # column, message. Nil when the binding predates the face —
201
+ # callers keep using #validate/#errors for the flat shape.
202
+ EngineFinding = Struct.new(:kind, :component, :line, :column,
203
+ :message)
204
+
205
+ def structured_errors(node_or_xml)
206
+ check_released!
207
+ doc = case node_or_xml
208
+ when Moxml::Document then node_or_xml
209
+ when Moxml::Node then node_or_xml.document
210
+ else @context.parse(node_or_xml.to_s)
211
+ end
212
+ adapter = @context.config.adapter
213
+ rows = if adapter.respond_to?(:xsd_structured_errors)
214
+ adapter.xsd_structured_errors(@handle, doc.native)
215
+ end
216
+ return nil unless rows
217
+
218
+ rows.map do |r|
219
+ EngineFinding.new(r.kind, r.component, r.line, r.column,
220
+ r.message)
221
+ end
222
+ end
223
+
189
224
  # Deterministic drop (#362.3): true when the adapter freed
190
225
  # the engine-side schema NOW; false when the adapter can only
191
226
  # wait for GC (documented per adapter). After a true release
@@ -122,6 +122,19 @@ RSpec.describe "XPath idioms across adapters" do
122
122
  expect(doc.at_xpath("count(//a)")).to eq(1.0)
123
123
  end
124
124
 
125
+ # The parent of an attribute node is its OWNER element
126
+ # (XPath 1.0 section 2.2) — the load-bearing arm of standoc's
127
+ # svg id disambiguation (#474).
128
+ it "answers the parent-of-attribute step with the owner element" do
129
+ doc = ctx.parse("<r><svg id='s'><use id='u'/></svg></r>")
130
+ svg = doc.xpath("//*[local-name()='svg']").first
131
+ expect(svg.xpath("./@id/..").map(&:name)).to eq(["svg"])
132
+ expect(svg.xpath("./@id/parent::*").map(&:name)).to eq(["svg"])
133
+ expect(
134
+ svg.xpath(".//*[@id] | ./@id/..").map(&:name).sort,
135
+ ).to eq(%w[svg use])
136
+ end
137
+
125
138
  # The live-tree contract: a sibling inserted after an XPath
126
139
  # evaluation must be visible to at_xpath and xpath. leptris's
127
140
  # node-set lane caches a document index that sibling insertions
@@ -136,6 +149,34 @@ RSpec.describe "XPath idioms across adapters" do
136
149
  expect(doc.xpath("//c").map(&:name)).to eq(["c"])
137
150
  expect(doc.at_xpath("count(//c)")).to eq(1.0)
138
151
  end
152
+
153
+ # Attribute wrappers participate in node equality and document
154
+ # ordering: standoc's svg cleanup compares and sorts result
155
+ # sets that carry parent-wired attributes. Equality across an
156
+ # attribute and a node must answer (false), never raise, and
157
+ # two wrappers over one attribute are the same node.
158
+ it "compares attribute identity and orders attribute results" do
159
+ doc = ctx.parse("<r><svg id='a'><p>x</p></svg><svg id='b'/></r>")
160
+
161
+ attr_a = doc.xpath("//*[@id='a']").first.attributes.to_a.first
162
+ attr_b = doc.xpath("//*[@id='b']").first.attributes.to_a.first
163
+ expect(doc.root.children.to_a.first == attr_a).to be(false)
164
+ expect(
165
+ attr_a == doc.xpath("//*[@id='a']").first.attributes.to_a.first,
166
+ ).to be(true)
167
+ expect(attr_a == attr_b).to be(false)
168
+
169
+ expect(doc.xpath("//@id").map(&:value)).to eq(%w[a b])
170
+
171
+ # XPath document order: an attribute sorts after its owner
172
+ # element and before the element's children.
173
+ svg = doc.xpath("//*[@id='a']").first
174
+ expect(
175
+ svg.xpath("./@* | ./p").map do |node|
176
+ node.is_a?(Moxml::Attribute) ? "@#{node.name}" : node.name
177
+ end,
178
+ ).to eq(%w[@id p])
179
+ end
139
180
  end
140
181
  end
141
182
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # leptris#1866: a cross-document replace used to hand back the
6
+ # SCRATCH document's wrapper — the adapter's insert deep-copies the
7
+ # subtree into the target, so the returned wrapper's native pointer
8
+ # belonged to the scratch arena. Callers stored it (svg cleanup
9
+ # keeps `new_svg` for id bookkeeping), the scratch was collected,
10
+ # and every later read of the stored wrapper walked freed memory:
11
+ # serializer segfaults and `<use ="">` empty-attribute names in
12
+ # the output.
13
+ RSpec.describe "leptris cross-document replace survival" do
14
+ let(:context) { Moxml.new(:leptris) }
15
+
16
+ it "keeps the installed wrapper valid after the scratch is collected" do
17
+ host = context.parse(
18
+ '<root xmlns:xlink="http://www.w3.org/1999/xlink">' \
19
+ '<svg xmlns="http://www.w3.org/2000/svg" id="s1">' \
20
+ '<use xlink:href="#a"/><use href="#b"/></svg></root>',
21
+ )
22
+ scratch = context.parse(
23
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" id=\"fresh\">" \
24
+ "<use xlink:href=\"#fx\"/><use href=\"#b\"/></svg>",
25
+ )
26
+ olds = host.root.children.select { |c| c.name == "svg" }
27
+ olds[0].replace(scratch.root)
28
+ GC.start(full_mark: true, immediate_sweep: true)
29
+ out = host.root.to_xml
30
+ expect(out).not_to include("<use =")
31
+ # the installed copy's OWN content survives the scratch GC
32
+ expect(out).to include("fx").and include("#b")
33
+ # NOTE: pre-existing divergence D-22 (materialized-wrapper
34
+ # lane) — the UNREPLACED siblings of a cross-doc replace are
35
+ # dropped; reproduced on the stock adapter before this fix.
36
+ end
37
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # The parent step answers the OWNER element natively on bindings
6
+ # serving the fixed engine (1.9.385: leptris#1869 parent-of-attribute
7
+ # names the owner; the cross-doc adopt keeps its attribute chain) —
8
+ # moxml#474: the reverse-axis gate used to route every parent-axis
9
+ # expression to the Ruby engine, so the fast consumers (standoc's
10
+ # svg id cleanup runs `.//*[@id] | ./@id/..` per styled svg) paid
11
+ # the interpreter. Other reverse axes keep the Ruby lane (their
12
+ # native results are reverse-ordered; document order is the
13
+ # model's contract).
14
+ RSpec.describe "leptris native parent-axis lane" do
15
+ let(:ctx) { Moxml.new(:leptris) }
16
+ let(:doc) do
17
+ ctx.parse('<root><svg id="s"><use id="u"/></svg></root>')
18
+ end
19
+ let(:svg) { doc.xpath("//*[local-name()='svg']").first }
20
+
21
+ it "routes parent-of-attribute expressions to the native lane" do
22
+ native = svg.adapter.native_xpath(
23
+ svg.native, "./@id/..", {}
24
+ )
25
+ expect(native).not_to be_nil
26
+ end
27
+
28
+ it "answers the owner element through the facade" do
29
+ expect(svg.xpath("./@id/..").map(&:name)).to eq(["svg"])
30
+ expect(svg.xpath("./@id/..").first).to equal(svg)
31
+ end
32
+
33
+ it "serves the two-arm union natively" do
34
+ expect(
35
+ svg.xpath(".//*[@id] | ./@id/..").map(&:name).sort,
36
+ ).to eq(%w[svg use])
37
+ end
38
+
39
+ it "keeps non-parent reverse axes on the Ruby lane" do
40
+ doc2 = ctx.parse('<r><e a="1" b="2" c="3"/></r>')
41
+ e = doc2.xpath("//e").first
42
+ expect(e.xpath("ancestor::*").map(&:name)).to eq(["r"])
43
+ native = e.adapter.native_xpath(e.native, "ancestor::*", {})
44
+ expect(native).to be_nil
45
+ end
46
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Processing model §Validation (normative): pinned here.
4
+
5
+ require "spec_helper"
6
+
7
+ RSpec.describe Moxml::XSD do
8
+ let(:ctx) { Moxml.new(:leptris) }
9
+
10
+ def valid_lexical?(type, lexical)
11
+ described_class.builtin_valid?(ctx, type, lexical)
12
+ end
13
+
14
+ describe ".builtin_valid?" do
15
+ it "answers the facade-owned table identically without an engine" do
16
+ expect(valid_lexical?("xs:integer", "3")).to be(true)
17
+ expect(valid_lexical?("xs:integer", "x")).to be(false)
18
+ expect do
19
+ described_class.builtin_valid?(ctx, "xs:nope", "3")
20
+ end.to raise_error(ArgumentError)
21
+ end
22
+
23
+ it "applies whiteSpace=collapse before token-family checks" do
24
+ expect(valid_lexical?("xs:token", "a b")).to be(true)
25
+ expect(valid_lexical?("xs:ID", " id ")).to be(true)
26
+ expect(valid_lexical?("xs:NMTOKEN", "\tx ")).to be(true)
27
+ expect(valid_lexical?("xs:ID", "1d")).to be(false)
28
+ end
29
+
30
+ it "keeps unbounded integers unbounded" do
31
+ big = "1" * 29
32
+ expect(valid_lexical?("xs:integer", big)).to be(true)
33
+ expect(valid_lexical?("xs:positiveInteger", big)).to be(true)
34
+ expect(valid_lexical?("xs:nonNegativeInteger", big)).to be(true)
35
+ expect(valid_lexical?("xs:negativeInteger", "-1#{'0' * 20}")).to be(true)
36
+ expect(valid_lexical?("xs:nonPositiveInteger", "0")).to be(true)
37
+ expect(valid_lexical?("xs:nonPositiveInteger", "-0")).to be(true)
38
+ end
39
+
40
+ it "stops bounded integers at their own bounds" do
41
+ expect(valid_lexical?("xs:long", "9223372036854775807")).to be(true)
42
+ expect(valid_lexical?("xs:long", "9223372036854775808")).to be(false)
43
+ expect(valid_lexical?("xs:unsignedLong", "18446744073709551615")).to be(true)
44
+ expect(valid_lexical?("xs:unsignedLong", "18446744073709551616")).to be(false)
45
+ expect(valid_lexical?("xs:byte", "128")).to be(false)
46
+ expect(valid_lexical?("xs:unsignedByte", "256")).to be(false)
47
+ end
48
+
49
+ it "admits the float spellings the datatype spec defines" do
50
+ expect(valid_lexical?("xs:float", "+INF")).to be(true)
51
+ expect(valid_lexical?("xs:double", "-INF")).to be(true)
52
+ expect(valid_lexical?("xs:float", "NaN")).to be(true)
53
+ expect(valid_lexical?("xs:float", "1.5E-3")).to be(true)
54
+ expect(valid_lexical?("xs:decimal", "5.")).to be(true)
55
+ expect(valid_lexical?("xs:decimal", ".5")).to be(true)
56
+ expect(valid_lexical?("xs:decimal", "1e3")).to be(false)
57
+ end
58
+
59
+ it "checks date lexicals against the Gregorian rules" do
60
+ expect(valid_lexical?("xs:date", "2024-02-29")).to be(true)
61
+ expect(valid_lexical?("xs:date", "1999-02-29")).to be(false)
62
+ expect(valid_lexical?("xs:date", "2024-04-31")).to be(false)
63
+ expect(valid_lexical?("xs:date", "2024-13-01")).to be(false)
64
+ end
65
+
66
+ it "checks dateTime and time lexicals" do
67
+ expect(valid_lexical?("xs:dateTime", "2024-02-29T23:59:59Z")).to be(true)
68
+ expect(valid_lexical?("xs:dateTime", "1999-02-29T00:00:00")).to be(false)
69
+ expect(valid_lexical?("xs:time", "23:59:59.999+05:30")).to be(true)
70
+ expect(valid_lexical?("xs:time", "24:00:00")).to be(false)
71
+ expect(valid_lexical?("xs:time", "25:20:00")).to be(false)
72
+ expect(valid_lexical?("xs:time", "13:20:60")).to be(false)
73
+ end
74
+
75
+ it "checks the gregorian-day lexicals" do
76
+ expect(valid_lexical?("xs:gYearMonth", "2024-02Z")).to be(true)
77
+ expect(valid_lexical?("xs:gYearMonth", "2024-13")).to be(false)
78
+ expect(valid_lexical?("xs:gMonthDay", "--02-29")).to be(true)
79
+ expect(valid_lexical?("xs:gMonthDay", "--02-30")).to be(false)
80
+ expect(valid_lexical?("xs:gDay", "---31")).to be(true)
81
+ expect(valid_lexical?("xs:gDay", "---32")).to be(false)
82
+ end
83
+
84
+ it "enforces the duration component rules" do
85
+ expect(valid_lexical?("xs:duration", "P1Y2M3DT4H5M6.5S")).to be(true)
86
+ expect(valid_lexical?("xs:duration", "PT0S")).to be(true)
87
+ expect(valid_lexical?("xs:duration", "-P1D")).to be(true)
88
+ expect(valid_lexical?("xs:duration", "P")).to be(false)
89
+ expect(valid_lexical?("xs:duration", "PT")).to be(false)
90
+ expect(valid_lexical?("xs:duration", "P1Y2MT")).to be(false)
91
+ expect(valid_lexical?("xs:duration", "P200.5Y")).to be(false)
92
+ end
93
+
94
+ it "restricts the duration subtypes to their components" do
95
+ expect(valid_lexical?("xs:dayTimeDuration", "P1DT2H")).to be(true)
96
+ expect(valid_lexical?("xs:dayTimeDuration", "P1Y")).to be(false)
97
+ expect(valid_lexical?("xs:yearMonthDuration", "P1Y2M")).to be(true)
98
+ expect(valid_lexical?("xs:yearMonthDuration", "P1D")).to be(false)
99
+ expect(valid_lexical?("xs:yearMonthDuration", "P1YT1H")).to be(false)
100
+ end
101
+
102
+ it "checks binary lexical spaces" do
103
+ expect(valid_lexical?("xs:base64Binary", "aGVsbG8=")).to be(true)
104
+ expect(valid_lexical?("xs:base64Binary", "abcd")).to be(true)
105
+ expect(valid_lexical?("xs:base64Binary", "abcde")).to be(false)
106
+ expect(valid_lexical?("xs:base64Binary", "a b")).to be(false)
107
+ expect(valid_lexical?("xs:hexBinary", "0A")).to be(true)
108
+ expect(valid_lexical?("xs:hexBinary", "0AB")).to be(false)
109
+ end
110
+
111
+ it "rejects the reserved xmlns prefix in QName-family lexicals" do
112
+ expect(valid_lexical?("xs:QName", "foo")).to be(true)
113
+ expect(valid_lexical?("xs:QName", "ns:foo")).to be(true)
114
+ expect(valid_lexical?("xs:QName", "xmlns:xsi")).to be(false)
115
+ expect(valid_lexical?("xs:QName", ":foo")).to be(false)
116
+ expect(valid_lexical?("xs:QName", "a:b:c")).to be(false)
117
+ expect(valid_lexical?("xs:NOTATION", "foo")).to be(true)
118
+ end
119
+
120
+ it "counts list items, not characters" do
121
+ expect(valid_lexical?("xs:IDREFS", "a b")).to be(true)
122
+ expect(valid_lexical?("xs:IDREFS", "a")).to be(true)
123
+ expect(valid_lexical?("xs:IDREFS", "")).to be(false)
124
+ expect(valid_lexical?("xs:NMTOKENS", "a.b-c:d e")).to be(true)
125
+ expect(valid_lexical?("xs:ENTITIES", "a 1")).to be(false)
126
+ end
127
+
128
+ it "keeps the open lexical spaces open" do
129
+ expect(valid_lexical?("xs:string", "anything <>&")).to be(true)
130
+ expect(valid_lexical?("xs:anyURI", "http://example.com/a b")).to be(true)
131
+ expect(valid_lexical?("xs:boolean", "true")).to be(true)
132
+ expect(valid_lexical?("xs:boolean", "TRUE")).to be(false)
133
+ expect(valid_lexical?("xs:language", "en-US")).to be(true)
134
+ expect(valid_lexical?("xs:language", "en_US")).to be(false)
135
+ expect(valid_lexical?("xs:Name", "a:b")).to be(true)
136
+ expect(valid_lexical?("xs:NCName", "a:b")).to be(false)
137
+ end
138
+ end
139
+ end