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
data/lib/janeway/error.rb CHANGED
@@ -20,9 +20,38 @@ module Janeway
20
20
  # @param query [String] entire query string
21
21
  # @param location [Location] location of error
22
22
  def initialize(msg, query = nil, location = nil)
23
- super("Jsonpath query #{query} - #{msg}")
23
+ prefix = 'Jsonpath query '
24
+ pointer = query.is_a?(String) && location
25
+ # The pointer is only right if the header's copy of the query is on one line.
26
+ # scrub swaps each invalid byte for one replacement character, so the columns stay valid
27
+ # and a query that is not valid UTF-8 cannot make building the error raise.
28
+ shown = query
29
+ if pointer
30
+ shown = shown.scrub unless shown.valid_encoding?
31
+ shown = shown.tr("\r\n", ' ')
32
+ end
33
+ full = +''
34
+ full << "#{prefix}#{shown}" if query
35
+ full << " (col #{location.col})" if location
36
+ full << ' - ' unless full.empty?
37
+ full << msg
38
+ full << pointer_line(shown, location, prefix.length) if pointer
39
+ super(full)
24
40
  @query = query
25
41
  @location = location
26
42
  end
43
+
44
+ private
45
+
46
+ # Build the line of carets that marks the problem, aligned under the query in the message header.
47
+ # @param line [String] the query as shown in the header, on one line
48
+ # @param location [Location]
49
+ # @param offset [Integer] width of the text that comes before the query in the header
50
+ # @return [String] starts with a newline
51
+ def pointer_line(line, location, offset)
52
+ col = (location.col || 0).clamp(0, line.length)
53
+ length = (location.length || 1).clamp(1, [line.length - col, 1].max)
54
+ "\n#{' ' * (offset + col)}#{'^' * length}"
55
+ end
27
56
  end
28
57
  end
@@ -29,14 +29,7 @@ module Janeway
29
29
  raise Error, "Invalid parameter - count() expects node list, got #{arg.value.inspect}" if arg.literal?
30
30
  raise Error, 'Too many parameters for count() function call' unless current.type == :group_end
31
31
 
32
- # Define function body
33
- AST::Function.new('count', parameters) do |node_list|
34
- if node_list.is_a?(Array)
35
- node_list.size
36
- else
37
- 1 # the count of a non-empty singular nodelist such as count(@) is always 1.
38
- end
39
- end
32
+ AST::Function.new('count', parameters)
40
33
  end
41
34
  end
42
35
  end
@@ -21,18 +21,7 @@ module Janeway
21
21
  end
22
22
  raise err('Too many parameters for length() function call') unless current.type == :group_end
23
23
 
24
- # Meaning of return value depends on the JSON type:
25
- # * string - number of Unicode scalar values in the string.
26
- # * array - number of elements in the array.
27
- # * object - number of members in the object.
28
- # For any other argument value, the result is the special result Nothing.
29
- AST::Function.new('length', parameters) do |value|
30
- if [Array, Hash, String].include?(value.class)
31
- value.size
32
- else
33
- :nothing
34
- end
35
- end
24
+ AST::Function.new('length', parameters)
36
25
  end
37
26
  end
38
27
  end
@@ -19,16 +19,10 @@ module Janeway
19
19
  # otherwise.
20
20
  #
21
21
  #
22
- # The regexp dialect is called "I-Regexp" and is defined in RFC9485.
23
- #
24
- # Fortunately a shortcut is availalble, that RFC contains instructions
25
- # for converting an I-Regexp to ruby's regexp format.
26
- # @see https://www.rfc-editor.org/rfc/rfc9485.html#name-pcre-re2-and-ruby-regexps
27
- #
28
- # The instructions are:
29
- # * For any unescaped dots (.) outside character classes (first
30
- # alternative of charClass production), replace the dot with [^\n\r].
31
- # * Enclose the regexp in \A(?: and )\z.
22
+ # The pattern is validated and translated by Janeway::IRegexp (RFC 9485). A pattern that is not an
23
+ # I-Regexp gives LogicalFalse (RFC 9535 sections 2.4.6 and 2.4.7). A literal pattern that is over
24
+ # one of the limits in IRegexp (MAX_LENGTH, MAX_REPEAT, MAX_GROUP_DEPTH) raises Janeway::Error when
25
+ # the query is parsed. In a pattern ^ and $ are ordinary characters, as RFC 9485 has no anchors.
32
26
  #
