better_errors 2.3.0 → 2.9.1

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.
Files changed (44) hide show
  1. checksums.yaml +5 -5
  2. data/.coveralls.yml +1 -0
  3. data/.github/workflows/ci.yml +130 -0
  4. data/.github/workflows/release.yml +64 -0
  5. data/.gitignore +3 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +1 -1
  8. data/Gemfile +7 -8
  9. data/README.md +81 -6
  10. data/better_errors.gemspec +18 -1
  11. data/gemfiles/pry010.gemfile +10 -0
  12. data/gemfiles/pry011.gemfile +9 -0
  13. data/gemfiles/pry09.gemfile +9 -0
  14. data/gemfiles/rack.gemfile +8 -0
  15. data/gemfiles/rack_boc.gemfile +9 -0
  16. data/gemfiles/rails42.gemfile +10 -0
  17. data/gemfiles/rails42_boc.gemfile +11 -0
  18. data/gemfiles/rails42_haml.gemfile +11 -0
  19. data/gemfiles/rails50.gemfile +9 -0
  20. data/gemfiles/rails50_boc.gemfile +10 -0
  21. data/gemfiles/rails50_haml.gemfile +10 -0
  22. data/gemfiles/rails51.gemfile +9 -0
  23. data/gemfiles/rails51_boc.gemfile +10 -0
  24. data/gemfiles/rails51_haml.gemfile +10 -0
  25. data/gemfiles/rails52.gemfile +9 -0
  26. data/gemfiles/rails52_boc.gemfile +10 -0
  27. data/gemfiles/rails52_haml.gemfile +10 -0
  28. data/gemfiles/rails60.gemfile +8 -0
  29. data/gemfiles/rails60_boc.gemfile +9 -0
  30. data/gemfiles/rails60_haml.gemfile +9 -0
  31. data/lib/better_errors/editor.rb +99 -0
  32. data/lib/better_errors/error_page.rb +39 -9
  33. data/lib/better_errors/exception_hint.rb +29 -0
  34. data/lib/better_errors/inspectable_value.rb +45 -0
  35. data/lib/better_errors/middleware.rb +68 -15
  36. data/lib/better_errors/raised_exception.rb +25 -4
  37. data/lib/better_errors/stack_frame.rb +25 -7
  38. data/lib/better_errors/templates/main.erb +97 -42
  39. data/lib/better_errors/templates/text.erb +5 -2
  40. data/lib/better_errors/templates/variable_info.erb +12 -5
  41. data/lib/better_errors/version.rb +1 -1
  42. data/lib/better_errors.rb +29 -30
  43. metadata +119 -7
  44. data/.travis.yml +0 -9
