jmespath-ruby 0.1.2 → 0.1.3

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -1
  3. data/README.md +177 -4
  4. data/completions/_jmespath +50 -0
  5. data/completions/jmespath.bash +178 -0
  6. data/docs/guides/compilation.md +310 -0
  7. data/docs/guides/completion.md +69 -0
  8. data/docs/guides/errors.md +200 -3
  9. data/docs/guides/functions.md +33 -0
  10. data/docs/guides/search.md +23 -0
  11. data/lib/jmespath/ast.rb +6 -1
  12. data/lib/jmespath/cli/argument_parser.rb +31 -0
  13. data/lib/jmespath/cli/arguments.rb +80 -0
  14. data/lib/jmespath/cli/failure_diagnostic.rb +1 -0
  15. data/lib/jmespath/cli/function_listing.rb +48 -0
  16. data/lib/jmespath/cli/help.rb +43 -0
  17. data/lib/jmespath/cli/interactive_session.rb +106 -0
  18. data/lib/jmespath/cli.rb +112 -16
  19. data/lib/jmespath/data_access.rb +12 -9
  20. data/lib/jmespath/diagnostic.rb +4 -3
  21. data/lib/jmespath/errors.rb +14 -0
  22. data/lib/jmespath/explanation.rb +95 -0
  23. data/lib/jmespath/expression.rb +93 -7
  24. data/lib/jmespath/failure_capture.rb +65 -0
  25. data/lib/jmespath/functions/coercions.rb +12 -5
  26. data/lib/jmespath/functions/conversion.rb +19 -1
  27. data/lib/jmespath/functions/function_call.rb +1 -1
  28. data/lib/jmespath/functions/json_value.rb +50 -0
  29. data/lib/jmespath/functions/references.rb +11 -10
  30. data/lib/jmespath/functions/signature.rb +6 -4
  31. data/lib/jmespath/functions.rb +12 -6
  32. data/lib/jmespath/input.rb +37 -0
  33. data/lib/jmespath/normalizer.rb +22 -5
  34. data/lib/jmespath/parser/projections.rb +5 -5
  35. data/lib/jmespath/parser.rb +2 -2
  36. data/lib/jmespath/plan.rb +17 -2
  37. data/lib/jmespath/search_result.rb +23 -0
  38. data/lib/jmespath/syntax_diagnostic.rb +32 -0
  39. data/lib/jmespath/tracing/collector.rb +109 -0
  40. data/lib/jmespath/tracing/evaluator.rb +113 -0
  41. data/lib/jmespath/tracing/field_lookup.rb +49 -0
  42. data/lib/jmespath/version.rb +1 -1
  43. data/lib/jmespath.rb +43 -8
  44. data/sig/jmespath.rbs +47 -3
  45. metadata +17 -1
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ # @api private
5
+ class Explanation
6
+ OPERATORS = { eq: "==", ne: "!=", lt: "<", lte: "<=", gt: ">", gte: ">=" }.freeze
7
+ ROLES = {
8
+ group: "expression", subexpression: "step", pipe: "step", index: "input", slice: "input",
9
+ flatten: "input", projection: %w[input expression].freeze, value_projection: %w[input expression].freeze,
10
+ filter_projection: %w[input condition expression].freeze, expression_reference: "expression",
11
+ function: "argument", and: "operand", or: "operand", not: "operand",
12
+ comparison: %w[left right].freeze, multi_select_list: "element", multi_select_hash: "entry", entry: "expression"
13
+ }.freeze
14
+
15
+ def self.syntax_children(syntax)
16
+ return syntax.children unless syntax.kind == :filter_projection
17
+
18
+ [syntax.children.fetch(0), syntax.children.fetch(2), syntax.children.fetch(1)]
19
+ end
20
+
21
+ def self.build(source, root)
22
+ # @type var result: explanation
23
+ result = { source: source.encode(Encoding::UTF_8).freeze, root: new.node(root) }
24
+ result.freeze
25
+ end
26
+
27
+ def initialize
28
+ @next_id = 0
29
+ end
30
+
31
+ def node(syntax)
32
+ id = @next_id
33
+ @next_id += 1
34
+ operation = operation(syntax.kind)
35
+ # @type var range: [Integer, Integer]?
36
+ range = syntax.synthetic? ? nil : [syntax.start, syntax.finish].freeze
37
+ # @type var result: explanation_node
38
+ result = {
39
+ id: id, operation: operation,
40
+ source_range: range,
41
+ details: details(syntax).freeze, children: children(syntax, operation).freeze
42
+ }
43
+ result.freeze
44
+ end
45
+
46
+ private
47
+
48
+ def operation(kind)
49
+ return "comparison" if OPERATORS.key?(kind)
50
+ return "entry" if kind == :pair
51
+
52
+ kind.to_s.freeze
53
+ end
54
+
55
+ def details(syntax)
56
+ operator = OPERATORS[syntax.kind]
57
+ return { operator: operator } if operator
58
+
59
+ case syntax.kind
60
+ when :field, :function, :pair then { name: syntax.value.to_s.dup.freeze }
61
+ when :literal then { value: snapshot(syntax.value) }
62
+ when :index then { index: Integer(syntax.value) }
63
+ when :slice then slice_details(syntax.value)
64
+ else {}
65
+ end
66
+ end
67
+
68
+ def slice_details(value)
69
+ start, stop, step = Array(value)
70
+ { start: snapshot(start), stop: snapshot(stop), step: snapshot(step) }
71
+ end
72
+
73
+ def children(syntax, operation)
74
+ roles = ROLES.fetch(operation.to_sym, [])
75
+ syntax_children(syntax).each_with_index.map do |entry, index|
76
+ # @type var result: explanation_child
77
+ result = { role: roles.is_a?(String) ? roles : roles.fetch(index), node: node(entry) }
78
+ result.freeze
79
+ end
80
+ end
81
+
82
+ def syntax_children(syntax)
83
+ self.class.syntax_children(syntax)
84
+ end
85
+
86
+ def snapshot(value)
87
+ case value
88
+ when Hash then value.to_h { |key, entry| [key.to_s.dup.freeze, snapshot(entry)] }.freeze
89
+ when Array then value.map { |entry| snapshot(entry) }.freeze
90
+ when String then value.dup.freeze
91
+ else value
92
+ end
93
+ end
94
+ end
95
+ end
@@ -12,7 +12,7 @@ module JMESPath
12
12
  @source = source.dup.freeze