33
27
  # tl;dr: How is this different from the search function?
34
28
  # "match" must match the entire string, "search" matches a substring.
@@ -52,14 +46,11 @@ module Janeway
52
46
  parameters << parse_function_parameter
53
47
  raise Error, 'Too many parameters for match() function call' unless current.type == :group_end
54
48
 
55
- AST::Function.new('match', parameters) do |str, str_iregexp|
56
- if str.is_a?(String) && str_iregexp.is_a?(String)
57
- regexp = translate_iregex_to_ruby_regex(str_iregexp)
58
- regexp.match?(str)
59
- else
60
- false # result defined by RFC9535
61
- end
62
- end
49
+ validate_literal_pattern(parameters.last) # after the syntax checks, so they are reported first
50
+
51
+ # Body (including literal-regex fast path) is supplied by
52
+ # Functions::REGISTRY at FunctionInterpreter construction time.
53
+ AST::Function.new('match', parameters)
63
54
  end
64
55
  end
65
56
  end
@@ -25,6 +25,11 @@ module Janeway
25
25
  # the result is LogicalTrue if at least one such substring exists and is
26
26
  # LogicalFalse otherwise.
27
27
  #
28
+ # The pattern is validated and translated by Janeway::IRegexp (RFC 9485). A pattern that is not an
29
+ # I-Regexp gives LogicalFalse (RFC 9535 sections 2.4.6 and 2.4.7). A literal pattern that is over
30
+ # one of the limits in IRegexp (MAX_LENGTH, MAX_REPEAT, MAX_GROUP_DEPTH) raises Janeway::Error when
31
+ # the query is parsed. In a pattern ^ and $ are ordinary characters, as RFC 9485 has no anchors.
32
+ #
28
33
  # tl;dr: How is this different from the match function?
29
34
  # "match" must match the entire string, "search" matches a substring.
30
35
  def parse_function_search
@@ -45,16 +50,13 @@ module Janeway
45
50
  # Otherwise it is an expression that takes the regexp from the input document,
46
51
  # and the iregexp will not be available until interpretation.
47
52
  parameters << parse_function_parameter
48
- raise Error, 'Too many parameters for match() function call' unless current.type == :group_end
53
+ raise Error, 'Too many parameters for search() function call' unless current.type == :group_end
54
+
55
+ validate_literal_pattern(parameters.last) # after the syntax checks, so they are reported first
49
56
 
50
- AST::Function.new('search', parameters) do |str, str_iregexp|
51
- if str.is_a?(String) && str_iregexp.is_a?(String)
52
- regexp = translate_iregex_to_ruby_regex(str_iregexp, anchor: false)
53
- regexp.match?(str)
54
- else
55
- false # result defined by RFC9535
56
- end
57
- end
57
+ # Body (including literal-regex fast path) is supplied by
58
+ # Functions::REGISTRY at FunctionInterpreter construction time.
59
+ AST::Function.new('search', parameters)
58
60
  end
59
61
  end
60
62
  end
@@ -36,13 +36,7 @@ module Janeway
36
36
  parameters = [parse_function_parameter]
37
37
  raise Error, 'Too many parameters for value() function call' unless current.type == :group_end
38
38
 
39
- AST::Function.new('value', parameters) do |nodes|
40
- if nodes.is_a?(Array) && nodes.size == 1
41
- nodes.first
42
- else
43
- :nothing
44
- end
45
- end
39
+ AST::Function.new('value', parameters)
46
40
  end
47
41
  end
48
42
  end
@@ -1,39 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'ast/function'
4
+ require_relative 'ast/string_type'
5
+ require_relative 'iregexp'
4
6
 
5
7
  module Janeway
6
- # Mixin to provide JSONPath function handlers for Parser
8
+ # Mixin to provide JSONPath function handlers for Parser, plus the built-in function registry.
7
9
  module Functions