@@ -0,0 +1,99 @@
1
+ require "uri"
2
+
3
+ module BetterErrors
4
+ class Editor
5
+ KNOWN_EDITORS = [
6
+ { symbols: [:atom], sniff: /atom/i, url: "atom://core/open/file?filename=%{file}&line=%{line}" },
7
+ { symbols: [:emacs, :emacsclient], sniff: /emacs/i, url: "emacs://open?url=file://%{file}&line=%{line}" },
8
+ { symbols: [:idea], sniff: /idea/i, url: "idea://open?file=%{file}&line=%{line}" },
9
+ { symbols: [:macvim, :mvim], sniff: /vim/i, url: "mvim://open?url=file://%{file_unencoded}&line=%{line}" },
10
+ { symbols: [:rubymine], sniff: /mine/i, url: "x-mine://open?file=%{file}&line=%{line}" },
11
+ { symbols: [:sublime, :subl, :st], sniff: /subl/i, url: "subl://open?url=file://%{file}&line=%{line}" },
12
+ { symbols: [:textmate, :txmt, :tm], sniff: /mate/i, url: "txmt://open?url=file://%{file}&line=%{line}" },
13
+ { symbols: [:vscode, :code], sniff: /code/i, url: "vscode://file/%{file}:%{line}" },
14
+ { symbols: [:vscodium, :codium], sniff: /codium/i, url: "vscodium://file/%{file}:%{line}" },
15
+ ]
16
+
17
+ def self.for_formatting_string(formatting_string)
18
+ new proc { |file, line|
19
+ formatting_string % { file: URI.encode_www_form_component(file), file_unencoded: file, line: line }
20
+ }
21
+ end
22
+
23
+ def self.for_proc(url_proc)
24
+ new url_proc
25
+ end
26
+
27
+ # Automatically sniffs a default editor preset based on
28
+ # environment variables.
29
+ #
30
+ # @return [Symbol]
31
+ def self.default_editor
32
+ editor_from_environment_formatting_string ||
33
+ editor_from_environment_editor ||
34
+ editor_from_symbol(:textmate)
35
+ end
36
+
37
+ def self.editor_from_environment_editor
38
+ if ENV["BETTER_ERRORS_EDITOR"]
39
+ editor = editor_from_command(ENV["BETTER_ERRORS_EDITOR"])
40
+ return editor if editor
41
+ puts "BETTER_ERRORS_EDITOR environment variable is not recognized as a supported Better Errors editor."
42
+ end
43
+ if ENV["EDITOR"]
44
+ editor = editor_from_command(ENV["EDITOR"])
45
+ return editor if editor
46
+ puts "EDITOR environment variable is not recognized as a supported Better Errors editor. Using TextMate by default."
47
+ else
48
+ puts "Since there is no EDITOR or BETTER_ERRORS_EDITOR environment variable, using Textmate by default."
49
+ end
50
+ end
51
+
52
+ def self.editor_from_command(editor_command)
53
+ env_preset = KNOWN_EDITORS.find { |preset| editor_command =~ preset[:sniff] }
54
+ for_formatting_string(env_preset[:url]) if env_preset
55
+ end
56
+
57
+ def self.editor_from_environment_formatting_string
58
+ return unless ENV['BETTER_ERRORS_EDITOR_URL']
59
+
60
+ for_formatting_string(ENV['BETTER_ERRORS_EDITOR_URL'])
61
+ end
62
+
63
+ def self.editor_from_symbol(symbol)
64
+ KNOWN_EDITORS.each do |preset|
65
+ return for_formatting_string(preset[:url]) if preset[:symbols].include?(symbol)
66
+ end
67
+ end
68
+
69
+ def initialize(url_proc)
70
+ @url_proc = url_proc
71
+ end
72
+
73
+ def url(raw_path, line)
74
+ if virtual_path && raw_path.start_with?(virtual_path)
75
+ if host_path
76
+ file = raw_path.sub(%r{\A#{virtual_path}}, host_path)
77
+ else
78
+ file = raw_path.sub(%r{\A#{virtual_path}/}, '')
79
+ end
80
+ else
81
+ file = raw_path
82
+ end
83
+
84
+ url_proc.call(file, line)
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :url_proc
90
+
91
+ def virtual_path
92
+ @virtual_path ||= ENV['BETTER_ERRORS_VIRTUAL_PATH']
93
+ end
94
+
95
+ def host_path
96
+ @host_path ||= ENV['BETTER_ERRORS_HOST_PATH']
97
+ end
98
+ end
99
+ end
@@ -26,8 +26,13 @@ module BetterErrors
26
26
  @id ||= SecureRandom.hex(8)
27
27
  end
28
28
 
29
- def render(template_name = "main")
29
+ def render(template_name = "main", csrf_token = nil)
30
30
  binding.eval(self.class.template(template_name).src)
31
+ rescue => e
32
+ # Fix the backtrace, which doesn't identify the template that failed (within Better Errors).
33
+ # We don't know the line number, so just injecting the template path has to be enough.
34
+ e.backtrace.unshift "#{self.class.template_path(template_name)}:0"
35
+ raise
31
36
  end
32
37
 
33
38
  def do_variables(opts)
@@ -59,7 +64,23 @@ module BetterErrors
59
64
  end
60
65
 
61
66
  def exception_message
62
- exception.message.lstrip
67
+ exception.message.strip.gsub(/(\r?\n\s*\r?\n)+/, "\n")
68
+ end
69
+
70
+ def exception_hint
71
+ exception.hint
72
+ end
73
+
74
+ def active_support_actions
75
+ return [] unless defined?(ActiveSupport::ActionableError)
76
+
77
+ ActiveSupport::ActionableError.actions(exception.type)
78
+ end
79
+
80
+ def action_dispatch_action_endpoint
81
+ return unless defined?(ActionDispatch::ActionableExceptions)
82
+
83
+ ActionDispatch::ActionableExceptions.endpoint
63
84
  end
64
85
 
65
86
  def application_frames
@@ -70,9 +91,10 @@ module BetterErrors
70
91
  application_frames.first || backtrace_frames.first
71
92
  end
72
93
 
73
- private
94
+ private
95
+
74
96
  def editor_url(frame)
75
- BetterErrors.editor[frame.filename, frame.line]
97
+ BetterErrors.editor.url(frame.filename, frame.line)
76
98
  end
77
99
 
78
100
  def rack_session
@@ -104,11 +126,19 @@ module BetterErrors
104
126
  end
105
127
 
106
128
  def inspect_value(obj)
107
- CGI.escapeHTML(obj.inspect)
108
- rescue NoMethodError
109
- "<span class='unsupported'>(object doesn't support inspect)</span>"
110
- rescue Exception
111
- "<span class='unsupported'>(exception was raised in inspect)</span>"
129
+ if BetterErrors.ignored_classes.include? obj.class.name
130
+ "<span class='unsupported'>(Instance of ignored class. "\
131
+ "#{obj.class.name ? "Remove #{CGI.escapeHTML(obj.class.name)} from" : "Modify"}"\
132
+ " BetterErrors.ignored_classes if you need to see it.)</span>"
133
+ else
134
+ InspectableValue.new(obj).to_html
135
+ end
136
+ rescue BetterErrors::ValueLargerThanConfiguredMaximum
137
+ "<span class='unsupported'>(Object too large. "\
138
+ "#{obj.class.name ? "Modify #{CGI.escapeHTML(obj.class.name)}#inspect or a" : "A"}"\
139
+ "djust BetterErrors.maximum_variable_inspect_size if you need to see it.)</span>"
140
+ rescue Exception => e
141
+ "<span class='unsupported'>(exception #{CGI.escapeHTML(e.class.to_s)} was raised in inspect)</span>"
112
142
  end
113
143
 
114
144
  def eval_and_respond(index, code)
@@ -0,0 +1,29 @@
1
+ module BetterErrors
2
+ class ExceptionHint
3
+ def initialize(exception)
4
+ @exception = exception
5
+ end
6
+
7
+ def hint
8
+ case exception
9
+ when NoMethodError
10
+ /\Aundefined method `(?<method>[^']+)' for (?<val>[^:]+):(?<klass>\w+)/.match(exception.message) do |match|
11
+ if match[:val] == "nil"
12
+ return "Something is `nil` when it probably shouldn't be."
13
+ elsif !match[:klass].start_with? '0x'
14
+ return "`#{match[:method]}` is being called on a `#{match[:klass]}` object, "\
15
+ "which might not be the type of object you were expecting."
16
+ end
17
+ end
18
+ when NameError
19
+ /\Aundefined local variable or method `(?<method>[^']+)' for/.match(exception.message) do |match|
20
+ return "`#{match[:method]}` is probably misspelled."
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :exception
28
+ end
29
+ end
@@ -0,0 +1,45 @@
1
+ require "cgi"
2
+ require "objspace" rescue nil
3
+
4
+ module BetterErrors
5
+ class ValueLargerThanConfiguredMaximum < StandardError; end
6
+
7
+ class InspectableValue
8
+ def initialize(value)
9
+ @original_value = value
10
+ end
11
+
12
+ def to_html
13
+ raise ValueLargerThanConfiguredMaximum unless value_small_enough_to_inspect?
14
+ value_as_html
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :original_value
20
+
21
+ def value_as_html
22
+ @value_as_html ||= CGI.escapeHTML(value)
23
+ end
24
+
25
+ def value
26
+ @value ||= begin
27
+ if original_value.respond_to? :inspect
28
+ original_value.inspect
29
+ else
30
+ original_value
31
+ end
32
+ end
33
+ end
34
+
35
+ def value_small_enough_to_inspect?
36
+ return true if BetterErrors.maximum_variable_inspect_size.nil?
37
+
38
+ if defined?(ObjectSpace) && defined?(ObjectSpace.memsize_of) && ObjectSpace.memsize_of(value)
39
+ ObjectSpace.memsize_of(value) <= BetterErrors.maximum_variable_inspect_size
40
+ else
41
+ value_as_html.length <= BetterErrors.maximum_variable_inspect_size
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "ipaddr"
3
+ require "securerandom"
3
4
  require "set"
4
5
  require "rack"
5
6
 
@@ -33,12 +34,14 @@ module BetterErrors
33
34
  # Adds an address to the set of IP addresses allowed to access Better
34
35
  # Errors.
35
36
  def self.allow_ip!(addr)
36
- ALLOWED_IPS << IPAddr.new(addr)
37
+ ALLOWED_IPS << (addr.is_a?(IPAddr) ? addr : IPAddr.new(addr))
37
38
  end
38
39
 
39
40
  allow_ip! "127.0.0.0/8"
40
41
  allow_ip! "::1/128" rescue nil # windows ruby doesn't have ipv6 support
41
42
 
43
+ CSRF_TOKEN_COOKIE_NAME = "BetterErrors-#{BetterErrors::VERSION}-CSRF-Token"
44
+
42
45
  # A new instance of BetterErrors::Middleware
43
46
  #
44
47
  # @param app The Rack app/middleware to wrap with Better Errors
@@ -72,7 +75,7 @@ module BetterErrors
72
75
  def better_errors_call(env)
73
76
  case env["PATH_INFO"]
74
77
  when %r{/__better_errors/(?<id>.+?)/(?<method>\w+)\z}
75
- internal_call env, $~
78
+ internal_call(env, $~[:id], $~[:method])
76
79
  when %r{/__better_errors/?\z}
77
80
  show_error_page env
78
81
  else
@@ -89,11 +92,14 @@ module BetterErrors
89
92
  end
90
93
 
91
94
  def show_error_page(env, exception=nil)
95
+ request = Rack::Request.new(env)
96
+ csrf_token = request.cookies[CSRF_TOKEN_COOKIE_NAME] || SecureRandom.uuid
97
+
92
98
  type, content = if @error_page
93
99
  if text?(env)
94
100
  [ 'plain', @error_page.render('text') ]
95
101
  else
96
- [ 'html', @error_page.render ]
102
+ [ 'html', @error_page.render('main', csrf_token) ]
97
103
  end
98
104
  else
99
105
  [ 'html', no_errors_page ]
@@ -104,32 +110,57 @@ module BetterErrors
104
110
  status_code = ActionDispatch::ExceptionWrapper.new(env, exception).status_code
105
111
  end
106
112
 
107
- [status_code, { "Content-Type" => "text/#{type}; charset=utf-8" }, [content]]
113
+ response = Rack::Response.new(content, status_code, { "Content-Type" => "text/#{type}; charset=utf-8" })
114
+
115
+ unless request.cookies[CSRF_TOKEN_COOKIE_NAME]
116
+ response.set_cookie(CSRF_TOKEN_COOKIE_NAME, value: csrf_token, path: "/", httponly: true, same_site: :strict)
117
+ end
118
+
119
+ # In older versions of Rack, the body returned here is actually a Rack::BodyProxy which seems to be a bug.
120
+ # (It contains status, headers and body and does not act like an array of strings.)
121
+ # Since we already have status code and body here, there's no need to use the ones in the Rack::Response.
122
+ (_status_code, headers, _body) = response.finish
123
+ [status_code, headers, [content]]
108
124
  end
109
125
 
110
126
  def text?(env)
111
127
  env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest" ||
112
- !env["HTTP_ACCEPT"].to_s.include?('html')
128
+ !env["HTTP_ACCEPT"].to_s.include?('html')
113
129
  end
114
130
 
115
131
  def log_exception
116
132
  return unless BetterErrors.logger
117
133
 
118
134
  message = "\n#{@error_page.exception_type} - #{@error_page.exception_message}:\n"
119
- @error_page.backtrace_frames.each do |frame|
120
- message << " #{frame}\n"
121
- end
135
+ message += backtrace_frames.map { |frame| " #{frame}\n" }.join
122
136
 
123
137
  BetterErrors.logger.fatal message
124
138
  end
125
139
 
126
- def internal_call(env, opts)
140
+ def backtrace_frames
141
+ if defined?(Rails) && defined?(Rails.backtrace_cleaner)
142
+ Rails.backtrace_cleaner.clean @error_page.backtrace_frames.map(&:to_s)
143
+ else
144
+ @error_page.backtrace_frames
145
+ end
146
+ end
147
+
148
+ def internal_call(env, id, method)
149
+ return not_found_json_response unless %w[variables eval].include?(method)
127
150
  return no_errors_json_response unless @error_page
128
- return invalid_error_json_response if opts[:id] != @error_page.id
151
+ return invalid_error_json_response if id != @error_page.id
152
+
153
+ request = Rack::Request.new(env)
154
+ return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME]
155
+
156
+ request.body.rewind
157
+ body = JSON.parse(request.body.read)
158
+ return invalid_csrf_token_json_response unless request.cookies[CSRF_TOKEN_COOKIE_NAME] == body['csrfToken']
159
+
160
+ return not_acceptable_json_response unless request.content_type == 'application/json'
129
161
 
130
- env["rack.input"].rewind
131
- response = @error_page.send("do_#{opts[:method]}", JSON.parse(env["rack.input"].read))
132
- [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(response)]]
162
+ response = @error_page.send("do_#{method}", body)
163
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(response)]]
133
164
  end
