jmespath-ruby 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4b7ecb0c9ca2ab8eaaf5a69595600e5c2ba575ab4dcc11847325b0ff0379eae
4
- data.tar.gz: a1717f3a5ec852e59c787dba29d6718fc234a2dd004d8da5d6010eecd7c5f359
3
+ metadata.gz: f9f2db0a8d51af9e3258964210daf3aa1391f97370fca0d0e5610045aad9e012
4
+ data.tar.gz: ec560bc1d5607e14175267d6b1bb82329a3d126686d99393c2cd6a8e7bc62585
5
5
  SHA512:
6
- metadata.gz: 04e8205ad8044b27b98099646b4bc7bbc529a2d1a4c9d0bc67a4261597ecb5859421471df1105c90ffca08e620429fba5765f9e344553ad7745a5ec2f8f4eba1
7
- data.tar.gz: 40391ec7795107985e8e0bdcd6902fb942814663c4a08eeba37d8d9d36552fcffeae05ee173e76ab5eec2711abe06213a6dc1ada955bf18f61caac8dabacc08f
6
+ metadata.gz: b8b8a4b5d663ae5b6ac54d093867c94ea420d836d44f1de5de1b781c21a984277a8211f7a6343392b335d0e451b7f3f749802e0ad910fc0af45d573fb305d2b9
7
+ data.tar.gz: 985c5eddf2e47265eaa74f37afd4ea73094ae84f6a87ec90ec0c0501be1af03b3c46a4d95207be57d42ec47f4b94dc2b632a1e87b5eabfa712e3ba4232753794
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for t
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.2] - 2026-11-02
9
+
10
+ ### Added
11
+
12
+ - Add `JMESPath::Diagnostic.format` for syntax reasons, source coordinates, query excerpts, and carets.
13
+ - Expose immutable function validation data, including argument positions, expected and actual types, arity, and query byte ranges.
14
+ - Add `Functions#names`, `Functions#metadata`, and optional custom function descriptions for immutable registry inspection.
15
+ - Suggest similar names from the active registry for unknown functions; expose the missing name and ranked suggestions on the error.
16
+
17
+ ### Changed
18
+
19
+ - Show located CLI syntax, function type, and arity diagnostics; describe validation and suggestions in CLI help.
20
+ - Report malformed CLI input as `Invalid JSON input` without exposing JSON parser messages or document fragments.
21
+
8
22
  ## [0.1.1] - 2026-11-01
9
23
 
10
24
  ### Changed
@@ -31,6 +45,7 @@ Initial implementation for Ruby 3.3 and later.
31
45
  The compatibility guide documents intentional corrections and limits.
32
46
  Applications that depend on the gem name `jmespath` must change that dependency explicitly.
33
47
 
34
- [unreleased]: https://github.com/svyatov/jmespath-ruby/compare/v0.1.1...HEAD
48
+ [unreleased]: https://github.com/svyatov/jmespath-ruby/compare/v0.1.2...HEAD
49
+ [0.1.2]: https://github.com/svyatov/jmespath-ruby/compare/v0.1.1...v0.1.2
35
50
  [0.1.1]: https://github.com/svyatov/jmespath-ruby/compare/v0.1.0...v0.1.1
36
51
  [0.1.0]: https://github.com/svyatov/jmespath-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -12,7 +12,7 @@ Search JSON and Ruby data with reusable queries and typed custom functions.
12
12
  In your Ruby project, add the gem with Bundler. Run `bundle init` first if the project has no Gemfile.
13
13
 
14
14
  ```sh
15
- bundle add jmespath-ruby --version "~> 0.1.0"
15
+ bundle add jmespath-ruby --version "~> 0.1.2"
16
16
  ```
17
17
 
18
18
  Save this Ruby example as `example.rb`:
@@ -33,10 +33,10 @@ On MRI 4.0.9, jmespath-ruby beats each competing implementation on every workloa
33
33
 
34
34
  | Implementation | Version | Correct workloads | jmespath-ruby public-search speedup |
