janeway-jsonpath 1.0.0 → 1.1.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/janeway/ast/array_slice_selector.rb +6 -90
  3. data/lib/janeway/ast/binary_operator.rb +57 -51
  4. data/lib/janeway/ast/child_segment.rb +2 -5
  5. data/lib/janeway/ast/current_node.rb +2 -15
  6. data/lib/janeway/ast/descendant_segment.rb +0 -3
  7. data/lib/janeway/ast/expression.rb +34 -5
  8. data/lib/janeway/ast/function.rb +15 -10
  9. data/lib/janeway/ast/name_selector.rb +12 -7
  10. data/lib/janeway/ast/root_node.rb +2 -15
  11. data/lib/janeway/ast/selector.rb +5 -6
  12. data/lib/janeway/ast/unary_operator.rb +12 -2
  13. data/lib/janeway/ast/wildcard_selector.rb +2 -2
  14. data/lib/janeway/enumerator.rb +2 -4
  15. data/lib/janeway/error.rb +6 -1
  16. data/lib/janeway/functions/count.rb +1 -8
  17. data/lib/janeway/functions/length.rb +1 -12
  18. data/lib/janeway/functions/match.rb +3 -8
  19. data/lib/janeway/functions/search.rb +3 -8
  20. data/lib/janeway/functions/value.rb +1 -7
  21. data/lib/janeway/functions.rb +75 -2
  22. data/lib/janeway/interpreter.rb +50 -31
  23. data/lib/janeway/interpreters/array_slice_selector_delete_if.rb +10 -13
  24. data/lib/janeway/interpreters/array_slice_selector_interpreter.rb +56 -13
  25. data/lib/janeway/interpreters/binary_operator_interpreter.rb +18 -13
  26. data/lib/janeway/interpreters/descendant_segment_interpreter.rb +32 -13
  27. data/lib/janeway/interpreters/filter_selector_interpreter.rb +26 -34
  28. data/lib/janeway/interpreters/function_interpreter.rb +5 -1
  29. data/lib/janeway/interpreters/index_selector_delete_if.rb +0 -1
  30. data/lib/janeway/interpreters/iteration_helper.rb +19 -5
  31. data/lib/janeway/interpreters/root_node_delete_if.rb +57 -16
  32. data/lib/janeway/interpreters/tree_constructor.rb +0 -20
  33. data/lib/janeway/interpreters/wildcard_selector_delete_if.rb +16 -0
  34. data/lib/janeway/interpreters/yielder.rb +8 -2
  35. data/lib/janeway/lexer.rb +61 -78
  36. data/lib/janeway/location.rb +3 -1
  37. data/lib/janeway/normalized_path.rb +11 -2
  38. data/lib/janeway/parser.rb +46 -34
  39. data/lib/janeway/query.rb +47 -2
  40. data/lib/janeway/token.rb +2 -5
  41. data/lib/janeway/version.rb +1 -1
  42. metadata +2 -9
  43. data/lib/janeway/interpreters/array_slice_selector_deleter.rb +0 -41
  44. data/lib/janeway/interpreters/child_segment_deleter.rb +0 -19
  45. data/lib/janeway/interpreters/filter_selector_deleter.rb +0 -65
  46. data/lib/janeway/interpreters/index_selector_deleter.rb +0 -26
  47. data/lib/janeway/interpreters/name_selector_deleter.rb +0 -27
  48. data/lib/janeway/interpreters/root_node_deleter.rb +0 -34
  49. data/lib/janeway/interpreters/wildcard_selector_deleter.rb +0 -32
@@ -6,21 +6,28 @@ module Janeway
6
6
  module Interpreters
7
7
  # Mixin for interpreter classes that yield to a block
8
8
  module IterationHelper
9
+ # Sentinel block used by Interpreter#make_deleter to turn a DeleteIf
10
+ # into an unconditional deleter. `equal?`-comparable so future fast
11
+ # paths (bulk clear) can detect it.
12
+ PASS_ALL = proc { true }.freeze
9
13
  # Returns a Proc that yields the correct number of parameters to a block
10
14
  #
11
15
  # block.arity is -1 when no block is given, and an enumerator is being returned
12
16
  # @return [Proc] which takes 3 parameters
13
17
  def make_yield_proc(&block)
