lutaml-path 0.1.1 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -1
- data/lib/lutaml/path/parser.rb +18 -7
- data/lib/lutaml/path/transformer.rb +6 -4
- data/lib/lutaml/path/version.rb +1 -1
- data/lib/lutaml/path.rb +4 -7
- data/lutaml-path.gemspec +2 -2
- data/spec/lutaml/path_spec.rb +134 -2
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8c0a8ead1612a0e7855fa1b1deb4e3ba69ac7e979d8d0a821fd352e2aa52c32
|
|
4
|
+
data.tar.gz: 5942c6e8c60f1166e9554661e02e0684be30e7232a65e78c37f93195e5cfbcc3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74a28aeccc015653c0e64e266aa323eb1f88bc1b12ddef91fdfb251b1582307b708b111c19a37d8f52db7a73158e7690a3a4a4fe93a247cc9dc7c2dd73e42240
|
|
7
|
+
data.tar.gz: 74bedffad67bc25619f7b50d0ba213eaba57afc3c0577fd956d0f17b638d52ea39db9418cc57e45892d54135c5aca7d264680e8532a916eb5f11d2349c44212c
|
data/.rubocop.yml
CHANGED
data/lib/lutaml/path/parser.rb
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# lib/lutaml/path/parser.rb
|
|
4
|
-
require "
|
|
4
|
+
require "parsanol"
|
|
5
|
+
require_relative "errors"
|
|
5
6
|
|
|
6
7
|
module Lutaml
|
|
7
8
|
module Path
|
|
8
|
-
class Parser <
|
|
9
|
+
class Parser < Parsanol::Parser
|
|
10
|
+
def parse(input, mode_or_opts = {}, **kwargs)
|
|
11
|
+
options = mode_or_opts.is_a?(Hash) ? mode_or_opts : {}
|
|
12
|
+
# Keep recursive grammar failures catchable on the Ruby engine.
|
|
13
|
+
super(input, **options, **kwargs, mode: :ruby)
|
|
14
|
+
rescue SystemStackError
|
|
15
|
+
raise ParseError, "expression nests too deeply to parse"
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
rule(:sp) { match('\s').repeat }
|
|
10
19
|
rule(:sp?) { sp.maybe }
|
|
11
20
|
|
|
@@ -88,14 +97,16 @@ module Lutaml
|
|
|
88
97
|
# silently parses as `and` + the attribute "roid" rather than raising.
|
|
89
98
|
rule(:and_op) { str("&&") | (str("and") >> ident_char.absent?) }
|
|
90
99
|
|
|
100
|
+
# Parse each operand once; repeating it in an alternative compounds
|
|
101
|
+
# backtracking inside nested groups.
|
|
91
102
|
rule(:and_expr) do
|
|
92
|
-
(not_expr.as(:left) >> sp? >> and_op.as(:operator) >>
|
|
93
|
-
sp? >> and_expr.as(:right)).as(:binary)
|
|
103
|
+
(not_expr.as(:left) >> (sp? >> and_op.as(:operator) >>
|
|
104
|
+
sp? >> and_expr.as(:right)).maybe).as(:binary)
|
|
94
105
|
end
|
|
95
106
|
|
|
96
107
|
rule(:or_expr) do
|
|
97
|
-
(and_expr.as(:left) >> sp? >> str("||").as(:operator) >>
|
|
98
|
-
sp? >> or_expr.as(:right)).as(:binary)
|
|
108
|
+
(and_expr.as(:left) >> (sp? >> str("||").as(:operator) >>
|
|
109
|
+
sp? >> or_expr.as(:right)).maybe).as(:binary)
|
|
99
110
|
end
|
|
100
111
|
|
|
101
112
|
rule(:condition) { or_expr }
|
|
@@ -171,7 +182,7 @@ module Lutaml
|
|
|
171
182
|
end
|
|
172
183
|
|
|
173
184
|
# Full path expression. `absolute` is ALWAYS emitted (nil when relative)
|
|
174
|
-
# so the transform sees one stable key set:
|
|
185
|
+
# so the transform sees one stable key set: Parsanol matches a hash on its
|
|
175
186
|
# exact keys, and a second root rule would have to be duplicated for
|
|
176
187
|
# every key added later.
|
|
177
188
|
rule(:path_expr) do
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# lib/lutaml/path/transformer.rb
|
|
4
|
-
require "
|
|
4
|
+
require "parsanol"
|
|
5
5
|
|
|
6
6
|
module Lutaml
|
|
7
7
|
module Path
|
|
8
|
-
class Transformer <
|
|
8
|
+
class Transformer < Parsanol::Transform
|
|
9
9
|
# --- condition AST ----------------------------------------------------
|
|
10
10
|
# Tokenise into names rather than splitting on a naive lookbehind: a name
|
|
11
11
|
# may itself end in an escaped backslash, so "a\\\\.b" is the two names
|
|
@@ -15,7 +15,7 @@ module Lutaml
|
|
|
15
15
|
names = a.to_s.scan(/(?:\\.|[^.\\])+/)
|
|
16
16
|
Condition::AttributeRef.new(names.map { |n| n.gsub(/\\(.)/) { ::Regexp.last_match(1) } })
|
|
17
17
|
end
|
|
18
|
-
# An empty quoted value ('') makes
|
|
18
|
+
# An empty quoted value ('') makes Parsanol's repeat yield [] rather than a
|
|
19
19
|
# slice, so `simple` would never match and the node would stay raw.
|
|
20
20
|
rule(string: subtree(:s)) do
|
|
21
21
|
raw = s.is_a?(Array) ? "" : s.to_s
|
|
@@ -30,7 +30,7 @@ module Lutaml
|
|
|
30
30
|
Condition::Comparison.new(d[:l], d[:o].to_s, d[:r])
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
#
|
|
33
|
+
# Parsanol collapses a one-element repetition to a bare Hash rather than a
|
|
34
34
|
# one-element Array, and Array({v: x}) would yield [[:v, x]] via Hash#to_a
|
|
35
35
|
# -- so `in ('ISBN')` used to raise TypeError out of Lutaml::Path.parse.
|
|
36
36
|
# Wrap-then-flatten normalises both shapes.
|
|
@@ -40,6 +40,8 @@ module Lutaml
|
|
|
40
40
|
|
|
41
41
|
rule(not: subtree(:n)) { Condition::Negation.new(n) }
|
|
42
42
|
|
|
43
|
+
rule(binary: { left: subtree(:l) }) { l }
|
|
44
|
+
|
|
43
45
|
rule(binary: { left: subtree(:l), operator: simple(:o), right: subtree(:r) }) do |d|
|
|
44
46
|
Condition::BinaryOperation.new(d[:o].to_s, d[:l], d[:r])
|
|
45
47
|
end
|
data/lib/lutaml/path/version.rb
CHANGED
data/lib/lutaml/path.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "parsanol"
|
|
4
4
|
require_relative "path/version"
|
|
5
5
|
require_relative "path/errors"
|
|
6
6
|
require_relative "path/parser"
|
|
@@ -20,13 +20,10 @@ module Lutaml
|
|
|
20
20
|
def self.parse(input)
|
|
21
21
|
tree = Parser.new.parse(input)
|
|
22
22
|
Transformer.new.apply(tree)
|
|
23
|
-
rescue
|
|
23
|
+
rescue Parsanol::ParseFailed => e
|
|
24
24
|
raise ParseError, e.message
|
|
25
25
|
rescue SystemStackError
|
|
26
|
-
#
|
|
27
|
-
# nested deeply enough (~200 parentheses) exhausts the stack. That is a
|
|
28
|
-
# property of the input, not a bug in the caller: parse must only ever
|
|
29
|
-
# raise ParseError.
|
|
26
|
+
# Transforming deeply nested conditions can also exhaust the stack.
|
|
30
27
|
raise ParseError, "expression nests too deeply to parse"
|
|
31
28
|
end
|
|
32
29
|
|
|
@@ -43,7 +40,7 @@ module Lutaml
|
|
|
43
40
|
def self.parse_instance(input)
|
|
44
41
|
tree = Parser.new.parse(input)
|
|
45
42
|
Transformer.new.apply(tree)
|
|
46
|
-
rescue
|
|
43
|
+
rescue Parsanol::ParseFailed => e
|
|
47
44
|
raise ParseError, e.message
|
|
48
45
|
rescue SystemStackError
|
|
49
46
|
raise ParseError, "expression nests too deeply to parse"
|
data/lutaml-path.gemspec
CHANGED
|
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
|
|
|
16
16
|
|
|
17
17
|
spec.bindir = "exe"
|
|
18
18
|
spec.require_paths = ["lib"]
|
|
19
|
-
spec.required_ruby_version = Gem::Requirement.new(">= 3.
|
|
19
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
|
|
20
20
|
|
|
21
21
|
# Specify which files should be added to the gem when it is released.
|
|
22
22
|
# The `git ls-files -z` loads the files in the
|
|
@@ -29,6 +29,6 @@ Gem::Specification.new do |spec|
|
|
|
29
29
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
|
30
30
|
|
|
31
31
|
spec.add_dependency "lutaml-model", ">= 0.8"
|
|
32
|
-
spec.add_dependency "
|
|
32
|
+
spec.add_dependency "parsanol", "~> 1.3"
|
|
33
33
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
34
34
|
end
|
data/spec/lutaml/path_spec.rb
CHANGED
|
@@ -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
|
|
@@ -298,7 +430,7 @@ RSpec.describe Lutaml::Path do
|
|
|
298
430
|
end
|
|
299
431
|
|
|
300
432
|
it "parses an in-list of any arity" do
|
|
301
|
-
# A one-element list collapses to a bare Hash in
|
|
433
|
+
# A one-element list collapses to a bare Hash in Parsanol rather than a
|
|
302
434
|
# one-element Array. Getting that wrong raised TypeError straight out of
|
|
303
435
|
# .parse, bypassing the ParseError rescue. The README only ever shows a
|
|
304
436
|
# three-element list, so this was never exercised.
|
|
@@ -357,7 +489,7 @@ RSpec.describe Lutaml::Path do
|
|
|
357
489
|
end
|
|
358
490
|
|
|
359
491
|
it "parses an empty quoted value" do
|
|
360
|
-
#
|
|
492
|
+
# Parsanol yields [] rather than a slice for an empty repeat, so a
|
|
361
493
|
# `simple` transform rule would never match and the node would stay raw.
|
|
362
494
|
path = described_class.parse("obj.x[a='']")
|
|
363
495
|
expect(path.conditions.first.rhs)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lutaml-path
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -25,19 +25,19 @@ dependencies:
|
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '0.8'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
28
|
+
name: parsanol
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - "~>"
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
33
|
+
version: '1.3'
|
|
34
34
|
type: :runtime
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '
|
|
40
|
+
version: '1.3'
|
|
41
41
|
description: Parser for element paths
|
|
42
42
|
email:
|
|
43
43
|
- open.source@ribose.com
|
|
@@ -88,7 +88,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
88
88
|
requirements:
|
|
89
89
|
- - ">="
|
|
90
90
|
- !ruby/object:Gem::Version
|
|
91
|
-
version: 3.
|
|
91
|
+
version: 3.2.0
|
|
92
92
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
93
93
|
requirements:
|
|
94
94
|
- - ">="
|