seeing_is_believing 0.0.3 → 0.0.4

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/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- seeing_is_believing (0.0.1)
4
+ seeing_is_believing (0.0.4)
5
5
 
6
6
  GEM
7
7
  remote: http://rubygems.org/
@@ -15,6 +15,7 @@ GEM
15
15
  diff-lcs (1.1.3)
16
16
  gherkin (2.11.5)
17
17
  json (>= 1.4.6)
18
+ ichannel (5.1.1)
18
19
  json (1.7.6)
19
20
  rspec (2.12.0)
20
21
  rspec-core (~> 2.12.0)
@@ -30,5 +31,6 @@ PLATFORMS
30
31
 
31
32
  DEPENDENCIES
32
33
  cucumber (~> 1.2.1)
34
+ ichannel (~> 5.1.1)
33
35
  rspec (~> 2.12.0)
34
36
  seeing_is_believing!
data/Readme.md CHANGED
@@ -9,8 +9,8 @@ Reeaally rough at the moment, but it works for simple examples.
9
9
 
10
10
  Also comes with a binary to show how it might be used.
11
11
 
12
- Use
13
- ===
12
+ Use The Binary
13
+ ==============
14
14
 
15
15
  ```ruby
16
16
  # $ seeing_is_believing proving_grounds/basic_functionality.rb
@@ -21,6 +21,8 @@ end # => 5
21
21
  def meth(n)
22
22
  n # => "12", "34"
23
23
  end # => nil
24
+
25
+ # some invocations
24
26
  meth "12" # => "12"
25
27
  meth "34" # => "34"
26
28
  ```
@@ -37,6 +39,27 @@ raise "ZOMG!" # ~> RuntimeError: ZOMG!
37
39
  ZOMG!