13
13
  @root = root
14
14
  @options = options
15
- @plan = Plan.build(root, options)
15
+ @plan = Plan.build(root, options) { |native_functions| @native_functions = native_functions }
16
16
  @max_depth = max_depth
17
17
  freeze
18
18
  end
@@ -23,18 +23,88 @@ module JMESPath
23
23
  # @param keywords [Hash] keyword evaluation options
24
24
  # @return [Object] the query result, including nil
25
25
  def search(data, options = Options::EMPTY, **keywords)
26
- if options.equal?(Options::EMPTY) && keywords.empty?
27
- return @plan.call(data.is_a?(Hash) ? data : Input.load(data))
28
- end
26
+ FailureCapture.application do
27
+ if options.equal?(Options::EMPTY) && keywords.empty?
28
+ return @plan.call(data.is_a?(Hash) ? data : Input.load(data))
29
+ end
29
30
 
30
- config = Options.new(options, keywords, default_depth: @max_depth, defaults: @options)
31
- evaluate(Input.load(data), config)
31
+ config = Options.new(options, keywords, default_depth: @max_depth, defaults: @options)
32
+ evaluate(Input.load(data), config)
33
+ end
32
34
  end
33
35
 
34
36
  # Search input using the same options and behavior as {#search}.
35
37
  # @return [Object] the query result, including nil
36
38
  alias call search
37
39
 
