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.
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Path
5
+ # Common supertype for parsed paths. Holds only whether the path is
6
+ # absolute.
7
+ #
8
+ # It deliberately declares neither match? nor segments: InstancePath
9
+ # cannot honour match? under parse-only, and the two subclasses hold
10
+ # genuinely different element types (PathSegment answers match?; Step,
11
+ # which can carry a filter, must not).
12
+ class AbstractPath
13
+ attr_reader :absolute
14
+
15
+ def initialize(absolute: false)
16
+ @absolute = absolute
17
+ end
18
+
19
+ def absolute?
20
+ @absolute
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_segment"
4
+
5
+ module Lutaml
6
+ module Path
7
+ # The filter-condition AST.
8
+ #
9
+ # Parse-only: these nodes carry structure and render themselves, and
10
+ # evaluate nothing. They are Structs because there is no behaviour to
11
+ # justify hand-written classes, and Struct supplies value equality for
12
+ # free. Data.define would also freeze them, but it is Ruby 3.2+ and this
13
+ # gem supports 3.0.
14
+ #
15
+ # Every node freezes itself, along with any collection or string it owns:
16
+ # a parsed AST must be immutable, and Struct otherwise hands out public
17
+ # setters. Freezing the Struct alone is shallow -- `names << "x"` would
18
+ # still mutate, and would silently invalidate the Struct's own hash.
19
+ module Condition
20
+ # A dotted left-hand side: "role.type" -> ["role", "type"].
21
+ AttributeRef = Struct.new(:names) do
22
+ def initialize(*) = super.tap { names.each(&:freeze).freeze and freeze }
23
+
24
+ # Re-escapes BOTH the dot and the backslash the transformer unescaped.
25
+ # Escaping only the dot is incomplete: a name ending in a backslash
26
+ # would render as "a\.b" and re-parse as the single name "a.b".
27
+ def to_s
28
+ names.map { |n| n.gsub(/([\\.])/) { "\\#{::Regexp.last_match(1)}" } }.join(".")
29
+ end
30
+ end
31
+
32
+ # A right-hand literal. `source` is the unescaped lexeme and is never
33
+ # coerced: pattern? must read it directly so a numeric never reaches
34
+ # String#match?.
35
+ Value = Struct.new(:source, :type) do
36
+ def initialize(*) = super.tap { source.freeze and freeze }
37
+
38
+ def pattern?
39
+ type == :string && source.match?(PathSegment::GLOB_CHARS)
40
+ end
41
+
42
+ # Re-escapes BOTH the quote and the backslash the transformer
43
+ # unescaped. Escaping only the quote is incomplete: a value ending in a
44
+ # backslash would render as 'abc\' and its trailing escape would eat
45
+ # the closing quote.
46
+ def to_s
47
+ return source if type == :number
48
+
49
+ "'#{source.gsub(/(['\\])/) { "\\#{::Regexp.last_match(1)}" }}'"
50
+ end
51
+ end
52
+
53
+ Comparison = Struct.new(:lhs, :op, :rhs) do
54
+ def initialize(*) = super.tap { op.freeze and freeze }
55
+
56
+ def to_s = "#{lhs}#{op}#{rhs}"
57
+ end
58
+
59
+ # `literals`, not `values`: a member named `values` would shadow
60
+ # Struct#values, which returns the member list.
61
+ Membership = Struct.new(:lhs, :literals) do
62
+ def initialize(*) = super.tap { literals.freeze and freeze }
63
+
64
+ def to_s = "#{lhs} in (#{literals.join(",")})"
65
+ end
66
+
67
+ # `exists` carries no operand, so there is no member to declare. Written
68
+ # as a plain class rather than a zero-member Struct, which Ruby permits
69
+ # only from 3.3 while this gem supports >= 3.0.
70
+ class Existence
71
+ def initialize = freeze
72
+
73
+ def to_s = "exists"
74
+
75
+ # instance_of?, not is_a?: exact-class equality is what the Struct gave,
76
+ # and hash is keyed on the class, so is_a? would make a subclass compare
77
+ # asymmetrically against a differing hash.
78
+ def ==(other) = other.instance_of?(self.class)
79
+ alias eql? ==
80
+
81
+ def hash = self.class.hash
82
+ end
83
+
84
+ # && and || are structurally identical, so one type carries both.
85
+ # to_s ALWAYS parenthesises: parse(p.to_s) == p asserts TREE equality,
86
+ # and explicit parens make re-association impossible on a re-parse.
87
+ BinaryOperation = Struct.new(:operator, :left, :right) do
88
+ def initialize(*) = super.tap { operator.freeze and freeze }
89
+
90
+ def to_s = "(#{left} #{operator} #{right})"
91
+ end
92
+
93
+ Negation = Struct.new(:operand) do
94
+ def initialize(*) = super.tap { freeze }
95
+
96
+ def to_s = "!#{operand}"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Path
5
+ # Evaluates Condition AST nodes against a context object. The
6
+ # condition classes are parse-only by design; this module is the
7
+ # evaluation half, wired to the same name-resolution the instance
8
+ # traversal uses.
9
+ module ConditionEvaluator
10
+ module_function
11
+
12
+ def evaluate(condition, object)
13
+ case condition
14
+ when Condition::Comparison then compare(condition, object)
15
+ when Condition::Membership then membership(condition, object)
16
+ when Condition::Existence then true
17
+ when Condition::Negation then !evaluate(condition.operand, object)
18
+ when Condition::BinaryOperation then evaluate_binary(condition, object)
19
+ end
20
+ end
21
+
22
+ def evaluate_binary(condition, object)
23
+ left = evaluate(condition.left, object)
24
+ right = evaluate(condition.right, object)
25
+ condition.operator == "&&" ? left && right : left || right
26
+ end
27
+
28
+ def compare(condition, object)
29
+ actual = read_path(object, condition.lhs)
30
+ expected = condition.rhs
31
+ case condition.op
32
+ when "=" then values_equal(actual, expected)
33
+ when "!=" then !values_equal(actual, expected)
34
+ else ordered(actual, condition.op, expected)
35
+ end
36
+ end
37
+
38
+ def membership(condition, object)
39
+ actual = read_path(object, condition.lhs)
40
+ condition.literals.any? { |literal| values_equal(actual, literal) }
41
+ end
42
+
43
+ def values_equal(actual, value)
44
+ return false if actual.nil?
45
+
46
+ value.pattern? ? value_match?(value, actual) : values_coerced_equal(actual, value)
47
+ end
48
+
49
+ def value_match?(value, actual)
50
+ File.fnmatch(value.source, actual.to_s, File::FNM_EXTGLOB)
51
+ end
52
+
53
+ def values_coerced_equal(actual, value)
54
+ pair = numeric_pair(actual, value)
55
+ pair ? pair[0] == pair[1] : actual.to_s == value.source
56
+ end
57
+
58
+ def ordered(actual, operator, value)
59
+ return false if actual.nil?
60
+
61
+ pair = numeric_pair(actual, value)
62
+ if pair
63
+ actual = pair[0]
64
+ value = pair[1]
65
+ end
66
+ actual.to_s.public_send(ruby_operator(operator), value.to_s)
67
+ end
68
+
69
+ def ruby_operator(operator)
70
+ { "=" => :==, "!=" => :"!=", ">=" => :>=, "<=" => :<=, ">" => :>, "<" => :< }.fetch(operator)
71
+ end
72
+
73
+ def numeric_pair(actual, value)
74
+ numeric(actual) && numeric(value) ? [numeric(actual), numeric(value)] : nil
75
+ end
76
+
77
+ # Accepts both AST literals (Value structs) and raw data values.
78
+ def numeric(value)
79
+ raw = value.respond_to?(:source) ? value.source : value
80
+ Float(raw)
81
+ rescue ::ArgumentError, ::TypeError
82
+ nil
83
+ end
84
+
85
+ def read_path(object, attribute_ref)
86
+ context = [object]
87
+ attribute_ref.names.each do |name|
88
+ context = context.flat_map { |node| exact_children(node, name) }
89
+ end
90
+ return nil if context.empty?
91
+
92
+ context.first
93
+ end
94
+
95
+ def exact_children(node, name)
96
+ entries(node).filter_map do |child_name, value|
97
+ next if value.nil?
98
+ next unless child_name.to_s == name
99
+
100
+ value.is_a?(::Enumerable) && !value.is_a?(::Hash) && !value.is_a?(::String) ? value.to_a : value
101
+ end.flatten(1)
102
+ end
103
+
104
+ def entries(node)
105
+ Resolver.entries(node)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,24 +1,82 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "abstract_path"
4
+
3
5
  module Lutaml
