lutaml-path 0.1.0 → 0.1.1
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/rake.yml +15 -0
- data/.github/workflows/release.yml +30 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +7 -0
- data/README.adoc +651 -36
- data/lib/lutaml/path/abstract_path.rb +24 -0
- data/lib/lutaml/path/condition.rb +100 -0
- data/lib/lutaml/path/condition_evaluator.rb +109 -0
- data/lib/lutaml/path/element_path.rb +66 -8
- data/lib/lutaml/path/errors.rb +12 -0
- data/lib/lutaml/path/instance_path.rb +63 -0
- data/lib/lutaml/path/parser.rb +164 -13
- data/lib/lutaml/path/path_segment.rb +31 -5
- data/lib/lutaml/path/resolver.rb +154 -0
- data/lib/lutaml/path/step.rb +50 -0
- data/lib/lutaml/path/transformer.rb +63 -21
- data/lib/lutaml/path/version.rb +1 -1
- data/lib/lutaml/path.rb +34 -3
- data/lutaml-path.gemspec +2 -1
- data/spec/lutaml/path_spec.rb +674 -29
- data/spec/lutaml/resolver_spec.rb +182 -0
- metadata +29 -6
- data/.github/workflows/main.yml +0 -27
data/spec/lutaml/path_spec.rb
CHANGED
|
@@ -18,7 +18,12 @@ RSpec.describe Lutaml::Path do
|
|
|
18
18
|
expect(path.segments.map(&:name)).to eq(%w[Package Element])
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
it "returns an ElementPath, never a raw parse tree" do
|
|
22
|
+
expect(described_class.parse("Package::Class")).to be_a(Lutaml::Path::ElementPath)
|
|
23
|
+
expect(described_class.parse("::Package::Class")).to be_a(Lutaml::Path::ElementPath)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "parses paths with patterns" do
|
|
22
27
|
path = described_class.parse("Package::*::Base*")
|
|
23
28
|
expect(path.segments.map(&:pattern?)).to eq([false, true, true])
|
|
24
29
|
expect(path.segments.map(&:name)).to eq(["Package", "*", "Base*"])
|
|
@@ -29,17 +34,436 @@ RSpec.describe Lutaml::Path do
|
|
|
29
34
|
expect(path.segments.map(&:name)).to eq(["core::types", "Element"])
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
it "does not treat escaped separators as patterns" do
|
|
38
|
+
path = described_class.parse("core\\::types::Element")
|
|
39
|
+
expect(path.segments.map(&:pattern?)).to eq([false, false])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "rejects a leading escaped separator" do
|
|
43
|
+
expect { described_class.parse("\\::Rectangle") }.to raise_error(described_class::ParseError)
|
|
44
|
+
expect { described_class.parse("\\::Rectangle::Shape") }.to raise_error(described_class::ParseError)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "allows an escaped separator in a later segment" do
|
|
48
|
+
path = described_class.parse("model::\\::odd")
|
|
49
|
+
expect(path.segments.map(&:name)).to eq(["model", "::odd"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "allows an escaped separator after a real leading separator" do
|
|
53
|
+
path = described_class.parse("::\\::odd")
|
|
54
|
+
expect(path.absolute?).to be true
|
|
55
|
+
expect(path.segments.map(&:name)).to eq(["::odd"])
|
|
56
|
+
end
|
|
57
|
+
|
|
32
58
|
it "handles Unicode characters" do
|
|
33
59
|
path = described_class.parse("建物::窓::ガラス")
|
|
34
60
|
expect(path.segments.map(&:name)).to eq(%w[建物 窓 ガラス])
|
|
35
61
|
end
|
|
36
62
|
|
|
37
|
-
|
|
63
|
+
it "handles glob patterns" do
|
|
38
64
|
path = described_class.parse("pkg::{a,b}*::[0-9]*")
|
|
39
65
|
expect(path.segments.last.pattern?).to be true
|
|
40
66
|
expect(path.match?(%w[pkg btest 123])).to be true
|
|
41
67
|
end
|
|
42
68
|
|
|
69
|
+
it "matches a single-segment wildcard end to end" do
|
|
70
|
+
path = described_class.parse("Package::*::BaseClass")
|
|
71
|
+
expect(path.match?(%w[Package core BaseClass])).to be true
|
|
72
|
+
expect(path.match?(%w[Package BaseClass])).to be false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "matches brace alternation end to end" do
|
|
76
|
+
path = described_class.parse("model::{Abstract,Base}Class")
|
|
77
|
+
expect(path.match?(%w[model AbstractClass])).to be true
|
|
78
|
+
expect(path.match?(%w[model BaseClass])).to be true
|
|
79
|
+
expect(path.match?(%w[model OtherClass])).to be false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "matches a deep wildcard at any depth" do
|
|
83
|
+
path = described_class.parse("Pkg::**::Element")
|
|
84
|
+
expect(path.match?(%w[Pkg Element])).to be true
|
|
85
|
+
expect(path.match?(%w[Pkg a Element])).to be true
|
|
86
|
+
expect(path.match?(%w[Pkg a b Element])).to be true
|
|
87
|
+
expect(path.match?(%w[Other a Element])).to be false
|
|
88
|
+
expect(path.match?(%w[Pkg a b Other])).to be false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "matches multiple deep wildcards" do
|
|
92
|
+
path = described_class.parse("Pkg::**::mid::**::End")
|
|
93
|
+
expect(path.match?(%w[Pkg a mid b End])).to be true
|
|
94
|
+
expect(path.match?(%w[Pkg mid End])).to be true
|
|
95
|
+
expect(path.match?(%w[Pkg mid Other])).to be false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "matches a leading deep wildcard" do
|
|
99
|
+
path = described_class.parse("**::Element")
|
|
100
|
+
expect(path.match?(%w[Element])).to be true
|
|
101
|
+
expect(path.match?(%w[a b Element])).to be true
|
|
102
|
+
expect(path.match?(%w[a b Other])).to be false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "matches a trailing deep wildcard" do
|
|
106
|
+
path = described_class.parse("Pkg::**")
|
|
107
|
+
expect(path.match?(%w[Pkg])).to be true
|
|
108
|
+
expect(path.match?(%w[Pkg a b])).to be true
|
|
109
|
+
expect(path.match?(%w[Other])).to be false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "matches a bare deep wildcard against any depth" do
|
|
113
|
+
path = described_class.parse("**")
|
|
114
|
+
expect(path.match?([])).to be true
|
|
115
|
+
expect(path.match?(%w[a])).to be true
|
|
116
|
+
expect(path.match?(%w[a b c])).to be true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "collapses adjacent deep wildcards" do
|
|
120
|
+
path = described_class.parse("Pkg::**::**::End")
|
|
121
|
+
expect(path.match?(%w[Pkg End])).to be true
|
|
122
|
+
expect(path.match?(%w[Pkg a b End])).to be true
|
|
123
|
+
expect(path.match?(%w[Pkg a b])).to be false
|
|
124
|
+
expect(path.match?(%w[Other End])).to be false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "requires an absolute deep-wildcard path to consume the whole candidate" do
|
|
128
|
+
path = described_class.parse("::Pkg::**::End")
|
|
129
|
+
expect(path.match?(%w[Pkg End])).to be true
|
|
130
|
+
expect(path.match?(%w[Pkg a End])).to be true
|
|
131
|
+
expect(path.match?(%w[Pkg a End extra])).to be false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "matches a relative deep-wildcard path as a prefix" do
|
|
135
|
+
path = described_class.parse("Pkg::**::Element")
|
|
136
|
+
expect(path.match?(%w[Pkg a Element extra])).to be true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "treats a double-asterisk substring as a glob, not a deep wildcard" do
|
|
140
|
+
path = described_class.parse("pkg::a**b::x")
|
|
141
|
+
expect(path.segments.map(&:deep_wildcard?)).to eq([false, false, false])
|
|
142
|
+
expect(path.match?(%w[pkg aQQb x])).to be true
|
|
143
|
+
expect(path.match?(%w[pkg aQQb extra x])).to be false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "parses dot navigation" do
|
|
147
|
+
path = described_class.parse("obj.edition.number")
|
|
148
|
+
expect(path).to be_a(Lutaml::Path::InstancePath)
|
|
149
|
+
expect(path.base_steps.map(&:name)).to eq(%w[obj])
|
|
150
|
+
expect(path.attribute_steps.map(&:name)).to eq(%w[edition number])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "parses a mixed :: and . path" do
|
|
154
|
+
path = described_class.parse("::Shapes::Rectangle.width")
|
|
155
|
+
expect(path).to be_a(Lutaml::Path::InstancePath)
|
|
156
|
+
expect(path.absolute?).to be true
|
|
157
|
+
expect(path.base_steps.map(&:name)).to eq(%w[Shapes Rectangle])
|
|
158
|
+
expect(path.attribute_steps.map(&:name)).to eq(%w[width])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "unescapes an escaped dot inside an attribute name" do
|
|
162
|
+
path = described_class.parse("::Rectangle.width\\.length")
|
|
163
|
+
expect(path.attribute_steps.map(&:name)).to eq(["width.length"])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "rejects a leading escaped dot" do
|
|
167
|
+
expect { described_class.parse("\\.width.length") }
|
|
168
|
+
.to raise_error(described_class::ParseError)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "keeps a dotless path an ElementPath" do
|
|
172
|
+
expect(described_class.parse("model::shapes::Rectangle::area"))
|
|
173
|
+
.to be_a(Lutaml::Path::ElementPath)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "treats a bracket at offset 0 as a charset, even with an operator body" do
|
|
177
|
+
# Clause 1. These are NOT in the accepted break set (which is offset > 0
|
|
178
|
+
# only). A naive filter_ahead guard breaks these.
|
|
179
|
+
expect(described_class.parse("pkg::[A-Z]*").segments.last.pattern?).to be true
|
|
180
|
+
expect(described_class.parse("pkg::[exists]").segments.last.name).to eq("[exists]")
|
|
181
|
+
expect(described_class.parse("pkg::[a=b]").segments.last.name).to eq("[a=b]")
|
|
182
|
+
expect(described_class.parse("pkg::[!CV]ase").segments.last.name).to eq("[!CV]ase")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "classifies brackets as charset or filter" do
|
|
186
|
+
expect(described_class.parse("pkg::Class[A-Z]").segments.last.name).to eq("Class[A-Z]")
|
|
187
|
+
expect(described_class.parse("pkg::Base*[0-9]").segments.last.name).to eq("Base*[0-9]")
|
|
188
|
+
expect(described_class.parse("pkg::a[b-c]d").segments.last.name).to eq("a[b-c]d")
|
|
189
|
+
expect(described_class.parse("pkg::x[!foo]").segments.last.name).to eq("x[!foo]")
|
|
190
|
+
expect(described_class.parse("contributor[exists]")).to be_a(Lutaml::Path::InstancePath)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "parses every filter form the README documents" do
|
|
194
|
+
c = Lutaml::Path::Condition
|
|
195
|
+
cmp = described_class.parse("obj.contributor[role.type='publisher']")
|
|
196
|
+
expect(cmp.attribute_steps.last.condition)
|
|
197
|
+
.to eq(c::Comparison.new(c::AttributeRef.new(%w[role type]), "=",
|
|
198
|
+
c::Value.new("publisher", :string)))
|
|
199
|
+
|
|
200
|
+
expect(described_class.parse("obj.docidentifier[type!='ISBN']")
|
|
201
|
+
.attribute_steps.last.condition.op).to eq("!=")
|
|
202
|
+
|
|
203
|
+
expect(described_class.parse("obj.docidentifier[type in ('ISBN','ISSN','DOI')]")
|
|
204
|
+
.attribute_steps.last.condition.literals.map(&:source)).to eq(%w[ISBN ISSN DOI])
|
|
205
|
+
|
|
206
|
+
expect(described_class.parse("obj.contributor[exists]")
|
|
207
|
+
.attribute_steps.last.condition).to eq(c::Existence.new)
|
|
208
|
+
|
|
209
|
+
expect(described_class.parse("obj.contributor[role.type='author' && organization.type='standards']")
|
|
210
|
+
.attribute_steps.last.condition.operator).to eq("&&")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it "binds ! tightest, then comparison, then &&, then ||" do
|
|
214
|
+
cond = described_class.parse("obj.x[a='1' || b='2' && c='3']").attribute_steps.last.condition
|
|
215
|
+
expect(cond.operator).to eq("||")
|
|
216
|
+
expect(cond.right.operator).to eq("&&")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "honours parenthesised grouping" do
|
|
220
|
+
cond = described_class.parse("obj.x[(a='1' || b='2') && c='3']").attribute_steps.last.condition
|
|
221
|
+
expect(cond.operator).to eq("&&")
|
|
222
|
+
expect(cond.left.operator).to eq("||")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "rejects a malformed filter loudly rather than silently treating it as a charset" do
|
|
226
|
+
expect { described_class.parse("obj.contributor[role.type=]") }
|
|
227
|
+
.to raise_error(described_class::ParseError)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it "rejects operator-bearing charsets whose body is not a condition" do
|
|
231
|
+
# The accepted regression. These were working charsets; the sniff now
|
|
232
|
+
# claims them as filters and they fail to parse as conditions. Loud.
|
|
233
|
+
["pkg::x[a=b]", "pkg::Class[=]", "pkg::y[<]"].each do |input|
|
|
234
|
+
expect { described_class.parse(input) }
|
|
235
|
+
.to raise_error(described_class::ParseError), "expected #{input} to raise"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "reclassifies an operator-bearing charset whose body IS a condition" do
|
|
240
|
+
# "Class[exists]" was a charset matching Classe/Classx. Its body parses
|
|
241
|
+
# as a real condition, so it becomes a filter rather than a ParseError.
|
|
242
|
+
# Still a break, but it surfaces at match? rather than at parse.
|
|
243
|
+
path = described_class.parse("pkg::Class[exists]")
|
|
244
|
+
expect(path).to be_a(Lutaml::Path::InstancePath)
|
|
245
|
+
expect(path.conditions).to eq([Lutaml::Path::Condition::Existence.new])
|
|
246
|
+
expect { path.match?(%w[pkg Classe]) }
|
|
247
|
+
.to raise_error(Lutaml::Path::ResolutionError)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it "accepts the chained example from the README" do
|
|
251
|
+
path = described_class.parse(
|
|
252
|
+
"obj.contributor[role.type='author'].organization[name in ('ISO','IEC')].name"
|
|
253
|
+
)
|
|
254
|
+
expect(path.attribute_steps.map(&:name)).to eq(%w[contributor organization name])
|
|
255
|
+
expect(path.conditions.length).to eq(2)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "keeps a deep wildcard with a filter recognisable" do
|
|
259
|
+
path = described_class.parse("obj.contributor.**[name='ISO']")
|
|
260
|
+
expect(path.attribute_steps.last.deep_wildcard?).to be true
|
|
261
|
+
expect(path.attribute_steps.last.condition).not_to be_nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it "round-trips both path types through to_s" do
|
|
265
|
+
[
|
|
266
|
+
"Element",
|
|
267
|
+
"::Package::Element",
|
|
268
|
+
"Package::*::Base*",
|
|
269
|
+
"core\\::types::Element",
|
|
270
|
+
"obj.edition.number",
|
|
271
|
+
"::Shapes::Rectangle.width",
|
|
272
|
+
"obj.contributor[role.type='author']",
|
|
273
|
+
"obj.x[(a='1' || b='2') && c='3']",
|
|
274
|
+
"obj.docidentifier[type in ('ISBN','DOI')]",
|
|
275
|
+
"obj.contributor[exists]"
|
|
276
|
+
].each do |input|
|
|
277
|
+
parsed = described_class.parse(input)
|
|
278
|
+
expect(described_class.parse(parsed.to_s)).to eq(parsed), "round-trip failed for #{input}"
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "does not mistake a word operator inside an ordinary word for an operator" do
|
|
283
|
+
# The sniff is token-aware. Without that, "in" inside "print", "and"
|
|
284
|
+
# inside "standard", and "exists" inside "coexists" would claim these
|
|
285
|
+
# ordinary character sets as filters and reject them.
|
|
286
|
+
{
|
|
287
|
+
"pkg::Class[print]" => "Class[print]",
|
|
288
|
+
"pkg::Class[standard]" => "Class[standard]",
|
|
289
|
+
"pkg::Class[sand]" => "Class[sand]",
|
|
290
|
+
"pkg::Class[index]" => "Class[index]",
|
|
291
|
+
"pkg::Class[min]" => "Class[min]",
|
|
292
|
+
"pkg::Class[coexists]" => "Class[coexists]"
|
|
293
|
+
}.each do |input, name|
|
|
294
|
+
path = described_class.parse(input)
|
|
295
|
+
expect(path).to be_a(Lutaml::Path::ElementPath), "expected #{input} to stay a charset"
|
|
296
|
+
expect(path.segments.last.name).to eq(name)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it "parses an in-list of any arity" do
|
|
301
|
+
# A one-element list collapses to a bare Hash in Parslet rather than a
|
|
302
|
+
# one-element Array. Getting that wrong raised TypeError straight out of
|
|
303
|
+
# .parse, bypassing the ParseError rescue. The README only ever shows a
|
|
304
|
+
# three-element list, so this was never exercised.
|
|
305
|
+
{
|
|
306
|
+
"obj.d[type in ('ISBN')]" => %w[ISBN],
|
|
307
|
+
"obj.d[type in('ISBN')]" => %w[ISBN],
|
|
308
|
+
"obj.d[type in ('ISBN','DOI')]" => %w[ISBN DOI],
|
|
309
|
+
"obj.d[type in ('ISBN', 'ISSN', 'DOI')]" => %w[ISBN ISSN DOI],
|
|
310
|
+
"obj.d[n in (1,2)]" => %w[1 2]
|
|
311
|
+
}.each do |input, sources|
|
|
312
|
+
expect(described_class.parse(input).conditions.first.literals.map(&:source))
|
|
313
|
+
.to eq(sources), "failed for #{input}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it "allows a condition keyword to be used as an attribute name" do
|
|
318
|
+
%w[exists in and].each do |word|
|
|
319
|
+
path = described_class.parse("obj.x[#{word}='1']")
|
|
320
|
+
expect(path.conditions.first.lhs.names).to eq([word]), "failed for #{word}"
|
|
321
|
+
end
|
|
322
|
+
# ...while a bare `exists` is still the existence predicate.
|
|
323
|
+
expect(described_class.parse("obj.c[exists]").conditions.first)
|
|
324
|
+
.to eq(Lutaml::Path::Condition::Existence.new)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
it "raises ParseError, never SystemStackError, on a deeply nested filter" do
|
|
328
|
+
# The condition grammar recurses through group/not/and/or. parse must
|
|
329
|
+
# only ever raise ParseError, so the stack limit must not leak.
|
|
330
|
+
[200, 500, 2000].each do |depth|
|
|
331
|
+
expect { described_class.parse("obj.x[#{"(" * depth}exists]") }
|
|
332
|
+
.to raise_error(described_class::ParseError), "leaked at depth #{depth}"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it "does not mistake a word operator after a non-ASCII character" do
|
|
337
|
+
# word_char must be Unicode-aware: with [A-Za-z0-9_], "é" is not a word
|
|
338
|
+
# character, so the "in" in "[éin]" looks like the `in` operator.
|
|
339
|
+
# The sniff tokenises with the same alphabet as attribute_ref, so any
|
|
340
|
+
# character an identifier may contain -- including emoji -- keeps the
|
|
341
|
+
# word whole. [[:word:]] alone is not enough: it excludes emoji.
|
|
342
|
+
{
|
|
343
|
+
"pkg::Class[éin]" => "Class[éin]",
|
|
344
|
+
"pkg::Class[üand]" => "Class[üand]",
|
|
345
|
+
"pkg::Class[标in]" => "Class[标in]",
|
|
346
|
+
"pkg::Class[🙂in]" => "Class[🙂in]"
|
|
347
|
+
}.each do |input, name|
|
|
348
|
+
expect(described_class.parse(input).segments.last.name).to eq(name)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
it "round-trips a value containing an escaped quote" do
|
|
353
|
+
path = described_class.parse("obj.x[a='O\\'Reilly']")
|
|
354
|
+
expect(path.conditions.first.rhs.source).to eq("O'Reilly")
|
|
355
|
+
expect(path.to_s).to eq("obj.x[a='O\\'Reilly']")
|
|
356
|
+
expect(described_class.parse(path.to_s)).to eq(path)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
it "parses an empty quoted value" do
|
|
360
|
+
# Parslet yields [] rather than a slice for an empty repeat, so a
|
|
361
|
+
# `simple` transform rule would never match and the node would stay raw.
|
|
362
|
+
path = described_class.parse("obj.x[a='']")
|
|
363
|
+
expect(path.conditions.first.rhs)
|
|
364
|
+
.to eq(Lutaml::Path::Condition::Value.new("", :string))
|
|
365
|
+
expect(path.to_s).to eq("obj.x[a='']")
|
|
366
|
+
expect(described_class.parse(path.to_s)).to eq(path)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
it "treats a dot inside a character set as literal, not a separator" do
|
|
370
|
+
# An unclaimed bracket group is consumed whole, so "." and "::" inside a
|
|
371
|
+
# character set do not split the segment. Both are working charsets on
|
|
372
|
+
# main and must stay ElementPaths.
|
|
373
|
+
{
|
|
374
|
+
"pkg::Class[.]" => "Class[.]",
|
|
375
|
+
"pkg::Class[a.b]" => "Class[a.b]",
|
|
376
|
+
"pkg::Class[a::b]" => "Class[a::b]"
|
|
377
|
+
}.each do |input, name|
|
|
378
|
+
path = described_class.parse(input)
|
|
379
|
+
expect(path).to be_a(Lutaml::Path::ElementPath), "expected #{input} to stay a charset"
|
|
380
|
+
expect(path.segments.last.name).to eq(name)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it "treats an escaped dot in a condition attribute as one name" do
|
|
385
|
+
path = described_class.parse("obj.x[a\\.b='x']")
|
|
386
|
+
expect(path.conditions.first.lhs.names).to eq(["a.b"])
|
|
387
|
+
expect(described_class.parse("obj.x[role.type='x']").conditions.first.lhs.names)
|
|
388
|
+
.to eq(%w[role type])
|
|
389
|
+
# ...and it must survive a round-trip: rendering ["a.b"] as "a.b" would
|
|
390
|
+
# re-parse as the two names ["a", "b"].
|
|
391
|
+
expect(path.to_s).to eq("obj.x[a\\.b='x']")
|
|
392
|
+
expect(described_class.parse(path.to_s)).to eq(path)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
it "requires a token boundary after the word operator `exists`" do
|
|
396
|
+
# Without it, "existsand b='2'" silently parses as `(exists and b='2')`.
|
|
397
|
+
["obj.x[existsand b='2']", "obj.x[existsand(exists)]"].each do |input|
|
|
398
|
+
expect { described_class.parse(input) }
|
|
399
|
+
.to raise_error(described_class::ParseError), "expected #{input} to raise"
|
|
400
|
+
end
|
|
401
|
+
# ...while every legitimate use of exists still works.
|
|
402
|
+
expect(described_class.parse("obj.c[exists]").conditions.first)
|
|
403
|
+
.to eq(Lutaml::Path::Condition::Existence.new)
|
|
404
|
+
expect(described_class.parse("obj.c[!exists]").conditions.first)
|
|
405
|
+
.to be_a(Lutaml::Path::Condition::Negation)
|
|
406
|
+
expect(described_class.parse("obj.x[existsx='1']").conditions.first.lhs.names)
|
|
407
|
+
.to eq(%w[existsx])
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it "requires a token boundary after the word operator `and`" do
|
|
411
|
+
# Without it, "android='2'" silently parses as `and` + the attribute
|
|
412
|
+
# "roid" — a wrong result rather than an error.
|
|
413
|
+
expect { described_class.parse("obj.x[a='1' android='2']") }
|
|
414
|
+
.to raise_error(described_class::ParseError)
|
|
415
|
+
# ...while `and` as a real operator, and `android` as an attribute name,
|
|
416
|
+
# both still work.
|
|
417
|
+
expect(described_class.parse("obj.x[a='1' and b='2']").conditions.first.right.lhs.names)
|
|
418
|
+
.to eq(%w[b])
|
|
419
|
+
expect(described_class.parse("obj.x[android='2']").conditions.first.lhs.names)
|
|
420
|
+
.to eq(%w[android])
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
it "escapes the escape character, not just the delimiter" do
|
|
424
|
+
# CodeQL: incomplete string escaping. Escaping only the delimiter leaves
|
|
425
|
+
# a trailing backslash eating the closing quote ('abc\') and a name
|
|
426
|
+
# ending in a backslash collapsing two names into one (a\.b).
|
|
427
|
+
backslash = "\\"
|
|
428
|
+
{
|
|
429
|
+
"abc#{backslash}" => "value ending in a backslash",
|
|
430
|
+
"a#{backslash}b" => "value containing a backslash",
|
|
431
|
+
"a#{backslash}'b" => "backslash then quote",
|
|
432
|
+
"a#{backslash}*b" => "backslash-star stays a literal for fnmatch"
|
|
433
|
+
}.each do |source, why|
|
|
434
|
+
value = Lutaml::Path::Condition::Value.new(source, :string)
|
|
435
|
+
path = described_class.parse("obj.x[a=#{value}]")
|
|
436
|
+
expect(path.conditions.first.rhs.source).to eq(source), "failed: #{why}"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
{
|
|
440
|
+
["a#{backslash}", "b"] => "first name ends in a backslash",
|
|
441
|
+
["a#{backslash}b"] => "name contains a backslash",
|
|
442
|
+
["a.b", "c#{backslash}"] => "escaped dot and trailing backslash"
|
|
443
|
+
}.each do |names, why|
|
|
444
|
+
ref = Lutaml::Path::Condition::AttributeRef.new(names)
|
|
445
|
+
path = described_class.parse("obj.x[#{ref}='z']")
|
|
446
|
+
expect(path.conditions.first.lhs.names).to eq(names), "failed: #{why}"
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
it "binds in and exists at the predicate level, like comparison" do
|
|
451
|
+
# The README documents these exact groupings; pin them so the docs
|
|
452
|
+
# cannot drift from the parser.
|
|
453
|
+
membership = described_class.parse("obj.f[a in ('x','y') && b = 'z']").conditions.first
|
|
454
|
+
expect(membership.operator).to eq("&&")
|
|
455
|
+
expect(membership.left).to be_a(Lutaml::Path::Condition::Membership)
|
|
456
|
+
expect(membership.right).to be_a(Lutaml::Path::Condition::Comparison)
|
|
457
|
+
|
|
458
|
+
existence = described_class.parse("obj.f[exists && a = '1']").conditions.first
|
|
459
|
+
expect(existence.left).to be_a(Lutaml::Path::Condition::Existence)
|
|
460
|
+
|
|
461
|
+
# a = 'x' || b = 'y' && c = 'z' => (a='x') || ((b='y') && (c='z'))
|
|
462
|
+
mixed = described_class.parse("obj.f[a = 'x' || b = 'y' && c = 'z']").conditions.first
|
|
463
|
+
expect(mixed.operator).to eq("||")
|
|
464
|
+
expect(mixed.right.operator).to eq("&&")
|
|
465
|
+
end
|
|
466
|
+
|
|
43
467
|
it "raises error on empty segments" do
|
|
44
468
|
expect { described_class.parse("pkg::::element") }.to raise_error(described_class::ParseError)
|
|
45
469
|
end
|
|
@@ -48,41 +472,262 @@ RSpec.describe Lutaml::Path do
|
|
|
48
472
|
expect { described_class.parse("") }.to raise_error(described_class::ParseError)
|
|
49
473
|
end
|
|
50
474
|
end
|
|
475
|
+
end
|
|
51
476
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
477
|
+
RSpec.describe Lutaml::Path::PathSegment do
|
|
478
|
+
it "matches exact names" do
|
|
479
|
+
segment = described_class.new("Element")
|
|
480
|
+
expect(segment.match?("Element")).to be true
|
|
481
|
+
expect(segment.match?("Other")).to be false
|
|
482
|
+
end
|
|
58
483
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
484
|
+
it "matches patterns" do
|
|
485
|
+
segment = described_class.new("Base*")
|
|
486
|
+
expect(segment.match?("BaseClass")).to be true
|
|
487
|
+
expect(segment.match?("Other")).to be false
|
|
64
488
|
end
|
|
65
489
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
Lutaml::Path::PathSegment.new("*", is_pattern: true),
|
|
71
|
-
Lutaml::Path::PathSegment.new("Element")
|
|
72
|
-
])
|
|
490
|
+
it "derives pattern-ness from the name" do
|
|
491
|
+
expect(described_class.new("Base*").pattern?).to be true
|
|
492
|
+
expect(described_class.new("Element").pattern?).to be false
|
|
493
|
+
end
|
|
73
494
|
|
|
74
|
-
|
|
75
|
-
|
|
495
|
+
it "matches brace alternation" do
|
|
496
|
+
segment = described_class.new("{Base,Case}")
|
|
497
|
+
expect(segment.match?("Base")).to be true
|
|
498
|
+
expect(segment.match?("Case")).to be true
|
|
499
|
+
expect(segment.match?("Vase")).to be false
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
it "matches character sets, ranges, and negation" do
|
|
503
|
+
expect(described_class.new("[abc]x").match?("bx")).to be true
|
|
504
|
+
expect(described_class.new("[abc]x").match?("dx")).to be false
|
|
505
|
+
expect(described_class.new("[a-z]*").match?("query")).to be true
|
|
506
|
+
expect(described_class.new("[!CV]ase").match?("Base")).to be true
|
|
507
|
+
expect(described_class.new("[!CV]ase").match?("Case")).to be false
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
it "recognizes a deep wildcard" do
|
|
511
|
+
expect(described_class.new("**").deep_wildcard?).to be true
|
|
512
|
+
expect(described_class.new("*").deep_wildcard?).to be false
|
|
513
|
+
expect(described_class.new("a**b").deep_wildcard?).to be false
|
|
514
|
+
expect(described_class.new("Element").deep_wildcard?).to be false
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
it "matches a double-asterisk substring as an ordinary glob" do
|
|
518
|
+
segment = described_class.new("a**b")
|
|
519
|
+
expect(segment.match?("axyb")).to be true
|
|
520
|
+
expect(segment.match?("ab")).to be true
|
|
521
|
+
expect(segment.match?("zb")).to be false
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
RSpec.describe Lutaml::Path::ElementPath do
|
|
526
|
+
it "matches path segments" do
|
|
527
|
+
path = described_class.new([
|
|
528
|
+
Lutaml::Path::PathSegment.new("pkg"),
|
|
529
|
+
Lutaml::Path::PathSegment.new("*"),
|
|
530
|
+
Lutaml::Path::PathSegment.new("Element")
|
|
531
|
+
])
|
|
532
|
+
|
|
533
|
+
expect(path.match?(%w[pkg sub Element])).to be true
|
|
534
|
+
expect(path.match?(%w[other sub Element])).to be false
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
it "respects absolute paths" do
|
|
538
|
+
path = described_class.new([
|
|
539
|
+
Lutaml::Path::PathSegment.new("pkg"),
|
|
540
|
+
Lutaml::Path::PathSegment.new("Element")
|
|
541
|
+
], absolute: true)
|
|
542
|
+
|
|
543
|
+
expect(path.match?(%w[pkg Element])).to be true
|
|
544
|
+
expect(path.match?(%w[root pkg Element])).to be false
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
it "keeps single wildcard matching exactly one segment" do
|
|
548
|
+
path = Lutaml::Path.parse("pkg::*::Element")
|
|
549
|
+
expect(path.match?(%w[pkg sub Element])).to be true
|
|
550
|
+
expect(path.match?(%w[pkg Element])).to be false
|
|
551
|
+
expect(path.match?(%w[pkg a b Element])).to be false
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
it "matches deep patterns without exhausting the stack" do
|
|
555
|
+
pattern = (["a"] + (["**"] * 5000) + ["b"]).join("::")
|
|
556
|
+
path = Lutaml::Path.parse(pattern)
|
|
557
|
+
|
|
558
|
+
expect { path.match?(%w[a x y z b]) }.not_to raise_error
|
|
559
|
+
expect(path.match?(%w[a x y z b])).to be true
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
RSpec.describe Lutaml::Path::AbstractPath do
|
|
564
|
+
it "holds absoluteness and nothing else" do
|
|
565
|
+
expect(described_class.new(absolute: true).absolute?).to be true
|
|
566
|
+
expect(described_class.new.absolute?).to be false
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
it "does not declare match? — a subclass may be unable to honour it" do
|
|
570
|
+
expect(described_class.instance_methods).not_to include(:match?)
|
|
571
|
+
expect(described_class.instance_methods).not_to include(:segments)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
it "is the supertype of ElementPath" do
|
|
575
|
+
expect(Lutaml::Path::ElementPath.ancestors).to include(described_class)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
RSpec.describe Lutaml::Path::Condition do
|
|
580
|
+
# NOT `C = Lutaml::Path::Condition` — a constant assigned inside a block
|
|
581
|
+
# trips Lint/ConstantDefinitionInBlock and would turn rake red.
|
|
582
|
+
def cond = Lutaml::Path::Condition
|
|
583
|
+
|
|
584
|
+
it "renders a comparison the way the README writes it" do
|
|
585
|
+
c = cond::Comparison.new(cond::AttributeRef.new(%w[role type]), "=",
|
|
586
|
+
cond::Value.new("author", :string))
|
|
587
|
+
expect(c.to_s).to eq("role.type='author'")
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
it "renders membership and existence" do
|
|
591
|
+
m = cond::Membership.new(cond::AttributeRef.new(%w[type]),
|
|
592
|
+
[cond::Value.new("ISBN", :string), cond::Value.new("DOI", :string)])
|
|
593
|
+
expect(m.to_s).to eq("type in ('ISBN','DOI')")
|
|
594
|
+
expect(cond::Existence.new.to_s).to eq("exists")
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
it "always parenthesises binary operations so structure round-trips" do
|
|
598
|
+
inner = cond::BinaryOperation.new("||", cond::Existence.new, cond::Existence.new)
|
|
599
|
+
outer = cond::BinaryOperation.new("&&", cond::Existence.new, inner)
|
|
600
|
+
expect(outer.to_s).to eq("(exists && (exists || exists))")
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
it "compares by value" do
|
|
604
|
+
a = cond::Comparison.new(cond::AttributeRef.new(%w[a]), "=", cond::Value.new("x", :string))
|
|
605
|
+
b = cond::Comparison.new(cond::AttributeRef.new(%w[a]), "=", cond::Value.new("x", :string))
|
|
606
|
+
expect(a).to eq(b)
|
|
607
|
+
expect(a.hash).to eq(b.hash)
|
|
608
|
+
expect({ a => 1 }[b]).to eq(1)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
it "is frozen — a parsed AST must be immutable" do
|
|
612
|
+
v = cond::Value.new("x", :string)
|
|
613
|
+
expect(v).to be_frozen
|
|
614
|
+
expect { v.source = "y" }.to raise_error(FrozenError)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
it "freezes deeply — the collections and strings it owns, not just the Struct" do
|
|
618
|
+
# A shallow freeze leaves `names << "x"` working, which also silently
|
|
619
|
+
# invalidates the Struct's own hash.
|
|
620
|
+
ref = cond::AttributeRef.new(["role"])
|
|
621
|
+
val = cond::Value.new("x", :string)
|
|
622
|
+
member = cond::Membership.new(ref, [val])
|
|
623
|
+
cmp = cond::Comparison.new(ref, "=", val)
|
|
624
|
+
binary = cond::BinaryOperation.new("&&", cmp, cmp)
|
|
625
|
+
expect { ref.names << "X" }.to raise_error(FrozenError)
|
|
626
|
+
expect { ref.names.first << "X" }.to raise_error(FrozenError)
|
|
627
|
+
expect { val.source << "!" }.to raise_error(FrozenError)
|
|
628
|
+
expect { member.literals << val }.to raise_error(FrozenError)
|
|
629
|
+
expect { cmp.op << "!" }.to raise_error(FrozenError)
|
|
630
|
+
expect { binary.operator << "!" }.to raise_error(FrozenError)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
it "derives pattern? from the raw source, only for strings" do
|
|
634
|
+
expect(cond::Value.new("stand*", :string).pattern?).to be true
|
|
635
|
+
expect(cond::Value.new("standards", :string).pattern?).to be false
|
|
636
|
+
expect(cond::Value.new("30", :number).pattern?).to be false
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
RSpec.describe Lutaml::Path::Step do
|
|
641
|
+
def seg(name) = Lutaml::Path::PathSegment.new(name)
|
|
642
|
+
|
|
643
|
+
it "delegates name, pattern? and deep_wildcard? to its segment" do
|
|
644
|
+
step = described_class.new(seg("**"))
|
|
645
|
+
expect(step.name).to eq("**")
|
|
646
|
+
expect(step.deep_wildcard?).to be true
|
|
647
|
+
expect(described_class.new(seg("Base*")).pattern?).to be true
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
it "keeps deep_wildcard? true when a filter is attached" do
|
|
651
|
+
step = described_class.new(seg("**"), condition: Lutaml::Path::Condition::Existence.new)
|
|
652
|
+
expect(step.deep_wildcard?).to be true
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
it "never answers match? — it can carry a filter it cannot evaluate" do
|
|
656
|
+
expect(described_class.instance_methods).not_to include(:match?)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
it "renders its own brackets" do
|
|
660
|
+
expect(described_class.new(seg("contributor")).to_s).to eq("contributor")
|
|
661
|
+
step = described_class.new(seg("contributor"), condition: Lutaml::Path::Condition::Existence.new)
|
|
662
|
+
expect(step.to_s).to eq("contributor[exists]")
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
it "compares by value" do
|
|
666
|
+
expect(described_class.new(seg("a"))).to eq(described_class.new(seg("a")))
|
|
667
|
+
expect(described_class.new(seg("a"))).not_to eq(described_class.new(seg("b")))
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
RSpec.describe Lutaml::Path::InstancePath do
|
|
672
|
+
def step(name) = Lutaml::Path::Step.new(Lutaml::Path::PathSegment.new(name))
|
|
673
|
+
|
|
674
|
+
it "is an AbstractPath" do
|
|
675
|
+
expect(described_class.ancestors).to include(Lutaml::Path::AbstractPath)
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
it "separates the base steps from the attribute steps" do
|
|
679
|
+
path = described_class.new(base_steps: [step("Shapes"), step("Rectangle")],
|
|
680
|
+
attribute_steps: [step("width")], absolute: true)
|
|
681
|
+
expect(path.absolute?).to be true
|
|
682
|
+
expect(path.base_steps.map(&:name)).to eq(%w[Shapes Rectangle])
|
|
683
|
+
expect(path.attribute_steps.map(&:name)).to eq(%w[width])
|
|
684
|
+
expect(path.steps.map(&:name)).to eq(%w[Shapes Rectangle width])
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
it "raises a scoped ResolutionError from match?" do
|
|
688
|
+
path = described_class.new(base_steps: [step("obj")], attribute_steps: [step("title")])
|
|
689
|
+
expect { path.match?(%w[obj title]) }
|
|
690
|
+
.to raise_error(Lutaml::Path::ResolutionError, /parsed but not resolved/)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# ResolutionError descends from StandardError, so a caller guarding a
|
|
694
|
+
# mixed batch of paths degrades instead of crashing. NotImplementedError
|
|
695
|
+
# is a ScriptError and would escape this rescue.
|
|
696
|
+
it "is catchable by a bare rescue, unlike NotImplementedError" do
|
|
697
|
+
path = described_class.new(base_steps: [step("obj")], attribute_steps: [step("title")])
|
|
698
|
+
caught = begin
|
|
699
|
+
path.match?(%w[obj title])
|
|
700
|
+
rescue StandardError
|
|
701
|
+
:rescued
|
|
76
702
|
end
|
|
703
|
+
expect(caught).to eq(:rescued)
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# Equality is exact-class throughout the path AST, matching
|
|
708
|
+
# Condition::Existence. `hash` is keyed on self.class, so an is_a? test would
|
|
709
|
+
# let a base instance equal a subclass instance whose hash differs: eql? would
|
|
710
|
+
# report true while `{ base => 1 }[subclass]` returned nil.
|
|
711
|
+
RSpec.describe "path AST equality" do
|
|
712
|
+
[
|
|
713
|
+
[Lutaml::Path::PathSegment, ->(k) { k.new("a") }],
|
|
714
|
+
[Lutaml::Path::Step, ->(k) { k.new(Lutaml::Path::PathSegment.new("a")) }],
|
|
715
|
+
[Lutaml::Path::ElementPath, ->(k) { k.new([Lutaml::Path::PathSegment.new("a")]) }],
|
|
716
|
+
[Lutaml::Path::InstancePath,
|
|
717
|
+
->(k) { k.new(base_steps: [Lutaml::Path::Step.new(Lutaml::Path::PathSegment.new("a"))]) }]
|
|
718
|
+
].each do |klass, build|
|
|
719
|
+
it "compares #{klass} by exact class, keeping eql? symmetric and hash-consistent" do
|
|
720
|
+
base = build.call(klass)
|
|
721
|
+
twin = build.call(klass)
|
|
722
|
+
sub = build.call(Class.new(klass))
|
|
77
723
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Lutaml::Path::PathSegment.new("Element")
|
|
82
|
-
], absolute: true)
|
|
724
|
+
expect(base).to eq(twin)
|
|
725
|
+
expect(base.hash).to eq(twin.hash)
|
|
726
|
+
expect({ base => 1 }[twin]).to eq(1)
|
|
83
727
|
|
|
84
|
-
expect(
|
|
85
|
-
expect(
|
|
728
|
+
expect(base).not_to eq(sub)
|
|
729
|
+
expect(sub).not_to eq(base)
|
|
730
|
+
expect({ base => 1 }[sub]).to be_nil
|
|
86
731
|
end
|
|
87
732
|
end
|
|
88
733
|
end
|