35
35
  | --- | --- | ---: | ---: |
36
- | **jmespath-ruby** | **0.1.0** | **✅ 18/18** | **Baseline** |
37
- | jmespath (jmespath.rb) | 1.6.2 | ✅ 18/18 | **7.97x** |
38
- | BurtPath | 1.1.2 | ❌ 15/18 | **9.99x** |
39
- | Jamespath | 0.5.1 | ❌ 13/18 | **8.07x** |
36
+ | **jmespath-ruby** | **0.1.0** | **18/18** | **Baseline** |
37
+ | jmespath (jmespath.rb) | 1.6.2 | 18/18 | **7.97x** |
38
+ | BurtPath | 1.1.2 | 15/18 | **9.99x** |
39
+ | Jamespath | 0.5.1 | 13/18 | **8.07x** |
40
40
 
41
41
  Speedups use geometric means of median times across each competitor's correctly evaluated workloads.
42
42
  Measurements use five rounds of 20,000 searches on an Apple M1 Pro.
@@ -60,11 +60,11 @@ The [comparison with existing implementations](docs/guides/comparison.md) includ
60
60
 
61
61
  | Feature | jmespath-ruby | jmespath | BurtPath | Jamespath | jmespath-jruby |
62
62
  | --- | --- | --- | --- | --- | --- |
63
- | Public query compilation | ✅ | ❌ | ❌ | ✅ | ✅ |
64
- | Documented typed function registry | ✅ | ❌ | ❌ | ❌ | ❌ |
65
- | Packaged RBS signatures | ✅ | ❌ | ❌ | ❌ | ❌ |
66
- | Pure Ruby implementation | ✅ | ✅ | ✅ | ✅ | ❌ |
67
- | Ordinary `JMESPath.search` calls | ✅ | ✅ | ✅ | ❌ | ❌ |
63
+ | Public query compilation | Yes | No | No | Yes | Yes |
64
+ | Documented typed function registry | Yes | No | No | No | No |
65
+ | Packaged RBS signatures | Yes | No | No | No | No |
66
+ | Pure Ruby implementation | Yes | Yes | Yes | Yes | No |
67
+ | Ordinary `JMESPath.search` calls | Yes | Yes | Yes | No | No |
68
68
 
69
69
  `jmespath` and BurtPath support reuse through private runtime classes.
70
70
  The [full comparison](docs/guides/comparison.md) distinguishes those classes from public compilation APIs.
@@ -76,7 +76,7 @@ Replace that Gemfile entry:
76
76
 
77
77
  ```diff
78
78
  -gem "jmespath"
79
- +gem "jmespath-ruby", "~> 0.1.0"
79
+ +gem "jmespath-ruby", "~> 0.1.2"
80
80
  ```
81
81
 
82
82
  Run `bundle install`. Your Ruby code keeps the same entry points:
@@ -144,6 +144,25 @@ Registration returns a separate immutable registry.
144
144
  An existing function requires `override: true` to replace it.
145
145
  Every argument, including variadic arguments, is validated before the callback runs.
146
146
 
