jmespath-ruby 0.1.3 → 1.0.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.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ module CLI
5
+ # @api private
6
+ class InteractiveCompletion
7
+ def initialize(functions: Functions.default)
8
+ @names = functions.names.sort
9
+ end
10
+
11
+ def complete(line, point:)
12
+ # @type var unchanged: completion_result
13
+ unchanged = { line: line, point: point, candidates: [] }
14
+ token = token(line, point)
15
+ return unchanged unless token
16
+
17
+ prefix, start, finish = token
18
+ names = @names.select { |name| name.start_with?(prefix) }
19
+ unchanged[:candidates] = names
20
+ return unchanged if names.empty?
21
+
22
+ common = common_prefix(names)
23
+ return unchanged if names.length > 1 && common == prefix
24
+
25
+ { line: replace(line, start, finish, common),
26
+ point: start + common.bytesize, candidates: names }
27
+ end
28
+
29
+ private
30
+
31
+ def replace(line, start, finish, common)
32
+ line.byteslice(0, start).to_s + common + line.byteslice(finish..).to_s
33
+ end
34
+
35
+ def token(line, point)
36
+ return unless valid_cursor?(line, point)
37
+
38
+ before = line.byteslice(0, point).to_s
39
+ return if quoted?(before)
40
+
41
+ prefix = identifier_prefix(before)
42
+ return unless prefix&.match?(/\A[A-Za-z_]/)
43
+
44
+ suffix = line.byteslice(point..).to_s[/\A[A-Za-z0-9_]*/].to_s
45
+ [prefix, point - prefix.bytesize, point + suffix.bytesize]
46
+ end
47
+
48
+ def identifier_prefix(before)
49
+ before.reverse[/\A[A-Za-z0-9_]+/]&.reverse
50
+ end
51
+
52
+ def valid_cursor?(line, point)
53
+ source = line.dup.force_encoding(Encoding::UTF_8)
54
+ return false unless source.valid_encoding? && point.between?(0, source.bytesize)
55
+ return false unless source.byteslice(0, point).to_s.valid_encoding?
56
+ return false if source.include?("\n")
57
+
58
+ query?(source, point)
59
+ end
60
+
61
+ def query?(line, point)
62
+ return true unless line.match?(/\A[ \t]*:/)
63
+
64
+ command = /\A[ \t]*:(?:explain|trace)[ \t]+/.match(line)
65
+ return false unless command
66
+
67
+ point >= command[0].bytesize
68
+ end
69
+
70
+ def quoted?(before)
71
+ index = 0
72
+ quote = nil
73
+ while index < before.bytesize
74
+ byte = before.getbyte(index)
75
+ if quote
76
+ index += 1 if byte == 92
77
+ quote = nil if byte == quote
78
+ elsif [34, 39, 96].include?(byte)
79
+ quote = byte
80
+ end
81
+ index += 1
82
+ end
83
+ !quote.nil?
84
+ end
85
+
86
+ def common_prefix(names)
87
+ common = names.fetch(0)
88
+ common = common[0...-1] until names.fetch(-1).start_with?(common)
89
+ common
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "interactive_editor_state"
4
+ require_relative "interactive_completion"
5
+
6
+ module JMESPath
7
+ module CLI
8
+ # @api private
9
+ class InteractiveEditor
10
+ HISTORY_LIMIT = 500
11
+
12
+ def self.supported?(input:, errors:)
13
+ return false unless terminal_environment?
14
+
15
+ return false unless [input, errors].all? { |stream| stream.is_a?(IO) && !stream.closed? && stream.tty? }
16
+
17
+ require "io/console"
18
+ backend_supported?
19
+ end
20
+
21
+ def self.backend_supported?
22
+ require "reline"
23
+ backend = Reline::IOGate
24
+ !backend.dumb? && backend.respond_to?(:input=) && backend.respond_to?(:output=)
25
+ end
26
+
27
+ def self.terminal_environment?
28
+ ![nil, "", "dumb"].include?(ENV.fetch("TERM", nil)) && !ENV.key?("RELINE_STDERR_TTY")
29
+ end
30
+
31
+ def initialize(input:, errors:)
32
+ require "reline"
33
+ require_relative "interactive_key_stroke"
34
+ require_relative "interactive_line_editor"
35
+ @input = input
36
+ @errors = errors
37
+ @history = []
38
+ @pending = []
39
+ @completion = InteractiveCompletion.new
40
+ @writing_errors = false
41
+ end
42
+
43
+ def open
44
+ state = InteractiveEditorState.new
45
+ begin
46
+ state.configure(@input, @errors)
47
+ Reline.completion_proc = method(:complete)
48
+ yield
49
+ ensure
50
+ state.restore
51
+ end
52
+ end
53
+
54
+ def gets
55
+ return @pending.shift unless @pending.empty?
56
+
57
+ read_line
58
+ rescue Interrupt
59
+ @errors.puts("^C")
60
+ retry
61
+ end
62
+
63
+ def remember(query)
64
+ @history << query
65
+ @history.shift if @history.length > HISTORY_LIMIT
66
+ Reline::HISTORY.replace(@history)
67
+ end
68
+
69
+ def writing_errors?
70
+ @writing_errors
71
+ end
72
+
73
+ private
74
+
75
+ def read_line
76
+ @writing_errors = true
77
+ line = Reline.readline("jmespath> ", false)
78
+ @writing_errors = false
79
+ return unless line
80
+
81
+ lines = Reline.core.line_editor.whole_lines
82
+ return line if lines.length == 1
83
+
84
+ @pending = lines
85
+ @pending.shift
86
+ rescue InteractiveKeyStroke::InvalidInput => e
87
+ reject_invalid(e)
88
+ end
89
+
90
+ def reject_invalid(error)
91
+ terminator = error.ended? ? error.byte : discard_line
92
+ @errors.puts("")
93
+ @writing_errors = false
94
+ return if terminator.nil? || terminator == 4
95
+
96
+ "\xFF".b
97
+ end
98
+
99
+ def discard_line
100
+ # @type var input: Object & _RawInput
101
+ input = @input
102
+ input.raw(intr: true) do
103
+ loop do
104
+ byte = Reline::IOGate.getc(Float::INFINITY)
105
+ break byte if byte.nil? || [4, 10, 13].include?(byte)
106
+ end
107
+ end
108
+ end
109
+
110
+ def complete(_target)
111
+ line = Reline.line_buffer
112
+ result = @completion.complete(line, point: Reline.point)
113
+ if result[:line] != line
114
+ Reline.delete_text
115
+ Reline.insert_text(result[:line])
116
+ Reline.point = result[:point]
117
+ elsif result[:candidates].length > 1
118
+ list_candidates(result[:candidates])
119
+ end
120
+ nil
121
+ end
122
+
123
+ def list_candidates(candidates)
124
+ Reline.core.line_editor.render_finished
125
+ @errors.puts(candidates.join(" "))
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ module CLI
5
+ # @api private
6
+ class InteractiveEditorState
7
+ PROPERTIES = %i[completion_proc output_modifier_proc prompt_proc auto_indent_proc pre_input_hook
8
+ dig_perfect_match_proc last_incremental_search].freeze
9
+
10
+ def initialize
11
+ @core = Reline.core
12
+ # Reline 0.7 exposes only input=; retain the previous reader for restoration.
13
+ @input = Reline::IOGate.instance_variable_get(:@input)
14
+ @output = @core.output
15
+ @configuration = [@core.config, @core.key_stroke, @core.line_editor]
16
+ @callbacks = PROPERTIES.to_h { |key| [key, Reline.public_send(key)] }
17
+ @history = Reline::HISTORY.to_a
18
+ @history_size = @core.config.history_size
19
+ end
20
+
21
+ def configure(input, errors)
22
+ Reline.input = input
23
+ Reline.output = errors
24
+ @core.config.history_size = InteractiveEditor::HISTORY_LIMIT
25
+ configure_core
26
+ PROPERTIES.each { |key| Reline.public_send(:"#{key}=", nil) }
27
+ Reline::HISTORY.clear
28
+ end
29
+
30
+ def configure_core
31
+ config = Reline::Config.new
32
+ config.read
33
+ config.autocompletion = false
34
+ config.disable_completion = false
35
+ config.history_size = InteractiveEditor::HISTORY_LIMIT
36
+ Reline::IOGate.set_default_key_bindings(config)
37
+ @core.config = config
38
+ @core.key_stroke = InteractiveKeyStroke.new(config, Reline::IOGate.encoding)
39
+ @core.line_editor = InteractiveLineEditor.new(config)
40
+ end
41
+
42
+ def restore
43
+ Reline.input = @input
44
+ Reline.output = @output
45
+ @core.config, @core.key_stroke, @core.line_editor = @configuration
46
+ @core.config.history_size = @history_size
47
+ @callbacks.each { |key, value| Reline.public_send(:"#{key}=", value) }
48
+ Reline::HISTORY.replace(@history)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ module CLI
5
+ # @api private
6
+ module InteractiveHelp
7
+ TEXT = <<~TEXT
8
+ Interactive: jmespath --interactive --file PATH
9
+ jmespath --interactive --file document.json
10
+ Enter one UTF-8 query per line; empty lines and lines containing only spaces or tabs are ignored.
11
+ Queries accept LF, CRLF, and a final line without a newline.
12
+ :help shows session help; :quit or EOF exits with status 0, even after query failures.
13
+ :functions lists built-in names, signatures, and descriptions on stderr.
14
+ :explain QUERY shows the query's operation tree and source locations on stderr without evaluating data.
15
+ :trace QUERY evaluates once, writes events to stderr, and writes one compact JSON result to stdout.
16
+ Traces retain at most 1000 events and report truncation after evaluation completes.
17
+ Query errors, unknown commands, and invalid UTF-8 lines report diagnostics; the session continues.
18
+ Failed queries print no result. Successful queries print compact JSON and a newline to stdout.
19
+ Prompts, help, and diagnostics use stderr. Prompts appear only when stdin is a terminal.
20
+ Results flush after each query when supported; piped query input produces no prompts.
21
+ The document loads once and its file closes before the first prompt or query read.
22
+ Interactive mode requires --file and rejects query sources, other modes, information flags, and repeats.
23
+ Startup or stream failures exit with status 1; usage failures return 2 before reading input.
24
+ CLI.run input needs gets; tty? is optional and absent detection means no prompts.
25
+ Writers need puts; output flush is optional. Terminal prompts require stderr print and flush.
26
+ Supported terminals provide editing and recall of the last 500 queries for this session only.
27
+ Ctrl-C cancels the current edit. History includes failed queries, excludes commands, and creates no files.
28
+ Editing requires terminal IO on stdin and stderr and a usable TERM; other streams use a plain reader.
29
+ Tab completes built-in function names outside quoted identifiers and literals, preserving surrounding text.
30
+ Unique matches insert only a name. Ambiguous matches extend a common prefix or list sorted alternatives.
31
+ Empty or unknown prefixes remain unchanged. Caller-owned streams remain open.
32
+ Multiline editing and document reloads are unavailable.
33
+ TEXT
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ module CLI
5
+ # @api private
6
+ class InteractiveKeyStroke < Reline::KeyStroke
7
+ # @api private
8
+ class InvalidInput < StandardError
9
+ attr_reader :byte
10
+
11
+ def initialize(byte)
12
+ @byte = byte
13
+ super("Invalid UTF-8 query")
14
+ end
15
+
16
+ def ended?
17
+ [4, 10, 13].include?(@byte)
18
+ end
19
+ end
20
+
21
+ def match_status(input)
22
+ validate_prefix(input)
23
+ super
24
+ end
25
+
26
+ def expand(input)
27
+ keys, rest = super
28
+ keys = keys.map do |key|
29
+ case key.method_symbol
30
+ when :bracketed_paste_start
31
+ Reline::Key.new(read_paste, :insert_multiline_text)
32
+ when :quoted_insert, :ed_quoted_insert
33
+ Reline::Key.new(read_quoted, :insert_raw_char)
34
+ else
35
+ key
36
+ end
37
+ end
38
+ [keys, rest]
39
+ end
40
+
41
+ private
42
+
43
+ def validate_prefix(input)
44
+ decoder = Encoding::Converter.new("UTF-8", "UTF-16LE")
45
+ source = input.pack("C*")
46
+ status = decoder.primitive_convert(source, +"", nil, nil, Encoding::Converter::PARTIAL_INPUT)
47
+ raise InvalidInput, input.fetch(-1) unless status == :source_buffer_empty
48
+ end
49
+
50
+ def read_paste
51
+ marker = "\e[201~".b
52
+ buffer = +"".b
53
+ until buffer.end_with?(marker)
54
+ byte = Reline::IOGate.getc(Float::INFINITY)
55
+ break unless byte
56
+
57
+ buffer << byte
58
+ end
59
+ text = buffer.delete_suffix(marker).force_encoding(Encoding::UTF_8)
60
+ raise InvalidInput, 255 unless text.valid_encoding?
61
+
62
+ text
63
+ end
64
+
65
+ def read_quoted
66
+ # @type var bytes: Array[Integer]
67
+ bytes = []
68
+ loop do
69
+ byte = Reline::IOGate.getc(Float::INFINITY)
70
+ return "" unless byte
71
+
72
+ bytes << byte
73
+ validate_prefix(bytes)
74
+ text = bytes.pack("C*").force_encoding(Encoding::UTF_8)
75
+ return text if text.valid_encoding?
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JMESPath
4
+ module CLI
5
+ # @api private
6
+ class InteractiveLineEditor < Reline::LineEditor
7
+ # Dialogs registered by another Reline consumer do not belong to this session.
8
+ def add_dialog_proc(_name, _callback, _context = nil); end
9
+ end
10
+ end
11
+ end
@@ -4,7 +4,13 @@ module JMESPath
4
4
  module CLI
