seeing_is_believing 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
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