lutaml-path 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/rake.yml +15 -0
- data/.github/workflows/release.yml +30 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +7 -0
- data/README.adoc +651 -36
- data/lib/lutaml/path/abstract_path.rb +24 -0
- data/lib/lutaml/path/condition.rb +100 -0
- data/lib/lutaml/path/condition_evaluator.rb +109 -0
- data/lib/lutaml/path/element_path.rb +66 -8
- data/lib/lutaml/path/errors.rb +12 -0
- data/lib/lutaml/path/instance_path.rb +63 -0
- data/lib/lutaml/path/parser.rb +164 -13
- data/lib/lutaml/path/path_segment.rb +31 -5
- data/lib/lutaml/path/resolver.rb +154 -0
- data/lib/lutaml/path/step.rb +50 -0
- data/lib/lutaml/path/transformer.rb +63 -21
- data/lib/lutaml/path/version.rb +1 -1
- data/lib/lutaml/path.rb +34 -3
- data/lutaml-path.gemspec +2 -1
- data/spec/lutaml/path_spec.rb +674 -29
- data/spec/lutaml/resolver_spec.rb +182 -0
- metadata +29 -6
- data/.github/workflows/main.yml +0 -27
|
@@ -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
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
data/lib/lutaml/path/parser.rb
CHANGED
|
@@ -6,39 +6,190 @@ require "parslet"
|
|
|
6
6
|
module Lutaml
|
|
7
7
|
module Path
|
|
8
8
|
class Parser < Parslet::Parser
|
|
9
|
-
rule(:
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
(
|
|
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
|
-
#
|
|
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
|
-
|
|
38
|
-
|
|
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(:
|
|
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
|
-
|
|
6
|
+
GLOB_CHARS = /[*?\[{]/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|