janeway-jsonpath 1.0.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 +4 -4
- data/README.md +1 -1
- data/bin/janeway +4 -2
- data/lib/janeway/ast/array_slice_selector.rb +6 -90
- data/lib/janeway/ast/binary_operator.rb +57 -51
- data/lib/janeway/ast/child_segment.rb +2 -5
- data/lib/janeway/ast/current_node.rb +2 -15
- data/lib/janeway/ast/descendant_segment.rb +0 -3
- data/lib/janeway/ast/expression.rb +34 -5
- data/lib/janeway/ast/function.rb +15 -10
- data/lib/janeway/ast/name_selector.rb +12 -7
- data/lib/janeway/ast/root_node.rb +2 -15
- data/lib/janeway/ast/selector.rb +5 -6
- data/lib/janeway/ast/string_type.rb +4 -0
- data/lib/janeway/ast/unary_operator.rb +12 -2
- data/lib/janeway/ast/wildcard_selector.rb +2 -2
- data/lib/janeway/enumerator.rb +2 -4
- data/lib/janeway/error.rb +30 -1
- data/lib/janeway/functions/count.rb +1 -8
- data/lib/janeway/functions/length.rb +1 -12
- data/lib/janeway/functions/match.rb +9 -18
- data/lib/janeway/functions/search.rb +11 -9
- data/lib/janeway/functions/value.rb +1 -7
- data/lib/janeway/functions.rb +130 -30
- data/lib/janeway/interpreter.rb +50 -31
- data/lib/janeway/interpreters/array_slice_selector_delete_if.rb +10 -13
- data/lib/janeway/interpreters/array_slice_selector_interpreter.rb +56 -13
- data/lib/janeway/interpreters/binary_operator_interpreter.rb +18 -13
- data/lib/janeway/interpreters/descendant_segment_interpreter.rb +32 -13
- data/lib/janeway/interpreters/filter_selector_interpreter.rb +26 -34
- data/lib/janeway/interpreters/function_interpreter.rb +5 -1
- data/lib/janeway/interpreters/index_selector_delete_if.rb +0 -1
- data/lib/janeway/interpreters/iteration_helper.rb +19 -5
- data/lib/janeway/interpreters/root_node_delete_if.rb +57 -16
- data/lib/janeway/interpreters/tree_constructor.rb +0 -20
- data/lib/janeway/interpreters/wildcard_selector_delete_if.rb +16 -0
- data/lib/janeway/interpreters/yielder.rb +8 -2
- data/lib/janeway/iregexp.rb +402 -0
- data/lib/janeway/lexer.rb +64 -79
- data/lib/janeway/location.rb +3 -1
- data/lib/janeway/normalized_path.rb +11 -2
- data/lib/janeway/parser.rb +47 -35
- data/lib/janeway/query.rb +47 -2
- data/lib/janeway/token.rb +40 -5
- data/lib/janeway/version.rb +1 -1
- metadata +2 -8
- data/lib/janeway/interpreters/array_slice_selector_deleter.rb +0 -41
- data/lib/janeway/interpreters/child_segment_deleter.rb +0 -19
- data/lib/janeway/interpreters/filter_selector_deleter.rb +0 -65
- data/lib/janeway/interpreters/index_selector_deleter.rb +0 -26
- data/lib/janeway/interpreters/name_selector_deleter.rb +0 -27
- data/lib/janeway/interpreters/root_node_deleter.rb +0 -34
- data/lib/janeway/interpreters/wildcard_selector_deleter.rb +0 -32
|
@@ -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
|
@@ -38,9 +38,23 @@ module Janeway
|
|
|
38
38
|
TWO_CHAR_LEX_FIRST = TWO_CHAR_LEX.map { |lexeme| lexeme[0] }.freeze
|
|
39
39
|
ONE_OR_TWO_CHAR_LEX = ONE_CHAR_LEX & TWO_CHAR_LEX.map { |str| str[0] }.freeze
|
|
40
40
|
|
|
41
|
+
# O(1)-lookup companions to the arrays above. Hash#include? and the reverse
|
|
42
|
+
# lookup avoid the linear scan that .include? / .key would do per token.
|
|
43
|
+
LEXEME_TO_TYPE = OPERATORS.invert.freeze
|
|
44
|
+
ONE_CHAR_LEX_SET = ONE_CHAR_LEX.each_with_object({}) { |c, h| h[c] = true }.freeze
|
|
45
|
+
TWO_CHAR_LEX_SET = TWO_CHAR_LEX.each_with_object({}) { |c, h| h[c] = true }.freeze
|
|
46
|
+
TWO_CHAR_LEX_FIRST_SET = TWO_CHAR_LEX_FIRST.each_with_object({}) { |c, h| h[c] = true }.freeze
|
|
47
|
+
ONE_OR_TWO_CHAR_LEX_SET = ONE_OR_TWO_CHAR_LEX.each_with_object({}) { |c, h| h[c] = true }.freeze
|
|
48
|
+
|
|
41
49
|
WHITESPACE = " \t\n\r"
|
|
42
50
|
KEYWORD = %w[true false null].freeze
|
|
51
|
+
KEYWORD_SET = KEYWORD.each_with_object({}) { |k, h| h[k] = true }.freeze
|
|
43
52
|
FUNCTIONS = %w[length count match search value].freeze
|
|
53
|
+
FUNCTIONS_SET = FUNCTIONS.each_with_object({}) { |k, h| h[k] = true }.freeze
|
|
54
|
+
|
|
55
|
+
# Hoisted from lex_delimited_string. Given a delimiter, the value is the "other" delimiter.
|
|
56
|
+
OTHER_DELIMITER = { "'" => '"', '"' => "'" }.freeze
|
|
57
|
+
QUOTE_LEX_SET = { "'" => true, '"' => true }.freeze
|
|
44
58
|
|
|
45
59
|
# faster to check membership in a string than an array of char (benchmarked ruby 3.1.2)
|
|
46
60
|
ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
@@ -89,18 +103,18 @@ module Janeway
|
|
|
89
103
|
return if WHITESPACE.include?(c)
|
|
90
104
|
|
|
91
105
|
token =
|
|
92
|
-
if
|
|
106
|
+
if ONE_OR_TWO_CHAR_LEX_SET.include?(c)
|
|
93
107
|
token_from_one_or_two_char_lex(c)
|
|
94
|
-
elsif
|
|
108
|
+
elsif ONE_CHAR_LEX_SET.include?(c)
|
|
95
109
|
token_from_one_char_lex(c)
|
|
96
|
-
elsif
|
|
110
|
+
elsif TWO_CHAR_LEX_FIRST_SET.include?(c)
|
|
97
111
|
token_from_two_char_lex(c)
|
|
98
|
-
elsif
|
|
112
|
+
elsif QUOTE_LEX_SET.include?(c)
|
|
99
113
|
lex_delimited_string(c)
|
|
100
114
|
elsif digit?(c)
|
|
101
115
|
lex_number
|
|
102
116
|
elsif name_first_char?(c)
|
|
103
|
-
lex_member_name_shorthand(ignore_keywords:
|
|
117
|
+
lex_member_name_shorthand(ignore_keywords: previous_token_type == :dot)
|
|
104
118
|
end
|
|
105
119
|
raise err("Unknown character: #{c.inspect}") unless token
|
|
106
120
|
|
|
@@ -127,20 +141,20 @@ module Janeway
|
|
|
127
141
|
raise err("Operator #{lexeme.inspect} must not be followed by whitespace")
|
|
128
142
|
end
|
|
129
143
|
|
|
130
|
-
Token.new(
|
|
144
|
+
Token.new(LEXEME_TO_TYPE[lexeme], lexeme, nil, current_location)
|
|
131
145
|
end
|
|
132
146
|
|
|
133
147
|
# Consumes an operator that could be either 1 or 2 chars in length
|
|
134
148
|
# @return [Token]
|
|
135
149
|
def token_from_one_or_two_char_lex(lexeme)
|
|
136
|
-
next_two_chars =
|
|
137
|
-
if
|
|
150
|
+
next_two_chars = lexeme + lookahead
|
|
151
|
+
if TWO_CHAR_LEX_SET.include?(next_two_chars)
|
|
138
152
|
consume
|
|
139
153
|
if next_two_chars == '..' && WHITESPACE.include?(lookahead)
|
|
140
154
|
raise err("Operator #{next_two_chars.inspect} must not be followed by whitespace")
|
|
141
155
|
end
|
|
142
156
|
|
|
143
|
-
Token.new(
|
|
157
|
+
Token.new(LEXEME_TO_TYPE[next_two_chars], next_two_chars, nil, current_location)
|
|
144
158
|
else
|
|
145
159
|
token_from_one_char_lex(lexeme)
|
|
146
160
|
end
|
|
@@ -149,11 +163,11 @@ module Janeway
|
|
|
149
163
|
# Consumes a 2 char operator
|
|
150
164
|
# @return [Token]
|
|
151
165
|
def token_from_two_char_lex(lexeme)
|
|
152
|
-
next_two_chars =
|
|
153
|
-
raise err("Unknown operator \"#{lexeme}\"") unless
|
|
166
|
+
next_two_chars = lexeme + lookahead
|
|
167
|
+
raise err("Unknown operator \"#{lexeme}\"") unless TWO_CHAR_LEX_SET.include?(next_two_chars)
|
|
154
168
|
|
|
155
169
|
consume
|
|
156
|
-
Token.new(
|
|
170
|
+
Token.new(LEXEME_TO_TYPE[next_two_chars], next_two_chars, nil, current_location)
|
|
157
171
|
end
|
|
158
172
|
|
|
159
173
|
def consume
|
|
@@ -166,17 +180,31 @@ module Janeway
|
|
|
166
180
|
consume while digit?(lookahead)
|
|
167
181
|
end
|
|
168
182
|
|
|
183
|
+
# @return [String] source substring for the lexeme currently being built
|
|
184
|
+
def current_lexeme
|
|
185
|
+
source[lexeme_start_p..(next_p - 1)]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Type of the most-recently-emitted token, or nil if none.
|
|
189
|
+
# Named lookback for the two context-sensitive lexing decisions
|
|
190
|
+
# (identifier-in-brackets rejection; keyword ignore after `.`).
|
|
191
|
+
# @return [Symbol, nil]
|
|
192
|
+
def previous_token_type
|
|
193
|
+
@tokens.last&.type
|
|
194
|
+
end
|
|
195
|
+
|
|
169
196
|
# @param delimiter [String] eg. ' or "
|
|
170
197
|
# @return [Token] string token
|
|
171
198
|
def lex_delimited_string(delimiter)
|
|
172
|
-
allowed_delimiters = %w[' "]
|
|
173
199
|
# the "other" delimiter char, which is not currently being treated as a delimiter
|
|
174
|
-
non_delimiter =
|
|
200
|
+
non_delimiter = OTHER_DELIMITER[delimiter]
|
|
175
201
|
|
|
176
202
|
literal_chars = []
|
|
203
|
+
offsets = [] # index within the lexeme of the start of each literal char, see Token#offsets
|
|
177
204
|
while lookahead != delimiter && source_uncompleted?
|
|
178
205
|
# Transform escaped representation to literal chars
|
|
179
206
|
next_char = lookahead
|
|
207
|
+
offsets << (next_p - lexeme_start_p)
|
|
180
208
|
literal_chars <<
|
|
181
209
|
if next_char == '\\'
|
|
182
210
|
if lookahead(2) == delimiter
|
|
@@ -190,7 +218,7 @@ module Janeway
|
|
|
190
218
|
end
|
|
191
219
|
elsif unescaped?(next_char)
|
|
192
220
|
consume
|
|
193
|
-
elsif
|
|
221
|
+
elsif QUOTE_LEX_SET.include?(next_char) && next_char != delimiter
|
|
194
222
|
consume
|
|
195
223
|
else
|
|
196
224
|
raise err("invalid character #{next_char.inspect}")
|
|
@@ -204,9 +232,9 @@ module Janeway
|
|
|
204
232
|
literal = literal_chars.join
|
|
205
233
|
|
|
206
234
|
# lexeme value includes delimiters and literal escape characters
|
|
207
|
-
lexeme =
|
|
235
|
+
lexeme = current_lexeme
|
|
208
236
|
|
|
209
|
-
Token.new(:string, lexeme, literal, current_location)
|
|
237
|
+
Token.new(:string, lexeme, literal, current_location, offsets)
|
|
210
238
|
end
|
|
211
239
|
|
|
212
240
|
# Read escape char literals, and transform them into the described character
|
|
@@ -285,10 +313,11 @@ module Janeway
|
|
|
285
313
|
# @param low_surrogate_hex [String] string of hex digits, eg. "DE09"
|
|
286
314
|
# @return [String] UTF-8 string containing a single multi-byte unicode character, eg. "😉"
|
|
287
315
|
def convert_surrogate_pair_to_codepoint(high_surrogate_hex, low_surrogate_hex)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
316
|
+
# Callers guarantee 4-char inputs (consume_four_hex_digits followed by
|
|
317
|
+
# high_surrogate?/low_surrogate? which pre-check size). The old defensive
|
|
318
|
+
# guard here referenced a non-existent `hex_string` local — dead code
|
|
319
|
+
# that would NameError if ever reached.
|
|
320
|
+
#
|
|
292
321
|
# Calculate the code point from the surrogate pair values
|
|
293
322
|
# algorithm from https://russellcottrell.com/greek/utilities/SurrogatePairCalculator.htm
|
|
294
323
|
high = high_surrogate_hex.hex
|
|
@@ -317,20 +346,19 @@ module Janeway
|
|
|
317
346
|
#
|
|
318
347
|
# @return [String]
|
|
319
348
|
def consume_four_hex_digits
|
|
320
|
-
hex_digits =
|
|
349
|
+
hex_digits = String.new(capacity: 4)
|
|
321
350
|
4.times do
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
when
|
|
351
|
+
c = consume
|
|
352
|
+
hex_digits << c
|
|
353
|
+
case c.ord
|
|
354
|
+
when 0x30..0x39 then next # '0'..'9'
|
|
355
|
+
when 0x41..0x46 then next # 'A'..'F'
|
|
326
356
|
when 0x61..0x66 then next # 'a'..'f'
|
|
327
357
|
else
|
|
328
|
-
raise err("Invalid unicode escape sequence: \\u#{hex_digits
|
|
358
|
+
raise err("Invalid unicode escape sequence: \\u#{hex_digits}")
|
|
329
359
|
end
|
|
330
360
|
end
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
hex_digits.join
|
|
361
|
+
hex_digits
|
|
334
362
|
end
|
|
335
363
|
|
|
336
364
|
# Consume a numeric string. May be an integer, fractional, or exponent.
|
|
@@ -353,13 +381,13 @@ module Janeway
|
|
|
353
381
|
consume # "+" / "-"
|
|
354
382
|
end
|
|
355
383
|
unless digit?(lookahead)
|
|
356
|
-
lexeme =
|
|
384
|
+
lexeme = current_lexeme
|
|
357
385
|
raise err("Exponent 'e' must be followed by number: #{lexeme.inspect}")
|
|
358
386
|
end
|
|
359
387
|
consume_digits
|
|
360
388
|
end
|
|
361
389
|
|
|
362
|
-
lexeme =
|
|
390
|
+
lexeme = current_lexeme
|
|
363
391
|
if lexeme.start_with?('0') && lexeme.size > 1
|
|
364
392
|
raise err("Number may not start with leading zero: #{lexeme.inspect}")
|
|
365
393
|
end
|
|
@@ -375,36 +403,6 @@ module Janeway
|
|
|
375
403
|
|
|
376
404
|
# Consume an alphanumeric string.
|
|
377
405
|
# If `ignore_keywords`, the result is always an :identifier token.
|
|
378
|
-
# Otherwise, keywords and function names will be recognized and tokenized as those types.
|
|
379
|
-
#
|
|
380
|
-
# @param ignore_keywords [Boolean]
|
|
381
|
-
def lex_identifier(ignore_keywords: false)
|
|
382
|
-
consume while alpha_numeric?(lookahead)
|
|
383
|
-
|
|
384
|
-
identifier = source[lexeme_start_p..(next_p - 1)]
|
|
385
|
-
type =
|
|
386
|
-
if KEYWORD.include?(identifier) && !ignore_keywords
|
|
387
|
-
identifier.to_sym
|
|
388
|
-
elsif FUNCTIONS.include?(identifier) && !ignore_keywords
|
|
389
|
-
:function
|
|
390
|
-
else
|
|
391
|
-
:identifier
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
Token.new(type, identifier, identifier, current_location)
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
# Parse an identifier string which is not within delimiters.
|
|
398
|
-
# The standard set of unicode code points are allowed.
|
|
399
|
-
# No character escapes are allowed.
|
|
400
|
-
# Keywords and function names are ignored in this context.
|
|
401
|
-
# @return [Token]
|
|
402
|
-
def lex_unescaped_identifier
|
|
403
|
-
consume while unescaped?(lookahead)
|
|
404
|
-
identifier = source[lexeme_start_p..(next_p - 1)]
|
|
405
|
-
Token.new(:identifier, identifier, identifier, current_location)
|
|
406
|
-
end
|
|
407
|
-
|
|
408
406
|
# Return true if string matches the definition of "unescaped" from RFC9535:
|
|
409
407
|
# unescaped = %x20-21 / ; see RFC 8259
|
|
410
408
|
# ; omit 0x22 "
|
|
@@ -428,19 +426,6 @@ module Janeway
|
|
|
428
426
|
end
|
|
429
427
|
end
|
|
430
428
|
|
|
431
|
-
def escapable?(char)
|
|
432
|
-
case char.ord
|
|
433
|
-
when 0x62 then true # backspace
|
|
434
|
-
when 0x66 then true # form feed
|
|
435
|
-
when 0x6E then true # line feed
|
|
436
|
-
when 0x72 then true # carriage return
|
|
437
|
-
when 0x74 then true # horizontal tab
|
|
438
|
-
when 0x2F then true # slash
|
|
439
|
-
when 0x5C then true # backslash
|
|
440
|
-
else false
|
|
441
|
-
end
|
|
442
|
-
end
|
|
443
|
-
|
|
444
429
|
# True if character is suitable as the first character in a name selector
|
|
445
430
|
# using shorthand notation (ie. no bracket notation.)
|
|
446
431
|
#
|
|
@@ -481,14 +466,14 @@ module Janeway
|
|
|
481
466
|
def lex_member_name_shorthand(ignore_keywords: false)
|
|
482
467
|
# Abort if name is preceded by child_start. Catches non-delimited identifiers in brackets,
|
|
483
468
|
# eg. $["key"] is allowed, but $[key] is not
|
|
484
|
-
raise err('Identifier within brackets must be surrounded by quotes') if
|
|
469
|
+
raise err('Identifier within brackets must be surrounded by quotes') if previous_token_type == :child_start
|
|
485
470
|
|
|
486
471
|
consume while name_char?(lookahead)
|
|
487
|
-
identifier =
|
|
472
|
+
identifier = current_lexeme
|
|
488
473
|
type =
|
|
489
|
-
if
|
|
474
|
+
if KEYWORD_SET.include?(identifier) && !ignore_keywords
|
|
490
475
|
identifier.to_sym
|
|
491
|
-
elsif
|
|
476
|
+
elsif FUNCTIONS_SET.include?(identifier) && !ignore_keywords
|
|
492
477
|
:function
|
|
493
478
|
else
|
|
494
479
|
:identifier
|
data/lib/janeway/location.rb
CHANGED