8
- # Convert IRegexp format to ruby regexp equivalent, following the instructions in rfc9485.
9
- # @see https://www.rfc-editor.org/rfc/rfc9485.html#name-pcre-re2-and-ruby-regexps
10
- # @param iregex [String]
11
- # @param anchor [Boolean] add anchors to match the string start and end
12
- # @return [Regexp]
13
- def translate_iregex_to_ruby_regex(iregex, anchor: true)
14
- # * For any unescaped dots (.) outside character classes (first
15
- # alternative of charClass production), replace the dot with [^\n\r].
16
- chars = iregex.chars
17
- in_char_class = false
18
- indexes = []
19
- chars.each_with_index do |char, i|
20
- case char
21
- when '[' then in_char_class = true
22
- when ']'
23
- in_char_class = false unless chars[i - 1] == '\\' # escaped ] does not close char class
24
- when '.'
25
- next if in_char_class || chars[i - 1] == '\\' # escaped dot
26
-
27
- indexes << i # replace this dot
28
- end
29
- end
30
- indexes.reverse_each do |i|
31
- chars[i] = '[^\n\r]'
32
- end
10
+ # Reject a literal pattern that exceeds an I-Regexp limit, at the point where the query is parsed.
11
+ #
12
+ # A literal that is not an I-Regexp is not an error: match() and search() return LogicalFalse
13
+ # for it, see build_regex_body. A literal over a limit is a valid I-Regexp that this library
14
+ # refuses to run, so the user is told, with the position in the query.
15
+ # @see https://www.rfc-editor.org/rfc/rfc9485.html#name-security-considerations
16
+ # RFC 9485 section 8: an implementation may limit range quantifiers in range and composability
17
+ # @param parameter [AST::Expression] the second parameter of match() or search(), just parsed
18
+ # @raise [Janeway::Error]
19
+ def validate_literal_pattern(parameter)
20
+ return unless parameter.is_a?(AST::StringType)
33
21
 
34
- # * Enclose the regexp in \A(?: and )\z.
35
- regex_str = anchor ? format('\A(?:%s)\z', chars.join) : chars.join
36
- Regexp.new(regex_str)
22
+ IRegexp.compile(parameter.value)
23
+ rescue IRegexp::Invalid
24
+ nil # RFC 9535 section 2.4.6: the function returns LogicalFalse, see build_regex_body
25
+ rescue IRegexp::LimitExceeded => e
26
+ raise Error.new("I-Regexp #{e.message}", @jsonpath, parameter.token&.location_of(e.offset, e.length))
37
27
  end
38
28
 
39
29
  # All jsonpath function parameters are one of these accepted types.
@@ -54,10 +44,120 @@ module Janeway
54
44
  consume
55
45
  result
56
46
  end