5
5
  # @api private
6
6
  class InteractiveSession
7
- HELP = "Enter one query per line; blank lines are ignored.\n:help shows help; :quit ends the session."
7
+ HELP = "Enter one query per line; blank lines are ignored.\n:help shows help; :quit ends the session.\n" \
8
+ ":functions lists built-ins; :explain QUERY shows structure without evaluating the document.\n" \
9
+ ":trace QUERY evaluates once; at most 1000 events use stderr and one JSON result uses stdout.\n" \
10
+ "Supported terminals provide editing and recall of the last 500 queries for this session only.\n" \
11
+ "Ctrl-C cancels the current edit. Other streams use the plain line reader.\n" \
12
+ "Tab completes function names outside quotes; ambiguous prefixes list sorted names.\n" \
13
+ "Prompts and diagnostics use stderr; successful JSON results use stdout."
8
14
 
9
15
  def initialize(data, input:, output:, errors:)
10
16
  @data = data
@@ -15,13 +21,13 @@ module JMESPath
15
21
  end
16
22
 
17
23
  def run
18
- loop do
19
- prompt
20
- line = @input.gets
21
- break unless line
22
- break if process?(line)
24
+ if InteractiveEditor.supported?(input: @input, errors: @errors)
25
+ editor = InteractiveEditor.new(input: @input, errors: @errors)
26
+ @editor = editor
27
+ editor.open { read_queries }
28
+ else
29
+ read_queries
23
30
  end