4
6
  module Path
5
- class ElementPath
6
- attr_reader :segments, :absolute
7
+ class ElementPath < AbstractPath
8
+ attr_reader :segments
7
9
 
8
10
  def initialize(segments, absolute: false)
11
+ super(absolute: absolute)
9
12
  @segments = Array(segments)
10
- @absolute = absolute
11
13
  end
12
14
 
13
- def absolute?
14
- @absolute
15
+ def to_s
16
+ "#{absolute? ? "::" : ""}#{segments.join("::")}"
17
+ end
18
+
19
+ def ==(other)
20
+ other.instance_of?(self.class) &&
21
+ absolute? == other.absolute? &&
22
+ segments == other.segments
23
+ end
24
+ alias eql? ==
25
+
26
+ def hash
27
+ [self.class, absolute?, segments].hash
15
28
  end
16
29
 
17
30
  def match?(path_segments)
18
- return false if absolute? && path_segments.length != segments.length
19
- return false if path_segments.length < segments.length
31
+ reachable = terminal_row(path_segments.length)
32
+
33
+ segments.reverse_each do |segment|
34
+ reachable = advance(segment, path_segments, reachable)
35
+ end
36
+
37
+ reachable[0]
38
+ end
39
+
40
+ private
41
+
42
+ # Row for a fully-consumed pattern: an absolute pattern must have consumed
43
+ # the whole candidate; a relative pattern matches a prefix, so any trailing
44
+ # candidate segments are allowed.
45
+ def terminal_row(path_length)
46
+ Array.new(path_length + 1) do |path_index|
47
+ absolute? ? path_index == path_length : true
48
+ end
49
+ end
50
+
51
+ # Fold one pattern segment (walking right to left) into the reachability
52
+ # row. reachable[i] answers "can the rest of the pattern match path[i..]?".
53
+ def advance(segment, path_segments, reachable)
54
+ if segment.deep_wildcard?
55
+ deep_wildcard_row(path_segments.length, reachable)
56
+ else
57
+ segment_row(segment, path_segments, reachable)
58
+ end
59
+ end
60
+
61
+ # A `**` reaches i by consuming zero (the rest of the pattern reaches i) or
62
+ # one more segment (the same `**` reaches i + 1).
63
+ def deep_wildcard_row(path_length, reachable)
64
+ row = Array.new(path_length + 1)
65
+ row[path_length] = reachable[path_length]
66
+ (path_length - 1).downto(0) { |i| row[i] = reachable[i] || row[i + 1] }
67
+ row
68
+ end
20
69
 