47
+
48
+ # Largest number of distinct document-supplied patterns that one function call remembers.
49
+ DYNAMIC_PATTERN_CACHE_SIZE = 100
50
+
51
+ # Build a match/search body. When the pattern is a StringType literal, the parser has already
52
+ # validated it (Functions#validate_literal_pattern, which discards the result); the interpreter
53
+ # compiles it once more, with the anchoring this function needs, when it is built.
54
+ # Otherwise the pattern comes from the input document and is compiled when it is first used.
55
+ #
56
+ # A pattern that is not an I-Regexp, or that exceeds a limit, gives LogicalFalse.
57
+ # @see https://www.rfc-editor.org/rfc/rfc9535.html#name-match-function-extension
58
+ # RFC 9535 section 2.4.6: "If the first argument is not a string or the second argument is
59
+ # not a string conforming to [RFC9485], the result is LogicalFalse." Section 2.4.7 says the
60
+ # same for search().
61
+ # A literal that exceeds a limit has already been rejected by the parser, see
62
+ # Functions#validate_literal_pattern.
63
+ def self.build_regex_body(parameters, anchor:)
64
+ literal = parameters[1].is_a?(AST::StringType) ? parameters[1].value : nil
65
+ # The cache holds compiled regexps keyed by pattern text, so reusing it for other subjects is
66
+ # correct. It lives as long as the interpreter tree, which Query#interpreter_tree_for caches
67
+ # on an unfrozen Query.
68
+ cache = {} # pattern string => Regexp, or nil when the pattern is unusable
69
+ cache[literal] = compile_or_nil(literal, anchor) if literal # a literal is compiled once, now
70
+ lambda do |str, pattern|
71
+ pattern = literal if literal
72
+ next false unless str.is_a?(String) && pattern.is_a?(String)
73
+ # A pattern in the wrong encoding is false and never reaches the cache: Hash lookup compares
74
+ # strings by content, so a binary 'a' would otherwise share the entry of a UTF-8 'a'.
75
+ next false unless IRegexp.valid_encoding?(pattern)
76
+ # RFC 9485 section 8 lets an implementation limit patterns; the limit is this library's own
77
+ # choice. Checked before parsing so that a huge pattern costs almost nothing.
78
+ next false if pattern.length > IRegexp::MAX_LENGTH
79
+
80
+ unless cache.key?(pattern)
81
+ cache.shift if cache.size >= DYNAMIC_PATTERN_CACHE_SIZE
82
+ cache[pattern] = compile_or_nil(pattern, anchor)
83
+ end
84
+
85
+ !cache[pattern].nil? && match_safely(cache[pattern], str)
86
+ end
87
+ end
88
+
89
+ # RFC 9535 section 2.4.6: a pattern that does not conform to RFC 9485 gives LogicalFalse, so it is nil here.
90
+ # A pattern over a limit (RFC 9485 section 8) is treated the same way.
91
+ # @return [Regexp, nil] nil when the pattern is not usable, see build_regex_body
92
+ def self.compile_or_nil(pattern, anchor)
93
+ IRegexp.compile(pattern, anchor: anchor)
94
+ rescue IRegexp::Invalid, IRegexp::LimitExceeded
95
+ nil
96
+ end
97
+
98
+ # Match, and treat a match that Ruby cannot or will not complete as no match.
99
+ #
100
+ # Deliberate deviation from RFC 9535 sections 2.4.6 and 2.4.7, which say the result is LogicalTrue or
101
+ # LogicalFalse and say nothing about a match that cannot complete: a timeout, or a subject that is
102
+ # not valid UTF-8, gives false. RFC 9485 section 8 (security considerations) is why a timeout exists.
103
+ # @param regexp [Regexp]
104
+ # @param str [String]
105
+ # @return [Boolean]
106
+ def self.match_safely(regexp, str)
107
+ regexp.match?(str)
108
+ rescue IRegexp::TIMEOUT_ERROR, ArgumentError, Encoding::CompatibilityError
109
+ # TIMEOUT_ERROR: the match took longer than IRegexp::MATCH_TIMEOUT.
110
+ # ArgumentError, Encoding::CompatibilityError: str is not valid UTF-8.
111
+ false
112
+ end
113
+
114
+ # Metadata + body builder for every built-in JSONPath function.
115
+ #
116
+ # arity — number of parameters
117
+ # literal_return — true when the result is a literal (see RFC 9535
118
+ # well-typedness of function extensions)
119
+ # build — proc that takes the parsed `parameters` list and
120
+ # returns the body proc (parameters allow per-instance
121
+ # optimization, e.g. literal-regex compilation)
122
+ REGISTRY = {
123
+ 'count' => {
124
+ arity: 1,
125
+ literal_return: true,
126
+ build: ->(_) { ->(nodes) { nodes.is_a?(Array) ? nodes.size : 1 } },
127
+ },
128
+ 'length' => {
129
+ arity: 1,
130
+ literal_return: true,
131
+ build: lambda { |_|
132
+ lambda { |value|
133
+ [Array, Hash, String].include?(value.class) ? value.size : :nothing
134
+ }
135
+ },
136
+ },
137
+ 'value' => {
138
+ arity: 1,
139
+ literal_return: true,
140
+ build: lambda { |_|
141
+ lambda { |nodes|
142
+ nodes.is_a?(Array) && nodes.size == 1 ? nodes.first : :nothing
143
+ }
144
+ },
145
+ },
146
+ 'match' => {
147
+ arity: 2,
148
+ literal_return: false,
149
+ build: ->(parameters) { Functions.build_regex_body(parameters, anchor: true) },
150
+ },
151
+ 'search' => {
152
+ arity: 2,
153
+ literal_return: false,
154
+ build: ->(parameters) { Functions.build_regex_body(parameters, anchor: false) },
155
+ },
156
+ }.freeze
57
157
  end
58
158
  end
59
159
 
60
- # Require function definitions
160
+ # Require function parse-method definitions
61
161
  Dir.children("#{__dir__}/functions/").each do |path|
62
162
  require_relative "functions/#{path[0..-4]}"
63
163
  end
@@ -33,7 +33,7 @@ module Janeway
33
33
  @query = query
34
34
  @type = as
35
35
  @jsonpath = query.jsonpath
