better_errors-creditkudos 2.1.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.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.travis.yml +8 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +11 -0
- data/LICENSE.txt +22 -0
- data/README.md +128 -0
- data/Rakefile +13 -0
- data/better_errors-creditkudos.gemspec +28 -0
- data/lib/better_errors.rb +152 -0
- data/lib/better_errors/code_formatter.rb +63 -0
- data/lib/better_errors/code_formatter/html.rb +26 -0
- data/lib/better_errors/code_formatter/text.rb +14 -0
- data/lib/better_errors/error_page.rb +129 -0
- data/lib/better_errors/exception_extension.rb +17 -0
- data/lib/better_errors/middleware.rb +141 -0
- data/lib/better_errors/rails.rb +28 -0
- data/lib/better_errors/raised_exception.rb +68 -0
- data/lib/better_errors/repl.rb +30 -0
- data/lib/better_errors/repl/basic.rb +20 -0
- data/lib/better_errors/repl/pry.rb +78 -0
- data/lib/better_errors/stack_frame.rb +111 -0
- data/lib/better_errors/templates/main.erb +1032 -0
- data/lib/better_errors/templates/text.erb +21 -0
- data/lib/better_errors/templates/variable_info.erb +72 -0
- data/lib/better_errors/version.rb +3 -0
- data/spec/better_errors/code_formatter_spec.rb +92 -0
- data/spec/better_errors/error_page_spec.rb +122 -0
- data/spec/better_errors/middleware_spec.rb +180 -0
- data/spec/better_errors/raised_exception_spec.rb +72 -0
- data/spec/better_errors/repl/basic_spec.rb +18 -0
- data/spec/better_errors/repl/pry_spec.rb +40 -0
- data/spec/better_errors/repl/shared_examples.rb +18 -0
- data/spec/better_errors/stack_frame_spec.rb +157 -0
- data/spec/better_errors/support/my_source.rb +20 -0
- data/spec/better_errors_spec.rb +73 -0
- data/spec/spec_helper.rb +5 -0
- data/spec/without_binding_of_caller.rb +9 -0
- metadata +136 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
<%== text_heading("=", "%s at %s" % [exception_type, request_path]) %>
|
2
|
+
|
3
|
+
> <%== exception_message %>
|
4
|
+
<% if backtrace_frames.any? %>
|
5
|
+
|
6
|
+
<%== text_heading("-", "%s, line %i" % [first_frame.pretty_path, first_frame.line]) %>
|
7
|
+
|
8
|
+
``` ruby
|
9
|
+
<%== text_formatted_code_block(first_frame) %>```
|
10
|
+
|
11
|
+
App backtrace
|
12
|
+
-------------
|
13
|
+
|
14
|
+
<%== application_frames.map { |s| " - #{s}" }.join("\n") %>
|
15
|
+
|
16
|
+
Full backtrace
|
17
|
+
--------------
|
18
|
+
|
19
|
+
<%== backtrace_frames.map { |s| " - #{s}" }.join("\n") %>
|
20
|
+
|
21
|
+
<% end %>
|
@@ -0,0 +1,72 @@
|
|
1
|
+
<header class="trace_info clearfix">
|
2
|
+
<div class="title">
|
3
|
+
<h2 class="name"><%= @frame.name %></h2>
|
4
|
+
<div class="location"><span class="filename"><a href="<%= editor_url(@frame) %>"><%= @frame.pretty_path %></a></span></div>
|
5
|
+
</div>
|
6
|
+
<div class="code_block clearfix">
|
7
|
+
<%== html_formatted_code_block @frame %>
|
8
|
+
</div>
|
9
|
+
|
10
|
+
<% if BetterErrors.binding_of_caller_available? && @frame.frame_binding %>
|
11
|
+
<div class="repl">
|
12
|
+
<div class="console">
|
13
|
+
<pre></pre>
|
14
|
+
<div class="prompt"><span>>></span> <input/></div>
|
15
|
+
</div>
|
16
|
+
</div>
|
17
|
+
<% end %>
|
18
|
+
</header>
|
19
|
+
|
20
|
+
<% if BetterErrors.binding_of_caller_available? && @frame.frame_binding %>
|
21
|
+
<div class="hint">
|
22
|
+
This is a live shell. Type in here.
|
23
|
+
</div>
|
24
|
+
|
25
|
+
<div class="variable_info"></div>
|
26
|
+
<% end %>
|
27
|
+
|
28
|
+
<% unless BetterErrors.binding_of_caller_available? %>
|
29
|
+
<div class="hint">
|
30
|
+
<strong>Tip:</strong> add <code>gem "binding_of_caller"</code> to your Gemfile to enable the REPL and local/instance variable inspection.
|
31
|
+
</div>
|
32
|
+
<% end %>
|
33
|
+
|
34
|
+
<div class="sub">
|
35
|
+
<h3>Request info</h3>
|
36
|
+
<div class='inset variables'>
|
37
|
+
<table class="var_table">
|
38
|
+
<% if rails_params %>
|
39
|
+
<tr><td class="name">Request parameters</td><td><pre><%== inspect_value rails_params %></pre></td></tr>
|
40
|
+
<% end %>
|
41
|
+
<% if rack_session %>
|
42
|
+
<tr><td class="name">Rack session</td><td><pre><%== inspect_value rack_session %></pre></td></tr>
|
43
|
+
<% end %>
|
44
|
+
</table>
|
45
|
+
</div>
|
46
|
+
</div>
|
47
|
+
|
48
|
+
<% if BetterErrors.binding_of_caller_available? && @frame.frame_binding %>
|
49
|
+
<div class="sub">
|
50
|
+
<h3>Local Variables</h3>
|
51
|
+
<div class='inset variables'>
|
52
|
+
<table class="var_table">
|
53
|
+
<% @frame.local_variables.each do |name, value| %>
|
54
|
+
<tr><td class="name"><%= name %></td><td><pre><%== inspect_value value %></pre></td></tr>
|
55
|
+
<% end %>
|
56
|
+
</table>
|
57
|
+
</div>
|
58
|
+
</div>
|
59
|
+
|
60
|
+
<div class="sub">
|
61
|
+
<h3>Instance Variables</h3>
|
62
|
+
<div class="inset variables">
|
63
|
+
<table class="var_table">
|
64
|
+
<% @frame.instance_variables.each do |name, value| %>
|
65
|
+
<tr><td class="name"><%= name %></td><td><pre><%== inspect_value value %></pre></td></tr>
|
66
|
+
<% end %>
|
67
|
+
</table>
|
68
|
+
</div>
|
69
|
+
</div>
|
70
|
+
|
71
|
+
<!-- <%= Time.now.to_f - @var_start_time %> seconds -->
|
72
|
+
<% end %>
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module BetterErrors
|
4
|
+
describe CodeFormatter do
|
5
|
+
let(:filename) { File.expand_path("../support/my_source.rb", __FILE__) }
|
6
|
+
|
7
|
+
let(:formatter) { CodeFormatter.new(filename, 8) }
|
8
|
+
|
9
|
+
it "picks an appropriate scanner" do
|
10
|
+
formatter.coderay_scanner.should == :ruby
|
11
|
+
end
|
12
|
+
|
13
|
+
it "shows 5 lines of context" do
|
14
|
+
formatter.line_range.should == (3..13)
|
15
|
+
|
16
|
+
formatter.context_lines.should == [
|
17
|
+
"three\n",
|
18
|
+
"four\n",
|
19
|
+
"five\n",
|
20
|
+
"six\n",
|
21
|
+
"seven\n",
|
22
|
+
"eight\n",
|
23
|
+
"nine\n",
|
24
|
+
"ten\n",
|
25
|
+
"eleven\n",
|
26
|
+
"twelve\n",
|
27
|
+
"thirteen\n"
|
28
|
+
]
|
29
|
+
end
|
30
|
+
|
31
|
+
it "works when the line is right on the edge" do
|
32
|
+
formatter = CodeFormatter.new(filename, 20)
|
33
|
+
formatter.line_range.should == (15..20)
|
34
|
+
end
|
35
|
+
|
36
|
+
describe CodeFormatter::HTML do
|
37
|
+
it "highlights the erroring line" do
|
38
|
+
formatter = CodeFormatter::HTML.new(filename, 8)
|
39
|
+
formatter.output.should =~ /highlight.*eight/
|
40
|
+
end
|
41
|
+
|
42
|
+
it "works when the line is right on the edge" do
|
43
|
+
formatter = CodeFormatter::HTML.new(filename, 20)
|
44
|
+
formatter.output.should_not == formatter.source_unavailable
|
45
|
+
end
|
46
|
+
|
47
|
+
it "doesn't barf when the lines don't make any sense" do
|
48
|
+
formatter = CodeFormatter::HTML.new(filename, 999)
|
49
|
+
formatter.output.should == formatter.source_unavailable
|
50
|
+
end
|
51
|
+
|
52
|
+
it "doesn't barf when the file doesn't exist" do
|
53
|
+
formatter = CodeFormatter::HTML.new("fkdguhskd7e l", 1)
|
54
|
+
formatter.output.should == formatter.source_unavailable
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe CodeFormatter::Text do
|
59
|
+
it "highlights the erroring line" do
|
60
|
+
formatter = CodeFormatter::Text.new(filename, 8)
|
61
|
+
formatter.output.should == <<-TEXT.gsub(/^ /, "")
|
62
|
+
3 three
|
63
|
+
4 four
|
64
|
+
5 five
|
65
|
+
6 six
|
66
|
+
7 seven
|
67
|
+
> 8 eight
|
68
|
+
9 nine
|
69
|
+
10 ten
|
70
|
+
11 eleven
|
71
|
+
12 twelve
|
72
|
+
13 thirteen
|
73
|
+
TEXT
|
74
|
+
end
|
75
|
+
|
76
|
+
it "works when the line is right on the edge" do
|
77
|
+
formatter = CodeFormatter::Text.new(filename, 20)
|
78
|
+
formatter.output.should_not == formatter.source_unavailable
|
79
|
+
end
|
80
|
+
|
81
|
+
it "doesn't barf when the lines don't make any sense" do
|
82
|
+
formatter = CodeFormatter::Text.new(filename, 999)
|
83
|
+
formatter.output.should == formatter.source_unavailable
|
84
|
+
end
|
85
|
+
|
86
|
+
it "doesn't barf when the file doesn't exist" do
|
87
|
+
formatter = CodeFormatter::Text.new("fkdguhskd7e l", 1)
|
88
|
+
formatter.output.should == formatter.source_unavailable
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module BetterErrors
|
4
|
+
describe ErrorPage do
|
5
|
+
let!(:exception) { raise ZeroDivisionError, "you divided by zero you silly goose!" rescue $! }
|
6
|
+
|
7
|
+
let(:error_page) { ErrorPage.new exception, { "PATH_INFO" => "/some/path" } }
|
8
|
+
|
9
|
+
let(:response) { error_page.render }
|
10
|
+
|
11
|
+
let(:empty_binding) {
|
12
|
+
local_a = :value_for_local_a
|
13
|
+
local_b = :value_for_local_b
|
14
|
+
|
15
|
+
@inst_c = :value_for_inst_c
|
16
|
+
@inst_d = :value_for_inst_d
|
17
|
+
|
18
|
+
binding
|
19
|
+
}
|
20
|
+
|
21
|
+
it "includes the error message" do
|
22
|
+
response.should include("you divided by zero you silly goose!")
|
23
|
+
end
|
24
|
+
|
25
|
+
it "includes the request path" do
|
26
|
+
response.should include("/some/path")
|
27
|
+
end
|
28
|
+
|
29
|
+
it "includes the exception class" do
|
30
|
+
response.should include("ZeroDivisionError")
|
31
|
+
end
|
32
|
+
|
33
|
+
context "variable inspection" do
|
34
|
+
let(:exception) { empty_binding.eval("raise") rescue $! }
|
35
|
+
|
36
|
+
if BetterErrors.binding_of_caller_available?
|
37
|
+
it "shows local variables" do
|
38
|
+
html = error_page.do_variables("index" => 0)[:html]
|
39
|
+
html.should include("local_a")
|
40
|
+
html.should include(":value_for_local_a")
|
41
|
+
html.should include("local_b")
|
42
|
+
html.should include(":value_for_local_b")
|
43
|
+
end
|
44
|
+
else
|
45
|
+
it "tells the user to add binding_of_caller to their gemfile to get fancy features" do
|
46
|
+
html = error_page.do_variables("index" => 0)[:html]
|
47
|
+
html.should include(%{gem "binding_of_caller"})
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
it "shows instance variables" do
|
52
|
+
html = error_page.do_variables("index" => 0)[:html]
|
53
|
+
html.should include("inst_c")
|
54
|
+
html.should include(":value_for_inst_c")
|
55
|
+
html.should include("inst_d")
|
56
|
+
html.should include(":value_for_inst_d")
|
57
|
+
end
|
58
|
+
|
59
|
+
it "shows filter instance variables" do
|
60
|
+
BetterErrors.stub(:ignored_instance_variables).and_return([ :@inst_d ])
|
61
|
+
html = error_page.do_variables("index" => 0)[:html]
|
62
|
+
html.should include("inst_c")
|
63
|
+
html.should include(":value_for_inst_c")
|
64
|
+
html.should_not include('<td class="name">@inst_d</td>')
|
65
|
+
html.should_not include("<pre>:value_for_inst_d</pre>")
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
it "doesn't die if the source file is not a real filename" do
|
70
|
+
exception.stub(:backtrace).and_return([
|
71
|
+
"<internal:prelude>:10:in `spawn_rack_application'"
|
72
|
+
])
|
73
|
+
response.should include("Source unavailable")
|
74
|
+
end
|
75
|
+
|
76
|
+
context 'with an exception with blank lines' do
|
77
|
+
class SpacedError < StandardError
|
78
|
+
def initialize(message = nil)
|
79
|
+
message = "\n\n#{message}" if message
|
80
|
+
super
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
let!(:exception) { raise SpacedError, "Danger Warning!" rescue $! }
|
85
|
+
|
86
|
+
it 'should not include leading blank lines from exception_message' do
|
87
|
+
exception.message.should =~ /\A\n\n/
|
88
|
+
error_page.exception_message.should_not =~ /\A\n\n/
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
context 'with an inspect size limit set' do
|
93
|
+
before { BetterErrors.maximum_variable_inspect_size = 50_000 }
|
94
|
+
|
95
|
+
it "shows variables with inspects that are below the inspect size threshold" do
|
96
|
+
content = 'AAAAA'
|
97
|
+
empty_binding.instance_variable_set('@small', content)
|
98
|
+
|
99
|
+
html = error_page.do_variables("index" => 0)[:html]
|
100
|
+
html.should_not include "object too large"
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
it "hides variables with inspects that are above the inspect size threshold" do
|
105
|
+
content = 'A' * (BetterErrors.maximum_variable_inspect_size)
|
106
|
+
empty_binding.instance_variable_set('@big', content)
|
107
|
+
|
108
|
+
html = error_page.do_variables("index" => 0)[:html]
|
109
|
+
html.should include "object too large"
|
110
|
+
end
|
111
|
+
|
112
|
+
it "shows variables with large inspects if max inspect size is disabled" do
|
113
|
+
content = 'A' * (BetterErrors.maximum_variable_inspect_size)
|
114
|
+
BetterErrors.maximum_variable_inspect_size = nil
|
115
|
+
empty_binding.instance_variable_set('@big', content)
|
116
|
+
|
117
|
+
html = error_page.do_variables("index" => 0)[:html]
|
118
|
+
html.should_not include "object too large"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module BetterErrors
|
4
|
+
describe Middleware do
|
5
|
+
let(:app) { Middleware.new(->env { ":)" }) }
|
6
|
+
let(:exception) { RuntimeError.new("oh no :(") }
|
7
|
+
|
8
|
+
it "passes non-error responses through" do
|
9
|
+
app.call({}).should == ":)"
|
10
|
+
end
|
11
|
+
|
12
|
+
it "calls the internal methods" do
|
13
|
+
app.should_receive :internal_call
|
14
|
+
app.call("PATH_INFO" => "/__better_errors/1/preform_awesomness")
|
15
|
+
end
|
16
|
+
|
17
|
+
it "calls the internal methods on any subfolder path" do
|
18
|
+
app.should_receive :internal_call
|
19
|
+
app.call("PATH_INFO" => "/any_sub/folder/path/__better_errors/1/preform_awesomness")
|
20
|
+
end
|
21
|
+
|
22
|
+
it "shows the error page" do
|
23
|
+
app.should_receive :show_error_page
|
24
|
+
app.call("PATH_INFO" => "/__better_errors/")
|
25
|
+
end
|
26
|
+
|
27
|
+
it "shows the error page on any subfolder path" do
|
28
|
+
app.should_receive :show_error_page
|
29
|
+
app.call("PATH_INFO" => "/any_sub/folder/path/__better_errors/")
|
30
|
+
end
|
31
|
+
|
32
|
+
it "doesn't show the error page to a non-local address" do
|
33
|
+
app.should_not_receive :better_errors_call
|
34
|
+
app.call("REMOTE_ADDR" => "1.2.3.4")
|
35
|
+
end
|
36
|
+
|
37
|
+
it "shows to a whitelisted IP" do
|
38
|
+
BetterErrors::Middleware.allow_ip! '77.55.33.11'
|
39
|
+
app.should_receive :better_errors_call
|
40
|
+
app.call("REMOTE_ADDR" => "77.55.33.11")
|
41
|
+
end
|
42
|
+
|
43
|
+
it "respects the X-Forwarded-For header" do
|
44
|
+
app.should_not_receive :better_errors_call
|
45
|
+
app.call(
|
46
|
+
"REMOTE_ADDR" => "127.0.0.1",
|
47
|
+
"HTTP_X_FORWARDED_FOR" => "1.2.3.4",
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
it "doesn't blow up when given a blank REMOTE_ADDR" do
|
52
|
+
expect { app.call("REMOTE_ADDR" => " ") }.to_not raise_error
|
53
|
+
end
|
54
|
+
|
55
|
+
it "doesn't blow up when given an IP address with a zone index" do
|
56
|
+
expect { app.call("REMOTE_ADDR" => "0:0:0:0:0:0:0:1%0" ) }.to_not raise_error
|
57
|
+
end
|
58
|
+
|
59
|
+
context "when requesting the /__better_errors manually" do
|
60
|
+
let(:app) { Middleware.new(->env { ":)" }) }
|
61
|
+
|
62
|
+
it "shows that no errors have been recorded" do
|
63
|
+
status, headers, body = app.call("PATH_INFO" => "/__better_errors")
|
64
|
+
body.join.should match /No errors have been recorded yet./
|
65
|
+
end
|
66
|
+
|
67
|
+
it "shows that no errors have been recorded on any subfolder path" do
|
68
|
+
status, headers, body = app.call("PATH_INFO" => "/any_sub/folder/path/__better_errors")
|
69
|
+
body.join.should match /No errors have been recorded yet./
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
context "when handling an error" do
|
74
|
+
let(:app) { Middleware.new(->env { raise exception }) }
|
75
|
+
|
76
|
+
it "returns status 500" do
|
77
|
+
status, headers, body = app.call({})
|
78
|
+
|
79
|
+
status.should == 500
|
80
|
+
end
|
81
|
+
|
82
|
+
if Exception.new.respond_to?(:cause)
|
83
|
+
context "cause" do
|
84
|
+
class OtherException < Exception
|
85
|
+
def initialize(message)
|
86
|
+
super(message)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
it "shows Original Exception if it responds_to and has an cause" do
|
91
|
+
app = Middleware.new(->env {
|
92
|
+
begin
|
93
|
+
raise "Original Exception"
|
94
|
+
rescue
|
95
|
+
raise OtherException.new("Other Exception")
|
96
|
+
end
|
97
|
+
})
|
98
|
+
|
99
|
+
status, _, body = app.call({})
|
100
|
+
|
101
|
+
status.should == 500
|
102
|
+
body.join.should_not match(/\n> Other Exception\n/)
|
103
|
+
body.join.should match(/\n> Original Exception\n/)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
else
|
107
|
+
context "original_exception" do
|
108
|
+
class OriginalExceptionException < Exception
|
109
|
+
attr_reader :original_exception
|
110
|
+
|
111
|
+
def initialize(message, original_exception = nil)
|
112
|
+
super(message)
|
113
|
+
@original_exception = original_exception
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
it "shows Original Exception if it responds_to and has an original_exception" do
|
118
|
+
app = Middleware.new(->env {
|
119
|
+
raise OriginalExceptionException.new("Other Exception", Exception.new("Original Exception"))
|
120
|
+
})
|
121
|
+
|
122
|
+
status, _, body = app.call({})
|
123
|
+
|
124
|
+
status.should == 500
|
125
|
+
body.join.should_not match(/Other Exception/)
|
126
|
+
body.join.should match(/Original Exception/)
|
127
|
+
end
|
128
|
+
|
129
|
+
it "won't crash if the exception responds_to but doesn't have an original_exception" do
|
130
|
+
app = Middleware.new(->env {
|
131
|
+
raise OriginalExceptionException.new("Other Exception")
|
132
|
+
})
|
133
|
+
|
134
|
+
status, _, body = app.call({})
|
135
|
+
|
136
|
+
status.should == 500
|
137
|
+
body.join.should match(/Other Exception/)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
it "returns ExceptionWrapper's status_code" do
|
143
|
+
ad_ew = double("ActionDispatch::ExceptionWrapper")
|
144
|
+
ad_ew.stub('new').with({}, exception ){ double("ExceptionWrapper", status_code: 404) }
|
145
|
+
stub_const('ActionDispatch::ExceptionWrapper', ad_ew)
|
146
|
+
|
147
|
+
status, headers, body = app.call({})
|
148
|
+
|
149
|
+
status.should == 404
|
150
|
+
end
|
151
|
+
|
152
|
+
it "returns UTF-8 error pages" do
|
153
|
+
status, headers, body = app.call({})
|
154
|
+
|
155
|
+
headers["Content-Type"].should match /charset=utf-8/
|
156
|
+
end
|
157
|
+
|
158
|
+
it "returns text pages by default" do
|
159
|
+
status, headers, body = app.call({})
|
160
|
+
|
161
|
+
headers["Content-Type"].should match /text\/plain/
|
162
|
+
end
|
163
|
+
|
164
|
+
it "returns HTML pages by default" do
|
165
|
+
# Chrome's 'Accept' header looks similar this.
|
166
|
+
status, headers, body = app.call("HTTP_ACCEPT" => "text/html,application/xhtml+xml;q=0.9,*/*")
|
167
|
+
|
168
|
+
headers["Content-Type"].should match /text\/html/
|
169
|
+
end
|
170
|
+
|
171
|
+
it "logs the exception" do
|
172
|
+
logger = Object.new
|
173
|
+
logger.should_receive :fatal
|
174
|
+
BetterErrors.stub(:logger).and_return(logger)
|
175
|
+
|
176
|
+
app.call({})
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|