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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8584a51742dcb31b61b6bc37e68f2edf493a1e7a48708c870e47cadeeb4beef6
4
- data.tar.gz: 51a38b742ecacf795837ae41a9eea14245ba1eb5293796b179cb0eb939b600b8
3
+ metadata.gz: c8c0a8ead1612a0e7855fa1b1deb4e3ba69ac7e979d8d0a821fd352e2aa52c32
4
+ data.tar.gz: 5942c6e8c60f1166e9554661e02e0684be30e7232a65e78c37f93195e5cfbcc3
5
5
  SHA512:
6
- metadata.gz: 15f279e14d46e29330f325c4bd649d96a78aae0612b3db20accb0b310b9988197c29129ddbfb333a4338a7417363d26cf3f3c6056e2b4399beaf262aefc8c593
7
- data.tar.gz: 4ba0a918560a74402a7bfd5088cf5ed83a5ff96881d4d200425dd9a4bca82eeb6c31672da5414b82abe1bce088ecb290e4dc716ebe374934e77fa9dcefc68ddc
6
+ metadata.gz: 74a28aeccc015653c0e64e266aa323eb1f88bc1b12ddef91fdfb251b1582307b708b111c19a37d8f52db7a73158e7690a3a4a4fe93a247cc9dc7c2dd73e42240
7
+ data.tar.gz: 74bedffad67bc25619f7b50d0ba213eaba57afc3c0577fd956d0f17b638d52ea39db9418cc57e45892d54135c5aca7d264680e8532a916eb5f11d2349c44212c
data/.rubocop.yml CHANGED
@@ -1,7 +1,7 @@
1
1
  inherit_from: .rubocop_todo.yml
2
2
 
3
3
  AllCops:
4
- TargetRubyVersion: 3.0
4
+ TargetRubyVersion: 3.2
5
5
 
6
6
  Style/StringLiterals:
7
7
  EnforcedStyle: double_quotes
@@ -1,11 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # lib/lutaml/path/parser.rb
4
- require "parslet"
4
+ require "parsanol"
5
+ require_relative "errors"
5
6
 
6
7
  module Lutaml
7
8
  module Path
8
- class Parser < Parslet::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) | not_expr
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) | and_expr
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: Parslet matches a hash on its
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 "parslet"
4
+ require "parsanol"
5
5
 
6
6
  module Lutaml
7
7
  module Path
8
- class Transformer < Parslet::Transform
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 Parslet's repeat yield [] rather than a
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
- # Parslet collapses a one-element repetition to a bare Hash rather than a
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Path
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/lutaml/path.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parslet"
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 Parslet::ParseFailed => e
23
+ rescue Parsanol::ParseFailed => e
24
24
  raise ParseError, e.message
25
25
  rescue SystemStackError
26
- # The condition grammar recurses through group/not/and/or, so an input
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 Parslet::ParseFailed => e
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.0.0")
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 "parslet", "~> 2"
32
+ spec.add_dependency "parsanol", "~> 1.3"
33
33
  spec.metadata["rubygems_mfa_required"] = "true"
34
34
  end
@@ -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 Parslet rather than a
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
- # Parslet yields [] rather than a slice for an empty repeat, so a
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.1.1
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-14 00:00:00.000000000 Z
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: parslet
28
+ name: parsanol
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '2'
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: '2'
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.0.0
91
+ version: 3.2.0
92
92
  required_rubygems_version: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - ">="