36
- @root = query_to_interpreter_tree(@query, &block)
36
+ @root = query.interpreter_tree_for(@type) { query_to_interpreter_tree(@query, &block) }
37
37
  end
38
38
 
39
39
  # Return multiline JSON string describing the interpreter tree.
@@ -51,65 +51,84 @@ module Janeway
51
51
  # @return [Object]
52
52
  def interpret(input)
53
53
  @root.interpret(nil, nil, input, [])
54
- rescue StandardError => e
55
- # Error during interpretation. Convert it to a Janeway::Error and include the query in the message
56
- error = err(e.message)
57
- error.set_backtrace e.backtrace
58
- raise error
54
+ rescue Janeway::Error => e
55
+ # Reformat known interpretation errors to include the query in the message.
56
+ # Real bugs (NoMethodError, TypeError, NameError, ...) are intentionally
57
+ # NOT caught here — they should surface with a real backtrace, not be
58
+ # masked as "Jsonpath query ..." messages.
59
+ raise if e.query # already annotated
60
+
61
+ annotated = err(e.message)
62
+ annotated.set_backtrace e.backtrace
63
+ raise annotated
59
64
  end
60
65
 
61
66
  private
62
67
 
63
68
  # Build a tree of interpreter classes based on the query's abstract syntax tree.
64
69
  #
65
- # Child segments require special handling.
66
- # See the detailed explanation at the end of this file.
70
+ # Four phases, one per private helper:
71
+ # 1. build_interpreters — one interpreter per AST node
72
+ # 2. append_terminal — Yielder / Deleter / DeleteIf for the caller's mode
73
+ # 3. link_next_pointers — wire each interpreter to the next one
74
+ # 4. rewire_child_segments — push tail selectors into each ChildSegmentInterpreter
75
+ # (branch rewrite explained in the docstring at the end of this file)
67
76
  #
68
77
  # @return [Interpreters::RootNodeInterpreter]
69
78
  def query_to_interpreter_tree(query, &block)
70
- # Build an interpreter for each selector
71
- interpreters =
72
- query.node_list.map do |node|
73
- Interpreters::TreeConstructor.ast_node_to_interpreter(node)
74
- end
79
+ interpreters = build_interpreters(query)
80
+ append_terminal(interpreters, &block)
81
+ link_next_pointers(interpreters)
82
+ rewire_child_segments(interpreters)
83
+ end
84
+
85
+ # Phase 1: one interpreter per AST node in the query's top-level chain.
86
+ def build_interpreters(query)
87
+ query.node_list.map { |node| Interpreters::TreeConstructor.ast_node_to_interpreter(node) }
88
+ end
75
89
 
76
- # Append iterotor / deleter as needed, to support #each, #delete operations
90
+ # Phase 2: append the terminal interpreter that matches the caller's mode.
91
+ # Deleter / delete_if REPLACE the last selector (the terminal semantics
92
+ # live in the leaf itself); iterator APPENDS a Yielder after it.
93
+ def append_terminal(interpreters, &block)
77
94
  case @type
78
95
  when :iterator then interpreters.push(Yielder.new(&block))
79
- when :deleter then interpreters.push make_deleter(interpreters.pop)
80
- when :delete_if then interpreters.push make_delete_if(interpreters.pop, &block)
96
+ when :deleter then interpreters.push(make_deleter(interpreters.pop))
97
+ when :delete_if then interpreters.push(make_delete_if(interpreters.pop, &block))
81
98
  end
99
+ end
82
100
 
83
- # Link interpreters together
101
+ # Phase 3: link `.next` from each interpreter to the following one.
102
+ def link_next_pointers(interpreters)
84
103
  interpreters.each_with_index do |node, i|
85
104
  node.next = interpreters[i + 1]
86
105
  end
106
+ end
87
107
 
88
- # Child segments which contain multiple selectors are branches in the interpreter tree.
89
- #
90
- # For every child segment, remove all following interpreters and push
91
- # them "inside" the child segment interpreter.
92
- #
93
- # Work backwards in case there are multiple child segments.
94
- # For full explanation see the explanation at the end of this file.
95
- selectors = []
108
+ # Phase 4: for every ChildSegmentInterpreter, remove the interpreters that
109
+ # follow it in the flat chain and push them into the child segment as
110
+ # branches. Work backwards so nested child segments compose correctly.
111
+ # @return [Interpreters::Base] root of the resulting tree
112
+ def rewire_child_segments(interpreters)
113
+ pending = []
96
114
  interpreters.reverse_each do |node|