38
40
  ```
39
41
 
42
+ Use The Lib
43
+ ===========
44
+
45
+ ```ruby
46
+ require 'seeing_is_believing'
47
+
48
+ believer = SeeingIsBelieving.new("%w[a b c].each do |i|
49
+ i.upcase
50
+ end")
51
+
52
+ result = believer.call
53
+ result # => #<SeeingIsBelieving::Result:0x007faeed1a5b78 @max_line_number=3, @min_line_number=1, @results={2=>['"A"', '"B"', '"C"'], 3=>['["a", "b", "c"]']}>
54
+
55
+ result.to_a # => [ [1, []],
56
+ # [2, ['"A"', '"B"', '"C"']],
57
+ # [3, ['["a", "b", "c"]']]
58
+ # ]
59
+
60
+ result[2] # => ['"A"', '"B"', '"C"']
61
+ ```
62
+
40
63
  Install
41
64
  =======
42
65
 
@@ -49,11 +72,10 @@ Or if you haven't fixed your gem home, and you aren't using any version managers
49
72
  Known Issues
50
73
  ============
51
74
 
52
- * comments will kill it, probably going to have to actually parse the code to fix this
53
- * multi-line strings will probably kill it, probably going to have to actually parse the code to fix this
54
- * I have no idea what happens if you talk to stdout/stderr directly. This should become a non-issue if we evaluate it in its own process like xmpfilter.
55
- * If it dies, it will take your program with it. Same as above. (e.g. running the binary against itself will cause it to recursively invoke itself forever WARNING: DON'T REALLY DO THAT, ITS CRAYAZAY)
56
75
  * No idea what happens if you give it a syntactically invalid file. It probably just raises an exception, but might possibly freeze up or something.
76
+ * heredocs breaks things maybe also `BEGIN/END` and `=begin/=end`
77
+ * There are expressions which continue on the next line even though the previous line is a valid expression, e.g. "3\n.times { |i| i }" which will blow up. This is a fundamental flaw in the algorithm and will either require a smarter algorithm, or some sort of more sophisticated parsing in order to handle correctly
78
+ * Probably doesn't handle stdin correctly
57
79
 
58
80
  License
59
81
  =======
@@ -6,9 +6,8 @@ require 'seeing_is_believing/example_use'
6
6
  status = 0
7
7
 
8
8
  ARGV.each do |filename|
9
- believer = SeeingIsBelieving::ExampleUse.new(File.read filename)
10
- believer.call
11
- $stdout.puts believer.output
9
+ believer = SeeingIsBelieving::ExampleUse.new File.read(filename), filename
10
+ $stdout.puts believer.call
12
11
  if believer.has_exception?
13
12
  $stderr.puts believer.exception.message
14
13
  status = 1
@@ -19,6 +19,11 @@ Feature: Running the binary
19
19
  # some invocations
20
20
  meth "12"
21
21
  meth "34"
22
+
23
+ # multilinezzz
24
+ "a
25
+ b
26
+ c"
22
27
  """
23
28
  When I run "seeing_is_believing basic_functionality.rb"
24
29
  Then stderr is empty
@@ -36,6 +41,11 @@ Feature: Running the binary
36
41
  # some invocations
37
42
  meth "12" # => "12"
38
43
  meth "34" # => "34"
44
+
45
+ # multilinezzz
46
+ "a
47
+ b
48
+ c" # => "a\n b\n c"
39
49
  """
40
50
 
41
51
  Scenario: Raising exceptions
@@ -55,7 +65,86 @@ Feature: Running the binary
55
65
  1 + 1
56
66
  """
57
67
 
68
+ Scenario: Passing previous output back into input
69
+ Given the file "previous_output.rb":
70
+ """
71
+ 1 + 1 # => not 2
72
+ 2 + 2 # ~> Exception, something
73
+
74
+
75
+ # >> some stdout output
76
+
77
+ # !> some stderr output
78
+ __END__
79
+ """
80
+ When I run "seeing_is_believing previous_output.rb"
81
+ Then stderr is empty
82
+ And the exit status is 0
83
+ And stdout is:
84
+ """
85
+ 1 + 1 # => 2
86
+ 2 + 2 # => 4
87
+
88
+ __END__
89
+ """
90
+
58
91
  Scenario: Printing within the file
92
+ Given the file "printing.rb":
93
+ """
94
+ print "hel"
95
+ puts "lo!"
96
+ puts ":)"
97
+ $stderr.puts "goodbye"
98
+ """
99
+ When I run "seeing_is_believing printing.rb"
100
+ Then stderr is empty
101
+ And the exit status is 0
102
+ And stdout is:
103
+ """
104
+ print "hel" # => nil
105
+ puts "lo!" # => nil
106
+ puts ":)" # => nil
107
+ $stderr.puts "goodbye" # => nil
108
+
109
+ # >> hello!
110
+ # >> :)
111
+
112
+ # !> goodbye
113
+ """
114
+
115
+ Scenario: Respects macros
116
+ Given the file "some_dir/uses_macros.rb":
117
+ """
118
+ __FILE__
119
+ __LINE__
120
+ $stdout.puts "omg"
121
+ $stderr.puts "hi"
122
+ DATA.read
123
+ __LINE__
124
+ __END__
125
+ 1
126
+ 2
127
+ """
128
+ When I run "seeing_is_believing some_dir/uses_macros.rb"
129
+ Then stderr is empty
130
+ And the exit status is 0
131
+ And stdout is:
132
+ """
133
+ __FILE__ # => "{{CommandLineHelpers.path_to 'some_dir/uses_macros.rb'}}"
134
+ __LINE__ # => 2
135
+ $stdout.puts "omg" # => nil
136
+ $stderr.puts "hi" # => nil
137
+ DATA.read # => "1\n2"
138
+ __LINE__ # => 6
139
+
140
+ # >> omg
141
+
142
+ # !> hi
143
+ __END__
144
+ 1
145
+ 2
146
+ """
147
+
59
148
  Scenario: Requiring other files
60
149
  Scenario: Syntactically invalid file
61
150
  Scenario: Passing a nonexistent file
@@ -7,11 +7,11 @@ When 'I run "$command"' do |command|
7
7
  end
8
8
 
9
9
  Then /^(stderr|stdout) is:$/ do |stream_name, output|
10
- @last_executed.send(stream_name).chomp.should == output
10
+ @last_executed.send(stream_name).chomp.should == eval_curlies(output)
11
11
  end
12
12
 
13
13
  Then /^(stderr|stdout) is "(.*?)"$/ do |stream_name, output|
14
- @last_executed.send(stream_name).chomp.should == output
14
+ @last_executed.send(stream_name).chomp.should == eval_curlies(output)
15
15
  end
16
16
 
17
17
  Then 'the exit status is $status' do |status|
@@ -13,14 +13,15 @@ module CommandLineHelpers
13
13
 
14
14
  def write_file(filename, body)
15
15
  in_proving_grounds do
16
+ FileUtils.mkdir_p File.dirname filename
16
17
  File.open(filename, 'w') { |file| file.write body }
17
18
  end
18
19
  end
19
20
 
20
21
  def execute(command)
21
22
  in_proving_grounds do
22
- command = "PATH=#{bin_dir}:$PATH #{command}"
23
- Invocation.new *Open3.capture3(command)
23
+ bin_in_path = {'PATH' => "#{bin_dir}:#{ENV['PATH']}"}
24
+ Invocation.new *Open3.capture3(bin_in_path, command)
24
25
  end
25
26
  end
26
27
 
@@ -47,6 +48,18 @@ module CommandLineHelpers
47
48
  def bin_dir
48
49
  File.join root_dir, "bin"
49
50
  end
51
+
52
+ def path_to(filename)
53
+ in_proving_grounds { File.join proving_grounds_dir, filename }
54
+ end
50
55
  end
51
56
 
52
57
  CommandLineHelpers.make_proving_grounds
58
+
59
+ module GeneralHelpers
60
+ def eval_curlies(string)
61
+ string.gsub(/{{(.*?)}}/) { eval $1 }
62
+ end
63
+ end
64
+
65
+ World GeneralHelpers
@@ -0,0 +1,7 @@
1
+ class SeeingIsBelieving
2
+ # all our errors will inherit from this so that a user
3
+ # can catch any error generated by this lib
4
+ SeeingIsBelievingError = Class.new StandardError
5
+
6
+ TempFileAlreadyExists = Class.new SeeingIsBelievingError
7
+ end
@@ -0,0 +1,118 @@
1
+ # Not sure what the best way to evaluate these is
2
+ # This approach will move the old file out of the way,
3
+ # write the program in its place, invoke it, and move it back.
4
+ #
5
+ # Another option is to replace __FILE__ macros ourselves
6
+ # and then write to a temp file but evaluate in the context
7
+ # of the expected directory. I'm not doing that just because
8
+ # I don't think the __FILE__ macro can be replaced correctly
9
+ # without parsing the code, changing the AST, and then
10
+ # regenerating it, which I'm not good enough to do. Though
11
+ # I did look at Ripper, and it will invoke on_kw("__FILE__")
12
+ # when it sees this.
13
+
14
+ require 'yaml'
15
+ require 'open3'
16
+ require 'fileutils'
17
+ require 'seeing_is_believing/error'
18
+ require 'seeing_is_believing/result'
19
+ require 'seeing_is_believing/hard_core_ensure'
20
+
21
+ class SeeingIsBelieving
22
+ class EvaluateByMovingFiles
23
+ attr_accessor :program, :filename, :error_stream
24
+
25
+ def initialize(program, filename, options={})
26
+ self.program = program
27
+ self.filename = File.expand_path(filename)
28
+ self.error_stream = options.fetch :error_stream, $stderr
29
+ end
30
+
31
+ def call
32
+ @result ||= HardCoreEnsure.call(
33
+ code: -> {
34
+ dont_overwrite_existing_tempfile!
35
+ move_file_to_tempfile
36
+ write_program_to_file
37
+ begin
38
+ evaluate_file
39
+ fail unless exitstatus.success?
40
+ deserialize_result
41
+ rescue Exception
42
+ record_error
43
+ raise $!
44
+ end
45
+ },
46
+ ensure: -> {
47
+ restore_backup
48
+ }
49
+ )
50
+ end
51
+
52
+ def file_directory
53
+ File.dirname filename
54
+ end
55
+
56
+ def temp_filename
57
+ File.join file_directory, "seeing_is_believing_backup.#{File.basename filename}"
58
+ end
59
+
60
+ private
61
+
62
+ attr_accessor :stdout, :stderr, :exitstatus
63
+
64
+ def dont_overwrite_existing_tempfile!
65
+ return unless File.exist? temp_filename
66
+ raise TempFileAlreadyExists,
67
+ "Trying to back up #{filename.inspect} (FILE) to #{temp_filename.inspect} (TEMPFILE) but TEMPFILE already exists."\
68
+ " You should check the contents of these files. If FILE is correct, then delete TEMPFILE."\
69
+ " Otherwise rename TEMPFILE to FILE."
70
+ end
71
+
72
+ def move_file_to_tempfile
73
+ return unless File.exist? filename
74
+ FileUtils.mv filename, temp_filename
75
+ @was_backed_up = true
76
+ end
77
+
78
+ def restore_backup
79
+ return unless @was_backed_up
80
+ FileUtils.mv temp_filename, filename
81
+ end
82
+
83
+ def write_program_to_file
84
+ File.open(filename, 'w') { |f| f.write program.to_s }
85
+ end
86
+
87
+ def evaluate_file
88
+ self.stdout, self.stderr, self.exitstatus = Open3.capture3(
89
+ 'ruby', '-W0', # no warnings (b/c I hijack STDOUT/STDERR)
90
+ '-I', File.expand_path('../..', __FILE__), # fix load path
91
+ '-r', 'seeing_is_believing/the_matrix', # hijack the environment so it can be recorded
92
+ '-C', file_directory, # run in the file's directory
93
+ filename
94
+ )
95
+ end
96
+
97
+ def fail
98
+ raise "Exitstatus: #{exitstatus.inspect},\nError: #{stderr.inspect}"
99
+ end
100
+
101
+ def deserialize_result
102
+ YAML.load stdout
103
+ end
104
+
105
+ def record_error
106
+ error_stream.puts "It blew up. Not too surprising given that seeing_is_believing is pretty rough around the edges, but still this shouldn't happen."
107
+ error_stream.puts "Please log an issue at: https://github.com/JoshCheek/seeing_is_believing/issues"
108
+ error_stream.puts
109
+ error_stream.puts "Program: #{program.inspect}"
110
+ error_stream.puts
111
+ error_stream.puts "Stdout: #{stdout.inspect}"
112
+ error_stream.puts
113
+ error_stream.puts "Stderr: #{stderr.inspect}"
114
+ error_stream.puts
115
+ error_stream.puts "Status: #{exitstatus.inspect}"
116
+ end
117
+ end
118
+ end
@@ -1,50 +1,96 @@
1
1
  require 'seeing_is_believing'
2
+ require 'seeing_is_believing/has_exception'
2
3
 
3
4
  class SeeingIsBelieving
4
5
  class ExampleUse
5
- attr_accessor :exception
6
+ include HasException
6
7
 
7
- def initialize(body)
8
- self.body = body
8
+ STDOUT_PREFIX = '# >>'
9
+ STDERR_PREFIX = '# !>'
10
+ EXCEPTION_PREFIX = '# ~>'
11
+ RESULT_PREFIX = '# =>'
12
+
13
+ def initialize(body, filename=nil)
14
+ self.body = remove_previous_output_from body
15
+ self.filename = filename
16
+ end
17
+
18
+ def new_body
19
+ @new_body ||= ''
9
20
  end
10
21
 
11
22
  def call
12
- body.each_line.with_index 1 do |line, index|
13
- line_results = results[index]
14
- self.exception = line_results.exception if line_results.has_exception?
15
- output << format_line(line.chomp, line_results)
16
- end
17
- output
23
+ evaluate_program
24
+ inherit_exception
25
+ add_each_line_until_data_segment
26
+ add_stdout
27
+ add_stderr
28
+ add_data_segment
29
+ new_body
18
30
  end
19
31
 
20
- def output
21
- @result ||= ''
32
+ private
33
+
34
+ attr_accessor :body, :filename, :file_result
35
+
36
+ def evaluate_program
37
+ self.file_result = SeeingIsBelieving.new(body, filename: filename).call
22
38
  end
23
39
 
24
- alias has_exception? exception
40
+ def inherit_exception
41
+ self.exception = file_result.exception
42
+ end
25
43
 
26
- private
44
+ def add_each_line_until_data_segment
45
+ body.each_line.with_index 1 do |line, index|
46
+ break if start_of_data_segment? line
47
+ new_body << format_line(line.chomp, file_result[index])
48
+ end
49
+ end
27
50
 
28
- attr_accessor :body
51
+ def add_data_segment
52
+ body.each_line
53
+ .drop_while { |line| not start_of_data_segment? line }
54
+ .each { |line| new_body << line }
55
+ end
29
56
 
30
- def results
31
- @results ||= SeeingIsBelieving.new(body).call
57
+ def start_of_data_segment?(line)
58
+ line.chomp == '__END__'
32
59
  end
33
60
 
34
61
  # max line length of the body + 2 spaces for padding
35
62
  def line_length
36
63
  @line_length ||= 2 + body.each_line
37
64
  .map(&:chomp)
65
+ .take_while { |line| not start_of_data_segment? line }
38
66
  .reject { |line| SyntaxAnalyzer.ends_in_comment? line }
39
67
  .map(&:length)
40
68
  .max
41
69
  end
42
70
 
71
+ def remove_previous_output_from(string)
72
+ string.gsub(/\s+(#{EXCEPTION_PREFIX}|#{RESULT_PREFIX}).*?$/, '')
73
+ .gsub(/(\n)?(^#{STDOUT_PREFIX}[^\n]*\r?\n?)+/m, '')
74
+ .gsub(/(\n)?(^#{STDERR_PREFIX}[^\n]*\r?\n?)+/m, '')
75
+ end
76
+
77
+ def add_stdout
78
+ return unless file_result.has_stdout?
79
+ new_body << "\n"
80
+ file_result.stdout.each_line { |line| new_body << "#{STDOUT_PREFIX} #{line}" }
81
+ end
82
+
83
+ def add_stderr
84
+ return unless file_result.has_stderr?
85
+ new_body << "\n"
86
+ file_result.stderr.each_line { |line| new_body << "#{STDERR_PREFIX} #{line}" }
87
+ end
88
+
43
89
  def format_line(line, line_results)
44
90
  if line_results.has_exception?
45
- sprintf "%-#{line_length}s# ~> %s: %s\n", line, line_results.exception.class, line_results.exception.message
91
+ sprintf "%-#{line_length}s#{EXCEPTION_PREFIX} %s: %s\n", line, line_results.exception.class, line_results.exception.message
46
92
  elsif line_results.any?
47
- sprintf "%-#{line_length}s# => %s\n", line, line_results.join(', ')
93
+ sprintf "%-#{line_length}s#{RESULT_PREFIX} %s\n", line, line_results.join(', ')
48
94
  else
49
95
  line + "\n"
50
96
  end
@@ -1,6 +1,8 @@
1
1
  require 'open3'
2
2
  require 'seeing_is_believing/syntax_analyzer'
3
3
 
4
+ # A lot of colouring going on in this file, maybe should extract a debugging object to contain it
5
+
4
6
  class SeeingIsBelieving
5
7
  class ExpressionList
6
8
  PendingExpression = Struct.new :expression, :children do
@@ -18,60 +20,70 @@ class SeeingIsBelieving
18
20
  self.should_debug = options.fetch :debug, false
19
21
  self.generator = options.fetch :generator
20
22
  self.on_complete = options.fetch :on_complete
21
- self.line_number = 0
22
- self.list = []
23
+ @line_number = 0
23
24
  end
24
25
 
25
26
  def call
27
+ expressions = []
26
28
  expression = nil
27
29
  begin
28
- generate
29
- expression = reduce_expressions
30
- end until list.empty?
30
+ pending_expression = generate
31
+ debug { "GENERATED: #{pending_expression.expression.inspect}, ADDING IT TO #{inspected_expressions expressions}" }
32
+ expressions << pending_expression
33
+ expression = reduce expressions
34
+ end until expressions.empty?
31
35
  expression
32
36
  end
33
37
 
34
38
  private
35
39
 
36
- attr_accessor :debug_stream, :should_debug, :generator, :on_complete, :line_number, :list
40
+ attr_accessor :debug_stream, :should_debug, :generator, :on_complete, :expressions
37
41
 
38
42
  def generate
39
43
  @line_number += 1
40
44
  expression = generator.call
41
- debug? && debug("GENERATED: #{expression.inspect}, ADDING IT TO #{inspected_list}")
42
- @list << PendingExpression.new(expression, [])
45
+ PendingExpression.new(expression, [])
43
46
  end
44
47
 
45
- def inspected_list
46
- "[#{@list.map { |pe| pe.inspect debug? }.join(', ')}]"
48
+ def inspected_expressions(expressions)
49
+ "[#{expressions.map { |pe| pe.inspect debug? }.join(', ')}]"
47
50
  end
48
51
 
49
52
  def debug?
50
53
  @should_debug
51
54
  end
52
55
 
53
- def debug(message)
54
- @debug_stream.puts message
56
+ def debug
57
+ @debug_stream.puts yield if debug?
55
58
  end
56
59
 
57
- def reduce_expressions
58
- @list.size.times do |i|
59
- # uhm, should this expression we are checking for validity consider the children?
60
- expression = @list[i..-1].map(&:expression).join("\n") # must use newline otherwise can get expressions like `a\\+b` that should be `a\\\n+b`, former is invalid
60
+ def reduce(expressions)
61
+ expressions.size.times do |i|
62
+ expression = expressions[i..-1].map(&:expression) # uhm, should this expression we are checking for validity consider the children?
63
+ .join("\n") # must use newline otherwise can get expressions like `a\\+b` that should be `a\\\n+b`, former is invalid
64
+ return if children_will_never_be_valid? expression
61
65
  next unless valid_ruby? expression
62
- result = on_complete.call(@list[i].expression,
63
- @list[i].children,
64
- @list[i+1..-1].map { |pe| [pe.expression, pe.children] }.flatten, # hmmm, not sure this is really correct, but it allows it to work for my use cases
66
+ result = on_complete.call(expressions[i].expression,
67
+ expressions[i].children,
68
+ expressions[i+1..-1].map { |pe| [pe.expression, pe.children] }.flatten, # hmmm, not sure this is really correct, but it allows it to work for my use cases
65
69
  @line_number)
66
- @list = @list[0, i]
67
- @list[i-1].children << result unless @list.empty?
68
- debug? && debug("REDUCED: #{result.inspect}, LIST: #{inspected_list}")
70
+ expressions.replace expressions[0, i]
71
+ expressions[i-1].children << result unless expressions.empty?
72
+ debug { "REDUCED: #{result.inspect}, LIST: #{inspected_expressions expressions}" }
69
73
  return result
70
74
  end
71
75
  end
72
76
 
73
77
  def valid_ruby?(expression)
74
- SyntaxAnalyzer.valid_ruby? expression
78
+ valid = SyntaxAnalyzer.valid_ruby? expression
79
+ debug { "#{valid ? "\e[31mIS NOT VALID:" : "\e[32mIS VALID:"}: #{expression.inspect}\e[0m" }
80
+ valid
81
+ end
82
+
83
+ def children_will_never_be_valid?(expression)
84
+ analyzer = SyntaxAnalyzer.new(expression)
85
+ analyzer.parse
86
+ analyzer.unclosed_string? || analyzer.unclosed_regexp?
75
87
  end
76
88
  end
77
89
  end
@@ -0,0 +1,52 @@
1
+ class SeeingIsBelieving
2
+ class HardCoreEnsure
3
+ def self.call(options)
4
+ new(options).call
5
+ end
6
+
7
+ def initialize(options)
8
+ self.options = options
9
+ validate_options
10
+ end
11
+
12
+ def call
13
+ trap_sigint
14
+ invoke_code
15
+ ensure
16
+ invoke_ensure
17
+ end
18
+
19
+ private
20
+
21
+ attr_accessor :options, :ensure_invoked, :old_handler
22
+
23
+ def trap_sigint
24
+ self.old_handler = trap 'INT' do
25
+ invoke_ensure
26
+ Process.kill 'INT', $$
27
+ end
28
+ end
29
+
30
+ def invoke_code
31
+ options[:code].call
32
+ end
33
+
34
+ def invoke_ensure
35
+ return if ensure_invoked
36
+ trap 'INT', old_handler
37
+ self.ensure_invoked = true
38
+ options[:ensure].call
39
+ end
40
+
41
+ def validate_options
42
+ raise ArgumentError, "Must pass the :code key" unless options.key? :code
43
+ raise ArgumentError, "Must pass the :ensure key" unless options.key? :ensure
44
+ unknown_keys = options.keys - [:code, :ensure]
45
+ if options.size == 3
46
+ raise ArgumentError, "Unknown key: #{unknown_keys.first.inspect}"
47
+ elsif options.size > 3
48
+ raise ArgumentError, "Unknown keys: #{unknown_keys.map(&:inspect).join(', ')}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,6 @@
1
+ class SeeingIsBelieving
2
+ module HasException
3
+ attr_accessor :exception
4
+ alias has_exception? exception
5
+ end
6
+ end