134
165
 
135
166
  def no_errors_page
@@ -151,18 +182,40 @@ module BetterErrors
151
182
  "The application has been restarted since this page loaded, " +
152
183
  "or the framework is reloading all gems before each request "
153
184
  end
154
- [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(
185
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
155
186
  error: 'No exception information available',
156
187
  explanation: explanation,
157
188
  )]]
158
189
  end
159
190
 
160
191
  def invalid_error_json_response
161
- [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(
192
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
162
193
  error: "Session expired",
163
194
  explanation: "This page was likely opened from a previous exception, " +
164
195
  "and the exception is no longer available in memory.",
165
196
  )]]
166
197
  end
198
+
199
+ def invalid_csrf_token_json_response
200
+ [200, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
201
+ error: "Invalid CSRF Token",
202
+ explanation: "The browser session might have been cleared, " +
203
+ "or something went wrong.",
204
+ )]]
205
+ end
206
+
207
+ def not_found_json_response
208
+ [404, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
209
+ error: "Not found",
210
+ explanation: "Not a recognized internal call.",
211
+ )]]
212
+ end
213
+
214
+ def not_acceptable_json_response
215
+ [406, { "Content-Type" => "application/json; charset=utf-8" }, [JSON.dump(
216
+ error: "Request not acceptable",
217
+ explanation: "The internal request did not match an acceptable content type.",
218
+ )]]
219
+ end
167
220
  end