40
+ # Search input and return its value or a library-owned failure.
41
+ # @param data [Object] the same input forms accepted by {#search}
42
+ # @param options [Hash] positional evaluation options
43
+ # @param keywords [Hash] keyword evaluation options
44
+ # @return [SearchResult] frozen outcome retaining the original value or error
45
+ # @raise [ArgumentError] evaluation options are invalid
46
+ # Application callback and conversion exceptions propagate unchanged.
47
+ # @example Distinguish successful null from failure
48
+ # JMESPath.compile("missing").try_search({}).success? #=> true
49
+ # JMESPath.compile("@").try_search(false).value #=> false
50
+ # JMESPath.compile("length(@)").try_search(4).success? #=> false
51
+ def try_search(data, options = Options::EMPTY, **keywords)
52
+ fast = options.equal?(Options::EMPTY) && keywords.empty?
53
+ config = fast ? @options : Options.new(options, keywords, default_depth: @max_depth, defaults: @options)
54
+ FailureCapture.run do
55
+ if fast
56
+ @plan.call(data.is_a?(Hash) ? data : Input.load(data))
57
+ else
58
+ evaluate(Input.load(data), config)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Search input and require a non-null result.
64
+ # @param data [Object] the same input forms accepted by {#search}
65
+ # @param options [Hash] positional evaluation options
66
+ # @param keywords [Hash] keyword evaluation options
67
+ # @return [Object] the query result, excluding nil
68
+ # @raise [Errors::NullResultError] evaluation returned nil
69
+ # @example Require a user's name
70
+ # JMESPath.compile("user.name").fetch({user: {name: "Ada"}}) #=> "Ada"
71
+ def fetch(data, options = Options::EMPTY, **keywords)
72
+ value = search(data, options, **keywords)
73
+ raise Errors::NullResultError.new(expression: source) if value.nil?
74
+
75
+ value
76
+ end
77
+
78
+ # Explain query structure without evaluating input or resolving functions.
79
+ # @return [Hash] deeply frozen source text and logical operation tree
80
+ # @example Inspect a field chain
81
+ # JMESPath.compile("user.name").explain[:root][:operation] #=> "subexpression"
82
+ def explain
83
+ Explanation.build(source, @root)
84
+ end
85
+
86
+ # Evaluate once with bounded diagnostics for null fields and omitted items.
87
+ # @param data [Object] the same input forms accepted by {#search}
88
+ # @param options [Hash] positional evaluation options
89
+ # @param limit [Integer] positive maximum number of retained events
90
+ # @param keywords [Hash] keyword evaluation options
91
+ # @return [Hash] frozen report with value, events, and truncated
92
+ # @raise [ArgumentError] limit or evaluation options are invalid
93
+ # @example Trace a missing field
94
+ # JMESPath.compile("name").trace({})[:events].first[:type] #=> "field_null"
95
+ def trace(data, options = Options::EMPTY, limit: 1000, **keywords)
96
+ FailureCapture.application do
97
+ raise ArgumentError, "limit must be a positive Integer" unless limit.is_a?(Integer) && limit.positive?
98
+
99
+ fast = options.equal?(Options::EMPTY) && keywords.empty?
100
+ config = fast ? @options : Options.new(options, keywords, default_depth: @max_depth, defaults: @options)
101
+ collector = Tracing::Collector.new(root: @root, limit: limit)
102
+ collector.finish(trace_value(trace_input(data, fast), config, collector))
103
+ ensure
104
+ collector&.close
105
+ end
106
+ end
107
+
38
108
  # @api private
39
109
  def configured(options)
40
110
  return self if options.functions.equal?(Functions.default) && !options.disable_visit_errors
@@ -45,7 +115,7 @@ module JMESPath
45
115
  # @api private
46
116
  def evaluate(data, config)
47
117
  if @root.depth > config.max_depth
48
- raise Errors::SyntaxError.new("Expression exceeds nesting limit", expression: source)
118
+ raise FailureCapture.owned(Errors::SyntaxError.new("Expression exceeds nesting limit", expression: source))
49
119
  end
50
120
 
51
121
  return @plan.call(data) if config.equal?(@options) || configured_for?(config)
@@ -56,6 +126,22 @@ module JMESPath
56
126
 
57
127
  private
58
128
 
129
+ def trace_input(data, fast)
130
+ return data if fast && data.is_a?(Hash)
131
+
132
+ Input.load(data)
133
+ end
134
+
135
+ def trace_value(data, config, collector)
136
+ if @root.depth > config.max_depth
137
+ raise Errors::SyntaxError.new("Expression exceeds nesting limit", expression: source)
138
+ end
139
+
140
+ evaluator = Tracing::Evaluator.new(config: config, collector: collector, root: @root,
141
+ planned: configured_for?(config), native_functions: @native_functions)
142
+ evaluator.evaluate(@root, data)
143
+ end
144
+
59
145
  def configured_for?(config)
60
146
  config.functions.equal?(@options.functions) && config.disable_visit_errors == @options.disable_visit_errors