147
+ Inspect registered functions in Ruby without executing their callbacks:
148
+
149
+ ```ruby
150
+ JMESPath::Functions.default.names.include?("length") #=> true
151
+ metadata = JMESPath::Functions.default.metadata("length")
152
+ metadata.signature #=> [[:string, :array, :object]]
153
+ metadata.description #=> "Character or element count."
154
+ ```
155
+
156
+ Use `description:` when registering a custom function to include its description in immutable metadata.
157
+ See [registry inspection](docs/guides/functions.md#registry-inspection) for all fields and override behavior.
158
+
159
+ ## Errors and diagnostics
160
+
161
+ Format syntax failures in Ruby with `JMESPath::Diagnostic.format(error)` to get source coordinates, a query excerpt, and a caret.
162
+ Function type and arity errors expose immutable validation data, including the function name, signature, argument counts or types, and available source ranges.
163
+ Unknown-function errors expose the missing name and up to three suggestions from the active registry.
164
+ See [errors and diagnostics](docs/guides/errors.md) for examples, nullable fields, and source-location limits.
165
+
147
166
  ## Command line
148
167
 
149
168
  Both `jmespath` and `jmespath.rb` accept an expression and a JSON document on standard input.
@@ -153,7 +172,12 @@ printf '%s\n' '{"user":{"name":"Ada"}}' | jmespath 'user.name'
153
172
  ```
154
173
 
155
174
  The result goes to standard output as JSON.
156
- Errors go to standard error with a nonzero status.
175
+ Syntax, function type, and arity failures include source coordinates, a query excerpt, and a caret when available.
176
+ Unknown functions suggest similar registered names.
177
+ Malformed JSON input reports `Invalid JSON input` without including document fragments.
178
+ These failures go to standard error and exit with status 1.
179
+ Usage failures exit with status 2; `--help` and `--version` exit with status 0 without reading input.
180
+ See [CLI diagnostics](docs/guides/errors.md#cli-diagnostics) for examples and fallback behavior.
157
181
 
158
182
  ## Guides
159
183
 
@@ -12,7 +12,50 @@ SyntaxError derives from that base; evaluation errors derive from RuntimeError.
12
12
  | `UnknownFunctionError` | Function absent from the supplied registry |
13
13
 
14
14
  SyntaxError exposes the expression, a zero-based byte position, and one-based line and character column.
15
- This distinguishes byte offsets from Unicode display positions.
15
+ Character columns count Unicode code points, independently of byte offsets and terminal display widths.
16
+
17
+ Use `JMESPath::Diagnostic.format(error)` to get the reason, source coordinates, query line, and caret as a string.
18
+ The formatter returns UTF-8 without a trailing newline and does not print output or read an input document.
19
+ For example, rescue a compilation failure and format it:
20
+
21
+ ```ruby
22
+ error = begin
23
+ JMESPath.compile("items[?active")
24
+ rescue JMESPath::Errors::SyntaxError => failure
25
+ failure
26
+ end
27
+ JMESPath::Diagnostic.format(error) #=> "Expected rbracket, received eof at line 1, column 14\nitems[?active\n ^"
28
+ ```
29
+
30
+ The resulting diagnostic is:
31
+
32
+ ```text
33
+ Expected rbracket, received eof at line 1, column 14
34
+ items[?active
35
+ ^
36
+ ```
37
+
38
+ `error.reason` returns the failure reason without the byte-offset suffix in `error.message`.
39
+ `error.location_known?` reports whether an explicit source position was supplied.
40
+ An omitted position retains the compatibility value zero but does not identify a precise failure location.
41
+ An explicitly supplied zero identifies the beginning of the expression, including an empty expression.
42
+
43
+ For unknown locations, unusable encodings, and invalid byte offsets, the formatter returns only the reason.
44
+ Invalid offsets include negative values, values beyond the expression, and positions within a multibyte character.
45
+ Invalid-encoding failures and compiled-expression nesting failures without a precise location use this fallback.
46
+ End-of-input failures place the caret after the last character, or on an empty final line after a trailing LF.
47
+
48
+ The excerpt expands tabs to four-column stops and escapes other C0 controls and DEL.
49
+ The formatted reason uses the same escaping, including visible escapes for embedded line breaks.
50
+ The stored reason and exception message retain their original text.
51
+ LF separates source lines; a carriage return appears as `\r`.
52
+ Caret indentation follows the rendered prefix, including tab expansion and visible escapes.
53
+ Each Unicode code point counts as one column.
54
+ Combining marks, emoji, and wide characters can therefore display differently from the caret's character-based alignment.
55
+ The formatter adds no colors and preserves exception messages.
56
+ The CLI uses it to render syntax failures.
57
+
58
+ Runtime failures use the error classes below:
16
59
 
17
60
  ```ruby
18
61
  JMESPath.search("length(@)", 7) # raises JMESPath::Errors::InvalidTypeError
@@ -25,6 +68,151 @@ Only library argument-type and arity failures are suppressed by `disable_visit_e
25
68
  Syntax, unknown-function, invalid-value, adapter, and callback failures remain visible.
26
69
  Missing fields and out-of-range indices normally return nil.
27
70
 
71
+ ## Function validation data
72
+
73
+ Library-owned `InvalidTypeError` and `InvalidArityError` failures expose immutable metadata.
74
+ Read these fields directly instead of parsing the message.
75
+
76
+ | Field | Meaning |
77
+ | --- | --- |
78
+ | `function_name` | Identifier of the failing function |
79
+ | `signature` | Complete declared signature as arrays of accepted type symbols |
80
+ | `variadic?` | Whether the final declared argument repeats |
81
+ | `source_range` | Exclusive range of zero-based byte offsets for the function call, or nil for direct calls |
82
+
83
+ Use this Ruby example to inspect a type failure:
84
+
85
+ ```ruby
86
+ error = begin
87
+ JMESPath.search("join(',', names)", {names: [1, 2]})
88
+ rescue JMESPath::Errors::InvalidTypeError => failure
89
+ failure
90
+ end
91
+ error.function_name #=> "join"
92
+ error.signature #=> [[:string], [:array_string]]
93
+ error.argument_position #=> 2
94
+ error.expected_types #=> [:array_string]
95
+ error.actual_type #=> :array
96
+ error.validation_stage #=> :argument
97
+ error.source_range #=> (0...16)
98
+ ```
99
+
100
+ Type errors expose `argument_position`, `expected_types`, `actual_type`, and `validation_stage`.
101
+ Argument positions start at one, including trailing variadic arguments.
102
+ Type symbols match function registration: `:array_number` and `:array_string` describe homogeneous arrays.
103
+ An accepted-types array describes a union; empty arrays satisfy both homogeneous-array constraints.
104
+ For homogeneous-array element failures, `expected_types` retains the declared array constraint and `actual_type` describes the outer array.
105
+ Metadata includes no element values or indices.
106
+
107
+ The declared `signature` describes all arguments; `expected_types` describes the specific failing check.
108
+ For example, `contains` accepts any second argument in its signature but requires a string when searching a string.
109
+ `validation_stage` distinguishes an `:argument` failure from an `:expression_result` failure.
110
+ Functions such as `sort_by` require expression results to be numbers or strings of one consistent type.
111
+ After the first valid result, `expected_types` contains only that result's type.
112
+ An error inside a referenced function identifies that inner function and its source range.
113
+
114
+ Arity errors expose `expected_arity` and `actual_arity`.
115
+ `expected_arity` is exact unless `variadic?` is true, when it is the minimum count.
116
+ Use this Ruby example for a direct call without source context:
117
+
118
+ ```ruby
119
+ error = begin
120
+ JMESPath::Functions.default.call("merge", [])
121
+ rescue JMESPath::Errors::InvalidArityError => failure
122
+ failure
123
+ end
124
+ error.function_name #=> "merge"
125
+ error.expected_arity #=> 1
126
+ error.actual_arity #=> 0
127
+ error.variadic? #=> true
128
+ error.source_range #=> nil
129
+ ```
130
+
131
+ Offsets refer to the original query's bytes, including bytes before multibyte characters.
132
+ Metadata contains no argument values or input documents.
133
+ Diagnostics preserve adapter conversion order and counts.
134
+ `actual_type` is `:unknown` when identifying a more specific type would require another adapter conversion.
135
+ Manually constructed errors can have nil metadata; application exceptions retain their identity, backtrace, and original metadata.
136
+ Custom registry overrides of `Functions#call` retain their dispatch contract and may raise errors without source context.
137
+
138
+ ## Unknown function data
139
+
140
+ Library-owned `UnknownFunctionError` failures expose `function_name` and `suggestions`.
141
+ The name identifies the missing function; suggestions contain up to three ranked names from the active registry.
142
+ The name, suggestion strings, and suggestions array are immutable copies.
143
+ Use these fields instead of parsing the error message.
144
+
145
+ Use this Ruby example to inspect a misspelled function:
146
+
147
+ ```ruby
148
+ error = begin
149
+ JMESPath.search("lenght(name)", {name: "Ada"})
150
+ rescue JMESPath::Errors::UnknownFunctionError => failure
151
+ failure
152
+ end
153
+ error.function_name #=> "lenght"
154
+ error.suggestions #=> ["length"]
155
+ error.message #=> "Unknown function: lenght. Did you mean length?"
156
+ ```
157
+
158
+ Messages retain `Unknown function: NAME` when no similar names exist.
159
+ Otherwise, they append `. Did you mean NAME1, NAME2?` using the ranked suggestions.
160
+ The CLI prints this message and exits with status 1.
161
+ `Diagnostic.format` accepts syntax errors; unknown-function diagnostics use `error.message`.
162
+ See [matching rules](functions.md#unknown-function-suggestions) for thresholds and ranking.
163
+
164
+ Lookup remains part of evaluation and follows argument evaluation.
165
+ Unsuppressed argument failures take precedence over a missing outer function, including callback failures when visit errors are disabled.
166
+ Unknown-function failures are not suppressed by `disable_visit_errors`.
167
+ Callback exceptions retain their identity, backtrace, and metadata.
168
+ Manually constructed errors default to a nil `function_name` and empty `suggestions`; positional message construction remains supported.
169
+
28
170
  Invalid JSON streams raise the JSON parser's error class.
29
171
  Invalid options raise ArgumentError.
30
- The CLI translates expected input failures to status 1 and usage failures to status 2.
172
+
173
+ ## CLI diagnostics
174
+
175
+ Both `jmespath` and `jmespath.rb` print expected input and query failures to stderr and exit with status 1.
176
+ Failures before output starts leave stdout empty.
177
+ Successful queries print one JSON result to stdout.
178
+ Usage failures print usage to stderr and exit with status 2 without reading stdin.
179
+ Help and version requests exit with status 0 without reading stdin.
180
+
181
+ To inspect a function type failure, run this command in a shell:
182
+
183
+ ```sh
184
+ printf '%s\n' '4' | jmespath 'length(@)'
185
+ ```
186
+
187
+ The command exits with status 1 and prints this diagnostic to stderr:
188
+
189
+ ```text
190
+ Invalid argument 1 for length: expected string, array, or object; received number at line 1, column 1
191
+ length(@)
192
+ ^
193
+ ```
194
+
195
+ Syntax failures show the reason, coordinates, query line, and caret through `Diagnostic.format`.
196
+ Function type failures report the argument position, expected types, and received type.
197
+ Expression-reference failures identify the rejected expression result and its argument position.
198
+ Arity failures report the expected and received counts, with `at least` marking a variadic minimum.
199
+
200
+ Type and arity carets identify the start of the failing function call, including nested calls.
201
+ They do not identify the exact invalid argument.
202
+ The CLI uses the formatter's character columns, tab expansion, and visible control escapes.
203
+ Wide characters, emoji, and combining marks can display differently from character-based caret alignment.
204
+ Missing or unusable source ranges omit coordinates, the query line, and the caret.
205
+ Incomplete validation metadata retains the escaped exception message and any usable source location.
206
+ Unknown-function diagnostics retain suggestions; invalid-value failures use message-only diagnostics.
207
+
208
+ Malformed stdin JSON prints `Invalid JSON input` without parser messages or document fragments.
209
+ For example, run this command in a shell:
210
+
211
+ ```sh
212
+ printf '%s\n' '{"private_marker":BROKEN}' | jmespath '@'
213
+ ```
214
+
215
+ The command prints `Invalid JSON input` to stderr, leaves stdout empty, and exits with status 1.
216
+ Stream and operating-system failures retain escaped messages.
217
+ Diagnostics add no colors, application backtraces, or input-document values.
218
+ Unrelated application exceptions propagate.
@@ -5,6 +5,11 @@ Expression references, written as `&expression`, defer evaluation until a functi
5
5
 
6
6
  ## Standard functions
7
7
 
8
+ Validation failures expose the full signature, rejected type, argument position, and optional source range.
9
+ See [function validation data](errors.md#function-validation-data) for examples and nullable fields.
10
+ Checks inside built-ins can impose narrower constraints than their declared signatures.
11
+ For example, `sort_by`, `min_by`, and `max_by` require expression-reference results to have one consistent number or string type.
12
+
8
13
  | Function | Accepted arguments | Result |
9
14
  | --- | --- | --- |
10
15
  | `abs` | number | Absolute value |
@@ -63,3 +68,67 @@ Registration returns a new frozen registry and leaves the original intact.
63
68
  Callbacks receive evaluated Ruby values and may retain selected object identity.
64
69
  Return a supported value and avoid mutating shared input.
65
70
  Callback exceptions propagate with their original object and backtrace.
71
+
72
+ ## Registry inspection
73
+
74
+ Use `names` to list functions in the active registry, including custom registrations and overrides.
75
+ Names use ascending, case-sensitive lexical order.
76
+ An empty registry returns an empty array.
77
+
78
+ Use these calls in Ruby to register a description and inspect the function:
79
+
80
+ ```ruby
81
+ functions = JMESPath::Functions.new.with("double", signature: [[:number]], description: "Double a number.") do |value|
82
+ value * 2
83
+ end
84
+ functions.names #=> ["double"]
85
+ metadata = functions.metadata("double")
86
+ metadata.name #=> "double"
87
+ metadata.signature #=> [[:number]]
88
+ metadata.variadic? #=> false
89
+ metadata.description #=> "Double a number."
90
+ functions.metadata("missing") #=> nil
91
+ JMESPath::Functions.default.metadata("length").description #=> "Character or element count."
92
+ ```
93
+
94
+ `metadata(name)` returns a `JMESPath::Functions::Metadata` record, or `nil` if the function is absent.
95
+
96
+ | Field | Meaning |
97
+ | --- | --- |
98
+ | `name` | Function identifier |
99
+ | `signature` | Accepted type symbols for each declared argument |
100
+ | `variadic?` | Whether the final declared argument repeats and remains required |
101
+ | `description` | Optional string, or `nil` when omitted |
102
+
103
+ Signatures preserve unions and typed arrays such as `[:string, :null]` and `[:array_number]`.
104
+ They describe declared arguments; runtime checks can impose further constraints, such as homogeneous results from a `sort_by` expression.
105
+ Return types are not inferred.
106
+
107
+ All built-ins have descriptions.
108
+ Custom descriptions accept strings, including empty strings, or `nil`.
109
+ Other values raise `ArgumentError`.
110
+ An override replaces all metadata, so omitting its description yields `nil`.
111
+
112
+ Inspection does not invoke function implementations.
113
+ The returned arrays, records, strings, and nested signature arrays are frozen.
114
+ Registration copies supplied strings and signature arrays without freezing the caller's objects.
115
+ Later registrations or changes to supplied inputs cannot alter earlier snapshots.
116
+ Shared registries support concurrent inspection; object identity across inspection calls is not guaranteed.
117
+
118
+ ## Unknown function suggestions
119
+
120
+ An unknown function raises `UnknownFunctionError` with up to three similar names from the active registry.
121
+ Candidates include custom functions; overrides appear once.
122
+ Empty registries and unrelated names yield no suggestions.
123
+ Names from other registries are excluded.
124
+
125
+ Matching is case-sensitive and uses optimal string alignment distance.
126
+ Insertion, deletion, replacement, and an adjacent letter swap each cost one edit.
127
+ The same substring cannot be edited twice, which distinguishes this distance from unrestricted transposition matching.
128
+ The maximum distance is one when the longer name has at most four characters, and two otherwise.
129
+ Candidates are ranked by ascending distance, then ascending lexical order.
130
+
131
+ Suggestions inspect registered names without executing candidate callbacks.
132
+ Function lookup happens during evaluation, after the arguments have been evaluated.
133
+ Compilation does not reject an unavailable function, and suggestions never correct or execute a query automatically.
134
+ See [unknown function data](errors.md#unknown-function-data) for structured fields and messages.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ module CLI
5
+ # @api private
6
+ module FailureDiagnostic
7
+ class << self
8
+ def format(error, expression)
9
+ return JMESPath::Diagnostic.format(error) if error.is_a?(Errors::SyntaxError)
10
+
11
+ position = source_position(error, expression)
12
+ diagnostic = if position
13
+ Errors::SyntaxError.new(message(error), expression: expression || "", position: position)
14
+ else
15
+ Errors::SyntaxError.new(message(error))
16
+ end
17
+ JMESPath::Diagnostic.format(diagnostic)
18
+ end
19
+
20
+ private
21
+
22
+ def message(error)
23
+ return "Invalid JSON input" if error.is_a?(JSON::ParserError)
24
+
25
+ details = case error
26
+ when Errors::InvalidTypeError then type_message(error)
27
+ when Errors::InvalidArityError then arity_message(error)
28
+ end
29
+ details || error.message
30
+ end
31
+
32
+ def type_message(error)
33
+ subject = type_subject(error)
34
+ types = error.expected_types
35
+ actual = error.actual_type
36
+ return unless subject && types && actual
37
+ return if types.empty?
38
+
39
+ "#{subject}: expected #{expected_types(types)}; received #{type_name(actual)}"
40
+ end
41
+
42
+ def type_subject(error)
43
+ name = error.function_name
44
+ position = error.argument_position
45
+ return unless name && position&.positive?
46
+
47
+ case error.validation_stage
48
+ when :argument then "Invalid argument #{position} for #{name}"
49
+ when :expression_result then "Invalid expression result for argument #{position} of #{name}"
50
+ end
51
+ end
52
+
53
+ def arity_message(error)
54
+ name = error.function_name
55
+ expected = error.expected_arity
56
+ actual = error.actual_arity
57
+ return unless name && expected && actual
58
+ return if expected.negative? || actual.negative?
59
+
60
+ count = arity_count(expected, error.variadic?)
61
+ return unless count
62
+
63
+ "Invalid number of arguments for #{name}: expected #{count}; received #{actual}"
64
+ end
65
+
66
+ def arity_count(expected, variadic)
67
+ return if variadic.nil?
68
+
69
+ variadic ? "at least #{expected}" : expected.to_s
70
+ end
71
+
72
+ def expected_types(types)
73
+ names = types.map { |type| type_name(type) }
74
+ return names.join(" or ") if names.length < 3
75
+
76
+ "#{names.take(names.length - 1).join(', ')}, or #{names.last}"
77
+ end
78
+
79
+ def type_name(type)
80
+ case type
81
+ when :array_string then "array of strings"
82
+ when :array_number then "array of numbers"
83
+ else type.to_s
84
+ end
85
+ end
86
+
87
+ def source_position(error, expression)
88
+ return unless expression&.valid_encoding?
89
+ return unless error.is_a?(Errors::InvalidTypeError) || error.is_a?(Errors::InvalidArityError)
90
+
91
+ range = error.source_range
92
+ range.begin if range && valid_range?(range, expression)
93
+ end
94
+
95
+ def valid_range?(range, expression)
96
+ start = range.begin
97
+ finish = range.end
98
+ return false unless start && finish && range.exclude_end?
99
+ return false unless valid_bounds?(start, finish, expression)
100
+
101
+ expression.byteslice(0, start).to_s.valid_encoding? && expression.byteslice(0, finish).to_s.valid_encoding?
102
+ end
103
+
104
+ def valid_bounds?(start, finish, expression)
105
+ start >= 0 && finish > start && finish <= expression.bytesize
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
data/lib/jmespath/cli.rb CHANGED
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../jmespath"
4
+ require_relative "cli/failure_diagnostic"
4
5
 
5
6
  module JMESPath
6
7
  # Runs one JMESPath query against a JSON document from standard input.
7
8
  module CLI
8
9
  USAGE = "Usage: jmespath EXPRESSION < document.json"
10
+ HELP = "#{USAGE}\n" \
11
+ "Function arguments and expression-reference results are validated; failures exit with status 1.\n" \
12
+ "Unknown functions suggest similar registered names.\n" \
13
+ "Query failures include source locations when available.".freeze
9
14
 
10
15
  # @param argv [Array<String>] command-line arguments
11
16
  # @param stdin [IO, StringIO] source JSON stream
@@ -23,13 +28,13 @@ module JMESPath
23
28
  stdout.puts(JSON.generate(JMESPath.search(argv.fetch(0), data)))
24
29
  0
25
30
  rescue JSON::ParserError, Errors::Error, IOError, SystemCallError => e
26
- stderr.puts(e.message)
31
+ stderr.puts(FailureDiagnostic.format(e, argv.first))
27
32
  1
28
33
  end
29
34
 
30
35
  # @api private
31
36
  def self.information(argv, output)
32
- output.puts(argv == ["--version"] ? "jmespath-ruby #{VERSION}" : USAGE)
37
+ output.puts(argv == ["--version"] ? "jmespath-ruby #{VERSION}" : HELP)
33
38
  0
34
39
  end
35
40
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ # Plain-text presentation of query syntax failures.
5
+ module Diagnostic
6
+ class << self
7
+ # Format a syntax failure without reading data or printing output.
8
+ # @param error [Errors::SyntaxError] the syntax failure
9
+ # @return [String] UTF-8 diagnostic without a trailing newline
10
+ # @example Format a located failure
11
+ # error = JMESPath::Errors::SyntaxError.new("Bad query", expression: "$", position: 0)
12
+ # JMESPath::Diagnostic.format(error) #=> "Bad query at line 1, column 1\n$\n^"
13
+ def format(error)
14
+ reason = render(error.reason.encode(Encoding::UTF_8, invalid: :replace, undef: :replace))
15
+ parts = source_parts(error)
16
+ return reason unless parts
17
+
18
+ located(reason, parts[0], parts[1])
19
+ end
20
+
21
+ private
22
+
23
+ def located(reason, source, prefix)
24
+ line = prefix.count("\n") + 1
25
+ line_prefix = prefix.rpartition("\n").last
26
+ excerpt = source.split("\n", -1).fetch(line - 1, "")
27
+ caret = "#{' ' * render(line_prefix).length}^"
28
+ "#{reason} at line #{line}, column #{line_prefix.length + 1}\n#{render(excerpt)}\n#{caret}"
29
+ end
30
+
31
+ def source_parts(error)
32
+ source = error.expression
33
+ return unless error.location_known? && error.position.between?(0, source.bytesize)
34
+
35
+ prefix = source.byteslice(0, error.position).to_s
36
+ return unless source.valid_encoding? && prefix.valid_encoding?
37
+
38
+ [source.encode(Encoding::UTF_8), prefix.encode(Encoding::UTF_8)]
39
+ rescue EncodingError
40
+ nil
41
+ end
42
+
43
+ def render(line)
44
+ column = 0
45
+ line.each_char.with_object(+"") do |character, result|
46
+ text = render_character(character, column)
47
+ result << text
48
+ column += text.length
49
+ end
50
+ end
51
+
52
+ def render_character(character, column)
53
+ return " " * (4 - (column % 4)) if character == "\t"
54
+ return character.dump[1...-1].to_s if character.ord < 32 || character.ord == 127
55
+
56
+ character
57
+ end
58
+ end
59
+ end
60
+ end