18
+ # Bind the block locally so the returned proc captures a local, not the
19
+ # containing instance's @block ivar (avoids an ivar read per yield).
20
+ b = block
14
21
  if block.arity.negative?
15
22
  # Yield just the value to an enumerator, to enable instance method calls on
16
23
  # matched values like this: enum.delete_if(&:even?)
17
- proc { |value, _parent, _path| @block.call(value) }
24
+ proc { |value, _parent, _path| b.call(value) }
18
25
  elsif block.arity > 3
19
26
  # Only do the work of constructing the normalized path when it is actually used
20
- proc { |value, parent, path| @block.call(value, parent, path.last, normalized_path(path)) }
27
+ proc { |value, parent, path| b.call(value, parent, path.last, normalized_path(path)) }
21
28
  else
22
29
  # block arity is 1, 2 or 3. Send all 3 parameters regardless.
23
- proc { |value, parent, path| @block.call(value, parent, path.last) }
30
+ proc { |value, parent, path| b.call(value, parent, path.last) }
24
31
  end
25
32
  end
26
33
 
@@ -37,8 +44,15 @@ module Janeway
37
44
  def normalized_path(components)
38
45
  # First component is the root identifer, the remaining components are
39
46
  # all index selectors or name selectors.
40
- # Handle the root identifier separately, because .normalize does not handle those.
41
- '$' + components[1..].map { NormalizedPath.normalize(_1) }.join
47
+ # Build the result string in a single pass avoids `components[1..]`
48
+ # (slice allocation) plus `.map { ... }.join` (intermediate Array).
49
+ result = +'$'
50
+ i = 1
51
+ while i < components.size
52
+ result << NormalizedPath.normalize(components[i])
53
+ i += 1
54
+ end
55
+ result
42
56
  end
43
57
  end
44
58
  end
@@ -1,33 +1,74 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'root_node_interpreter'
4
+ require_relative 'iteration_helper'
4
5
 
5
6
  module Janeway
6
7
  module Interpreters
7
- # Interprets the root node for deletion
8
- class RootNodeDeleter < RootNodeInterpreter
9
- # Delete all values from the root node.
10
- #
11
- # TODO: unclear, for deletion is there a difference between queries '$' and '$.*'?
8
+ # Delete values from the root node for which the block returns a truthy value.
9
+ class RootNodeDeleteIf < RootNodeInterpreter
10
+ include IterationHelper
11
+
12
+ # @param node [AST::RootNode]
13
+ def initialize(node, &block)
14
+ super(node)
15
+ @block = block
16
+
17
+ # Make a proc that yields the correct number of values to a block
18
+ @yield_proc = make_yield_proc(&block)
19
+ end
20
+
21
+ # Delete values from the root container for which the block returns truthy.
12
22
  #
13
23
  # @param _input [Array, Hash] the results of processing so far
14
24
  # @param _parent [Array, Hash] parent of the input object
15
25
  # @param root [Array, Hash] the entire input
16
26
  # @param _path [Array<String>] elements of normalized path to the current input
17
27
  # @return [Array] deleted elements
18
- def interpret(_input, _parent, root, _path)
28
+ def interpret(_input, _parent, root, _path = nil)
19
29
  case root
20
- when Array
21
- results = root.dup
22
- root.clear
23
- results
24
- when Hash
25
- results = root.values
26
- root.clear
27
- results
28
- else
29
- []
30
+ when Array then maybe_delete_array_values(root)
31
+ when Hash then maybe_delete_hash_values(root)
32
+ else []
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # @param input [Array]
39
+ def maybe_delete_array_values(input)
40
+ # Fast path: unconditional delete (from Interpreter#make_deleter).
41
+ if @block.equal?(IterationHelper::PASS_ALL)
42
+ results = input.dup
43
+ input.clear
44
+ return results
45
+ end
46
+
47
+ results = []
48
+ (input.size - 1).downto(0).each do |i|
49
+ next unless @yield_proc.call(input[i], input, ['$', i])
50
+
51
+ results << input.delete_at(i)
52
+ end
53
+ results.reverse
54
+ end
55
+
56
+ # @param input [Hash]
57
+ def maybe_delete_hash_values(input)
58
+ # Fast path: unconditional delete (from Interpreter#make_deleter).
59
+ if @block.equal?(IterationHelper::PASS_ALL)
60
+ results = input.values
61
+ input.clear
62
+ return results
63
+ end
64
+
65
+ results = []
66
+ input.each do |key, value|
67
+ next unless @yield_proc.call(value, input, ['$', key])
68
+
69
+ results << input.delete(key)
30
70
  end