168
221
  end
@@ -1,12 +1,23 @@
1
+ require 'better_errors/exception_hint'
2
+
1
3
  # @private
2
4
  module BetterErrors
3
5
  class RaisedException
4
- attr_reader :exception, :message, :backtrace
6
+ attr_reader :exception, :message, :backtrace, :hint
5
7
 
6
8
  def initialize(exception)
7
- if exception.respond_to?(:cause)
9
+ if exception.class.name == "ActionView::Template::Error" && exception.respond_to?(:cause)
10
+ # Rails 6+ exceptions of this type wrap the "real" exception, and the real exception
11
+ # is actually more useful than the ActionView-provided wrapper. Once Better Errors
12
+ # supports showing all exceptions in the cause stack, this should go away. Or perhaps
13
+ # this can be changed to provide guidance by showing the second error in the cause stack
14
+ # under this condition.
8
15
  exception = exception.cause if exception.cause
9
16
  elsif exception.respond_to?(:original_exception) && exception.original_exception
17
+ # This supports some specific Rails exceptions, and this is not intended to act the same as
18
+ # the Ruby's {Exception#cause}.
19
+ # It's possible this should only support ActionView::Template::Error, but by not changing
20
+ # this we're preserving longstanding behavior of Better Errors with Rails < 6.
10
21
  exception = exception.original_exception