61
147
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ # @api private
5
+ class FailureCapture
6
+ KEY = :jmespath_failure_capture
7
+ attr_reader :owned, :excluded
8
+
9
+ def initialize
10
+ @owned = {} # : Hash[StandardError, bool]
11
+ .compare_by_identity
12
+ @excluded = {} # : Hash[StandardError, bool]
13
+ .compare_by_identity
14
+ end
15
+
16
+ def self.run(&)
17
+ previous = Thread.current[KEY]
18
+ context = new
19
+ Thread.current[KEY] = context
20
+ begin
21
+ context.result(&)
22
+ ensure
23
+ Thread.current[KEY] = previous
24
+ end
25
+ end
26
+
27
+ def result
28
+ SearchResult.new(value: yield, error: nil)
29
+ rescue Errors::Error, JSON::ParserError, IOError, SystemCallError => e
30
+ raise unless owned.key?(e) && !excluded.key?(e)
31
+
32
+ SearchResult.new(value: nil, error: e)
33
+ end
34
+
35
+ def self.owned(error)
36
+ context = Thread.current[KEY]
37
+ context.owned[error] = true if context
38
+ error
39
+ end
40
+
41
+ def self.active?
42
+ !Thread.current[KEY].nil?
43
+ end
44
+
45
+ def self.application
46
+ context = Thread.current[KEY]
47
+ return yield unless context
48
+
49
+ Thread.current[KEY] = nil
50
+ begin
51
+ yield
52
+ rescue StandardError => e
53
+ context.excluded[e] = true
54
+ raise
55
+ ensure
56
+ Thread.current[KEY] = context
57
+ end
58
+ end
59
+
60
+ def self.excluded?(error)
61
+ context = Thread.current[KEY]
62
+ context ? context.excluded.key?(error) : false
63
+ end
64
+ end
65
+ end
@@ -13,25 +13,32 @@ module JMESPath
13
13
  return value if value.is_a?(Integer)
14
14
  return value if value.is_a?(Float) && value.finite?
15
15
 
16
- raise type_failure(value, "Expected a finite number", [:number], context, position, stage)
16
+ raise FailureCapture.owned(type_failure(value, "Expected a finite number", [:number], context, position, stage))
17
17
  end
18
18
 
19
19
  def string(value, context: nil, position: nil)
20
- DataAccess.string(value) || raise(type_failure(value, "Expected a string", [:string], context, position))
20
+ DataAccess.string(value) || raise(FailureCapture.owned(
21
+ type_failure(value, "Expected a string", [:string], context, position)
22
+ ))
21
23
  end
22
24
 
23
25
  def array(value, context: nil, position: nil)
24
- DataAccess.array(value) || raise(type_failure(value, "Expected an array", [:array], context, position))
26
+ DataAccess.array(value) || raise(FailureCapture.owned(
27
+ type_failure(value, "Expected an array", [:array], context, position)
28
+ ))
25
29
  end
26
30
 
27
31
  def reference(value, context: nil, position: nil)
28
32
  return value if value.is_a?(ExpressionReference)
29
33
 
30
- raise type_failure(value, "Expected an expression reference", [:expref], context, position)
34
+ raise FailureCapture.owned(type_failure(value, "Expected an expression reference", [:expref], context,
35
+ position))
31
36
  end
32
37
 
33
38
  def object(value, context: nil, position: nil)
34
- DataAccess.object(value) || raise(type_failure(value, "Expected an object", [:object], context, position))
39
+ DataAccess.object(value) || raise(FailureCapture.owned(
40
+ type_failure(value, "Expected an object", [:object], context, position)
41
+ ))
35
42
  end
36
43
 
37
44
  private
@@ -13,11 +13,27 @@ module JMESPath
13
13
  end
14
14
 
15
15
  def to_string(value, **)
16
- DataAccess.string(value) || JSON.generate(Types.normalize(value))
16
+ DataAccess.string(value) || generate_json(normalize_json(value))
17
17
  rescue JSON::NestingError
18
+ raise if FailureCapture.active?
19
+
18
20
  raise Errors::InvalidValueError, "Value is too deeply nested to serialize"
19
21
  end
20
22
 
