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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/bin/janeway +4 -2
  4. data/lib/janeway/ast/array_slice_selector.rb +6 -90
  5. data/lib/janeway/ast/binary_operator.rb +57 -51
  6. data/lib/janeway/ast/child_segment.rb +2 -5
  7. data/lib/janeway/ast/current_node.rb +2 -15
  8. data/lib/janeway/ast/descendant_segment.rb +0 -3
  9. data/lib/janeway/ast/expression.rb +34 -5
  10. data/lib/janeway/ast/function.rb +15 -10
  11. data/lib/janeway/ast/name_selector.rb +12 -7
  12. data/lib/janeway/ast/root_node.rb +2 -15
  13. data/lib/janeway/ast/selector.rb +5 -6
  14. data/lib/janeway/ast/string_type.rb +4 -0
  15. data/lib/janeway/ast/unary_operator.rb +12 -2
  16. data/lib/janeway/ast/wildcard_selector.rb +2 -2
  17. data/lib/janeway/enumerator.rb +2 -4
  18. data/lib/janeway/error.rb +30 -1
  19. data/lib/janeway/functions/count.rb +1 -8
  20. data/lib/janeway/functions/length.rb +1 -12
  21. data/lib/janeway/functions/match.rb +9 -18
  22. data/lib/janeway/functions/search.rb +11 -9
  23. data/lib/janeway/functions/value.rb +1 -7
  24. data/lib/janeway/functions.rb +130 -30
  25. data/lib/janeway/interpreter.rb +50 -31
  26. data/lib/janeway/interpreters/array_slice_selector_delete_if.rb +10 -13
  27. data/lib/janeway/interpreters/array_slice_selector_interpreter.rb +56 -13
  28. data/lib/janeway/interpreters/binary_operator_interpreter.rb +18 -13
  29. data/lib/janeway/interpreters/descendant_segment_interpreter.rb +32 -13
  30. data/lib/janeway/interpreters/filter_selector_interpreter.rb +26 -34
  31. data/lib/janeway/interpreters/function_interpreter.rb +5 -1
  32. data/lib/janeway/interpreters/index_selector_delete_if.rb +0 -1
  33. data/lib/janeway/interpreters/iteration_helper.rb +19 -5
  34. data/lib/janeway/interpreters/root_node_delete_if.rb +57 -16
  35. data/lib/janeway/interpreters/tree_constructor.rb +0 -20
  36. data/lib/janeway/interpreters/wildcard_selector_delete_if.rb +16 -0
  37. data/lib/janeway/interpreters/yielder.rb +8 -2
  38. data/lib/janeway/iregexp.rb +402 -0
  39. data/lib/janeway/lexer.rb +64 -79
  40. data/lib/janeway/location.rb +3 -1
  41. data/lib/janeway/normalized_path.rb +11 -2
  42. data/lib/janeway/parser.rb +47 -35
  43. data/lib/janeway/query.rb +47 -2
  44. data/lib/janeway/token.rb +40 -5
  45. data/lib/janeway/version.rb +1 -1
  46. metadata +2 -8
  47. data/lib/janeway/interpreters/array_slice_selector_deleter.rb +0 -41
  48. data/lib/janeway/interpreters/child_segment_deleter.rb +0 -19
  49. data/lib/janeway/interpreters/filter_selector_deleter.rb +0 -65
  50. data/lib/janeway/interpreters/index_selector_deleter.rb +0 -26
  51. data/lib/janeway/interpreters/name_selector_deleter.rb +0 -27
  52. data/lib/janeway/interpreters/root_node_deleter.rb +0 -34
  53. 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 ONE_OR_TWO_CHAR_LEX.include?(c)
106
+ if ONE_OR_TWO_CHAR_LEX_SET.include?(c)
93
107
  token_from_one_or_two_char_lex(c)
94
- elsif ONE_CHAR_LEX.include?(c)
108
+ elsif ONE_CHAR_LEX_SET.include?(c)
95
109
  token_from_one_char_lex(c)
96
- elsif TWO_CHAR_LEX_FIRST.include?(c)
110
+ elsif TWO_CHAR_LEX_FIRST_SET.include?(c)
97
111
  token_from_two_char_lex(c)
98
- elsif %w[" '].include?(c)
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: tokens.last&.type == :dot)
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(OPERATORS.key(lexeme), lexeme, nil, current_location)
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 = [lexeme, lookahead].join
137
- if TWO_CHAR_LEX.include?(next_two_chars)
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(OPERATORS.key(next_two_chars), next_two_chars, nil, current_location)
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 = [lexeme, lookahead].join
153
- raise err("Unknown operator \"#{lexeme}\"") unless TWO_CHAR_LEX.include?(next_two_chars)
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(OPERATORS.key(next_two_chars), next_two_chars, nil, current_location)
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 = allowed_delimiters.reject { |char| char == delimiter }.first
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 allowed_delimiters.include?(next_char) && next_char != delimiter
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 = source[lexeme_start_p..(next_p - 1)]
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
- [high_surrogate_hex, low_surrogate_hex].each do |hex_str|
289
- raise ArgumentError, "expect 4 hex digits, got #{hex_string.inspect}" unless hex_str.size == 4
290
- end
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
- hex_digits << consume
323
- case hex_digits.last.ord
324
- when 0x30..0x39 then next # '0'..'1'
325
- when 0x40..0x46 then next # 'A'..'F'
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.join}")
358
+ raise err("Invalid unicode escape sequence: \\u#{hex_digits}")
329
359
  end
330
360
  end
331
- raise err("Incomplete unicode escape sequence: \\u#{hex_digits.join}") if hex_digits.size < 4
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 = source[lexeme_start_p..(next_p - 1)]
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 = source[lexeme_start_p..(next_p - 1)]
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 @tokens.last&.type == :child_start
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 = source[lexeme_start_p..(next_p - 1)]
472
+ identifier = current_lexeme
488
473
  type =
489
- if KEYWORD.include?(identifier) && !ignore_keywords
474
+ if KEYWORD_SET.include?(identifier) && !ignore_keywords
490
475
  identifier.to_sym
491
- elsif FUNCTIONS.include?(identifier) && !ignore_keywords
476
+ elsif FUNCTIONS_SET.include?(identifier) && !ignore_keywords
492
477
  :function
493
478
  else
494
479
  :identifier
@@ -1,3 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- Location = Struct.new(:col, :length)
3
+ module Janeway
4
+ Location = Struct.new(:col, :length)
5
+ end