97
115
  if node.is_a?(Interpreters::ChildSegmentInterpreter)
98
116
  node.next = nil
99
- node.push(selectors.pop) until selectors.empty?
100
- selectors = [node]
117
+ node.push(pending.pop) until pending.empty?
118
+ pending = [node]
101
119
  else
102
- selectors << node
120
+ pending << node
103
121
  end
104
122
  end
105
-
106
- selectors.last
123
+ pending.last
107
124
  end
108
125
 
109
126
  # Make a Deleter that will delete the results matched by a Selector.
127
+ # Implemented as a DeleteIf with the PASS_ALL sentinel block — the
128
+ # separate Deleter classes were folded into DeleteIf.
110
129
  # @param interpreter [Interpreters::Base] interpeter subclass
111
130
  def make_deleter(interpreter)
112
- TreeConstructor.ast_node_to_deleter(interpreter.node)
131
+ TreeConstructor.ast_node_to_delete_if(interpreter.node, &Interpreters::IterationHelper::PASS_ALL)
113
132
  end
114
133
 
115
134
  # Make a DeleteIf that will delete the results matched by a Selector,
@@ -27,19 +27,13 @@ module Janeway
27
27
  # @return [Array]
28
28
  def interpret(input, _parent, _root, path)
29
29
  return [] unless input.is_a?(Array)
30
- return [] if selector&.step&.zero? # RFC: When step is 0, no elements are selected.
30
+ return [] if effective_step.zero? # RFC: When step is 0, no elements are selected.
31
31
 
32
- # Calculate the upper and lower indices of the target range
33
- lower = selector.lower_index(input.size)
34
- upper = selector.upper_index(input.size)
35
-
36
- # Convert bounds and step to index values.
37
- # Omit the final index, since no value is collected for that.
38
- # Delete indexes from largest to smallest, so that deleting an index does
39
- # not change the remaining indexes
32
+ # Delete matching indexes from largest to smallest so that deletion
33
+ # does not shift the remaining indexes.
34
+ indexes = index_range(input.size)
40
35
  results = []
41
- if selector.step.positive?
42
- indexes = lower.step(to: upper - 1, by: selector.step).to_a
36
+ if effective_step.positive?
43
37
  indexes.reverse_each do |i|
44
38
  next unless @yield_proc.call(input[i], input, path + [i])
45
39
 
@@ -47,8 +41,11 @@ module Janeway
47
41
  end
48
42
  results.reverse
49
43
  else
50
- indexes = upper.step(to: lower + 1, by: selector.step).to_a
51
- indexes.each { |i| results << input.delete_at(i) }
44
+ indexes.each do |i|
45
+ next unless @yield_proc.call(input[i], input, path + [i])
46
+
47
+ results << input.delete_at(i)
48
+ end
52
49
  results
53
50
  end
54
51
  end
@@ -17,19 +17,9 @@ module Janeway
17
17
  # @return [Array]
18
18
  def interpret(input, _parent, root, path)
19
19
  return [] unless input.is_a?(Array)
20
- return [] if selector&.step&.zero? # RFC: When step is 0, no elements are selected.
21
-
22
- # Calculate the upper and lower indices of the target range
23
- lower = selector.lower_index(input.size)
24
- upper = selector.upper_index(input.size)
25
-
26
- # Collect real index values. Omit the final index, since no value is collected for that.
27
- indexes =
28
- if selector.step.positive?
29
- lower.step(to: upper - 1, by: selector.step).to_a
30
- else
31
- upper.step(to: lower + 1, by: selector.step).to_a
32
- end
20
+ return [] if effective_step.zero? # RFC: When step is 0, no elements are selected.
21
+
22
+ indexes = index_range(input.size)
33
23
  return indexes.map { |i| input[i] } unless @next
34
24
 
35
25
  # Apply child selector to each node in the output node list
@@ -44,6 +34,59 @@ module Janeway
44
34
  def as_json
45
35
  { type: type, value: node.to_s, next: @next&.as_json }.compact
46
36
  end