23
+ def generate_json(value)
24
+ JSON.generate(value)
25
+ rescue JSON::NestingError => e
26
+ raise if FailureCapture.excluded?(e)
27
+
28
+ raise FailureCapture.owned(Errors::InvalidValueError.new("Value is too deeply nested to serialize"))
29
+ end
30
+
31
+ def normalize_json(value)
32
+ return Types.normalize(value) unless FailureCapture.active?
33
+
34
+ Normalizer.new(key: ->(key) { JSONValue.key(key) }) { |scalar| JSONValue.wrap(scalar) }.call(value)
35
+ end
36
+
21
37
  def to_number(value, **)
22
38
  return value if Types.number?(value)
23
39
 
@@ -30,3 +46,5 @@ module JMESPath
30
46
  end
31
47
  end
32
48
  end
49
+
50
+ require_relative "json_value"
@@ -17,7 +17,7 @@ module JMESPath
17
17
  if implementation.is_a?(Symbol)
18
18
  send(implementation, *arguments, context: context)
19
19
  else
20
- implementation.call(*arguments)
20
+ FailureCapture.application { implementation.call(*arguments) }
21
21
  end
22
22
  rescue Signature::TypeMismatch, Signature::ArityMismatch => e
23
23
  raise unless disable_visit_errors && e.origin.equal?(self)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ class Functions
5
+ # @api private
6
+ class JSONValue
7
+ def self.wrap(value)
8
+ case value
9
+ when NilClass, TrueClass, FalseClass, Integer, Float then value
10
+ when String then Kernel.instance_method(:instance_of?).bind_call(value, String) ? value : new(value)
11
+ else new(value)
12
+ end
13
+ end
14
+
15
+ def self.key(value)
16
+ case value
17
+ when Symbol then value
18
+ when String then JSONKey.new(value)
19
+ else new(value)
20
+ end
21
+ end
22
+
23
+ def initialize(value)
24
+ @value = value
25
+ end
26
+
27
+ def to_json(state)
28
+ FailureCapture.application do
29
+ @value.respond_to?(:to_json) ? @value.to_json(state) : state.generate(String.new(@value.to_s))
30
+ end
31
+ end
32
+
33
+ def to_s
34
+ FailureCapture.application { @value.to_s }
35
+ end
36
+ end
37
+
38
+ # @api private
39
+ class JSONKey < String
40
+ def initialize(value)
41
+ super
42
+ @value = value
43
+ end
44
+
45
+ def to_s
46
+ FailureCapture.application { @value.to_s }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -58,18 +58,19 @@ module JMESPath
58
58
  def checked_type(value, expected, context:)
59
59
  type = Types.name(value)
60
60
  unless SORTABLE_TYPES.include?(type)
61
- raise context.type_error(
62
- "Expression keys must be numbers or strings",
63
- position: 2, expected_types: expected ? [expected] : SORTABLE_TYPES, actual_type: type,
64
- origin: validation_origin, stage: :expression_result
65
- )
61
+ raise FailureCapture.owned(context.type_error(
62
+ "Expression keys must be numbers or strings",
63
+ position: 2, expected_types: expected ? [expected] : SORTABLE_TYPES,
64
+ actual_type: type,
65
+ origin: validation_origin, stage: :expression_result
66
+ ))
66
67
  end
67
68
  if expected && expected != type
68
- raise context.type_error(
69
- "Expression keys must have one type",
70
- position: 2, expected_types: [expected], actual_type: type,
71
- origin: validation_origin, stage: :expression_result
72
- )
69
+ raise FailureCapture.owned(context.type_error(
70
+ "Expression keys must have one type",
71
+ position: 2, expected_types: [expected], actual_type: type,
72
+ origin: validation_origin, stage: :expression_result
73
+ ))
73
74
  end
74
75
 
75
76
  type
@@ -44,8 +44,8 @@ module JMESPath
44
44
  def validate!(values, name, origin: nil, context: nil)
45
45
  context ||= ValidationContext.new(name, self)
46
46
  unless valid_arity?(values.length)
47
- raise context.arity_error("Invalid number of arguments for #{name}", actual_arity: values.length,
48
- origin: origin)
47
+ raise FailureCapture.owned(context.arity_error("Invalid number of arguments for #{name}",
48
+ actual_arity: values.length, origin: origin))
49
49
  end
50
50
 