71
+ results
31
72
  end
32
73
  end
33
74
  end
@@ -44,26 +44,6 @@ module Janeway
44
44
  end
45
45
  end
46
46
 
47
- # Return a Deleter interpreter for the given AST node.
48
- # This interpreter deletes matched values.
49
- #
50
- # @param expr [AST::Expression]
51
- # @return [Interprteters::Base]
52
- def self.ast_node_to_deleter(expr)
53
- case expr
54
- when AST::IndexSelector then IndexSelectorDeleter.new(expr)
55
- when AST::ArraySliceSelector then ArraySliceSelectorDeleter.new(expr)
56
- when AST::NameSelector then NameSelectorDeleter.new(expr)
57
- when AST::FilterSelector then FilterSelectorDeleter.new(expr)
58
- when AST::WildcardSelector then WildcardSelectorDeleter.new(expr)
59
- when AST::ChildSegment then ChildSegmentDeleter.new(expr)
60
- when AST::RootNode then RootNodeDeleter.new(expr)
61
- when nil then nil # caller has no @next node
62
- else
63
- raise "Unknown AST expression: #{expr.inspect}"
64
- end
65
- end
66
-
67
47
  # Return a DeleteIf interpreter for the given AST node.
68
48
  # This interpreter deletes matched values, but only after
69
49
  # yielding to a block that returns a truthy value.
@@ -36,6 +36,15 @@ module Janeway
36
36
  # @param input [Array]
37
37
  # @param path [Array]
38
38
  def maybe_delete_array_values(input, path)
39
+ # Fast path: unconditional delete (from Interpreter#make_deleter).
40
+ # Bulk clear is O(n) with much smaller constants than reverse-iterate
41
+ # + delete_at + per-element proc call.
42
+ if @block.equal?(IterationHelper::PASS_ALL)
43
+ results = input.dup
44
+ input.clear
45
+ return results
46
+ end
47
+
39
48
  results = []
40
49
  (input.size - 1).downto(0).each do |i|
41
50
  next unless @yield_proc.yield(input[i], input, path + [i])
@@ -48,6 +57,13 @@ module Janeway
48
57
  # @param input [Hash]
49
58
  # @param path [Array]
50
59
  def maybe_delete_hash_values(input, path)
60
+ # Fast path: unconditional delete (from Interpreter#make_deleter).
61
+ if @block.equal?(IterationHelper::PASS_ALL)
62
+ results = input.values
63
+ input.clear
64
+ return results
65
+ end
66
+
51
67
  results = []
52
68
  input.each do |key, value|
53
69
  next unless @yield_proc.yield(value, input, path + [key])
@@ -19,6 +19,12 @@ module Janeway
19
19
  @yield_proc = make_yield_proc(&block)
20
20
  end
21
21
 
22
+ # Frozen empty array — Yielder is only used by Enumerator#each and
23
+ # #find_paths, both of which discard the traversal's return value
24
+ # (results are surfaced via yield instead). Returning a shared frozen
25
+ # array avoids allocating a wrapper array per leaf.
26
+ EMPTY_RESULT = [].freeze
27
+
22
28
  # Yield each input value
23
29
  #
24
30
  # @param input [Array, Hash] the results of processing so far
@@ -26,10 +32,10 @@ module Janeway
26
32
  # @param _root [Array, Hash] the entire input
27
33
  # @param path [Array<String, Integer>] components of normalized path to the current input
28
34
  # @yieldparam [Object] matched value
29
- # @return [Object] input as node list
35
+ # @return [Array] frozen empty array — see EMPTY_RESULT
30
36
  def interpret(input, parent, _root, path)
31
37
  @yield_proc.call(input, parent, path)
32
- input.is_a?(Array) ? input : [input]
38
+ EMPTY_RESULT
33
39
  end
34
40
 
35
41
  # Dummy method from Interpreters::Base, allow child segment interpreter to disable the
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,12 +180,24 @@ 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 = []
177
203
  while lookahead != delimiter && source_uncompleted?