37
+
38
+ protected
39
+
40
+ # Concrete list of source-array indexes this slice selects, in
41
+ # iteration order. Consumed by both this interpreter and
42
+ # ArraySliceSelectorDeleteIf.
43
+ #
44
+ # @param input_size [Integer]
45
+ # @return [Array<Integer>]
46
+ def index_range(input_size)
47
+ lower, upper = bounds(input_size)
48
+ if effective_step.positive?
49
+ lower.step(to: upper - 1, by: effective_step).to_a
50
+ else
51
+ upper.step(to: lower + 1, by: effective_step).to_a
52
+ end
53
+ end
54
+
55
+ # Effective step: raw @step from the AST, or 1 if unspecified.
56
+ def effective_step
57
+ selector.step || 1
58
+ end
59
+
60
+ # Compute lower/upper bounds for the slice given the input array size.
61
+ # RFC 9535 pseudocode lives here (formerly on AST::ArraySliceSelector).
62
+ # @see https://www.rfc-editor.org/rfc/rfc9535.html#section-2.3.4.2.2
63
+ #
64
+ # @param input_size [Integer]
65
+ # @return [Array(Integer, Integer)] [lower, upper]
66
+ def bounds(input_size)
67
+ if effective_step >= 0
68
+ start = selector.start || 0
69
+ end_ = selector.end || input_size
70
+ else
71
+ start = selector.start || (input_size - 1)
72
+ end_ = selector.end || ((-1 * input_size) - 1)
73
+ end
74
+
75
+ n_start = normalize(start, input_size)
76
+ n_end = normalize(end_, input_size)
77
+
78
+ if effective_step >= 0
79
+ [n_start.clamp(0, input_size), n_end.clamp(0, input_size)]
80
+ else
81
+ [n_end.clamp(-1, input_size - 1), n_start.clamp(-1, input_size - 1)]
82
+ end
83
+ end
84
+
85
+ # IETF: slice parameters must be normalized before use as bounds —
86
+ # negative values count from the end of the array.
87
+ def normalize(index, len)
88
+ index >= 0 ? index : len + index
89
+ end
47
90
  end
48
91
  end
49
92
  end
@@ -13,6 +13,18 @@ module Janeway
13
13
  super
14
14
  @left = TreeConstructor.ast_node_to_interpreter(operator.left)
15
15
  @right = TreeConstructor.ast_node_to_interpreter(operator.right)
16
+
17
+ # Bind the per-op behavior at construction time to avoid dispatching
18
+ # on operator.name and allocating a symbol on every candidate value.
19
+ case operator.name
20
+ when :and, :or
21
+ @extract_single_value = false
22
+ when :equal, :not_equal, :less_than, :greater_than, :less_than_or_equal, :greater_than_or_equal
23
+ @extract_single_value = true
24
+ else
25
+ raise "Don't know how to handle binary operator #{operator.name.inspect}"
26
+ end
27
+ @op = method(:"interpret_#{operator.name}")
16
28
  end
17
29
 
18
30
  # The binary operators are all comparison operators that test equality.
@@ -28,20 +40,13 @@ module Janeway
28
40
  # @param root [Array, Hash] the entire input
29
41
  # @param _path [Array] ignored
30
42
  def interpret(input, parent, root, _path = nil)
31
- # FIXME: this branch could be pushed into the constructor, to convert O(n) work to O(1)
32
- case operator.name
33
- when :and, :or
34
- # handle node list for existence check
35
- lhs = @left.interpret(input, parent, root, [])
36
- rhs = @right.interpret(input, parent, root, [])
37
- when :equal, :not_equal, :less_than, :greater_than, :less_than_or_equal, :greater_than_or_equal
38
- # handle node values for comparison check
39
- lhs = to_single_value @left.interpret(input, parent, root, [])
40
- rhs = to_single_value @right.interpret(input, parent, root, [])
41
- else
42
- raise "Don't know how to handle binary operator #{operator.name.inspect}"
43
+ lhs = @left.interpret(input, parent, root, [])
44
+ rhs = @right.interpret(input, parent, root, [])
45
+ if @extract_single_value
46
+ lhs = to_single_value(lhs)
47
+ rhs = to_single_value(rhs)
43
48
  end
44
- send(:"interpret_#{operator.name}", lhs, rhs)
49
+ @op.call(lhs, rhs)
45
50
  end
46
51
 
47
52
  # Interpret a node and extract its value, in preparation for using the node