51
51
  values.each_with_index do |value, index|
@@ -60,8 +60,10 @@ module JMESPath
60
60
 
61
61
  def reject_type!(value, index, types, name, origin, context) # rubocop:disable Metrics/ParameterLists
62
62
  actual_type = Types.name(value)
63
- raise context.type_error("Invalid argument #{index + 1} for #{name}: #{actual_type}",
64
- position: index + 1, expected_types: types, actual_type: actual_type, origin: origin)
63
+ raise FailureCapture.owned(
64
+ context.type_error("Invalid argument #{index + 1} for #{name}: #{actual_type}",
65
+ position: index + 1, expected_types: types, actual_type: actual_type, origin: origin)
66
+ )
65
67
  end
66
68
 
67
69
  def valid_definition?(arguments)
@@ -88,16 +88,20 @@ module JMESPath
88
88
  # @raise [Errors::InvalidTypeError] argument types are incompatible
89
89
  # @raise [Errors::InvalidArityError] the number of arguments is invalid
90
90
  def call(name, arguments, disable_visit_errors: false)
91
- invoke(name, arguments, disable_visit_errors: disable_visit_errors)
91
+ FailureCapture.application { invoke(name, arguments, disable_visit_errors: disable_visit_errors) }
92
92
  end
93
93
 
94
94
  # @api private
95
95
  def call_with_context(name, arguments, source_range:, disable_visit_errors: nil)
96
96
  # Preserve compiled keyword omission separately from the evaluator's explicit false.
97
97
  unless method(:call).owner.equal?(Functions)
98
- return call(name, arguments) if disable_visit_errors.nil?
99
-
100
- return call(name, arguments, disable_visit_errors: disable_visit_errors)
98
+ return FailureCapture.application do
99
+ if disable_visit_errors.nil?
100
+ call(name, arguments)
101
+ else
102
+ call(name, arguments, disable_visit_errors: disable_visit_errors)
103
+ end
104
+ end
101
105
  end
102
106
 
103
107
  invoke(name, arguments, source_range: source_range, disable_visit_errors: !!disable_visit_errors)
@@ -112,8 +116,10 @@ module JMESPath
112
116
 
113
117
  def invoke(name, arguments, disable_visit_errors:, source_range: nil)
114
118
  definition = @definitions.fetch(name) do
115
- raise Errors::UnknownFunctionError.new("Unknown function: #{name}", function_name: name,
116
- suggestions: Suggestions.for(name, names))
119
+ raise FailureCapture.owned(
120
+ Errors::UnknownFunctionError.new("Unknown function: #{name}", function_name: name,
121
+ suggestions: Suggestions.for(name, names))
122
+ )
117
123
  end
118
124
  context = ValidationContext.new(name, definition.signature, source_range: source_range)
119
125
  invocation = disable_visit_errors ? FunctionCall.new : FunctionCall::STRICT
@@ -8,6 +8,7 @@ module JMESPath
8
8
  module Input
9
9
  def self.load(data)
10
10
  return data if data.is_a?(Hash) || data.is_a?(Array) || data.is_a?(Struct)
11
+ return captured_load(data) if FailureCapture.active?
11
12
 
12
13
  case data
13
14
  when Pathname then File.open(data, "r", encoding: "UTF-8") { |file| JSON.parse(file.read) }
@@ -15,5 +16,41 @@ module JMESPath
15
16
  else data
16
17
  end
17
18
  end
19
+
20
+ def self.captured_load(data)
21
+ case data
22
+ when Pathname then load_file(data)
23
+ when IO, StringIO
24
+ text = read(data)
25
+ parse(FailureCapture.application { text.to_s })
26
+ else data
27
+ end
28
+ end
29
+
30
+ def self.load_file(data)
31
+ path = FailureCapture.application { File.path(data) }
32
+ File.open(path, "r", encoding: "UTF-8") { |file| parse(read(file)) }
33
+ rescue IOError, SystemCallError => e
34
+ raise FailureCapture.owned(e)
35
+ end
36
+
37
+ def self.read(data)
38
+ return FailureCapture.application { data.read } unless native_read?(data)
39
+
40
+ data.read
41
+ rescue IOError, SystemCallError => e
42
+ raise FailureCapture.owned(e)
43
+ end
44
+
45
+ def self.native_read?(data)
46
+ reader = Kernel.instance_method(:method).bind_call(data, :read)
47
+ [IO, StringIO].include?(reader.owner) && reader.source_location.nil?
48
+ end
49
+
50
+ def self.parse(text)
51
+ JSON.parse(text)
52
+ rescue JSON::ParserError => e
53
+ raise FailureCapture.owned(e)
54
+ end
18
55
  end