24
- 0
25
31
  rescue IOError, SystemCallError => e
26
32
  report_stream_failure(e)
27
33
  1
@@ -29,6 +35,17 @@ module JMESPath
29
35
 
30
36
  private
31
37
 
38
+ def read_queries
39
+ loop do
40
+ prompt unless @editor
41
+ editor = @editor
42
+ line = editor ? editor.gets : @input.gets
43
+ break unless line
44
+ break if process?(line)
45
+ end
46
+ 0
47
+ end
48
+
32
49
  def process?(line)
33
50
  expression = line.dup.force_encoding(Encoding::UTF_8)
34
51
  unless expression.valid_encoding?
@@ -47,14 +64,21 @@ module JMESPath
47
64
  if command == ":help"
48
65
  diagnostic(HELP)
49
66
  elsif command.start_with?(":")
50
- diagnostic(FailureDiagnostic.format(Errors::Error.new("Unknown command: #{command}"), nil))
67
+ execute_command(expression)
51
68
  else
69
+ @editor&.remember(expression)
52
70
  result = evaluate(expression)
53
71
  write_result(result) if result
54
72
  end
55
73
  false
56
74
  end
57
75
 
76
+ def execute_command(expression)
77
+ report, result = InteractiveCommands.call(expression, data: @data)
78
+ diagnostic(report)
79
+ write_result(result) if result
80
+ end
81
+
58
82
  def evaluate(expression)
