janeway-jsonpath 1.1.0 → 1.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: ff463dd64e17811dddaa23fb3fb82c9bc5cb728dd5b288574f39010174c4f705
4
- data.tar.gz: 27f5f8d6c39435fb4ec97eb8a96c7e204c2f06a1e3e7dc569b606d4a52b5cf58
3
+ metadata.gz: 11e0e5480e8e6e35fec72b79fb6effff412dae3349ead88ecb8f257e072d68fb
4
+ data.tar.gz: 047f434419ccacdbc6b1107146c97dd8305a6aec000d83c7cd3c1eb813e0f78e
5
5
  SHA512:
6
- metadata.gz: 113d48240302143e72de6249d1975d0be52074374e94abd0d2a340e54867031653d6f912e0ccb3ab25e0b9734a3d33119bea1fb100c4283bce95560f9c02ad69
7
- data.tar.gz: cfdca8a82c1c38f7b3b646afcca7c54d6d996564f2f191cb9cd407fa5abcac8c258c4d4534ce7cd437739174298fda0c66b3d41602e4058a47b7f5d94e4b1208
6
+ metadata.gz: a767d7c2f72d25b61b5ae8ef87a08f3c67c9c7bacc387fe9f0bc9b343c85643eedcd7f7be4a445522e84f1eff23f570f83b71d50dca0a67d3b70264c1552489d
7
+ data.tar.gz: 35d6038891ab976044be4ca499896bd614f4feae8adf5d894a85309ace9e5d2d5245bea5f4be1594d27f473906b4c58c32b7fe3a774c64942d507d2b3f2ea2b6
data/README.md CHANGED
@@ -216,7 +216,7 @@ This is a jsonpath query string that uniquely points to the matched value.
216
216
  The `#delete` method deletes matched values from the input.