@@ -190,7 +216,7 @@ module Janeway
190
216
  end
191
217
  elsif unescaped?(next_char)
192
218
  consume
193
- elsif allowed_delimiters.include?(next_char) && next_char != delimiter
219
+ elsif QUOTE_LEX_SET.include?(next_char) && next_char != delimiter
194
220
  consume
195
221
  else
196
222
  raise err("invalid character #{next_char.inspect}")
@@ -204,7 +230,7 @@ module Janeway
204
230
  literal = literal_chars.join
205
231
 
206
232
  # lexeme value includes delimiters and literal escape characters
207
- lexeme = source[lexeme_start_p..(next_p - 1)]
233
+ lexeme = current_lexeme
208
234
 
209
235
  Token.new(:string, lexeme, literal, current_location)
210
236
  end
@@ -285,10 +311,11 @@ module Janeway
285
311
  # @param low_surrogate_hex [String] string of hex digits, eg. "DE09"
286
312
  # @return [String] UTF-8 string containing a single multi-byte unicode character, eg. "😉"
287
313
  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
-
314
+ # Callers guarantee 4-char inputs (consume_four_hex_digits followed by
315
+ # high_surrogate?/low_surrogate? which pre-check size). The old defensive
316
+ # guard here referenced a non-existent `hex_string` local — dead code
317
+ # that would NameError if ever reached.
318
+ #
292
319
  # Calculate the code point from the surrogate pair values
293
320
  # algorithm from https://russellcottrell.com/greek/utilities/SurrogatePairCalculator.htm
294
321
  high = high_surrogate_hex.hex
@@ -317,20 +344,19 @@ module Janeway
317
344
  #
318
345
  # @return [String]
319
346
  def consume_four_hex_digits
320
- hex_digits = []
347
+ hex_digits = String.new(capacity: 4)
321
348
  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'
349
+ c = consume
350
+ hex_digits << c
351
+ case c.ord
352
+ when 0x30..0x39 then next # '0'..'9'
353
+ when 0x41..0x46 then next # 'A'..'F'
326
354
  when 0x61..0x66 then next # 'a'..'f'
327
355
  else
328
- raise err("Invalid unicode escape sequence: \\u#{hex_digits.join}")
356
+ raise err("Invalid unicode escape sequence: \\u#{hex_digits}")
329
357
  end
330
358
  end
331
- raise err("Incomplete unicode escape sequence: \\u#{hex_digits.join}") if hex_digits.size < 4
332
-
333
- hex_digits.join
359
+ hex_digits
334
360
  end
335
361
 
336
362
  # Consume a numeric string. May be an integer, fractional, or exponent.
@@ -353,13 +379,13 @@ module Janeway
353
379
  consume # "+" / "-"
354
380
  end
355
381
  unless digit?(lookahead)
356
- lexeme = source[lexeme_start_p..(next_p - 1)]
382
+ lexeme = current_lexeme
357
383
  raise err("Exponent 'e' must be followed by number: #{lexeme.inspect}")
358
384
  end
359
385
  consume_digits
360
386
  end
361
387
 
362
- lexeme = source[lexeme_start_p..(next_p - 1)]
388
+ lexeme = current_lexeme
363
389
  if lexeme.start_with?('0') && lexeme.size > 1
364
390
  raise err("Number may not start with leading zero: #{lexeme.inspect}")
365
391
  end
@@ -375,36 +401,6 @@ module Janeway
375
401
 
376
402
  # Consume an alphanumeric string.
377
403
  # 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
404
  # Return true if string matches the definition of "unescaped" from RFC9535:
409
405
  # unescaped = %x20-21 / ; see RFC 8259
410
406
  # ; omit 0x22 "
@@ -428,19 +424,6 @@ module Janeway
428
424
  end
429
425
  end
430
426
 
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
427
  # True if character is suitable as the first character in a name selector
445
428
  # using shorthand notation (ie. no bracket notation.)
446
429
  #
@@ -481,14 +464,14 @@ module Janeway
481
464
  def lex_member_name_shorthand(ignore_keywords: false)
482
465
  # Abort if name is preceded by child_start. Catches non-delimited identifiers in brackets,
483
466
  # 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
467
+ raise err('Identifier within brackets must be surrounded by quotes') if previous_token_type == :child_start
485
468
 