21
- segments.zip(path_segments).all? { |seg, path_seg| seg.match?(path_seg) }
70
+ # A normal segment reaches i only by matching path[i] and having the rest
71
+ # of the pattern reach i + 1.
72
+ def segment_row(segment, path_segments, reachable)
73
+ path_length = path_segments.length
74
+ row = Array.new(path_length + 1)
75
+ row[path_length] = false
76
+ (path_length - 1).downto(0) do |i|
77
+ row[i] = segment.match?(path_segments[i]) && reachable[i + 1]
78
+ end
79
+ row
22
80
  end
23
81
  end
24
82
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Path
5
+ class ParseError < StandardError; end
6
+
7
+ # Raised when a path parses but the operation asked of it is not
8
+ # implemented. A StandardError, unlike Ruby's NotImplementedError, which
9
+ # descends from ScriptError and so escapes `rescue => e`.
10
+ class ResolutionError < StandardError; end
11
+ end
12
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_path"
4
+ require_relative "errors"
5
+
6
+ module Lutaml
7
+ module Path
8
+ # A parsed instance-data path: a base (the "::" anchor) plus dot-separated
9
+ # attribute navigation, with optional filter conditions on any step.
10
+ #
11
+ # Parse-only. It carries structure and renders itself; resolving it against
12
+ # real model data is out of scope, so match? raises rather than lying by
13
+ # matching on names and silently ignoring the conditions.
14
+ class InstancePath < AbstractPath
15
+ attr_reader :base_steps, :attribute_steps
16
+
17
+ def initialize(base_steps:, attribute_steps: [], absolute: false)
18
+ super(absolute: absolute)
19
+ @base_steps = Array(base_steps)
20
+ @attribute_steps = Array(attribute_steps)
21
+ end
22
+
23
+ def steps
24
+ base_steps + attribute_steps
25
+ end
26
+
27
+ def conditions
28
+ steps.filter_map(&:condition)
29
+ end
30
+
31
+ def match?(_path_segments)
32
+ raise ResolutionError,
33
+ "instance paths are parsed but not resolved: #{self}. " \
34
+ "If you meant a literal dot in an element name, escape it: a\\.b"
35
+ end
36
+
37
+ # Resolves this path against model instance data: a
38
+ # lutaml-model Serializable instance, a hash, or collections
39
+ # thereof. Plain single-segment paths resolve to a single value;
40
+ # filters, wildcards, and collections return arrays.
41
+ def resolve(root)
42
+ Resolver.resolve(root, self)
43
+ end
44
+
45
+ def to_s
46
+ "#{absolute? ? "::" : ""}#{base_steps.join("::")}" \
47
+ "#{attribute_steps.map { |s| ".#{s}" }.join}"
48
+ end
49
+
50
+ def ==(other)
51
+ other.instance_of?(self.class) &&
52
+ absolute? == other.absolute? &&
53
+ base_steps == other.base_steps &&
54
+ attribute_steps == other.attribute_steps
55
+ end
56
+ alias eql? ==
57
+
58
+ def hash
59
+ [self.class, absolute?, base_steps, attribute_steps].hash
60
+ end
61
+ end
62
+ end
63
+ end
@@ -6,39 +6,190 @@ require "parslet"
6
6
  module Lutaml
