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.
@@ -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
- xit "parses paths with patterns" do
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
- xit "handles glob patterns" do
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
- describe Lutaml::Path::PathSegment do
53
- it "matches exact names" do
54
- segment = described_class.new("Element")
55
- expect(segment.match?("Element")).to be true
56
- expect(segment.match?("Other")).to be false
57
- end
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
- it "matches patterns" do
60
- segment = described_class.new("Base*", is_pattern: true)
61
- expect(segment.match?("BaseClass")).to be true
62
- expect(segment.match?("Other")).to be false
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
- describe Lutaml::Path::ElementPath do
67
- it "matches path segments" do
68
- path = described_class.new([
69
- Lutaml::Path::PathSegment.new("pkg"),
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
- expect(path.match?(%w[pkg sub Element])).to be true
75
- expect(path.match?(%w[other sub Element])).to be false
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
- it "respects absolute paths" do
79
- path = described_class.new([
80
- Lutaml::Path::PathSegment.new("pkg"),
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(path.match?(%w[pkg Element])).to be true
85
- expect(path.match?(%w[root pkg Element])).to be false
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