11
22
  end
12
23
 
@@ -14,6 +25,7 @@ module BetterErrors
14
25
  @message = exception.message
15
26
 
16
27
  setup_backtrace
28
+ setup_hint
17
29
  massage_syntax_error
18
30
  end
19
31
 
@@ -36,8 +48,13 @@ module BetterErrors
36
48
 
37
49
  def setup_backtrace_from_bindings
38
50
  @backtrace = exception.__better_errors_bindings_stack.map { |binding|
39
- file = binding.eval "__FILE__"
40
- line = binding.eval "__LINE__"
51
+ if binding.respond_to?(:source_location) # Ruby >= 2.6
52
+ file = binding.source_location[0]
53
+ line = binding.source_location[1]
54
+ else
55
+ file = binding.eval "__FILE__"
56
+ line = binding.eval "__LINE__"
57
+ end
41
58
  name = binding.frame_description
42
59
  StackFrame.new(file, line, name, binding)
43
60
  }
@@ -64,5 +81,9 @@ module BetterErrors
64
81
  end
65
82
  end
66
83
  end
84
+
85
+ def setup_hint
86
+ @hint = ExceptionHint.new(exception).hint
87
+ end
67
88
  end
68
89
  end
@@ -69,21 +69,26 @@ module BetterErrors
69
69
  def local_variables