7
7
  module Path
8
8
  class Parser < Parslet::Parser
9
- rule(:space) { match('\s').repeat }
9
+ rule(:sp) { match('\s').repeat }
10
+ rule(:sp?) { sp.maybe }
10
11
 
11
12
  rule(:escaped_separator) { str("\\") >> str("::") }
13
+ rule(:escaped_dot) { str("\\") >> str(".") }
12
14
  rule(:separator) { str("::") }
15
+ rule(:dot) { str(".") }
13
16
 
14
- # Character rules
15
- rule(:glob_char) { match('[*?\[{]') }
17
+ # Character rules. A "." now terminates a segment, so it is excluded
18
+ # here; "\." escapes it, mirroring how "\::" escapes the separator.
16
19
  rule(:regular_char) do
17
- (separator.absent? >> str("\\").absent? >> any) |
18
- escaped_separator
20
+ (separator.absent? >> dot.absent? >> str("\\").absent? >> any) |
21
+ escaped_separator | escaped_dot
19
22
  end
20
23
 
21
- # Single segment can contain any regular chars or glob chars
24
+ # --- filter conditions ------------------------------------------------
25
+ # Single quotes only: every value in the README is single-quoted. "\'"
26
+ # escapes a quote and "\\" escapes a backslash -- both are needed, or a
27
+ # value ending in a backslash renders as 'abc\' and its trailing escape
28
+ # eats the closing quote. Any OTHER backslash is left intact so a future
29
+ # evaluator's fnmatch still receives "\*" as a literal asterisk.
30
+ rule(:quoted_string) do
31
+ str("'") >> (
32
+ (str("\\") >> (str("'") | str("\\"))) | (str("'").absent? >> any)
33
+ ).repeat.as(:string) >> str("'")
34
+ end
35
+
36
+ rule(:number) { match("[0-9]").repeat(1).as(:number) }
37
+ rule(:literal) { quoted_string | number }
38
+
39
+ rule(:ident_char) { match('[^\s\[\]().,=!<>&|\']') }
40
+ rule(:attribute_ref) do
41
+ (ident_char.repeat(1) >> (dot >> ident_char.repeat(1)).repeat).as(:attref)
42
+ end
43
+
44
+ # Maximal munch: "!=" before "!", ">=" before ">".
45
+ #
46
+ # Defined once and shared with sniff_op. The sniff exists so a typo'd
47
+ # filter fails loudly rather than degrading to a character set; an
48
+ # operator added here but forgotten there would produce exactly the
49
+ # silent degradation the sniff was built to prevent.
50
+ rule(:cmp_lexeme) do
51
+ str("!=") | str(">=") | str("<=") | str("=") | str("<") | str(">")
52
+ end
53
+
54
+ rule(:cmp_op) { cmp_lexeme.as(:op) }
55
+
56
+ # Like `and`, `exists` needs a token boundary: without it
57
+ # "existsand b='2'" silently parses as `(exists and b='2')`.
58
+ # `in` needs none — membership must proceed to "(" to match at all.
59
+ rule(:existence) { str("exists").as(:exists) >> ident_char.absent? }
60
+
61
+ rule(:membership) do
62
+ attribute_ref.as(:lhs) >> sp >> str("in") >> sp? >>
63
+ str("(") >> sp? >>
64
+ (literal.as(:v) >> (sp? >> str(",") >> sp? >> literal.as(:v)).repeat).as(:literals) >>
65
+ sp? >> str(")")
66
+ end
67
+
68
+ rule(:comparison) do
69
+ attribute_ref.as(:lhs) >> sp? >> cmp_op >> sp? >> literal.as(:rhs)
70
+ end
71
+
72
+ # Most specific first, bare keyword last. If `existence` led, "exists"
73
+ # would be consumed as the keyword and an attribute actually named
74
+ # "exists" could never be compared -- while "in" and "and" could, which
75
+ # is a silent asymmetry. Ordering this way, "[exists]" still resolves:
76
+ # comparison finds no operator, membership finds no "in", existence wins.
77
+ rule(:predicate) { membership | comparison | existence }
78
+
79
+ # Boolean layering encodes precedence: ! tightest, then comparison,
80
+ # then &&, then ||. or_expr must sit ABOVE and_expr so || becomes the
81
+ # root of "a || b && c". README never states precedence; this is the
82
+ # conventional order and is documented in the README by this PR.
83
+ rule(:group) { str("(") >> sp? >> or_expr >> sp? >> str(")") }
84
+ rule(:primary) { group | predicate }
85
+ rule(:not_expr) { (str("!") >> sp? >> not_expr).as(:not) | primary }
86
+
87
+ # `and` needs the same token boundary the sniff uses, or "android='2'"
88
+ # silently parses as `and` + the attribute "roid" rather than raising.
89
+ rule(:and_op) { str("&&") | (str("and") >> ident_char.absent?) }
90
+
91
+ rule(:and_expr) do
92
+ (not_expr.as(:left) >> sp? >> and_op.as(:operator) >>
93
+ sp? >> and_expr.as(:right)).as(:binary) | not_expr
94
+ end
95
+
96
+ rule(:or_expr) do
97
+ (and_expr.as(:left) >> sp? >> str("||").as(:operator) >>
98
+ sp? >> or_expr.as(:right)).as(:binary) | and_expr
99
+ end
100
+
101
+ rule(:condition) { or_expr }
102
+
103
+ # --- the bracket rule (operator-sniff) --------------------------------
104
+ # "[" opens a FILTER iff (1) it is at offset > 0 in its segment,
105
+ # (2) its matching "]" -- found quote-aware -- terminates the segment,
106
+ # and (3) its body contains a condition operator. Otherwise "[" is an
107
+ # ordinary glob character set belonging to the name.
108
+ rule(:seg_end) { separator | dot | any.absent? }
109
+
110
+ # The sniff is TOKEN-aware: it tokenises with ident_char, the same
111
+ # alphabet attribute_ref uses, so an identifier is one token wherever it
112
+ # appears and a word operator only counts when no ident_char follows it.
113
+ # Otherwise ordinary character sets are wrongly claimed as filters and
114
+ # rejected -- "[print]" and "[min]" would match on their inner "in",
115
+ # "[standard]" on "and", "[coexists]" on "exists".
116
+ rule(:sniff_word) { ident_char.repeat(1) }
117
+ rule(:sniff_atom) { quoted_string | sniff_word | (str("]").absent? >> any) }
118
+ # Comparisons and the and-family reuse the grammar's own rules, so they
119
+ # cannot drift out of sync. "||" is one fixed token. "in" and "exists"
120
+ # stay spelled out: the grammar bounds "in" by the "(" that must follow
121
+ # it rather than by a character lookahead, and `existence` captures the
122
+ # word it matches, so neither reads back cleanly as a bare matcher.
123
+ rule(:sniff_op) do
124
+ cmp_lexeme | and_op | str("||") |
125
+ ((str("in") | str("exists")) >> ident_char.absent?)
126
+ end
127
+
128
+ rule(:filter_ahead) do
129
+ str("[") >>
130
+ (str("]").absent? >> sniff_op.absent? >> sniff_atom).repeat >>
131
+ sniff_op >> sniff_atom.repeat >>
132
+ str("]") >> seg_end.present?
133
+ end
134
+
135
+ # Once the sniff fires the filter MUST parse: no fallback, so a typo'd
136
+ # filter fails loudly instead of silently becoming a character set.
137
+ rule(:filter) do
138
+ filter_ahead.present? >>
139
+ str("[") >> sp? >> condition.as(:condition) >> sp? >> str("]")
140
+ end
141
+
142
+ # A bracket group the sniff has not claimed is consumed WHOLE, so a "."
143
+ # or "::" inside a character set is literal rather than a separator:
144
+ # "Class[.]" and "Class[a.b]" stay single-segment charsets. regular_char
145
+ # still accepts "[" so an unbalanced "x[" remains a name.
146
+ rule(:charset_group) { str("[") >> (str("]").absent? >> any).repeat >> str("]") }
147
+ rule(:name_atom) { charset_group | regular_char }
148
+
149
+ # The first character ALWAYS belongs to the name -- clause 1 ("a [ at
150
+ # offset 0 is always a charset") encoded literally. Only later positions
151
+ # may stop at a filter. Both halves are load-bearing: without the
152
+ # filter_ahead.absent? guard the name swallows the whole filter and every
153
+ # filter parses as a charset; without the leading unconditional name_atom
154
+ # filter_ahead fires at offset 0 too and "pkg::[exists]" raises.
22
155
  rule(:segment_content) do