59
83
  JSON.generate(JMESPath.search(expression, @data))
60
84
  rescue JSON::NestingError
@@ -97,7 +121,9 @@ module JMESPath
97
121
  end
98
122
 
99
123
  def report_stream_failure(error)
100
- diagnostic(FailureDiagnostic.format(error, nil)) unless @writing_errors
124
+ return if @writing_errors || @editor&.writing_errors?
125
+
126
+ diagnostic(FailureDiagnostic.format(error, nil))
101
127
  rescue IOError, SystemCallError
102
128
  nil
103
129
  end
data/lib/jmespath/cli.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "../jmespath"
4
4
  require_relative "cli/failure_diagnostic"
5
5
  require_relative "cli/arguments"
6
6
  require_relative "cli/function_listing"
7
+ require_relative "cli/interactive_commands"
8
+ require_relative "cli/interactive_editor"
7
9
  require_relative "cli/interactive_session"
8
10
  require_relative "cli/help"
9
11
 
@@ -17,11 +19,12 @@ module JMESPath
17
19
  # @return [Integer] zero on success, one on input failure, two on usage failure
18
20
  def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr)
19
21
  return validation(argv, stderr) if literal_validation?(argv)
20
- return information(argv, stdout) if [%w[--help], %w[-h], %w[--version], %w[--functions]].include?(argv)
22
+ return help(argv, stdout) if %w[--help -h].include?(argv.first)
23
+ return information(argv, stdout) if [%w[--version], %w[--functions]].include?(argv)
21
24
 