217
217
  ```ruby
218
218
  # delete any bird whose name starts with "s" or is "eagle"
219
- Janeway.enum_for('$.birds[? search(@, "^s") || @=="eagle"]', data).delete
219
+ Janeway.enum_for('$.birds[? match(@, "s.*") || @=="eagle"]', data).delete
220
220
  # input bird list is now ['cormorant']
221
221
 
222
222
  # delete all dog names
data/bin/janeway CHANGED
@@ -197,8 +197,10 @@ begin
197
197
  options = parse_args(ARGV.dup)
198
198
  main(options)
199
199
  rescue Janeway::Error => e
200
- warn "Error: #{e.message}\nQuery: #{e.query}\n"
201
- warn " #{' ' * e.location.col}^" if e.location # point to the character at the location index
200
+ # The message shows the query, and carets under it when there is a location. The label is on its own
201
+ # line so that the carets stay under the header's copy of the query.
202
+ warn 'Error:'
203
+ warn e.message
202
204
  exit(1)
203
205
  rescue Interrupt, Errno::EPIPE
204
206
  abort("\n")
@@ -5,6 +5,10 @@ module Janeway
5
5
  # Represents a string literal within a filter expression.
6
6
  # May be used within a comparison or a jsonpath function call.
7
7
  class StringType < Janeway::AST::Expression
8
+ # The string token this literal was parsed from, nil otherwise.
9
+ # @return [Token, nil]
10
+ attr_accessor :token
11
+
8
12
  def to_s
9
13
  if @value.include?("'")
10
14
  %("#{@value}"')
data/lib/janeway/error.rb CHANGED
@@ -20,14 +20,38 @@ module Janeway
20
20
  # @param query [String] entire query string
21
21
  # @param location [Location] location of error
22
22
  def initialize(msg, query = nil, location = nil)
23
+ prefix = 'Jsonpath query '
24
+ pointer = query.is_a?(String) && location
25
+ # The pointer is only right if the header's copy of the query is on one line.
26
+ # scrub swaps each invalid byte for one replacement character, so the columns stay valid
27
+ # and a query that is not valid UTF-8 cannot make building the error raise.
28
+ shown = query
29
+ if pointer
30
+ shown = shown.scrub unless shown.valid_encoding?
31
+ shown = shown.tr("\r\n", ' ')
32
+ end
23
33
  full = +''
24
- full << "Jsonpath query #{query}" if query
34
+ full << "#{prefix}#{shown}" if query
25
35
  full << " (col #{location.col})" if location
26
36
  full << ' - ' unless full.empty?
27
37
  full << msg
38
+ full << pointer_line(shown, location, prefix.length) if pointer
28
39
  super(full)
29
40
  @query = query
30
41
  @location = location
31
42
  end
43
+
44
+ private
45
+
46
+ # Build the line of carets that marks the problem, aligned under the query in the message header.
47
+ # @param line [String] the query as shown in the header, on one line
48
+ # @param location [Location]
49
+ # @param offset [Integer] width of the text that comes before the query in the header
50
+ # @return [String] starts with a newline
51
+ def pointer_line(line, location, offset)
52
+ col = (location.col || 0).clamp(0, line.length)
53
+ length = (location.length || 1).clamp(1, [line.length - col, 1].max)
54
+ "\n#{' ' * (offset + col)}#{'^' * length}"
55
+ end
32
56
  end
33
57
  end
@@ -19,16 +19,10 @@ module Janeway
19
19
  # otherwise.
20
20
  #
21
21
  #
22
- # The regexp dialect is called "I-Regexp" and is defined in RFC9485.
23
- #
24
- # Fortunately a shortcut is availalble, that RFC contains instructions
25
- # for converting an I-Regexp to ruby's regexp format.
26
- # @see https://www.rfc-editor.org/rfc/rfc9485.html#name-pcre-re2-and-ruby-regexps
27
- #
28
- # The instructions are:
29
- # * For any unescaped dots (.) outside character classes (first
30
- # alternative of charClass production), replace the dot with [^\n\r].
31
- # * Enclose the regexp in \A(?: and )\z.
22
+ # The pattern is validated and translated by Janeway::IRegexp (RFC 9485). A pattern that is not an
23
+ # I-Regexp gives LogicalFalse (RFC 9535 sections 2.4.6 and 2.4.7). A literal pattern that is over
24
+ # one of the limits in IRegexp (MAX_LENGTH, MAX_REPEAT, MAX_GROUP_DEPTH) raises Janeway::Error when
25
+ # the query is parsed. In a pattern ^ and $ are ordinary characters, as RFC 9485 has no anchors.
32
26
  #
33
27
  # tl;dr: How is this different from the search function?
34
28
  # "match" must match the entire string, "search" matches a substring.
@@ -52,6 +46,8 @@ module Janeway
52
46
  parameters << parse_function_parameter
53
47
  raise Error, 'Too many parameters for match() function call' unless current.type == :group_end
54
48
 
49
+ validate_literal_pattern(parameters.last) # after the syntax checks, so they are reported first
50
+
55
51
  # Body (including literal-regex fast path) is supplied by
56
52
  # Functions::REGISTRY at FunctionInterpreter construction time.
57
53
  AST::Function.new('match', parameters)
@@ -25,6 +25,11 @@ module Janeway
25
25
  # the result is LogicalTrue if at least one such substring exists and is
26
26
  # LogicalFalse otherwise.
27
27
  #
28
+ # The pattern is validated and translated by Janeway::IRegexp (RFC 9485). A pattern that is not an
29
+ # I-Regexp gives LogicalFalse (RFC 9535 sections 2.4.6 and 2.4.7). A literal pattern that is over
30
+ # one of the limits in IRegexp (MAX_LENGTH, MAX_REPEAT, MAX_GROUP_DEPTH) raises Janeway::Error when
31
+ # the query is parsed. In a pattern ^ and $ are ordinary characters, as RFC 9485 has no anchors.
32
+ #
28
33
  # tl;dr: How is this different from the match function?
29
34
  # "match" must match the entire string, "search" matches a substring.
30
35
  def parse_function_search
@@ -45,7 +50,9 @@ module Janeway
45
50
  # Otherwise it is an expression that takes the regexp from the input document,
46
51
  # and the iregexp will not be available until interpretation.
47
52
  parameters << parse_function_parameter
48
- raise Error, 'Too many parameters for match() function call' unless current.type == :group_end
53
+ raise Error, 'Too many parameters for search() function call' unless current.type == :group_end
54
+
55
+ validate_literal_pattern(parameters.last) # after the syntax checks, so they are reported first
49
56
 
50
57
  # Body (including literal-regex fast path) is supplied by
51
58
  # Functions::REGISTRY at FunctionInterpreter construction time.
@@ -2,42 +2,29 @@
2
2
 
3
3
  require_relative 'ast/function'
4
4
  require_relative 'ast/string_type'
5
+ require_relative 'iregexp'
5
6
 
6
7
  module Janeway
7
- # Mixin to provide JSONPath function handlers for Parser, plus the shared
8
- # I-Regexp translation helper and the built-in function registry.
8
+ # Mixin to provide JSONPath function handlers for Parser, plus the built-in function registry.
9
9
  module Functions
10
- # Convert IRegexp format to ruby regexp equivalent, following the instructions in rfc9485.
11
- # @see https://www.rfc-editor.org/rfc/rfc9485.html#name-pcre-re2-and-ruby-regexps
12
- # @param iregex [String]
13
- # @param anchor [Boolean] add anchors to match the string start and end
14
- # @return [Regexp]
15
- def translate_iregex_to_ruby_regex(iregex, anchor: true)
16
- # * For any unescaped dots (.) outside character classes (first
17
- # alternative of charClass production), replace the dot with [^\n\r].
18
- chars = iregex.chars
19
- in_char_class = false
20
- indexes = []
21
- chars.each_with_index do |char, i|
22
- case char
23
- when '[' then in_char_class = true
24
- when ']'
25
- in_char_class = false unless chars[i - 1] == '\\' # escaped ] does not close char class
26
- when '.'
27
- next if in_char_class || chars[i - 1] == '\\' # escaped dot
28
-
29
- indexes << i # replace this dot
30
- end
31
- end
32
- indexes.reverse_each do |i|
33
- chars[i] = '[^\n\r]'
34
- end
10
+ # Reject a literal pattern that exceeds an I-Regexp limit, at the point where the query is parsed.
11
+ #
12
+ # A literal that is not an I-Regexp is not an error: match() and search() return LogicalFalse
13
+ # for it, see build_regex_body. A literal over a limit is a valid I-Regexp that this library
14
+ # refuses to run, so the user is told, with the position in the query.
15
+ # @see https://www.rfc-editor.org/rfc/rfc9485.html#name-security-considerations
16
+ # RFC 9485 section 8: an implementation may limit range quantifiers in range and composability
17
+ # @param parameter [AST::Expression] the second parameter of match() or search(), just parsed
18
+ # @raise [Janeway::Error]
19
+ def validate_literal_pattern(parameter)
20
+ return unless parameter.is_a?(AST::StringType)
35
21
 
36
- # * Enclose the regexp in \A(?: and )\z.
37
- regex_str = anchor ? format('\A(?:%s)\z', chars.join) : chars.join
38
- Regexp.new(regex_str)
22
+ IRegexp.compile(parameter.value)
23
+ rescue IRegexp::Invalid
24
+ nil # RFC 9535 section 2.4.6: the function returns LogicalFalse, see build_regex_body
25
+ rescue IRegexp::LimitExceeded => e
26
+ raise Error.new("I-Regexp #{e.message}", @jsonpath, parameter.token&.location_of(e.offset, e.length))
39
27
  end
40
- module_function :translate_iregex_to_ruby_regex
41
28
 
42
29
  # All jsonpath function parameters are one of these accepted types.
43
30
  # Parse the function parameter and return the result.
@@ -58,32 +45,72 @@ module Janeway
58
45
  result
59
46
  end
60
47
 
61
- # Build a match/search body. When the pattern is a StringType literal we
62
- # compile the regex once at parse time (see perf pass, commit fd8b92a).
63
- # Otherwise fall back to per-call compilation.
48
+ # Largest number of distinct document-supplied patterns that one function call remembers.
49
+ DYNAMIC_PATTERN_CACHE_SIZE = 100
50
+
51
+ # Build a match/search body. When the pattern is a StringType literal, the parser has already
52
+ # validated it (Functions#validate_literal_pattern, which discards the result); the interpreter
53
+ # compiles it once more, with the anchoring this function needs, when it is built.
54
+ # Otherwise the pattern comes from the input document and is compiled when it is first used.
55
+ #
56
+ # A pattern that is not an I-Regexp, or that exceeds a limit, gives LogicalFalse.
57
+ # @see https://www.rfc-editor.org/rfc/rfc9535.html#name-match-function-extension
58
+ # RFC 9535 section 2.4.6: "If the first argument is not a string or the second argument is
59
+ # not a string conforming to [RFC9485], the result is LogicalFalse." Section 2.4.7 says the
60
+ # same for search().
61
+ # A literal that exceeds a limit has already been rejected by the parser, see
62
+ # Functions#validate_literal_pattern.
64
63
  def self.build_regex_body(parameters, anchor:)
65
- literal_regexp =
66
- if parameters[1].is_a?(AST::StringType)
67
- begin
68
- translate_iregex_to_ruby_regex(parameters[1].value, anchor: anchor)
69
- rescue RegexpError
70
- nil # fall back to per-call compilation, which will raise at eval time
71
- end
72
- end
64
+ literal = parameters[1].is_a?(AST::StringType) ? parameters[1].value : nil
65
+ # The cache holds compiled regexps keyed by pattern text, so reusing it for other subjects is
66
+ # correct. It lives as long as the interpreter tree, which Query#interpreter_tree_for caches
67
+ # on an unfrozen Query.
68
+ cache = {} # pattern string => Regexp, or nil when the pattern is unusable
69
+ cache[literal] = compile_or_nil(literal, anchor) if literal # a literal is compiled once, now
70
+ lambda do |str, pattern|
71
+ pattern = literal if literal
72
+ next false unless str.is_a?(String) && pattern.is_a?(String)
73
+ # A pattern in the wrong encoding is false and never reaches the cache: Hash lookup compares
74
+ # strings by content, so a binary 'a' would otherwise share the entry of a UTF-8 'a'.
75
+ next false unless IRegexp.valid_encoding?(pattern)
76
+ # RFC 9485 section 8 lets an implementation limit patterns; the limit is this library's own
77
+ # choice. Checked before parsing so that a huge pattern costs almost nothing.
78
+ next false if pattern.length > IRegexp::MAX_LENGTH
73
79
 
74
- if literal_regexp
75
- ->(str, _pattern) { str.is_a?(String) && literal_regexp.match?(str) }
76
- else
77
- lambda do |str, pattern|
78
- if str.is_a?(String) && pattern.is_a?(String)
79
- translate_iregex_to_ruby_regex(pattern, anchor: anchor).match?(str)
80
- else
81
- false
82
- end
80
+ unless cache.key?(pattern)
81
+ cache.shift if cache.size >= DYNAMIC_PATTERN_CACHE_SIZE
82
+ cache[pattern] = compile_or_nil(pattern, anchor)
83
83
  end
84
+
85
+ !cache[pattern].nil? && match_safely(cache[pattern], str)
84
86
  end
85
87
  end
86
88
 
89
+ # RFC 9535 section 2.4.6: a pattern that does not conform to RFC 9485 gives LogicalFalse, so it is nil here.
90
+ # A pattern over a limit (RFC 9485 section 8) is treated the same way.
91
+ # @return [Regexp, nil] nil when the pattern is not usable, see build_regex_body
92
+ def self.compile_or_nil(pattern, anchor)
93
+ IRegexp.compile(pattern, anchor: anchor)
94
+ rescue IRegexp::Invalid, IRegexp::LimitExceeded
95
+ nil
96
+ end
97
+
98
+ # Match, and treat a match that Ruby cannot or will not complete as no match.
99
+ #
100
+ # Deliberate deviation from RFC 9535 sections 2.4.6 and 2.4.7, which say the result is LogicalTrue or
101
+ # LogicalFalse and say nothing about a match that cannot complete: a timeout, or a subject that is
102
+ # not valid UTF-8, gives false. RFC 9485 section 8 (security considerations) is why a timeout exists.
103
+ # @param regexp [Regexp]
104
+ # @param str [String]
105
+ # @return [Boolean]
106
+ def self.match_safely(regexp, str)
107
+ regexp.match?(str)
108
+ rescue IRegexp::TIMEOUT_ERROR, ArgumentError, Encoding::CompatibilityError
109
+ # TIMEOUT_ERROR: the match took longer than IRegexp::MATCH_TIMEOUT.
110
+ # ArgumentError, Encoding::CompatibilityError: str is not valid UTF-8.
111
+ false
112
+ end
113
+
87
114
  # Metadata + body builder for every built-in JSONPath function.
88
115
  #
89
116
  # arity — number of parameters
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error'
4
+
5
+ module Janeway
6
+ # Validates an I-Regexp and translates it to an equivalent Ruby Regexp.
7
+ #
8
+ # The pattern is parsed with the grammar from RFC 9485 section 3, and the Ruby source is emitted
9
+ # while parsing, so a pattern that is not an I-Regexp never reaches Regexp.new, and characters
10
+ # that Ruby treats as syntax but I-Regexp treats as ordinary characters are escaped.
11
+ #
12
+ # The limits below are this library's own choices. RFC 9485 section 8 allows an implementation to
13
+ # limit range quantifiers in composability and range, but gives no values.
14
+ # @see https://www.rfc-editor.org/rfc/rfc9485.html
15
+ module IRegexp
16
+ # Longest pattern accepted, in characters.
17
+ MAX_LENGTH = 10_000
18
+
19
+ # Largest number accepted in a range quantifier such as a{2,4}.
20
+ MAX_REPEAT = 1_000
21
+
22
+ # Deepest nesting of groups accepted. This also bounds the recursion depth of the parser.
23
+ MAX_GROUP_DEPTH = 100
24
+
25
+ # Seconds a single match may take, where the Ruby version supports a per-regexp timeout (3.2+).
26
+ MATCH_TIMEOUT = 1
27
+
28
+ # Regexp.new accepts the timeout: keyword from Ruby 3.2, which is also when Regexp.timeout appeared.
29
+ # Probe with respond_to? and not by trying the keyword: on an older Ruby a trailing hash would be
30
+ # read as the positional options argument, and a pattern would silently become case-insensitive.
31
+ TIMEOUT_SUPPORTED = Regexp.respond_to?(:timeout)
32
+
33
+ # Error raised when a match exceeds MATCH_TIMEOUT. Regexp::TimeoutError only exists on Ruby 3.2+,
34
+ # elsewhere this is a class that is never raised for that reason.
35
+ TIMEOUT_ERROR = defined?(Regexp::TimeoutError) ? Regexp::TimeoutError : Class.new(StandardError)
36
+
37
+ # Unicode general category names, from the IsCategory production of RFC 9485 section 3.
38
+ CATEGORY = /\A(?:L[lmotu]?|M[cen]?|N[dlo]?|P[c-fios]?|Z[lps]?|S[ckmo]?|C[cfno]?)\z/
39
+
40
+ # Base class for the errors IRegexp.compile raises. They describe a problem in a pattern, not in a
41
+ # JSONPath query, so they carry the index of the problem in the pattern and have no query or location.
42
+ class Error < Janeway::Error
43
+ # @return [Integer, nil] index of the problem in the pattern, nil when there is no single place
44
+ attr_reader :offset
45
+
46
+ # @param msg [String]
47
+ # @param offset [Integer, nil]
48
+ def initialize(msg, offset: nil)
49
+ super(msg)
50
+ @offset = offset
51
+ end
52
+ end
53
+
54
+ # Raised for a pattern that is not an I-Regexp.
55
+ class Invalid < Error; end
56
+
57
+ # Raised for an I-Regexp that exceeds one of the limits above.
58
+ class LimitExceeded < Error
59
+ # @return [Integer] number of pattern characters the problem covers
60
+ attr_reader :length
61
+
62
+ # @param msg [String]
63
+ # @param offset [Integer, nil]
64
+ # @param length [Integer]
65
+ def initialize(msg, offset: nil, length: 1)
66
+ super(msg, offset: offset)
67
+ @length = length
68
+ end
69
+ end
70
+
71
+ # Can this string be an I-Regexp at all, as far as its encoding goes?
72
+ #
73
+ # RFC 9485 section 1: a pattern is a sequence of Unicode scalar values. Accept UTF-8, and US-ASCII
74
+ # which is a subset of it. Any other encoding is refused and not transcoded, and that includes a
75
+ # binary (ASCII-8BIT) string even when its bytes are all ASCII.
76
+ # @param string [String]
77
+ # @return [Boolean]
78
+ def self.valid_encoding?(string)
79
+ [Encoding::UTF_8, Encoding::US_ASCII].include?(string.encoding) && string.valid_encoding?
80
+ end
81
+
82
+ # Validate an I-Regexp and translate it to a Ruby Regexp.
83
+ #
84
+ # @param iregex [String]
85
+ # @param anchor [Boolean] add anchors to match the string start and end
86
+ # @return [Regexp]
87
+ # @raise [Invalid] the pattern is not an I-Regexp
88
+ # @raise [LimitExceeded] the pattern is an I-Regexp, but is over one of the limits
89
+ def self.compile(iregex, anchor: true)
90
+ body = Parser.new(iregex).parse
91
+ # RFC 9485 section 5.4: enclose the regexp in \A(?: and )\z.
92
+ body = "\\A(?:#{body})\\z" if anchor
93
+ # RFC 9485 section 3: the pattern is valid I-Regexp, so a Ruby warning about it (such as a redundant
94
+ # nested repeat operator in (a*)*) is not the caller's business. A pattern from the input document
95
+ # must not be able to write to stderr. $VERBOSE is process-wide, so warnings from other threads are
96
+ # also suppressed during this one compile.
97
+ old_verbose = $VERBOSE
98
+ begin
99
+ $VERBOSE = nil
100
+ TIMEOUT_SUPPORTED ? Regexp.new(body, timeout: MATCH_TIMEOUT) : Regexp.new(body)
101
+ rescue RegexpError => e
102
+ # Deliberate deviation from RFC 9485 section 3: Ruby refuses some patterns that are valid
103
+ # I-Regexp, such as a{3,2}, and these are treated as not an I-Regexp.
104
+ raise Invalid, e.message
105
+ ensure
106
+ $VERBOSE = old_verbose
107
+ end
108
+ end
109
+
110
+ # Recursive-descent parser for the ABNF of RFC 9485 section 3, which builds the Ruby regexp source.
111
+ # Each method is named for the production it implements.
112
+ class Parser
113
+ # Characters allowed after a backslash. RFC 9485 section 3, SingleCharEsc.
114
+ SINGLE_CHAR_ESC = '()*+-.?[]^nrt{|}\\'
115
+
116
+ # catEsc and complEsc start with one of these after the backslash, RFC 9485 section 3
117
+ CATEGORY_ESCAPES = %w[p P].freeze
118
+
119
+ # @param iregex [String]
120
+ def initialize(iregex)
121
+ @original = iregex
122
+ @src = iregex.valid_encoding? ? iregex.chars : [] # array: indexing a multibyte String is O(n)
123
+ @i = 0
124
+ @out = +''
125
+ @depth = 0
126
+ @has_range = false # does the group being parsed contain a range quantifier?
127
+ @limit = nil # first limit violation, raised only if the pattern is otherwise valid
128
+ end
129
+
130
+ # @return [String] Ruby regexp source, without anchors
131
+ # @raise [Invalid, LimitExceeded]
132
+ def parse
133
+ raise Invalid, 'pattern is not valid UTF-8' unless IRegexp.valid_encoding?(@original) # RFC 9485 section 1
134
+
135
+ i_regexp
136
+ raise Invalid.new("unexpected #{@src[@i].inspect}", offset: @i) if @i < @src.length
137
+
138
+ limit("pattern is longer than #{MAX_LENGTH} characters", nil) if @src.length > MAX_LENGTH
139
+ raise @limit if @limit
140
+
141
+ @out
142
+ end
143
+
144
+ private
145
+
146
+ def peek
147
+ @src[@i]
148
+ end
149
+
150
+ # Grammar errors take precedence over limit errors, so a limit violation is remembered and
151
+ # only raised once the whole pattern is known to be an I-Regexp. The one exception is the group
152
+ # nesting limit, which is raised as soon as it is hit (see #group): reading on past it is what
153
+ # would overflow the stack.
154
+ def limit(msg, offset, length = 1)
155
+ @limit ||= LimitExceeded.new(msg, offset: offset, length: length)
156
+ end
157
+
158
+ # i-regexp = branch *( "|" branch )
159
+ # RFC 9485 section 3, the top production.
160
+ def i_regexp
161
+ branch
162
+ while peek == '|'
163
+ @i += 1
164
+ @out << '|'
165
+ branch
166
+ end
167
+ end
168
+
169
+ # branch = *piece
170
+ def branch
171
+ piece while !peek.nil? && peek != '|' && peek != ')'
172
+ end
173
+
174
+ # piece = atom [ quantifier ]
175
+ def piece
176
+ contains_range = atom
177
+ quantifier(contains_range)
178
+ end
179
+
180
+ # atom = NormalChar / charClass / ( "(" i-regexp ")" )
181
+ # @return [Boolean] true if the atom is a group that contains a range quantifier
182
+ def atom
183
+ case peek
184
+ when '(' then return group
185
+ when '[' then char_class
186
+ when '.'
187
+ # RFC 9485 section 5.4: replace an unescaped dot outside a character class with [^\n\r]
188
+ @i += 1
189
+ @out << '[^\n\r]'
190
+ when '\\' then escape
191
+ else normal_char(in_class: false)
192
+ end
193
+ false
194
+ end
195
+
196
+ # atom = ... / ( "(" i-regexp ")" ), RFC 9485 section 3.
197
+ # The nesting limit is this library's own choice, which RFC 9485 section 8 permits: it bounds recursion.
198
+ # Unlike the other limits it is raised at once and not remembered (see #limit), because parsing on
199
+ # past it is what would overflow the stack. So a too-deeply nested pattern that is also invalid is
200
+ # reported as LimitExceeded, not Invalid.
201
+ # @return [Boolean] true if the group contains a range quantifier
202
+ def group
203
+ open = @i
204
+ @i += 1
205
+ @depth += 1
206
+ if @depth > MAX_GROUP_DEPTH
207
+ raise LimitExceeded.new("groups are nested deeper than #{MAX_GROUP_DEPTH}", offset: open)
208
+ end
209
+
210
+ @out << '(?:'
211
+ outer_has_range = @has_range
212
+ @has_range = false
213
+ i_regexp
214
+ raise Invalid.new('unterminated group', offset: @i) unless peek == ')'
215
+
216
+ @i += 1
217
+ @depth -= 1
218
+ @out << ')'
219
+ inner_has_range = @has_range
220
+ @has_range = outer_has_range || inner_has_range
221
+ inner_has_range
222
+ end
223
+
224
+ # quantifier = ( "*" / "+" / "?" ) / range-quantifier
225
+ def quantifier(contains_range)
226
+ case peek
227
+ when '*', '+', '?'
228
+ @out << peek
229
+ @i += 1
230
+ when '{' then range_quantifier(contains_range)
231
+ end
232
+ end
233
+
234
+ # range-quantifier = "{" QuantExact [ "," [ QuantExact ] ] "}"
235
+ # QuantExact = 1*%x30-39
236
+ #
237
+ # RFC 9485 section 8 allows limiting range quantifiers in range and in composability: here a
238
+ # number above MAX_REPEAT, and a range quantifier applied to a group that contains one.
239
+ def range_quantifier(contains_range)
240
+ start = @i
241
+ counts, finish = scan_range_quantifier(start)
242
+ raise Invalid.new('malformed range quantifier', offset: start) unless counts
243
+
244
+ counts.each do |digits, offset|
245
+ next if digits.empty? || digits.to_i <= MAX_REPEAT
246
+
247
+ limit("repeat count #{digits} exceeds #{MAX_REPEAT}", offset, digits.length)
248
+ end
249
+ limit('range quantifier applied to a group that contains a range quantifier', start) if contains_range
250
+ @has_range = true
251
+ @out << @src[start...finish].join
252
+ @i = finish
253
+ end
254
+
255
+ # Match \{(\d+)(?:,(\d*))?\} at start by scanning the character array, which a Regexp cannot
256
+ # do in O(1) per call on a multibyte string. The digit runs can be long, so no fixed-size slice.
257
+ # @return [Array, nil] [[[digits, offset], ...], index after the closing brace], nil if malformed
258
+ def scan_range_quantifier(start)
259
+ return nil unless @src[start] == '{'
260
+
261
+ first_start = start + 1
262
+ first_end = digits_end(first_start)
263
+ return nil if first_end == first_start # QuantExact needs at least one digit
264
+
265
+ counts = [[@src[first_start...first_end].join, first_start]]
266
+ pos = first_end
267
+ if @src[pos] == ','
268
+ second_start = pos + 1
269
+ pos = digits_end(second_start)
270
+ counts << [@src[second_start...pos].join, second_start]
271
+ end
272
+ @src[pos] == '}' ? [counts, pos + 1] : nil
273
+ end
274
+
275
+ # @return [Integer] index of the first character at or after pos that is not an ASCII digit
276
+ def digits_end(pos)
277
+ pos += 1 while @src[pos]&.match?(/\A[0-9]\z/)
278
+ pos
279
+ end
280
+
281
+ # charClassExpr = "[" [ "^" ] ( "-" / CCE1 ) *CCE1 [ "-" ] "]"
282
+ def char_class
283
+ open = @i
284
+ @i += 1
285
+ @out << '['
286
+ negated = peek == '^'
287
+ if negated
288
+ @i += 1
289
+ @out << '^'
290
+ end
291
+ # RFC 9485 section 3: charClassExpr is not allowed to match [^]
292
+ raise Invalid.new('empty negated character class [^]', offset: open) if negated && peek == ']'
293
+ # RFC 9485 section 3: charClassExpr has at least one item, so [] is not allowed either
294
+ raise Invalid.new('empty character class []', offset: open) if peek == ']'
295
+
296
+ first = true
297
+ until peek == ']'
298
+ raise Invalid.new('unterminated character class', offset: @i) if peek.nil?
299
+
300
+ class_item(first)
301
+ first = false
302
+ end
303
+ @i += 1
304
+ @out << ']'
305
+ end
306
+
307
+ # CCE1 = ( CCchar [ "-" CCchar ] ) / charClassEsc, RFC 9485 section 3
308
+ def class_item(first)
309
+ if peek == '-'
310
+ # a "-" is only a plain character at the start or the end of the class
311
+ raise Invalid.new("misplaced '-' in character class", offset: @i) unless first || @src[@i + 1] == ']'
312
+
313
+ @i += 1
314
+ @out << '\-'
315
+ elsif peek == '\\' && CATEGORY_ESCAPES.include?(@src[@i + 1])
316
+ # charClassEsc is not a CCchar, so it takes no range: a following "-" is rejected by the next item
317
+ escape
318
+ else
319
+ cc_char
320
+ class_range_end if peek == '-' && @src[@i + 1] != ']'
321
+ end
322
+ end
323
+
324
+ # "-" CCchar, the end of a range. RFC 9485 section 3, CCE1.
325
+ def class_range_end
326
+ @i += 1
327
+ @out << '-'
328
+ raise Invalid.new('unterminated range in character class', offset: @i) if peek.nil? || peek == ']'
329
+
330
+ cc_char
331
+ end
332
+
333
+ # CCchar = ( %x00-2C / %x2E-5A / %x5E-D7FF / %xE000-10FFFF ) / SingleCharEsc
334
+ # RFC 9485 section 3: a SingleCharEsc is a CCchar, so it can start or end a range.
335
+ def cc_char
336
+ if peek == '\\' then escape(single_char_only: true)
337
+ else normal_char(in_class: true)
338
+ end
339
+ end
340
+
341
+ # NormalChar (outside a class) and CCchar (inside a class). RFC 9485 section 3.
342
+ # In I-Regexp "^" and "$" are ordinary characters, not anchors: the RFC 9485 author confirmed
343
+ # this, see https://github.com/jsonpath-standard/jsonpath-compliance-test-suite (open issue).
344
+ def normal_char(in_class:)
345
+ char = peek
346
+ raise Invalid.new('unexpected end of pattern', offset: @i) if char.nil?
347
+ unless allowed_char?(char.ord, in_class)
348
+ raise Invalid.new("character #{char.inspect} must be escaped", offset: @i)
349
+ end
350
+
351
+ @i += 1
352
+ # Regexp.escape does not escape "&", but Ruby reads "&&" in a class as set intersection
353
+ @out << (char == '&' ? '\&' : Regexp.escape(char))
354
+ end
355
+
356
+ # @param code [Integer] code point
357
+ def allowed_char?(code, in_class)
358
+ return false if (0xD800..0xDFFF).cover?(code) # surrogates
359
+
360
+ if in_class
361
+ # CCchar: %x00-2C / %x2E-5A / %x5E-D7FF / %xE000-10FFFF
362
+ code <= 0x2C || (0x2E..0x5A).cover?(code) || code >= 0x5E
363
+ else
364
+ # NormalChar: %x00-27 / "," / "-" / %x2F-3E / %x40-5A / %x5E-7A / %x7E-D7FF / %xE000-10FFFF
365
+ code <= 0x27 || [0x2C, 0x2D].include?(code) || (0x2F..0x3E).cover?(code) ||
366
+ (0x40..0x5A).cover?(code) || (0x5E..0x7A).cover?(code) || code >= 0x7E
367
+ end
368
+ end
369
+
370
+ # SingleCharEsc / catEsc / complEsc, RFC 9485 section 3
371
+ # @param single_char_only [Boolean] refuse catEsc and complEsc, where a CCchar is required
372
+ def escape(single_char_only: false)
373
+ start = @i
374
+ nxt = @src[@i + 1]
375
+ raise Invalid.new('trailing backslash', offset: start) if nxt.nil?
376
+
377
+ if CATEGORY_ESCAPES.include?(nxt)
378
+ if single_char_only
379
+ raise Invalid.new("\\#{nxt} is not allowed here, only a single-character escape", offset: start)
380
+ end
381
+
382
+ category_escape(start)
383
+ elsif SINGLE_CHAR_ESC.include?(nxt)
384
+ @i += 2
385
+ @out << { 'n' => '\n', 'r' => '\r', 't' => '\t' }.fetch(nxt) { "\\#{nxt}" }
386
+ else
387
+ raise Invalid.new("invalid escape \\#{nxt}", offset: start)
388
+ end
389
+ end
390
+
391
+ # catEsc = %s"\p{" charProp "}" ; complEsc = %s"\P{" charProp "}", RFC 9485 section 3
392
+ def category_escape(start)
393
+ # at most \p{Xx}, 6 characters
394
+ m = /\A\\[pP]\{([A-Za-z]{1,2})\}/.match(@src[start, 6].join)
395
+ raise Invalid.new('malformed category escape', offset: start) unless m && CATEGORY.match?(m[1])
396
+
397
+ @i = start + m[0].length
398
+ @out << (@src[start + 1] == 'P' ? "\\p{^#{m[1]}}" : m[0])
399
+ end
400
+ end
401
+ end
402
+ end
data/lib/janeway/lexer.rb CHANGED
@@ -200,9 +200,11 @@ module Janeway
200
200
  non_delimiter = OTHER_DELIMITER[delimiter]
201
201
 
202
202
  literal_chars = []
203
+ offsets = [] # index within the lexeme of the start of each literal char, see Token#offsets
203
204
  while lookahead != delimiter && source_uncompleted?
204
205
  # Transform escaped representation to literal chars
205
206
  next_char = lookahead
207
+ offsets << (next_p - lexeme_start_p)
206
208
  literal_chars <<
207
209
  if next_char == '\\'
208
210
  if lookahead(2) == delimiter
@@ -232,7 +234,7 @@ module Janeway
232
234
  # lexeme value includes delimiters and literal escape characters
233
235
  lexeme = current_lexeme
234
236
 
235
- Token.new(:string, lexeme, literal, current_location)
237
+ Token.new(:string, lexeme, literal, current_location, offsets)
236
238
  end
237
239
 
238
240
  # Read escape char literals, and transform them into the described character
@@ -184,7 +184,7 @@ module Janeway
184
184
  end
185
185
 
186
186
  def parse_string
187
- AST::StringType.new(current.literal)
187
+ AST::StringType.new(current.literal).tap { |node| node.token = current }
188
188
  end
189
189
 
190
190
  def parse_number
data/lib/janeway/token.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'forwardable'
4
+ require_relative 'location'
4
5
 
5
6
  module Janeway
6
7
  # Tokens are produced by the lexer, they represent jsonpath query elements in a low-level, non-hierarchical way.
@@ -9,13 +10,50 @@ module Janeway
9
10
 
10
11
  attr_reader :type, :lexeme, :literal, :location
11
12
 
13
+ # For a string token, the index within the lexeme of each character of the decoded literal,
14
+ # so lexeme[offsets[i]] is where literal[i] came from. It differs from i when the string
15
+ # contains escape sequences or the lexeme starts with a quote. Nil for other token types.
16
+ # @return [Array<Integer>, nil]
17
+ attr_reader :offsets
18
+
12
19
  def_delegators :@location, :line, :col, :length
13
20
 
14
- def initialize(type, lexeme, literal, location)
21
+ def initialize(type, lexeme, literal, location, offsets = nil)
15
22
  @type = type
16
23
  @lexeme = lexeme
17
24
  @literal = literal
18
25
  @location = location
26
+ @offsets = offsets
27
+ end
28
+
29
+ # Find where a character of a string token's decoded value came from in the query.
30
+ #
31
+ # Relies on two invariants: offsets[i] is the index within the lexeme of the start of literal[i],
32
+ # and location.col is the column of the lexeme in the query.
33
+ # @param index [Integer] index of a character in the decoded value
34
+ # @return [Integer, nil] column in the query of that character (or of the backslash that starts its
35
+ # escape); nil when the token has no offsets, or no character at that index
36
+ def column_of(index)
37
+ return nil if @offsets.nil? || !index.between?(0, @offsets.length - 1)
38
+
39
+ offset = @offsets[index]
40
+ offset && (@location.col + offset)
41
+ end
42
+
43
+ # Find where a run of characters of a string token's decoded value came from in the query.
44
+ # @param offset [Integer, nil] index in the decoded value of the first character
45
+ # @param length [Integer, nil] number of decoded characters, treated as 1 if nil or not positive
46
+ # @return [Location] from the first character to the start of the last one; the location of the whole
47
+ # token when offset is nil or the range cannot be found in the token
48
+ def location_of(offset, length = 1)
49
+ return @location unless offset
50
+
51
+ length = 1 unless length&.positive?
52
+ first = column_of(offset)
53
+ last = column_of(offset + length - 1)
54
+ return @location unless first && last
55
+
56
+ Location.new(first, last - first + 1)
19
57
  end
20
58
 
21
59
  def to_s
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Janeway
4
4
  # Version for janeway-jsonpath gem
5
- VERSION = '1.1.0'
5
+ VERSION = '1.2.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janeway-jsonpath
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fraser Hanson
@@ -84,6 +84,7 @@ files:
84
84
  - lib/janeway/interpreters/wildcard_selector_delete_if.rb
85
85
  - lib/janeway/interpreters/wildcard_selector_interpreter.rb
86
86
  - lib/janeway/interpreters/yielder.rb
87
+ - lib/janeway/iregexp.rb
87
88
  - lib/janeway/lexer.rb
88
89
  - lib/janeway/location.rb
89
90
  - lib/janeway/normalized_path.rb
@@ -109,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
110
  - !ruby/object:Gem::Version
110
111
  version: '0'
111
112
  requirements: []
112
- rubygems_version: 4.0.8
113
+ rubygems_version: 3.6.9
113
114
  specification_version: 4
114
115
  summary: jsonpath parser which implements the finalized IETF standard of Goessner
115
116
  JSONPath