23
- (glob_char | regular_char).repeat(1).as(:content) >>
24
- glob_char.present?.maybe.as(:is_pattern)
156
+ (name_atom >> (filter_ahead.absent? >> name_atom).repeat).as(:content)
25
157
  end
26
158
 
27
159
  rule(:segment) do
28
- segment_content.as(:segment)
160
+ (segment_content >> filter.maybe).as(:segment)
29
161
  end
30
162
 
31
163
  rule(:segments) do
32
164
  (separator >> segment).repeat.as(:more_segments)
33
165
  end
34
166
 
35
- # Full path expression - either absolute or relative
167
+ # The attribute part. "::" never follows "." (verified: zero README
168
+ # examples), so the namespace part is consumed first, then this.
169
+ rule(:attributes) do
170
+ (dot >> segment).repeat.as(:attrs)
171
+ end
172
+
173
+ # Full path expression. `absolute` is ALWAYS emitted (nil when relative)
174
+ # so the transform sees one stable key set: Parslet matches a hash on its
175
+ # exact keys, and a second root rule would have to be duplicated for
176
+ # every key added later.
36
177
  rule(:path_expr) do
37
- ((separator.as(:absolute) >> segment.as(:first_segment) >> segments) |
38
- (segment.as(:first_segment) >> segments))
178
+ separator.maybe.as(:absolute) >>
179
+ segment.as(:first_segment) >>
180
+ segments >>
181
+ attributes
182
+ end
183
+
184
+ # Neither leading separator may be escaped. README:212-218 forbids BOTH
185
+ # "\::Rectangle::Shape" and "\.width.length". Guard position 0 of the
186
+ # input; a real leading "::" is fine, so absolute paths and
187
+ # later-segment escapes are unaffected.
188
+ rule(:path) do
189
+ escaped_separator.absent? >> escaped_dot.absent? >> path_expr
39
190
  end
