rjq 0.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.
data/lib/rjq/cli.rb ADDED
@@ -0,0 +1,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rjq'
4
+
5
+ module Rjq
6
+ class CLI
7
+ HELP = <<~HELP.freeze
8
+ rjq - commandline JSON processor [version #{VERSION}]
9
+
10
+ Usage: rjq [options] <jq filter> [file...]
11
+ rjq [options] --args <jq filter> [strings...]
12
+ rjq [options] --jsonargs <jq filter> [JSON_TEXTS...]
13
+
14
+ rjq is a tool for processing JSON inputs, applying the given filter to
15
+ its JSON text inputs and producing the filter's results as JSON on
16
+ standard output.
17
+
18
+ Command options:
19
+ -n, --null-input use `null` as the single input value;
20
+ -R, --raw-input read each line as string instead of JSON;
21
+ -s, --slurp read all inputs into an array and use it as
22
+ the single input value;
23
+ -c, --compact-output compact instead of pretty-printed output;
24
+ -r, --raw-output output strings without escapes and quotes;
25
+ --raw-output0 implies -r and output NUL after each output;
26
+ -j, --join-output implies -r and output without newline after
27
+ each output;
28
+ -a, --ascii-output output strings by only ASCII characters
29
+ using escape sequences;
30
+ -S, --sort-keys sort keys of each object on output;
31
+ -C, --color-output colorize JSON output;
32
+ -M, --monochrome-output disable colored output;
33
+ --tab use tabs for indentation;
34
+ --indent n use n spaces for indentation (max 7 spaces);
35
+ --unbuffered flush output stream after each output;
36
+ --stream parse the input value in streaming fashion;
37
+ --stream-errors implies --stream and report parse error as
38
+ an array;
39
+ --seq parse input/output as application/json-seq;
40
+ --max-filter-depth n reject filters nested deeper than n;
41
+ --max-call-depth n bound non-tail user-function calls;
42
+ --max-instructions n bound executed bytecode instructions;
43
+ --max-replay-cache n bound values cached for filter replay;
44
+ -f, --from-file file load filter from the file;
45
+ -L directory search modules from the directory;
46
+ --arg name value set $name to the string value;
47
+ --argjson name value set $name to the JSON value;
48
+ --slurpfile name file set $name to an array of JSON values read
49
+ from the file;
50
+ --rawfile name file set $name to string contents of file;
51
+ --args consume remaining arguments as positional
52
+ string values;
53
+ --jsonargs consume remaining arguments as positional
54
+ JSON values;
55
+ -e, --exit-status set exit status code based on the output;
56
+ -V, --version show the version;
57
+ --build-configuration show rjq's build configuration;
58
+ -h, --help show the help;
59
+ -- terminates argument processing;
60
+
61
+ Named arguments are also available as $ARGS.named[], while
62
+ positional arguments are available as $ARGS.positional[].
63
+ HELP
64
+ OPTION_HELP = <<~HELP
65
+ Use rjq --help for help with command-line options,
66
+ or see the jq manpage, or online docs at https://jqlang.github.io/jq
67
+ HELP
68
+
69
+ class OptionError < StandardError; end
70
+ class EarlyExit < StandardError
71
+ attr_reader :status
72
+
73
+ def initialize(status)
74
+ @status = status
75
+ super()
76
+ end
77
+ end
78
+
79
+ def initialize(argv, stdin:, stdout:, stderr:)
80
+ @argv = argv.dup
81
+ @stdin = stdin
82
+ @stdout = stdout
83
+ @stderr = stderr
84
+ @opts = Runtime::DEFAULT_OPTIONS.merge(variables: { 'ARGS.named' => {}, 'ARGS.positional' => [] })
85
+ @filter = nil
86
+ @files = []
87
+ end
88
+
89
+ def run
90
+ parse!
91
+ return run_tests if @opts[:run_tests]
92
+
93
+ @opts[:stderr] = @stderr
94
+ @opts[:color] = @stdout.tty? && !ENV.key?('NO_COLOR') if @opts[:color].nil?
95
+ runtime_failed = false
96
+ @opts[:runtime_error_handler] = lambda do |error, _record|
97
+ runtime_failed = true
98
+ @stderr.puts("rjq: runtime error: #{error.message}")
99
+ end
100
+ last = nil
101
+ count = 0
102
+ runtime = Runtime.new(@filter || '.', @opts)
103
+ runtime.run_io_streams(input_streams).each do |value|
104
+ last = value
105
+ count += 1
106
+ write_value(runtime, value)
107
+ end
108
+ return 5 if runtime_failed
109
+ return exit_status(last, count) if @opts[:exit_status]
110
+
111
+ 0
112
+ rescue HaltError => e
113
+ @stderr.puts(e.message) if e.value
114
+ e.status
115
+ rescue EarlyExit => e
116
+ e.status
117
+ rescue OptionError => e
118
+ @stderr.puts(e.message)
119
+ @stderr.print(OPTION_HELP)
120
+ 2
121
+ rescue JSONParseError => e
122
+ @stderr.puts("rjq: JSON parse error: #{e.message}")
123
+ 5
124
+ rescue ParseError, CompileError => e
125
+ @stderr.puts("rjq: compile error: #{e.message}")
126
+ 3
127
+ rescue Rjq::RuntimeError => e
128
+ @stderr.puts("rjq: runtime error: #{e.message}")
129
+ 5
130
+ rescue SystemStackError
131
+ @stderr.puts('rjq: runtime error: recursion limit exceeded')
132
+ 5
133
+ rescue Errno::EPIPE
134
+ 0
135
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
136
+ @stderr.puts("rjq: #{e.message}")
137
+ 2
138
+ end
139
+
140
+ private
141
+
142
+ def parse!
143
+ until @argv.empty?
144
+ arg = @argv.shift
145
+ case arg
146
+ when '--'
147
+ if @filter.nil? && !@opts[:filter_file] && !@argv.empty?
148
+ @filter = @argv.shift
149
+ end
150
+ @files.concat(@argv)
151
+ @argv.clear
152
+ when /\A--/
153
+ parse_long(arg)
154
+ when /\A-(?:\d|\.)/
155
+ if @filter.nil? && !@opts[:filter_file]
156
+ @filter = arg
157
+ else
158
+ @files << arg
159
+ end
160
+ when /\A-[^-]/
161
+ parse_short(arg)
162
+ else
163
+ if @filter.nil? && !@opts[:filter_file]
164
+ @filter = arg
165
+ else
166
+ @files << arg
167
+ end
168
+ end
169
+ end
170
+
171
+ return unless @opts[:filter_file]
172
+
173
+ @files.unshift(@filter) if @filter
174
+ filter_file = @opts.delete(:filter_file)
175
+ @filter = File.read(filter_file)
176
+ @opts[:source_path] = File.realpath(filter_file)
177
+ end
178
+
179
+ def parse_long(arg)
180
+ case arg
181
+ when '--compact-output' then @opts[:compact] = true
182
+ when '--raw-output' then @opts[:raw_output] = true
183
+ when '--raw-output0' then @opts[:raw_output] = @opts[:raw_output0] = true
184
+ when '--join-output' then @opts[:raw_output] = @opts[:join_output] = true
185
+ when '--null-input' then @opts[:null_input] = true
186
+ when '--raw-input' then @opts[:raw_input] = true
187
+ when '--slurp' then @opts[:slurp] = true
188
+ when '--ascii-output' then @opts[:ascii] = true
189
+ when '--sort-keys' then @opts[:sort_keys] = true
190
+ when '--tab' then @opts[:tab] = true
191
+ when '--seq' then @opts[:seq] = true
192
+ when '--stream' then @opts[:stream] = true
193
+ when '--stream-errors' then @opts[:stream] = @opts[:stream_errors] = true
194
+ when '--exit-status' then @opts[:exit_status] = true
195
+ when '--unbuffered' then @opts[:unbuffered] = true
196
+ when '--color-output' then @opts[:color] = true
197
+ when '--monochrome-output' then @opts[:color] = false
198
+ when '--allow-comments' then @opts[:allow_comments] = true
199
+ when '--max-filter-depth'
200
+ @opts[:max_filter_depth] = validate_limit(next_arg('--max-filter-depth'), '--max-filter-depth', minimum: 1)
201
+ when '--max-call-depth'
202
+ @opts[:max_call_depth] = validate_limit(next_arg('--max-call-depth'), '--max-call-depth', minimum: 1)
203
+ when '--max-instructions'
204
+ @opts[:max_instructions] = validate_limit(next_arg('--max-instructions'), '--max-instructions', minimum: 0)
205
+ when '--max-replay-cache'
206
+ @opts[:max_replay_cache] = validate_limit(next_arg('--max-replay-cache'), '--max-replay-cache', minimum: 0)
207
+ when '--indent'
208
+ @opts[:indent] = validate_indent(next_arg('--indent', '--indent takes one parameter'))
209
+ @opts[:tab] = false
210
+ when '--arg' then bind_string(*next_args(2, '--arg takes two parameters (e.g. --arg varname value)'))
211
+ when '--argjson' then bind_json(*next_args(2, '--argjson takes two parameters (e.g. --argjson varname text)'))
212
+ when '--slurpfile' then bind_json_array(*next_args(2,
213
+ '--slurpfile takes two parameters (e.g. --slurpfile varname filename)'))
214
+ when '--rawfile' then bind_raw_file(*next_args(2,
215
+ '--rawfile takes two parameters (e.g. --rawfile varname filename)'))
216
+ when '--args' then consume_positional(json: false)
217
+ when '--jsonargs' then consume_positional(json: true)
218
+ when '--from-file' then @opts[:filter_file] = next_arg('--from-file')
219
+ when '--run-tests' then parse_run_tests
220
+ when '--build-configuration' then print_build_configuration_and_exit
221
+ when '--version' then print_version_and_exit
222
+ when '--help' then print_help_and_exit
223
+ else
224
+ raise OptionError, "rjq: Unknown option #{arg}"
225
+ end
226
+ end
227
+
228
+ def parse_short(arg)
229
+ chars = arg.delete_prefix('-').chars
230
+ until chars.empty?
231
+ char = chars.shift
232
+ case char
233
+ when 'c' then @opts[:compact] = true
234
+ when 'r' then @opts[:raw_output] = true
235
+ when 'j' then @opts[:raw_output] = @opts[:join_output] = true
236
+ when 'n' then @opts[:null_input] = true
237
+ when 'R' then @opts[:raw_input] = true
238
+ when 's' then @opts[:slurp] = true
239
+ when 'a' then @opts[:ascii] = true
240
+ when 'S' then @opts[:sort_keys] = true
241
+ when 'e' then @opts[:exit_status] = true
242
+ when 'f'
243
+ @opts[:filter_file] = chars.empty? ? next_arg('-f') : chars.join
244
+ chars.clear
245
+ when 'L'
246
+ (@opts[:library_path] ||= []) << (chars.empty? ? next_arg('-L') : chars.join)
247
+ chars.clear
248
+ when 'V' then print_version_and_exit
249
+ when 'h' then print_help_and_exit
250
+ when 'C', 'M'
251
+ @opts[:color] = char == 'C'
252
+ else
253
+ raise OptionError, "rjq: Unknown option -#{char}"
254
+ end
255
+ end
256
+ end
257
+
258
+ def parse_run_tests
259
+ @opts[:run_tests] = true
260
+ @opts[:run_tests_file] = @argv.shift unless @argv.empty?
261
+ @argv.clear
262
+ end
263
+
264
+ def bind_string(name, value)
265
+ @opts[:variables][name] = value
266
+ @opts[:variables]['ARGS.named'][name] = value
267
+ end
268
+
269
+ def bind_json(name, value)
270
+ parsed = JSON::Parser.parse_one(value)
271
+ @opts[:variables][name] = parsed
272
+ @opts[:variables]['ARGS.named'][name] = parsed
273
+ rescue JSONParseError => e
274
+ raise OptionError, "rjq: invalid JSON text passed to --argjson: #{e.message}"
275
+ end
276
+
277
+ def bind_json_array(name, path)
278
+ parsed = JSON::Parser.parse(File.read(path)).to_a
279
+ @opts[:variables][name] = parsed
280
+ @opts[:variables]['ARGS.named'][name] = parsed
281
+ end
282
+
283
+ def bind_raw_file(name, path)
284
+ bind_string(name, File.read(path))
285
+ end
286
+
287
+ def consume_positional(json:)
288
+ @filter ||= next_arg(json ? '--jsonargs filter' : '--args filter')
289
+ values = @argv.map { |arg| json ? JSON::Parser.parse_one(arg) : arg }
290
+ @opts[:variables]['ARGS.positional'] = values
291
+ @argv.clear
292
+ rescue JSONParseError => e
293
+ raise OptionError, "rjq: invalid JSON text passed to --jsonargs: #{e.message}"
294
+ end
295
+
296
+ def next_arg(option, message = "#{option} takes one parameter")
297
+ raise OptionError, "rjq: #{message}" if @argv.empty?
298
+
299
+ @argv.shift
300
+ end
301
+
302
+ def next_args(count, message)
303
+ raise OptionError, "rjq: #{message}" if @argv.length < count
304
+
305
+ @argv.shift(count)
306
+ end
307
+
308
+ def validate_indent(value)
309
+ indent = Integer(value)
310
+ raise OptionError, 'rjq: --indent must be between 0 and 7' unless indent.between?(0, 7)
311
+
312
+ indent
313
+ rescue ArgumentError
314
+ raise OptionError, 'rjq: --indent must be an integer'
315
+ end
316
+
317
+ def validate_limit(value, option, minimum:)
318
+ limit = Integer(value, 10)
319
+ raise OptionError, "rjq: #{option} must be at least #{minimum}" if limit < minimum
320
+
321
+ limit
322
+ rescue ArgumentError
323
+ raise OptionError, "rjq: #{option} must be an integer"
324
+ end
325
+
326
+ def input_streams
327
+ Enumerator.new do |yielder|
328
+ if @files.empty?
329
+ yielder << [@stdin, nil, false]
330
+ next
331
+ end
332
+
333
+ @files.each do |file|
334
+ if file == '-'
335
+ yielder << [@stdin, nil, false]
336
+ else
337
+ yielder << [File.open(file, 'rb'), file, true]
338
+ end
339
+ end
340
+ end
341
+ end
342
+
343
+ def write_value(runtime, value)
344
+ if @opts[:raw_output0] && value.is_a?(String) && value.include?("\0")
345
+ raise Rjq::RuntimeError, 'Cannot dump a string containing NUL with --raw-output0 option'
346
+ end
347
+
348
+ @stdout.print("\x1e") if @opts[:seq]
349
+ runtime.write_output(value, @stdout)
350
+ @stdout.print(output_separator)
351
+ @stdout.flush if @opts[:unbuffered] && @stdout.respond_to?(:flush)
352
+ end
353
+
354
+ def output_separator
355
+ return "\0" if @opts[:raw_output0]
356
+ return '' if @opts[:join_output]
357
+
358
+ "\n"
359
+ end
360
+
361
+ def exit_status(last, count)
362
+ return 4 if count.zero?
363
+ return 1 if last.nil? || last == false
364
+
365
+ 0
366
+ end
367
+
368
+ def run_tests
369
+ source = @opts[:run_tests_file] ? File.read(@opts[:run_tests_file]) : @stdin.read
370
+ results = run_test_groups(test_groups(source))
371
+ @stdout.puts("#{results.fetch(:passed)} of #{results.fetch(:checked)} tests passed " \
372
+ "(#{results.fetch(:malformed)} malformed, #{results.fetch(:skipped)} skipped)")
373
+ results.fetch(:failed).zero? && results.fetch(:malformed).zero? ? 0 : 1
374
+ rescue Errno::ENOENT, Errno::EACCES => e
375
+ @stderr.puts(e.message.include?('No such file') ? 'fopen: No such file or directory' : e.message)
376
+ 1
377
+ end
378
+
379
+ def test_groups(source)
380
+ groups = []
381
+ current = []
382
+ start_line = nil
383
+ fail_mode = false
384
+ source.each_line(chomp: true).with_index(1) do |line, line_number|
385
+ if line.empty?
386
+ groups << [fail_mode, current, start_line] unless current.empty?
387
+ current = []
388
+ start_line = nil
389
+ fail_mode = false
390
+ next
391
+ end
392
+ next if line.start_with?('#')
393
+
394
+ if line.start_with?('%%FAIL')
395
+ fail_mode = true
396
+ next
397
+ end
398
+ start_line ||= line_number
399
+ current << line
400
+ end
401
+ groups << [fail_mode, current, start_line] unless current.empty?
402
+ groups
403
+ end
404
+
405
+ def run_test_groups(groups)
406
+ groups.each_with_object({ checked: 0, passed: 0, failed: 0, malformed: 0,
407
+ skipped: 0 }) do |(fail_mode, lines, line_number), results|
408
+ results[:checked] += 1
409
+ unless lines.empty?
410
+ @stdout.puts("Test ##{results.fetch(:checked)}: '#{lines.first}' at line number #{line_number}")
411
+ end
412
+ if lines.length < (fail_mode ? 1 : 3)
413
+ results[:malformed] += 1
414
+ elsif test_group_passed?(fail_mode, lines)
415
+ results[:passed] += 1
416
+ else
417
+ results[:failed] += 1
418
+ end
419
+ end
420
+ end
421
+
422
+ def test_group_passed?(fail_mode, lines)
423
+ program, input, *expected = lines
424
+ if fail_mode
425
+ Rjq.compile(program).run(nil).to_a
426
+ return false
427
+ end
428
+
429
+ actual = JSON::Parser.parse(input).to_a.flat_map { |value| Rjq.run(program, value).to_a }
430
+ expected_values = expected.map { |line| JSON::Parser.parse_one(line) }
431
+ expected_values.length == actual.length && expected_values.zip(actual).all? do |left, right|
432
+ Value.equal?(left, right)
433
+ end
434
+ rescue Rjq::Error
435
+ fail_mode
436
+ rescue StandardError
437
+ false
438
+ end
439
+
440
+ def print_version_and_exit
441
+ @stdout.puts("rjq-#{VERSION}")
442
+ raise EarlyExit, 0
443
+ end
444
+
445
+ def print_help_and_exit
446
+ @stdout.print(HELP)
447
+ raise EarlyExit, 0
448
+ end
449
+
450
+ def print_build_configuration_and_exit
451
+ @stdout.puts("rjq=#{VERSION}")
452
+ @stdout.puts("ruby=#{RUBY_VERSION}p#{RUBY_PATCHLEVEL} (#{RUBY_PLATFORM})")
453
+ @stdout.puts('regexp-engine=ruby')
454
+ @stdout.puts('native-math=fiddle-libm')
455
+ @stdout.puts('json-parser=incremental')
456
+ raise EarlyExit, 0
457
+ end
458
+ end
459
+ end
data/lib/rjq/color.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rjq
4
+ module Color
5
+ DEFAULT = ['1;30', '0;39', '0;39', '0;39', '0;32', '1;39', '1;39', '1;34'].freeze
6
+
7
+ module_function
8
+
9
+ def colorize(json)
10
+ colors = (ENV['RJQ_COLORS'] || ENV.fetch('JQ_COLORS', nil)).to_s.split(':')
11
+ colors = DEFAULT unless valid_colors?(colors)
12
+ json.gsub(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:null|true|false)\b|[\[\]{}]/) do |token|
13
+ remainder = json[Regexp.last_match.end(0)..].to_s
14
+ object_key = token.start_with?('"') && remainder.match?(/\A\s*:/)
15
+ color = color_for(token, colors, object_key: object_key)
16
+ color ? "\e[#{color}m#{token}\e[0m" : token
17
+ end
18
+ end
19
+
20
+ def color_for(token, colors, object_key: false)
21
+ case token
22
+ when 'null' then colors[0]
23
+ when 'false' then colors[1]
24
+ when 'true' then colors[2]
25
+ when /\A-?\d/ then colors[3]
26
+ when /\A"/ then object_key ? colors[7] : colors[4]
27
+ when '[', ']' then colors[5]
28
+ when '{', '}' then colors[6]
29
+ end
30
+ end
31
+
32
+ def valid_colors?(colors)
33
+ colors.length >= 8 && colors.first(8).all? { |color| color.match?(/\A\d{1,3}(?:;\d{1,3})*\z/) }
34
+ end
35
+ end
36
+ end