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

Commit 438512e

Browse files
committed
Extract multiline failure expression by parsing Ruby source
1 parent fed7c4c commit 438512e

File tree

8 files changed

+480
-24
lines changed

8 files changed

+480
-24
lines changed

lib/rspec/core/formatters/exception_presenter.rb

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

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

144-
def failure_slash_error_line
145-
"Failure/Error: #{read_failed_line.strip}"
146+
def failure_slash_error_lines
147+
lines = read_failed_lines
148+
if lines.count == 1
149+
lines[0] = "Failure/Error: #{lines[0].strip}"
150+
else
151+
least_indentation = lines.map { |line| line[/^[ \t]*/] }.min
152+
lines = lines.map { |line| line.sub(/^#{least_indentation}/, ' ') }
153+
lines.unshift('Failure/Error:')
154+
end
155+
lines
146156
end
147157

148158
def exception_lines
@@ -175,22 +185,21 @@ def add_shared_group_lines(lines, colorizer)
175185
lines
176186
end
177187

178-
def read_failed_line
188+
def read_failed_lines
179189
matching_line = find_failed_line
180190
unless matching_line
181-
return "Unable to find matching line from backtrace"
191+
return ["Unable to find matching line from backtrace"]
182192
end
183193

184194
file_path, line_number = matching_line.match(/(.+?):(\d+)(|:\d+)/)[1..2]
185-
186-
if File.exist?(file_path)
187-
File.readlines(file_path)[line_number.to_i - 1] ||
188-
"Unable to find matching line in #{file_path}"
189-
else
190-
"Unable to find #{file_path} to read failed line"
191-
end
195+
RSpec::Support.require_rspec_core "formatters/snippet_extractor"
196+
SnippetExtractor.extract_expression_lines_at(file_path, line_number.to_i)
197+
rescue SnippetExtractor::NoSuchFileError
198+
["Unable to find #{file_path} to read failed line"]
199+
rescue SnippetExtractor::NoSuchLineError
200+
["Unable to find matching line in #{file_path}"]
192201
rescue SecurityError
193-
"Unable to read failed line"
202+
["Unable to read failed line"]
194203
end
195204

196205
def find_failed_line
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
source = source_from_file(file_path)
13+
line = source.lines[line_number - 1]
14+
raise NoSuchLineError unless line
15+
line
16+
end
17+
18+
def self.source_from_file(path)
19+
raise NoSuchFileError unless File.exist?(path)
20+
RSpec.world.source_cache.source_from_file(path)
21+
end
22+
23+
if RSpec::Support::RubyFeatures.ripper_supported?
24+
NoExpressionAtLineError = Class.new(StandardError)
25+
26+
PAREN_TOKEN_TYPE_PAIRS = {
27+
:on_lbracket => :on_rbracket,
28+
:on_lparen => :on_rparen,
29+
:on_lbrace => :on_rbrace,
30+
:on_heredoc_beg => :on_heredoc_end
31+
}
32+
33+
attr_reader :source, :beginning_line_number
34+
35+
def self.extract_expression_lines_at(file_path, beginning_line_number)
36+
source = source_from_file(file_path)
37+
new(source, beginning_line_number).expression_lines
38+
end
39+
40+
def initialize(source, beginning_line_number)
41+
@source = source
42+
@beginning_line_number = beginning_line_number
43+
end
44+
45+
def expression_lines
46+
line_range = line_range_of_expression
47+
source.lines[(line_range.begin - 1)..(line_range.end - 1)]
48+
rescue NoExpressionAtLineError
49+
[self.class.extract_line_at(source.path, beginning_line_number)]
50+
end
51+
52+
private
53+
54+
def line_range_of_expression
55+
@line_range_of_expression ||= begin
56+
line_range = line_range_of_location_nodes_in_expression
57+
initial_unclosed_parens = unclosed_paren_tokens_in_line_range(line_range)
58+
unclosed_parens = initial_unclosed_parens
59+
60+
until (initial_unclosed_parens & unclosed_parens).empty?
61+
line_range = (line_range.begin)..(line_range.end + 1)
62+
unclosed_parens = unclosed_paren_tokens_in_line_range(line_range)
63+
end
64+
65+
line_range
66+
end
67+
end
68+
69+
def unclosed_paren_tokens_in_line_range(line_range)
70+
tokens = FlatMap.flat_map(line_range) do |line_number|
71+
source.tokens_by_line_number[line_number]
72+
end
73+
74+
tokens.each_with_object([]) do |token, unclosed_tokens|
75+
if PAREN_TOKEN_TYPE_PAIRS.keys.include?(token.type)
76+
unclosed_tokens << token
77+
else
78+
index = unclosed_tokens.rindex do |unclosed_token|
79+
PAREN_TOKEN_TYPE_PAIRS[unclosed_token.type] == token.type
80+
end
81+
unclosed_tokens.delete_at(index) if index
82+
end
83+
end
84+
end
85+
86+
def line_range_of_location_nodes_in_expression
87+
line_numbers = expression_node.each_with_object(Set.new) do |node, set|
88+
set << node.location.line if node.location
89+
end
90+
91+
line_numbers.min..line_numbers.max
92+
end
93+
94+
def expression_node
95+
raise NoExpressionAtLineError if location_nodes_at_beginning_line.empty?
96+
97+
@expression_node ||= begin
98+
common_ancestor_nodes = location_nodes_at_beginning_line.map do |node|
99+
node.each_ancestor.to_a
100+
end.reduce(:&)
101+
102+
common_ancestor_nodes.find { |node| expression_outmost_node?(node) }
103+
end
104+
end
105+
106+
def expression_outmost_node?(node)
107+
return true unless node.parent
108+
return false if node.type.to_s.start_with?('@')
109+
![node, node.parent].all? do |n|
110+
# See `Ripper::PARSER_EVENTS` for the complete list of sexp types.
111+
type = n.type.to_s
112+
type.end_with?('call') || type.start_with?('method_add_')
113+
end
114+
end
115+
116+
def location_nodes_at_beginning_line
117+
source.nodes_by_line_number[beginning_line_number]
118+
end
119+
else
120+
# :nocov:
121+
def self.extract_expression_lines_at(file_path, beginning_line_number)
122+
[extract_line_at(file_path, beginning_line_number)]
123+
end
124+
# :nocov:
125+
end
126+
end
127+
end
128+
end
129+
end

lib/rspec/core/notifications.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
RSpec::Support.require_rspec_core "formatters/exception_presenter"
22
RSpec::Support.require_rspec_core "formatters/helpers"
33
RSpec::Support.require_rspec_core "shell_escape"
4-
RSpec::Support.require_rspec_support "encoded_string"
54

65
module RSpec::Core
76
# Notifications are value objects passed to formatters to provide them

lib/rspec/core/source.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ def tokens_by_line_number
5555
def inspect
5656
"#<#{self.class} #{path}>"
5757
end
58+
59+
# @private
60+
class Cache
61+
def initialize
62+
@sources_by_path = {}
63+
end
64+
65+
def source_from_file(path)
66+
@sources_by_path[path] ||= Source.from_file(path)
67+
end
68+
end
5869
end
5970
end
6071
end

lib/rspec/core/world.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ def reporter
107107
@configuration.reporter
108108
end
109109

110+
# @private
111+
def source_cache
112+
@source_cache ||= begin
113+
RSpec::Support.require_rspec_core "source"
114+
Source::Cache.new
115+
end
116+
end
117+
110118
# @api private
111119
#
112120
# Notify reporter of filters.

spec/rspec/core/formatters/exception_presenter_spec.rb

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ module RSpec::Core
6262
EOS
6363
end
6464

65-
it 'passes the indentation on to the `:detail_formatter` lambda so it can align things' do
65+
it 'aligns lines' do
6666
detail_formatter = Proc.new { "Some Detail" }
6767

6868
the_presenter = Formatters::ExceptionPresenter.new(exception, example, :indentation => 4,
@@ -169,9 +169,27 @@ module RSpec::Core
169169
EOS
170170
end
171171
end
172-
describe "#read_failed_line" do
173-
def read_failed_line
174-
presenter.send(:read_failed_line)
172+
describe "#read_failed_lines" do
173+
def read_failed_lines
174+
presenter.send(:read_failed_lines)
175+
end
176+
177+
context 'when the failed expression spans multiple lines', :if => RSpec::Support::RubyFeatures.ripper_supported? do
178+
let(:exception) do
179+
begin
180+
expect('RSpec').to start_with('R').
181+
and end_with('z')
182+
rescue RSpec::Expectations::ExpectationNotMetError => exception
183+
exception
184+
end
185+
end
186+
187+
it 'returns all the lines' do
188+
expect(read_failed_lines).to eq([
189+
" expect('RSpec').to start_with('R').",
190+
" and end_with('z')"
191+
])
192+
end
175193
end
176194

177195
context "when backtrace is a heterogeneous language stack trace" do
@@ -185,16 +203,21 @@ def read_failed_line
185203
end
186204

187205
it "is handled gracefully" do
188-
expect { read_failed_line }.not_to raise_error
206+
expect { read_failed_lines }.not_to raise_error
189207
end
190208
end
191209

192210
context "when backtrace will generate a security error" do
193211
let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{__LINE__}"]) }
194212

213+
before do
214+
# We lazily require SnippetExtractor but requiring it in $SAFE 3 environment raises error.
215+
RSpec::Support.require_rspec_core "formatters/snippet_extractor"
216+
end
217+
195218
it "is handled gracefully" do
196219
with_safe_set_to_level_that_triggers_security_errors do
197-
expect { read_failed_line }.not_to raise_error
220+
expect { read_failed_lines }.not_to raise_error
198221
end
199222
end
200223
end
@@ -203,7 +226,7 @@ def read_failed_line
203226
let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:10000000"]) }
204227

205228
it "reports the filename and that it was unable to find the matching line" do
206-
expect(read_failed_line).to include("Unable to find matching line")
229+
expect(read_failed_lines.first).to include("Unable to find matching line")
207230
end
208231
end
209232

@@ -213,7 +236,7 @@ def read_failed_line
213236

214237
it "reports the filename and that it was unable to find the matching line" do
215238
example.metadata[:absolute_file_path] = file
216-
expect(read_failed_line).to include("Unable to find #{file} to read failed line")
239+
expect(read_failed_lines.first).to include("Unable to find #{file} to read failed line")
217240
end
218241
end
219242

@@ -223,7 +246,7 @@ def read_failed_line
223246
let(:exception) { instance_double(Exception, :backtrace => ["#{relative_file}:#{line}"]) }
224247

225248
it 'still finds the backtrace line' do
226-
expect(read_failed_line).to include("line = __LINE__")
249+
expect(read_failed_lines.first).to include("line = __LINE__")
227250
end
228251
end
229252

@@ -243,7 +266,7 @@ def read_failed_line
243266
let(:exception) { instance_double(Exception, :backtrace => [ "#{__FILE__}:#{__LINE__}"]) }
244267

245268
it "doesn't hang when file exists" do
246-
expect(read_failed_line.strip).to eql(
269+
expect(read_failed_lines.first.strip).to eql(
247270
%Q[let(:exception) { instance_double(Exception, :backtrace => [ "\#{__FILE__}:\#{__LINE__}"]) }])
248271
end
249272
end

0 commit comments

Comments
 (0)