/
source_file.rb
349 lines (292 loc) · 10.3 KB
/
source_file.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# frozen_string_literal: true
module SimpleCov
#
# Representation of a source file including it's coverage data, source code,
# source lines and featuring helpers to interpret that data.
#
class SourceFile
# The full path to this source file (e.g. /User/colszowka/projects/simplecov/lib/simplecov/source_file.rb)
attr_reader :filename
# The array of coverage data received from the Coverage.result
attr_reader :coverage_data
def initialize(filename, coverage_data)
@filename = filename
@coverage_data = coverage_data
end
# The path to this source file relative to the projects directory
def project_filename
@filename.sub(Regexp.new("^#{Regexp.escape(SimpleCov.root)}"), "")
end
# The source code for this file. Aliased as :source
def src
# We intentionally read source code lazily to
# suppress reading unused source code.
@src ||= load_source
end
alias source src
def coverage_statistics
@coverage_statistics ||=
{
**line_coverage_statistics,
**branch_coverage_statistics
}
end
# Returns all source lines for this file as instances of SimpleCov::SourceFile::Line,
# and thus including coverage data. Aliased as :source_lines
def lines
@lines ||= build_lines
end
alias source_lines lines
# Returns all covered lines as SimpleCov::SourceFile::Line
def covered_lines
@covered_lines ||= lines.select(&:covered?)
end
# Returns all lines that should have been, but were not covered
# as instances of SimpleCov::SourceFile::Line
def missed_lines
@missed_lines ||= lines.select(&:missed?)
end
# Returns all lines that are not relevant for coverage as
# SimpleCov::SourceFile::Line instances
def never_lines
@never_lines ||= lines.select(&:never?)
end
# Returns all lines that were skipped as SimpleCov::SourceFile::Line instances
def skipped_lines
@skipped_lines ||= lines.select(&:skipped?)
end
# Returns the number of relevant lines (covered + missed)
def lines_of_code
coverage_statistics[:line]&.total
end
# Access SimpleCov::SourceFile::Line source lines by line number
def line(number)
lines[number - 1]
end
# The coverage for this file in percent. 0 if the file has no coverage lines
def covered_percent
coverage_statistics[:line]&.percent
end
def covered_strength
coverage_statistics[:line]&.strength
end
def no_lines?
lines.length.zero? || (lines.length == never_lines.size)
end
def relevant_lines
lines.size - never_lines.size - skipped_lines.size
end
#
# Return all the branches inside current source file
def branches
@branches ||= build_branches
end
def no_branches?
total_branches.empty?
end
def branches_coverage_percent
coverage_statistics[:branch]&.percent
end
#
# Return the relevant branches to source file
def total_branches
@total_branches ||= covered_branches + missed_branches
end
#
# Return hash with key of line number and branch coverage count as value
def branches_report
@branches_report ||= build_branches_report
end
#
# Select the covered branches
# Here we user tree schema because some conditions like case may have additional
# else that is not in declared inside the code but given by default by coverage report
#
# @return [Array]
#
def covered_branches
@covered_branches ||= branches.select(&:covered?)
end
#
# Select the missed branches with coverage equal to zero
#
# @return [Array]
#
def missed_branches
@missed_branches ||= branches.select(&:missed?)
end
def branches_for_line(line_number)
branches_report.fetch(line_number, [])
end
#
# Check if any branches missing on given line number
#
# @param [Integer] line_number
#
# @return [Boolean]
#
def line_with_missed_branch?(line_number)
branches_for_line(line_number).select { |_type, count| count.zero? }.any?
end
private
# no_cov_chunks is zero indexed to work directly with the array holding the lines
def no_cov_chunks
@no_cov_chunks ||= build_no_cov_chunks
end
def build_no_cov_chunks
no_cov_lines = src.map.with_index(1).select { |line_src, _index| LinesClassifier.no_cov_line?(line_src) }
# if we have an uneven number of nocovs we assume they go to the
# end of the file, the source doesn't really matter
# Can't deal with this within the each_slice due to differing
# behavior in JRuby: jruby/jruby#6048
no_cov_lines << ["", src.size] if no_cov_lines.size.odd?
no_cov_lines.each_slice(2).map do |(_line_src_start, index_start), (_line_src_end, index_end)|
index_start..index_end
end
end
def load_source
lines = []
# The default encoding is UTF-8
File.open(filename, "rb:UTF-8") do |file|
current_line = file.gets
if shebang?(current_line)
lines << current_line
current_line = file.gets
end
read_lines(file, lines, current_line)
end
end
SHEBANG_REGEX = /\A#!/.freeze
def shebang?(line)
SHEBANG_REGEX.match?(line)
end
def read_lines(file, lines, current_line)
return lines unless current_line
set_encoding_based_on_magic_comment(file, current_line)
lines.concat([current_line], ensure_remove_undefs(file.readlines))
end
RUBY_FILE_ENCODING_MAGIC_COMMENT_REGEX = /\A#\s*(?:-\*-)?\s*(?:en)?coding:\s*(\S+)\s*(?:-\*-)?\s*\z/.freeze
def set_encoding_based_on_magic_comment(file, line)
# Check for encoding magic comment
# Encoding magic comment must be placed at first line except for shebang
if (match = RUBY_FILE_ENCODING_MAGIC_COMMENT_REGEX.match(line))
file.set_encoding(match[1], "UTF-8")
end
end
def ensure_remove_undefs(file_lines)
# invalid/undef replace are technically not really necessary but nice to
# have and work around a JRuby incompatibility. Also moved here from
# simplecov-html to have encoding shenaningans in one place. See #866
# also setting these option on `file.set_encoding` doesn't seem to work
# properly so it has to be done here.
file_lines.each { |line| line.encode!("UTF-8", invalid: :replace, undef: :replace) }
end
def build_lines
coverage_exceeding_source_warn if coverage_data["lines"].size > src.size
lines = src.map.with_index(1) do |src, i|
SimpleCov::SourceFile::Line.new(src, i, coverage_data["lines"][i - 1])
end
process_skipped_lines(lines)
end
def process_skipped_lines(lines)
# the array the lines are kept in is 0-based whereas the line numbers in the nocov
# chunks are 1-based and are expected to be like this in other parts (and it's also
# arguably more understandable)
no_cov_chunks.each { |chunk| lines[(chunk.begin - 1)..(chunk.end - 1)].each(&:skipped!) }
lines
end
def lines_strength
lines.map(&:coverage).compact.reduce(:+)
end
# Warning to identify condition from Issue #56
def coverage_exceeding_source_warn
warn "Warning: coverage data provided by Coverage [#{coverage_data['lines'].size}] exceeds number of lines in #{filename} [#{src.size}]"
end
#
# Build full branches report
# Root branches represent the wrapper of all condition state that
# have inside the branches
#
# @return [Hash]
#
def build_branches_report
branches.reject(&:skipped?).each_with_object({}) do |branch, coverage_statistics|
coverage_statistics[branch.report_line] ||= []
coverage_statistics[branch.report_line] << branch.report
end
end
#
# Call recursive method that transform our static hash to array of objects
# @return [Array]
#
def build_branches
coverage_branch_data = coverage_data.fetch("branches", {})
branches = coverage_branch_data.flat_map do |condition, coverage_branches|
build_branches_from(condition, coverage_branches)
end
process_skipped_branches(branches)
end
def process_skipped_branches(branches)
return branches if no_cov_chunks.empty?
branches.each do |branch|
branch.skipped! if no_cov_chunks.any? { |no_cov_chunk| branch.overlaps_with?(no_cov_chunk) }
end
branches
end
# Since we are dumping to and loading from JSON, and we have arrays as keys those
# don't make their way back to us intact e.g. just as a string
#
# We should probably do something different here, but as it stands these are
# our data structures that we write so eval isn't _too_ bad.
#
# See #801
#
def restore_ruby_data_structure(structure)
# Tests use the real data structures (except for integration tests) so no need to
# put them through here.
return structure if structure.is_a?(Array)
# rubocop:disable Security/Eval
eval structure
# rubocop:enable Security/Eval
end
def build_branches_from(condition, branches)
# the format handed in from the coverage data is like this:
#
# [:then, 4, 6, 6, 6, 10]
#
# which is [type, id, start_line, start_col, end_line, end_col]
_condition_type, _condition_id, condition_start_line, * = restore_ruby_data_structure(condition)
branches.map do |branch_data, hit_count|
branch_data = restore_ruby_data_structure(branch_data)
build_branch(branch_data, hit_count, condition_start_line)
end
end
def build_branch(branch_data, hit_count, condition_start_line)
type, _id, start_line, _start_col, end_line, _end_col = branch_data
SourceFile::Branch.new(
start_line: start_line,
end_line: end_line,
coverage: hit_count,
inline: start_line == condition_start_line,
type: type
)
end
def line_coverage_statistics
{
line: CoverageStatistics.new(
total_strength: lines_strength,
covered: covered_lines.size,
missed: missed_lines.size
)
}
end
def branch_coverage_statistics
{
branch: CoverageStatistics.new(
covered: covered_branches.size,
missed: missed_branches.size
)
}
end
end
end