22
25
  query(argv, stdin, stdout, stderr)
23
26
  rescue OptionParser::ParseError
24
- stderr.puts(USAGE)
27
+ stderr.puts("#{USAGE}\nUse jmespath --help for options and topic help.")
25
28
  2
26
29
  rescue JSON::ParserError, Errors::Error, IOError, SystemCallError => e
27
30
  failure(e, nil, stderr)
@@ -29,18 +32,18 @@ module JMESPath
29
32
 
30
33
  # @api private
31
34
  def self.query(argv, stdin, stdout, stderr)
32
- expression, path, query_path, validate, pretty, raw_output, json_lines, interactive = parse_arguments(argv)
33
- return interactive_session(path, stdin, stdout, stderr) if interactive
35
+ expression, path, query_path, mode = parse_arguments(argv)
36
+ return interactive_session(path, stdin, stdout, stderr) if mode == "interactive"
34
37
 
35
38
  expression = read_query(expression, query_path)
36
- return validation(["--validate", expression], stderr) if validate
37
- if json_lines
39
+ return validation(["--validate", expression], stderr) if mode == "validate"
40
+ if mode == "json-lines"
38
41
  # @type var stdin: _LineReader
39
42
  return execute_lines(expression, path, stdin, stdout, stderr)
40
43
  end