19
56
  end
@@ -3,9 +3,11 @@
3
3
  module JMESPath
4
4
  # @api private
5
5
  class Normalizer
6
- def initialize
6
+ def initialize(key: nil, &scalar)
7
7
  @pending = []
8
8
  @active = {}
9
+ @scalar = scalar
10
+ @key = key
9
11
  end
10
12
 
11
13
  def call(value)
@@ -26,14 +28,27 @@ module JMESPath
26
28
 
27
29
  def visit(value, &assign)
28
30
  collection = DataAccess.array(value) || DataAccess.object(value)
29
- return assign.call(DataAccess.string(value) || value) unless collection
31
+ return assign.call(scalar(value)) unless collection
30
32
 
33
+ id = activate(value)
34
+ @pending << -> { @active.delete(id) }
35
+ collection.is_a?(Array) ? array(collection, &assign) : object(collection, &assign)
36
+ end
37
+
38
+ def scalar(value)
39
+ value = DataAccess.string(value) || value
40
+ transform = @scalar
41
+ transform ? transform.call(value) : value
42
+ end
43
+
44
+ def activate(value)
31
45
  id = value.object_id
32
- raise Errors::InvalidValueError, "Cyclic values cannot be compared or serialized" if @active[id]
46
+ if @active[id]
47
+ raise FailureCapture.owned(Errors::InvalidValueError.new("Cyclic values cannot be compared or serialized"))
48
+ end
33
49
 
34
50
  @active[id] = true
35
- @pending << -> { @active.delete(id) }
36
- collection.is_a?(Array) ? array(collection, &assign) : object(collection, &assign)
51
+ id
37
52
  end
38
53
 
39
54
  def array(values)
@@ -47,6 +62,8 @@ module JMESPath
47
62
  # @type var result: Hash[value, value]
48
63
  result = values.transform_values { nil }
49
64
  yield result
65
+ transform = @key
66
+ @pending << -> { result.transform_keys!(&transform) } if transform
50
67
  values.to_a.reverse_each { |key, value| enqueue(value) { |entry| result[key] = entry } }
51
68
  end
52
69
  end
@@ -7,7 +7,7 @@ module JMESPath
7
7
 
8
8
  def prefix_bracket(token)
9
9
  if %i[number colon].include?(peek.type) || (peek.type == :star && @tokens.fetch(@index + 1).type == :rbracket)
10
- return bracket_body(node(:current))
10
+ return bracket_body(node(:current, synthetic: true))
11
11
  end
12
12
 
13
13
  multi_select_list(token)
@@ -58,11 +58,11 @@ module JMESPath
58
58
  end
59
59
 
60
60
  def prefix_star(_token)
61
- node(:value_projection, nil, [node(:current), projection_rhs(20)])
61
+ node(:value_projection, nil, [node(:current, synthetic: true), projection_rhs(20)])
62
62
  end
63
63
 
64
64
  def prefix_flatten(token)
65
- infix_flatten(node(:current), token)
65
+ infix_flatten(node(:current, synthetic: true), token)
66
66
  end
67
67
 
68
68
  def infix_flatten(left, _token)
@@ -71,7 +71,7 @@ module JMESPath
71
71
  end
72
72
 
73
73
  def prefix_filter(token)
74
- infix_filter(node(:current), token)
74
+ infix_filter(node(:current, synthetic: true), token)
75
75
  end
76
76
 
77
77
  def infix_filter(left, _token)
@@ -81,7 +81,7 @@ module JMESPath
81
81
  end
82
82
 
83
83
  def projection_rhs(power)
84
- return node(:current) if POWER.fetch(peek.type, 0) < 10
84
+ return node(:current, synthetic: true) if POWER.fetch(peek.type, 0) < 10
85
85
  return expression(power) if %i[lbracket filter].include?(peek.type)
86
86
  return dot_rhs(power) if peek.type == :dot && advance
87
87