Skip to content
This repository was archived by the owner on Nov 30, 2024. It is now read-only.

Commit eca0b40

Browse files
committed
Extract multiline failure expression by parsing Ruby source
1 parent d35a3e1 commit eca0b40

File tree

7 files changed

+495
-81
lines changed

7 files changed

+495
-81
lines changed

features/expectation_framework_integration/aggregating_failures.feature

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -59,36 +59,41 @@ Feature: Aggregating Failures
5959
# ./spec/use_block_form_spec.rb:18
6060
# ./spec/use_block_form_spec.rb:10
6161
62-
1.1.1) Failure/Error: expect(response.status).to eq(200)
62+
1.1.1) Failure/Error:
63+
expect(response.status).to eq(200)
6364
6465
expected: 200
6566
got: 404
6667
6768
(compared using ==)
6869
# ./spec/use_block_form_spec.rb:19
6970
70-
1.1.2) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json")
71+
1.1.2) Failure/Error:
72+
expect(response.headers).to include("Content-Type" => "application/json")
7173
expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"}
7274
Diff:
7375
@@ -1,2 +1,2 @@
7476
-[{"Content-Type"=>"application/json"}]
7577
+"Content-Type" => "text/plain",
7678
# ./spec/use_block_form_spec.rb:20
7779
78-
1.1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}')
80+
1.1.3) Failure/Error:
81+
expect(response.body).to eq('{"message":"Success"}')
7982
8083
expected: "{\"message\":\"Success\"}"
8184
got: "Not Found"
8285
8386
(compared using ==)
8487
# ./spec/use_block_form_spec.rb:21
8588
86-
1.2) Failure/Error: expect(false).to be(true), "after hook failure"
89+
1.2) Failure/Error:
90+
expect(false).to be(true), "after hook failure"
8791
after hook failure
8892
# ./spec/use_block_form_spec.rb:6
8993
# ./spec/use_block_form_spec.rb:10
9094
91-
1.3) Failure/Error: expect(false).to be(true), "around hook failure"
95+
1.3) Failure/Error:
96+
expect(false).to be(true), "around hook failure"
9297
around hook failure
9398
# ./spec/use_block_form_spec.rb:12
9499
"""
@@ -120,23 +125,26 @@ Feature: Aggregating Failures
120125
1) Client follows a redirect
121126
Got 2 failures and 1 other error:
122127
123-
1.1) Failure/Error: expect(response.status).to eq(302)
128+
1.1) Failure/Error:
129+
expect(response.status).to eq(302)
124130
125131
expected: 302
126132
got: 404
127133
128134
(compared using ==)
129135
# ./spec/use_metadata_spec.rb:7
130136
131-
1.2) Failure/Error: expect(response.body).to eq('{"message":"Redirect"}')
137+
1.2) Failure/Error:
138+
expect(response.body).to eq('{"message":"Redirect"}')
132139
133140
expected: "{\"message\":\"Redirect\"}"
134141
got: "Not Found"
135142
136143
(compared using ==)
137144
# ./spec/use_metadata_spec.rb:8
138145
139-
1.3) Failure/Error: redirect_response = Client.make_request(response.headers.fetch('Location'))
146+
1.3) Failure/Error:
147+
redirect_response = Client.make_request(response.headers.fetch('Location'))
140148
KeyError:
141149
key not found: "Location"
142150
# ./spec/use_metadata_spec.rb:10
@@ -172,23 +180,26 @@ Feature: Aggregating Failures
172180
1) Client returns a successful response
173181
Got 3 failures:
174182
175-
1.1) Failure/Error: expect(response.status).to eq(200)
183+
1.1) Failure/Error:
184+
expect(response.status).to eq(200)
176185
177186
expected: 200
178187
got: 404
179188
180189
(compared using ==)
181190
# ./spec/enable_globally_spec.rb:13
182191
183-
1.2) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json")
192+
1.2) Failure/Error:
193+
expect(response.headers).to include("Content-Type" => "application/json")
184194
expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"}
185195
Diff:
186196
@@ -1,2 +1,2 @@
187197
-[{"Content-Type"=>"application/json"}]
188198
+"Content-Type" => "text/plain",
189199
# ./spec/enable_globally_spec.rb:14
190200
191-
1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}')
201+
1.3) Failure/Error:
202+
expect(response.body).to eq('{"message":"Success"}')
192203
193204
expected: "{\"message\":\"Success\"}"
194205
got: "Not Found"
@@ -225,7 +236,8 @@ Feature: Aggregating Failures
225236
1) Client returns a successful response
226237
Got 3 failures:
227238
228-
1.1) Failure/Error: expect(response.status).to eq(200)
239+
1.1) Failure/Error:
240+
expect(response.status).to eq(200)
229241
230242
expected: 200
231243
got: 404
@@ -236,23 +248,26 @@ Feature: Aggregating Failures
236248
1.2) Got 2 failures from failure aggregation block "testing headers".
237249
# ./spec/nested_failure_aggregation_spec.rb:9
238250
239-
1.2.1) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json")
251+
1.2.1) Failure/Error:
252+
expect(response.headers).to include("Content-Type" => "application/json")
240253
expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"}
241254
Diff:
242255
@@ -1,2 +1,2 @@
243256
-[{"Content-Type"=>"application/json"}]
244257
+"Content-Type" => "text/plain",
245258
# ./spec/nested_failure_aggregation_spec.rb:10
246259
247-
1.2.2) Failure/Error: expect(response.headers).to include("Content-Length" => "21")
260+
1.2.2) Failure/Error:
261+
expect(response.headers).to include("Content-Length" => "21")
248262
expected {"Content-Type" => "text/plain"} to include {"Content-Length" => "21"}
249263
Diff:
250264
@@ -1,2 +1,2 @@
251265
-[{"Content-Length"=>"21"}]
252266
+"Content-Type" => "text/plain",
253267
# ./spec/nested_failure_aggregation_spec.rb:11
254268
255-
1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}')
269+
1.3) Failure/Error:
270+
expect(response.body).to eq('{"message":"Success"}')
256271
257272
expected: "{\"message\":\"Success\"}"
258273
got: "Not Found"
@@ -285,15 +300,17 @@ Feature: Aggregating Failures
285300
1) Aggregating Failures has a normal expectation failure and a message expectation failure
286301
Got 2 failures:
287302
288-
1.1) Failure/Error: expect(response.status).to eq(200)
303+
1.1) Failure/Error:
304+
expect(response.status).to eq(200)
289305
290306
expected: 200
291307
got: 404
292308
293309
(compared using ==)
294310
# ./spec/mock_expectation_failure_spec.rb:10
295311
296-
1.2) Failure/Error: expect(client).to receive(:put).with("updated data")
312+
1.2) Failure/Error:
313+
expect(client).to receive(:put).with("updated data")
297314
(Double "Client").put("updated data")
298315
expected: 1 time with arguments: ("updated data")
299316
received: 0 times
@@ -326,23 +343,26 @@ Feature: Aggregating Failures
326343
# Not yet ready
327344
Got 3 failures:
328345
329-
1.1) Failure/Error: expect(response.status).to eq(200)
346+
1.1) Failure/Error:
347+
expect(response.status).to eq(200)
330348
331349
expected: 200
332350
got: 404
333351
334352
(compared using ==)
335353
# ./spec/pending_spec.rb:8
336354
337-
1.2) Failure/Error: expect(response.headers).to include("Content-Type" => "application/json")
355+
1.2) Failure/Error:
356+
expect(response.headers).to include("Content-Type" => "application/json")
338357
expected {"Content-Type" => "text/plain"} to include {"Content-Type" => "application/json"}
339358
Diff:
340359
@@ -1,2 +1,2 @@
341360
-[{"Content-Type"=>"application/json"}]
342361
+"Content-Type" => "text/plain",
343362
# ./spec/pending_spec.rb:9
344363
345-
1.3) Failure/Error: expect(response.body).to eq('{"message":"Success"}')
364+
1.3) Failure/Error:
365+
expect(response.body).to eq('{"message":"Success"}')
346366
347367
expected: "{\"message\":\"Success\"}"
348368
got: "Not Found"

lib/rspec/core/formatters/exception_presenter.rb

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# encoding: utf-8
2+
RSpec::Support.require_rspec_core "formatters/snippet_extractor"
3+
24
module RSpec
35
module Core
46
module Formatters
@@ -138,11 +140,15 @@ def exception_class_name(exception=@exception)
138140
end
139141

140142
def failure_lines
141-
@failure_lines ||= [failure_slash_error_line] + exception_lines
143+
@failure_lines ||= failure_slash_error_lines + exception_lines
142144
end
143145

144-
def failure_slash_error_line
145-
"Failure/Error: #{read_failed_line.strip}"
146+
def failure_slash_error_lines
147+
read_failed_lines.tap do |lines|
148+
least_indentation = lines.map { |line| line.match(/^[ \t]*/)[0] }.min
149+
lines.map! { |line| ' ' + line.gsub(/^#{least_indentation}/, '') }
150+
lines.unshift('Failure/Error:')
151+
end
146152
end
147153

148154
def exception_lines
@@ -164,22 +170,20 @@ def add_shared_group_lines(lines, colorizer)
164170
lines
165171
end
166172

167-
def read_failed_line
173+
def read_failed_lines
168174
matching_line = find_failed_line
169175
unless matching_line
170-
return "Unable to find matching line from backtrace"
176+
return ["Unable to find matching line from backtrace"]
171177
end
172178

173179
file_path, line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)[1..2]
174-
175-
if File.exist?(file_path)
176-
File.readlines(file_path)[line_number.to_i - 1] ||
177-
"Unable to find matching line in #{file_path}"
178-
else
179-
"Unable to find #{file_path} to read failed line"
180-
end
180+
SnippetExtractor.extract_expression_lines_at(file_path, line_number.to_i)
181+
rescue SnippetExtractor::NoSuchFileError
182+
["Unable to find #{file_path} to read failed line"]
183+
rescue SnippetExtractor::NoSuchLineError
184+
["Unable to find matching line in #{file_path}"]
181185
rescue SecurityError
182-
"Unable to read failed line"
186+
["Unable to read failed line"]
183187
end
184188

185189
def find_failed_line
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
RSpec::Support.require_rspec_core "source"
2+
3+
module RSpec
4+
module Core
5+
module Formatters
6+
# @private
7+
class SnippetExtractor
8+
NoSuchFileError = Class.new(StandardError)
9+
NoSuchLineError = Class.new(StandardError)
10+
11+
def self.extract_line_at(file_path, line_number)
12+
raise_error_if_file_does_not_exist!(file_path)
13+
line = File.readlines(file_path)[line_number - 1]
14+
raise NoSuchLineError unless line
15+
line.chomp
16+
end
17+
18+
def self.raise_error_if_file_does_not_exist!(path)
19+
raise NoSuchFileError unless File.exist?(path)
20+
end
21+
22+
if RSpec::Support::RubyFeatures.ripper_supported?
23+
NoExpressionAtLineError = Class.new(StandardError)
24+
25+
PAREN_TOKEN_TYPE_PAIRS = {
26+
:on_lbracket => :on_rbracket,
27+
:on_lparen => :on_rparen,
28+
:on_lbrace => :on_rbrace,
29+
:on_heredoc_beg => :on_heredoc_end
30+
}
31+
32+
attr_reader :source, :beginning_line_number
33+
34+
def self.extract_expression_lines_at(file_path, beginning_line_number)
35+
source = source_from_file(file_path)
36+
new(source, beginning_line_number).expression_lines
37+
end
38+
39+
def self.source_from_file(path)
40+
raise_error_if_file_does_not_exist!(path)
41+
@source_cache ||= {}
42+
@source_cache[path] ||= Source.from_file(path)
43+
end
44+
45+
def initialize(source, beginning_line_number)
46+
@source = source
47+
@beginning_line_number = beginning_line_number
48+
end
49+
50+
def expression_lines
51+
line_range = line_range_of_expression
52+
source.lines[(line_range.begin - 1)..(line_range.end - 1)]
53+
rescue NoExpressionAtLineError
54+
[self.class.extract_line_at(source.path, beginning_line_number)]
55+
end
56+
57+
private
58+
59+
def line_range_of_expression
60+
@line_range_of_expression ||= begin
61+
line_range = line_range_of_location_nodes_in_expression
62+
initial_unclosed_parens = unclosed_paren_tokens_in_line_range(line_range)
63+
unclosed_parens = initial_unclosed_parens
64+
65+
until (initial_unclosed_parens & unclosed_parens).empty?
66+
line_range = (line_range.begin)..(line_range.end + 1)
67+
unclosed_parens = unclosed_paren_tokens_in_line_range(line_range)
68+
end
69+
70+
line_range
71+
end
72+
end
73+
74+
def unclosed_paren_tokens_in_line_range(line_range)
75+
tokens = FlatMap.flat_map(line_range) do |line_number|
76+
source.tokens_by_line_number[line_number]
77+
end
78+
79+
tokens.each_with_object([]) do |token, unclosed_tokens|
80+
if PAREN_TOKEN_TYPE_PAIRS.keys.include?(token.type)
81+
unclosed_tokens << token
82+
else
83+
index = unclosed_tokens.rindex do |unclosed_token|
84+
PAREN_TOKEN_TYPE_PAIRS[unclosed_token.type] == token.type
85+
end
86+
unclosed_tokens.delete_at(index) if index
87+
end
88+
end
89+
end
90+
91+
def line_range_of_location_nodes_in_expression
92+
line_numbers = expression_node.each_with_object(Set.new) do |node, set|
93+
set << node.location.line if node.location
94+
end
95+
96+
line_numbers.min..line_numbers.max
97+
end
98+
99+
def expression_node
100+
raise NoExpressionAtLineError if location_nodes_at_beginning_line.empty?
101+
102+
# Finding the lowest common ancestor node of nodes at the beginning line.
103+
@expression_node ||= location_nodes_at_beginning_line.map do |node|
104+
node.each_ancestor.to_a
105+
end.reduce(:&).first
106+
end
107+
108+
def location_nodes_at_beginning_line
109+
source.nodes_by_line_number[beginning_line_number]
110+
end
111+
else
112+
# :nocov:
113+
def self.extract_expression_lines_at(file_path, beginning_line_number)
114+
[extract_line_at(file_path, beginning_line_number)]
115+
end
116+
# :nocov:
117+
end
118+
end
119+
end
120+
end
121+
end

0 commit comments

Comments
 (0)