41
44
 
42
45
  # @type var stdin: _Reader
43
- execute(expression, read_document(path, stdin), stdout, pretty, raw_output)
46
+ execute(expression, read_document(path, stdin), stdout, mode)
44
47
  rescue JSON::ParserError, Errors::Error => e
45
48
  failure(e, expression, stderr)
46
49
  end
@@ -75,12 +78,12 @@ module JMESPath
75
78
  end
76
79
 
77
80
  # @api private
78
- def self.execute(expression, data, stdout, pretty, raw_output)
81
+ def self.execute(expression, data, stdout, mode)
79
82
  result = JMESPath.search(expression, data)
80
- output = if raw_output && result.is_a?(String)
83
+ output = if mode == "raw-output" && result.is_a?(String)
81
84
  "#{result}\n"
82
85
  else
83
- pretty ? JSON.pretty_generate(result) : JSON.generate(result)
86
+ mode == "pretty" ? JSON.pretty_generate(result) : JSON.generate(result)
84
87
  end
85
88
  stdout.puts(output)
86
89
  0
@@ -123,6 +126,14 @@ module JMESPath
123
126
  1
124
127
  end
125
128
 
129
+ # @api private
130
+ def self.help(argv, output)
131
+ raise OptionParser::InvalidArgument, "expected at most one help topic" if argv.length > 2
132
+
133
+ output.puts(Help.render(argv[1]))
134
+ 0
135
+ end
136
+
126
137
  # @api private
127
138
  def self.information(argv, output)
128
139
  if argv == ["--functions"]
@@ -130,7 +141,7 @@ module JMESPath
130
141
  return 0
131
142
  end
132
143
 
133
- output.puts(argv == ["--version"] ? "jmespath-ruby #{VERSION}" : HELP)
144
+ output.puts("jmespath-ruby #{VERSION}")
134
145
  0
135
146
  end
136
147
  end
@@ -3,5 +3,5 @@
3
3
  # Queries JSON-compatible Ruby values using JMESPath expressions.
4
4
  module JMESPath
5
5
  # The semantic version of this implementation.
6
- VERSION = "0.1.3"
6
+ VERSION = "1.0.0"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jmespath-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '4'
32
+ - !ruby/object:Gem::Dependency
33
+ name: reline
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: 0.7.0
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: 0.7.0
32
46
  - !ruby/object:Gem::Dependency
33
47
  name: stringio
34
48
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +80,7 @@ files:
66
80
  - VERSIONING.md
67
81
  - completions/_jmespath
68
82
  - completions/jmespath.bash
83
+ - docs/guides/command-line.md
69
84
  - docs/guides/comparison.md
70
85
  - docs/guides/compatibility.md
71
86
  - docs/guides/compilation.md
@@ -85,6 +100,14 @@ files:
85
100
  - lib/jmespath/cli/failure_diagnostic.rb
86
101
  - lib/jmespath/cli/function_listing.rb
87
102
  - lib/jmespath/cli/help.rb
103
+ - lib/jmespath/cli/inspection_report.rb
104
+ - lib/jmespath/cli/interactive_commands.rb
105
+ - lib/jmespath/cli/interactive_completion.rb
106
+ - lib/jmespath/cli/interactive_editor.rb
107
+ - lib/jmespath/cli/interactive_editor_state.rb
108
+ - lib/jmespath/cli/interactive_help.rb
109
+ - lib/jmespath/cli/interactive_key_stroke.rb
110
+ - lib/jmespath/cli/interactive_line_editor.rb
88
111
  - lib/jmespath/cli/interactive_session.rb
89
112
  - lib/jmespath/data_access.rb
90
113
  - lib/jmespath/diagnostic.rb