40
191
 
41
- root(:path_expr)
192
+ root(:path)
42
193
  end
43
194
  end
44
195
  end
@@ -3,22 +3,48 @@
3
3
  module Lutaml
4
4
  module Path
5
5
  class PathSegment
6
- attr_reader :name, :pattern
6
+ GLOB_CHARS = /[*?\[{]/
7
7
 
8
- def initialize(name, is_pattern: false)
9
- @name = name.gsub('\::', "::")
10
- @pattern = is_pattern
8
+ attr_reader :name, :source
9
+
10
+ # Deliberately no `\[` unescape: a backslash before a glob character
11
+ # must survive into fnmatch's own escaper, which is what makes a literal
12
+ # "[" expressible. Unescaping it here would turn "Foo\[A-Z]" back into a
13
+ # character set.
14
+ def initialize(name)
15
+ @source = name
16
+ @name = name.gsub('\::', "::").gsub('\.', ".")
17
+ @pattern = @name.match?(GLOB_CHARS)
11
18
  end
12
19
 
13
20
  def pattern?
14
21
  @pattern
15
22
  end
16
23
 
24
+ def deep_wildcard?
25
+ name == "**"
26
+ end
27
+
17
28
  def match?(segment)
18
- return File.fnmatch(name, segment) if pattern?
29
+ return File.fnmatch(name, segment, File::FNM_EXTGLOB) if pattern?
19
30
 
20
31
  name == segment
21
32
  end
33
+
34
+ # Renders the RAW source, so escapes survive a round-trip: "core\::t"
35
+ # must render back as "core\::t", not as the unescaped "core::t".
36
+ def to_s
37
+ @source
38
+ end
39
+
40
+ def ==(other)
41
+ other.instance_of?(self.class) && name == other.name
42
+ end
43
+ alias eql? ==
44
+
45
+ def hash
46
+ [self.class, name].hash
47
+ end
22
48
  end
23
49
  end
24
50
  end