486
469
  consume while name_char?(lookahead)
487
- identifier = source[lexeme_start_p..(next_p - 1)]
470
+ identifier = current_lexeme
488
471
  type =
489
- if KEYWORD.include?(identifier) && !ignore_keywords
472
+ if KEYWORD_SET.include?(identifier) && !ignore_keywords
490
473
  identifier.to_sym
491
- elsif FUNCTIONS.include?(identifier) && !ignore_keywords
474
+ elsif FUNCTIONS_SET.include?(identifier) && !ignore_keywords
492
475
  :function
493
476
  else
494
477
  :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
@@ -11,6 +11,15 @@ module Janeway
11
11
  # Characters that do not need escaping, defined by hexadecimal range
12
12
  NORMAL_UNESCAPED_RANGES = [(0x20..0x26), (0x28..0x5B), (0x5D..0xD7FF), (0xE000..0x10FFFF)].freeze
13
13
 
14
+ # Fast-path check for `escape`: a single anchored regex covering the same
15
+ # ranges as NORMAL_UNESCAPED_RANGES. Avoids allocating a `chars` array and
16
+ # scanning four Range objects per character on the common all-normal case.
17
+ NORMAL_UNESCAPED_REGEX = Regexp.new(
18
+ '\A[' +
19
+ NORMAL_UNESCAPED_RANGES.map { |r| "\\u{#{r.min.to_s(16)}}-\\u{#{r.max.to_s(16)}}" }.join +
20
+ ']*\z'
21
+ ).freeze
22
+
14
23
  def self.normalize(value)
15
24
  case value
16
25
  when String then normalize_name(value)
@@ -33,8 +42,8 @@ module Janeway
33
42
  end
34
43
 
35
44
  def self.escape(str)
36
- # Common case, all chars are normal.
37
- return str if str.chars.all? { |char| NORMAL_UNESCAPED_RANGES.any? { |range| range.include?(char.ord) } }
45
+ # Common case, all chars are normal — one regex match, no allocations.
46
+ return str if NORMAL_UNESCAPED_REGEX.match?(str)
38
47
 
39
48
  # Some escaping must be done
40
49
  str.chars.map { |char| escape_char(char) }.join
@@ -16,6 +16,32 @@ module Janeway
16
16
  UNARY_OPERATORS = %w[! -].freeze
17
17
  BINARY_OPERATORS = %w[== != > < >= <= ,].freeze
18
18
  LOGICAL_OPERATORS = %w[&& ||].freeze
19
+
20
+ # O(1)-lookup Hash companions to the arrays above, plus the union used by
21
+ # determine_infix_function. Building these inline in the hot path
22
+ # allocated a fresh Array per token; hoisting eliminates it.
23
+ UNARY_OPERATOR_SET = UNARY_OPERATORS.each_with_object({}) { |op, h| h[op] = true }.freeze
24
+ BINARY_OPERATOR_SET = BINARY_OPERATORS.each_with_object({}) { |op, h| h[op] = true }.freeze
25
+ INFIX_OPERATOR_SET =
26
+ (BINARY_OPERATORS + LOGICAL_OPERATORS).each_with_object({}) { |op, h| h[op] = true }.freeze
27
+ PARSE_METHOD_TYPES =
28
+ %I[identifier number string true false nil function if while root current_node]
29
+ .each_with_object({}) { |t, h| h[t] = true }.freeze
30
+ TERMINATOR_TYPES = %I[child_end union eof].each_with_object({}) { |t, h| h[t] = true }.freeze
31
+
32
+ # O(1) dispatch table for determine_parsing_function's type-driven cases.
33
+ # Sits next to the PARSE_METHOD_TYPES set (the other type branch) so both
34
+ # are visible when adding a new token type.
35
+ PARSE_DISPATCH_BY_TYPE = {
36
+ group_start: :parse_grouped_expr,
37
+ :"\n" => :parse_terminator,
38
+ eof: :parse_terminator,
39
+ child_start: :parse_child_segment,
40
+ dot: :parse_dot_notation,
41
+ descendants: :parse_descendant_segment,
42
+ filter: :parse_filter_selector,
43
+ null: :parse_null,
44
+ }.freeze
19
45
  LOWEST_PRECEDENCE = 0