70
70
  return {} unless frame_binding
71
71
 
72
- frame_binding.eval("local_variables").each_with_object({}) do |name, hash|
72
+ lv = frame_binding.eval("local_variables")
73
+ return {} unless lv
74
+
75
+ lv.each_with_object({}) do |name, hash|
73
76
  # Ruby 2.2's local_variables will include the hidden #$! variable if
74
77
  # called from within a rescue context. This is not a valid variable name,
75
78
  # so the local_variable_get method complains. This should probably be
76
79
  # considered a bug in Ruby itself, but we need to work around it.
77
80
  next if name == :"\#$!"
78
81
 
79
- if defined?(frame_binding.local_variable_get)
80
- hash[name] = frame_binding.local_variable_get(name)
81
- else
82
- hash[name] = frame_binding.eval(name.to_s)
83
- end
82
+ hash[name] = local_variable(name)
84
83
  end
85
84
  end
86
85
 
86
+ def local_variable(name)
87
+ get_local_variable(name) || eval_local_variable(name)
88
+ rescue NameError => ex
89
+ "#{ex.class}: #{ex.message}"
90
+ end
91
+
87
92
  def instance_variables
88
93
  return {} unless frame_binding
89
94
  Hash[visible_instance_variables.map { |x|
@@ -92,7 +97,10 @@ module BetterErrors
92
97
  end
93
98
 
94
99
  def visible_instance_variables
95
- frame_binding.eval("instance_variables") - BetterErrors.ignored_instance_variables
100
+ iv = frame_binding.eval("instance_variables")
101
+ return {} unless iv
102
+
103
+ iv - BetterErrors.ignored_instance_variables
96
104
  end
97
105
 
98
106
  def to_s
@@ -114,5 +122,15 @@ module BetterErrors
114
122
  @method_name = "##{method_name}"
115
123
  end
116
124
  end
125
+
126
+ def get_local_variable(name)
127
+ if defined?(frame_binding.local_variable_get)
128
+ frame_binding.local_variable_get(name)
129
+ end
130
+ end
131
+
132
+ def eval_local_variable(name)
133
+ frame_binding.eval(name.to_s)
134
+ end
117
135
  end
118
136
  end