lutaml-path 0.1.0 → 0.2.0

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