20
46
  OPERATOR_PRECEDENCE = {
21
47
  ',' => 0,
@@ -65,12 +91,8 @@ module Janeway
65
91
 
66
92
  private
67
93
 
68
- def pending_tokens?
69
- @next_p < tokens.length
70
- end
71
-
72
94
  def next_not_terminator?
73
- next_token && next_token.type != :"\n" && next_token.type != :eof
95
+ next_token && next_token.type != :eof
74
96
  end
75
97
 
76
98
  # Make "next" token become "current" by moving the pointer
@@ -135,33 +157,21 @@ module Janeway
135
157
  end
136
158
 
137
159
  def determine_parsing_function
138
- parse_methods = %I[identifier number string true false nil function if while root current_node]
139
- if parse_methods.include?(current.type)
140
- :"parse_#{current.type}"
141
- elsif current.type == :group_start # (
142
- :parse_grouped_expr
143
- elsif %I[\n eof].include?(current.type)
144
- :parse_terminator
145
- elsif UNARY_OPERATORS.include?(current.lexeme)
146
- :parse_unary_operator
147
- elsif current.type == :child_start # [
148
- :parse_child_segment
149
- elsif current.type == :dot # .
150
- :parse_dot_notation
151
- elsif current.type == :descendants # ..
152
- :parse_descendant_segment
153
- elsif current.type == :filter # ?
154
- :parse_filter_selector
155
- elsif current.type == :null # null
156
- :parse_null
157
- else
158
- raise err("Don't know how to parse #{current}")
159
- end
160
+ # Two type-driven tables plus a lexeme-driven check for unary operators.
161
+ # PARSE_METHOD_TYPES synthesizes the method name (`:parse_<type>`);
162
+ # PARSE_DISPATCH_BY_TYPE maps to a fixed method name.
163
+ return :"parse_#{current.type}" if PARSE_METHOD_TYPES.include?(current.type)
164
+
165
+ dispatch = PARSE_DISPATCH_BY_TYPE[current.type]
166
+ return dispatch if dispatch
167
+ return :parse_unary_operator if UNARY_OPERATOR_SET.include?(current.lexeme)
168
+
169
+ raise err("Don't know how to parse #{current}")
160
170
  end
161
171
 
162
172
  # @return [nil, Symbol]
163
173
  def determine_infix_function(token = current)
164
- return unless (BINARY_OPERATORS + LOGICAL_OPERATORS).include?(token.lexeme)
174
+ return unless INFIX_OPERATOR_SET.include?(token.lexeme)
165
175
 
166
176
  :parse_binary_operator
167
177
  end
@@ -198,14 +208,16 @@ module Janeway
198
208
  end
199
209
 
200
210
  # '-' must be followed by a number token.
201
- # Parse number and apply - sign to its literal value
211
+ # Consume the minus, then replace the following number token in the
212
+ # token stream with a copy that has the negated literal. Replacing —
213
+ # rather than mutating in place — keeps Token immutable.
202
214
  consume
203
- parse_number
204
215
  unless current.literal.is_a?(Numeric)
205
216
  raise err("Minus operator \"-\" must be followed by number, got #{current.lexeme.inspect}")
206
217
  end
207
218
 
208
- current.literal *= -1
219
+ original = current
220
+ @tokens[@next_p - 1] = Token.new(original.type, original.lexeme, -original.literal, original.location)
209
221
  current
210
222
  end
211
223
 
@@ -481,11 +493,10 @@ module Janeway
481
493
  # Feed tokens to the FilterSelector until hitting a terminator
482
494
  def parse_filter_selector
483
495
  selector = AST::FilterSelector.new
484
- terminator_types = %I[child_end union eof]
485
- while next_token && !terminator_types.include?(next_token.type)
496
+ while next_token && !TERMINATOR_TYPES.include?(next_token.type)
486
497
  consume
487
498
  node =
488
- if BINARY_OPERATORS.include?(current.lexeme)
499
+ if BINARY_OPERATOR_SET.include?(current.lexeme)
489
500
  parse_binary_operator(selector.value)
490
501
  else
491
502
  parse_expr_recursively
@@ -529,6 +540,7 @@ module Janeway
529
540
 
530
541
  consume
531
542
  op.right = parse_expr_recursively(op_precedence)
543
+ op.validate!
532
544
 
533
545
  op
534
546
  end