simplecov 0.17.1 → 0.18.0.beta1

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.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ #
5
+ # Responsible for adapting the format of the coverage result whether it's default or with statistics
6
+ #
7
+ class ResultAdapter
8
+ attr_reader :result
9
+
10
+ def initialize(result)
11
+ @result = result
12
+ end
13
+
14
+ def self.call(*args)
15
+ new(*args).adapt
16
+ end
17
+
18
+ def adapt
19
+ return unless result
20
+
21
+ result.each_with_object({}) do |(file_name, cover_statistic), adapted_result|
22
+ if cover_statistic.is_a?(Array)
23
+ adapted_result.merge!(file_name => {:lines => cover_statistic})
24
+ else
25
+ adapted_result.merge!(file_name => cover_statistic)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -2,12 +2,12 @@
2
2
 
3
3
  require "json"
4
4
 
5
- #
6
- # Singleton that is responsible for caching, loading and merging
7
- # SimpleCov::Results into a single result for coverage analysis based
8
- # upon multiple test suites.
9
- #
10
5
  module SimpleCov
6
+ #
7
+ # Singleton that is responsible for caching, loading and merging
8
+ # SimpleCov::Results into a single result for coverage analysis based
9
+ # upon multiple test suites.
10
+ #
11
11
  module ResultMerger
12
12
  class << self
13
13
  # The path to the .resultset.json cache file
@@ -27,7 +27,7 @@ module SimpleCov
27
27
  if data
28
28
  begin
29
29
  JSON.parse(data) || {}
30
- rescue
30
+ rescue StandardError
31
31
  {}
32
32
  end
33
33
  else
@@ -40,8 +40,10 @@ module SimpleCov
40
40
  def stored_data
41
41
  synchronize_resultset do
42
42
  return unless File.exist?(resultset_path)
43
+
43
44
  data = File.read(resultset_path)
44
45
  return if data.nil? || data.length < 2
46
+
45
47
  data
46
48
  end
47
49
  end
@@ -55,9 +57,7 @@ module SimpleCov
55
57
  resultset.each do |command_name, data|
56
58
  result = SimpleCov::Result.from_hash(command_name => data)
57
59
  # Only add result if the timeout is above the configured threshold
58
- if (Time.now - result.created_at) < SimpleCov.merge_timeout
59
- results << result
60
- end
60
+ results << result if (Time.now - result.created_at) < SimpleCov.merge_timeout
61
61
  end
62
62
  results
63
63
  end
@@ -66,8 +66,9 @@ module SimpleCov
66
66
  # coverage data and the command_name for the result consisting of a join
67
67
  # on all source result's names
68
68
  def merge_results(*results)
69
- merged = SimpleCov::RawCoverage.merge_results(*results.map(&:original_result))
70
- result = SimpleCov::Result.new(merged)
69
+ parsed_results = JSON.parse(JSON.dump(results.map(&:original_result)), :symbolize_names => true)
70
+ combined_result = SimpleCov::Combine::ResultsCombiner.combine(*parsed_results)
71
+ result = SimpleCov::Result.new(combined_result)
71
72
  # Specify the command name
72
73
  result.command_name = results.map(&:command_name).sort.join(", ")
73
74
  result
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ #
5
+ # Responsible for producing file coverage metrics.
6
+ #
7
+ module SimulateCoverage
8
+ module_function
9
+
10
+ #
11
+ # Simulate normal file coverage report on
12
+ # ruby 2.5 and return similar hash with lines and branches keys
13
+ #
14
+ # Happens when a file wasn't required but still tracked.
15
+ #
16
+ # @return [Hash]
17
+ #
18
+ def call(absolute_path)
19
+ lines = File.foreach(absolute_path)
20
+
21
+ {
22
+ :lines => LinesClassifier.new.classify(lines),
23
+ # we don't want to parse branches ourselves...
24
+ # requiring files can have side effects and we don't want to trigger that
25
+ :branches => {}
26
+ }
27
+ end
28
+ end
29
+ end
@@ -6,79 +6,13 @@ module SimpleCov
6
6
  # source lines and featuring helpers to interpret that data.
7
7
  #
8
8
  class SourceFile
9
- # Representation of a single line in a source file including
10
- # this specific line's source code, line_number and code coverage,
11
- # with the coverage being either nil (coverage not applicable, e.g. comment
12
- # line), 0 (line not covered) or >1 (the amount of times the line was
13
- # executed)
14
- class Line
15
- # The source code for this line. Aliased as :source
16
- attr_reader :src
17
- # The line number in the source file. Aliased as :line, :number
18
- attr_reader :line_number
19
- # The coverage data for this line: either nil (never), 0 (missed) or >=1 (times covered)
20
- attr_reader :coverage
21
- # Whether this line was skipped
22
- attr_reader :skipped
23
-
24
- # Lets grab some fancy aliases, shall we?
25
- alias source src
26
- alias line line_number
27
- alias number line_number
28
-
29
- def initialize(src, line_number, coverage)
30
- raise ArgumentError, "Only String accepted for source" unless src.is_a?(String)
31
- raise ArgumentError, "Only Integer accepted for line_number" unless line_number.is_a?(Integer)
32
- raise ArgumentError, "Only Integer and nil accepted for coverage" unless coverage.is_a?(Integer) || coverage.nil?
33
- @src = src
34
- @line_number = line_number
35
- @coverage = coverage
36
- @skipped = false
37
- end
38
-
39
- # Returns true if this is a line that should have been covered, but was not
40
- def missed?
41
- !never? && !skipped? && coverage.zero?
42
- end
43
-
44
- # Returns true if this is a line that has been covered
45
- def covered?
46
- !never? && !skipped? && coverage > 0
47
- end
48
-
49
- # Returns true if this line is not relevant for coverage
50
- def never?
51
- !skipped? && coverage.nil?
52
- end
53
-
54
- # Flags this line as skipped
55
- def skipped!
56
- @skipped = true
57
- end
58
-
59
- # Returns true if this line was skipped, false otherwise. Lines are skipped if they are wrapped with
60
- # # :nocov: comment lines.
61
- def skipped?
62
- !!skipped
63
- end
64
-
65
- # The status of this line - either covered, missed, skipped or never. Useful i.e. for direct use
66
- # as a css class in report generation
67
- def status
68
- return "skipped" if skipped?
69
- return "never" if never?
70
- return "missed" if missed?
71
- return "covered" if covered?
72
- end
73
- end
74
-
75
9
  # The full path to this source file (e.g. /User/colszowka/projects/simplecov/lib/simplecov/source_file.rb)
76
10
  attr_reader :filename
77
11
  # The array of coverage data received from the Coverage.result
78
12
  attr_reader :coverage
79
13
 
80
14
  def initialize(filename, coverage)
81
- @filename = filename
15
+ @filename = filename.to_s
82
16
  @coverage = coverage
83
17
  end
84
18
 
@@ -102,19 +36,68 @@ module SimpleCov
102
36
  end
103
37
  alias source_lines lines
104
38
 
105
- def build_lines
106
- coverage_exceeding_source_warn if coverage.size > src.size
39
+ # Returns all covered lines as SimpleCov::SourceFile::Line
40
+ def covered_lines
41
+ @covered_lines ||= lines.select(&:covered?)
42
+ end
43
+
44
+ # Returns all lines that should have been, but were not covered
45
+ # as instances of SimpleCov::SourceFile::Line
46
+ def missed_lines
47
+ @missed_lines ||= lines.select(&:missed?)
48
+ end
49
+
50
+ # Returns all lines that are not relevant for coverage as
51
+ # SimpleCov::SourceFile::Line instances
52
+ def never_lines
53
+ @never_lines ||= lines.select(&:never?)
54
+ end
107
55
 
56
+ # Returns all lines that were skipped as SimpleCov::SourceFile::Line instances
57
+ def skipped_lines
58
+ @skipped_lines ||= lines.select(&:skipped?)
59
+ end
60
+
61
+ # Returns the number of relevant lines (covered + missed)
62
+ def lines_of_code
63
+ covered_lines.size + missed_lines.size
64
+ end
65
+
66
+ def build_lines
67
+ coverage_exceeding_source_warn if coverage[:lines].size > src.size
108
68
  lines = src.map.with_index(1) do |src, i|
109
- SimpleCov::SourceFile::Line.new(src, i, coverage[i - 1])
69
+ SimpleCov::SourceFile::Line.new(src, i, coverage[:lines][i - 1])
110
70
  end
111
-
112
71
  process_skipped_lines(lines)
113
72
  end
114
73
 
74
+ # no_cov_chunks is zero indexed to work directly with the array holding the lines
75
+ def no_cov_chunks
76
+ @no_cov_chunks ||= build_no_cov_chunks
77
+ end
78
+
79
+ def build_no_cov_chunks
80
+ no_cov_lines = src.map.with_index(1).select { |line, _index| LinesClassifier.no_cov_line?(line) }
81
+
82
+ warn "uneven number of nocov comments detected" if no_cov_lines.size.odd?
83
+
84
+ no_cov_lines.each_slice(2).map do |(_line_start, index_start), (_line_end, index_end)|
85
+ index_start..index_end
86
+ end
87
+ end
88
+
89
+ def process_skipped_lines(lines)
90
+ # the array the lines are kept in is 0-based whereas the line numbers in the nocov
91
+ # chunks are 1-based and are expected to be like this in other parts (and it's also
92
+ # arguably more understandable)
93
+ no_cov_chunks.each { |chunk| lines[(chunk.begin - 1)..(chunk.end - 1)].each(&:skipped!) }
94
+
95
+ lines
96
+ end
97
+
115
98
  # Warning to identify condition from Issue #56
116
99
  def coverage_exceeding_source_warn
117
- $stderr.puts "Warning: coverage data provided by Coverage [#{coverage.size}] exceeds number of lines in #{filename} [#{src.size}]"
100
+ warn "Warning: coverage data provided by Coverage [#{coverage[:lines].size}] exceeds number of lines in #{filename} [#{src.size}]"
118
101
  end
119
102
 
120
103
  # Access SimpleCov::SourceFile::Line source lines by line number
@@ -122,7 +105,7 @@ module SimpleCov
122
105
  lines[number - 1]
123
106
  end
124
107
 
125
- # The coverage for this file in percent. 0 if the file has no relevant lines
108
+ # The coverage for this file in percent. 0 if the file has no coverage lines
126
109
  def covered_percent
127
110
  return 100.0 if no_lines?
128
111
 
@@ -134,7 +117,7 @@ module SimpleCov
134
117
  def covered_strength
135
118
  return 0.0 if relevant_lines.zero?
136
119
 
137
- round_float(lines_strength / relevant_lines.to_f, 1)
120
+ (lines_strength / relevant_lines.to_f).round(1)
138
121
  end
139
122
 
140
123
  def no_lines?
@@ -149,55 +132,182 @@ module SimpleCov
149
132
  lines.size - never_lines.size - skipped_lines.size
150
133
  end
151
134
 
152
- # Returns all covered lines as SimpleCov::SourceFile::Line
153
- def covered_lines
154
- @covered_lines ||= lines.select(&:covered?)
135
+ #
136
+ # Return all the branches inside current source file
137
+ def branches
138
+ @branches ||= build_branches
155
139
  end
156
140
 
157
- # Returns all lines that should have been, but were not covered
158
- # as instances of SimpleCov::SourceFile::Line
159
- def missed_lines
160
- @missed_lines ||= lines.select(&:missed?)
141
+ def no_branches?
142
+ total_branches.empty?
161
143
  end
162
144
 
163
- # Returns all lines that are not relevant for coverage as
164
- # SimpleCov::SourceFile::Line instances
165
- def never_lines
166
- @never_lines ||= lines.select(&:never?)
145
+ def branches_coverage_percent
146
+ return 100.0 if no_branches?
147
+ return 0.0 if covered_branches.empty?
148
+
149
+ Float(covered_branches.size * 100.0 / total_branches.size.to_f)
167
150
  end
168
151
 
169
- # Returns all lines that were skipped as SimpleCov::SourceFile::Line instances
170
- def skipped_lines
171
- @skipped_lines ||= lines.select(&:skipped?)
152
+ #
153
+ # Return the relevant branches to source file
154
+ def total_branches
155
+ covered_branches + missed_branches
172
156
  end
173
157
 
174
- # Returns the number of relevant lines (covered + missed)
175
- def lines_of_code
176
- covered_lines.size + missed_lines.size
158
+ #
159
+ # Return hash with key of line number and branch coverage count as value
160
+ def branches_report
161
+ @branches_report ||= build_branches_report
177
162
  end
178
163
 
179
- # Will go through all source files and mark lines that are wrapped within # :nocov: comment blocks
180
- # as skipped.
181
- def process_skipped_lines(lines)
182
- skipping = false
183
-
184
- lines.each do |line|
185
- if SimpleCov::LinesClassifier.no_cov_line?(line.src)
186
- skipping = !skipping
187
- line.skipped!
188
- elsif skipping
189
- line.skipped!
190
- end
164
+ ## Related to source file branches statistics
165
+
166
+ #
167
+ # Call recursive method that transform our static hash to array of objects
168
+ # @return [Array]
169
+ #
170
+ def build_branches
171
+ coverage_branch_data = coverage.fetch(:branches, {})
172
+ branches = coverage_branch_data.flat_map do |condition, coverage_branches|
173
+ build_branches_from(condition, coverage_branches)
191
174
  end
175
+
176
+ process_skipped_branches(branches)
192
177
  end
193
178
 
194
- private
179
+ def process_skipped_branches(branches)
180
+ return branches if no_cov_chunks.empty?
181
+
182
+ branches.each do |branch|
183
+ branch.skipped! if no_cov_chunks.any? { |no_cov_chunk| branch.overlaps_with?(no_cov_chunk) }
184
+ end
185
+
186
+ branches
187
+ end
188
+
189
+ # Since we are dumping to and loading from JSON, and we have arrays as keys those
190
+ # don't make their way back to us intact e.g. just as a string or a symbol (currently keys are symbolized).
191
+ #
192
+ # We should probably do something different here, but as it stands these are
193
+ # our data structures that we write so eval isn't _too_ bad.
194
+ #
195
+ # See #801
196
+ #
197
+ def restore_ruby_data_structure(structure)
198
+ # Tests use the real data structures (except for integration tests) so no need to
199
+ # put them through here.
200
+ return structure if structure.is_a?(Array)
201
+
202
+ # as of right now the keys are still symbolized
203
+ # rubocop:disable Security/Eval
204
+ eval structure.to_s
205
+ # rubocop:enable Security/Eval
206
+ end
195
207
 
196
- # ruby 1.9 could use Float#round(places) instead
197
- # @return [Float]
198
- def round_float(float, places)
199
- factor = Float(10 * places)
200
- Float((float * factor).round / factor)
208
+ def build_branches_from(condition, branches)
209
+ # the format handed in from the coverage data is like this:
210
+ #
211
+ # [:then, 4, 6, 6, 6, 10]
212
+ #
213
+ # which is [type, id, start_line, start_col, end_line, end_col]
214
+ condition_type, condition_id, condition_start_line, * = restore_ruby_data_structure(condition)
215
+
216
+ branches
217
+ .map { |branch_data, hit_count| [restore_ruby_data_structure(branch_data), hit_count] }
218
+ .reject { |branch_data, _hit_count| ignore_branch?(branch_data, condition_type, condition_start_line) }
219
+ .map { |branch_data, hit_count| build_branch(branch_data, hit_count, condition_start_line, condition_id) }
220
+ end
221
+
222
+ def build_branch(branch_data, hit_count, condition_start_line, condition_id)
223
+ type, id, start_line, _start_col, end_line, _end_col = branch_data
224
+
225
+ SourceFile::Branch.new(
226
+ # rubocop these are keyword args please let me keep them, thank you
227
+ # rubocop:disable Style/HashSyntax
228
+ start_line: start_line,
229
+ end_line: end_line,
230
+ coverage: hit_count,
231
+ inline: start_line == condition_start_line,
232
+ positive: positive_branch?(condition_id, id, type)
233
+ # rubocop:enable Style/HashSyntax
234
+ )
235
+ end
236
+
237
+ def ignore_branch?(branch_data, condition_type, condition_start_line)
238
+ branch_type = branch_data[0]
239
+ branch_start_line = branch_data[2]
240
+
241
+ # branch coverage always reports case to be with an else branch even when
242
+ # there is no else branch to be covered, it's noticable by the reported start
243
+ # line being the same as that of the condition/case
244
+ condition_type == :case &&
245
+ branch_type == :else &&
246
+ condition_start_line == branch_start_line
247
+ end
248
+
249
+ #
250
+ # Branch is positive or negative.
251
+ # For `case` conditions, `when` always supposed as positive branch.
252
+ # For `if, else` conditions:
253
+ # coverage returns matrices ex: [:if, 0,..] => {[:then, 1,..], [:else, 2,..]},
254
+ # positive branch always has id equals to condition id incremented by 1.
255
+ #
256
+ # @return [Boolean]
257
+ #
258
+ def positive_branch?(condition_id, branch_id, branch_type)
259
+ return true if branch_type == :when
260
+
261
+ branch_id == (1 + condition_id)
262
+ end
263
+
264
+ #
265
+ # Select the covered branches
266
+ # Here we user tree schema because some conditions like case may have additional
267
+ # else that is not in declared inside the code but given by default by coverage report
268
+ #
269
+ # @return [Array]
270
+ #
271
+ def covered_branches
272
+ @covered_branches ||= branches.select(&:covered?)
273
+ end
274
+
275
+ #
276
+ # Select the missed branches with coverage equal to zero
277
+ #
278
+ # @return [Array]
279
+ #
280
+ def missed_branches
281
+ @missed_branches ||= branches.select(&:missed?)
282
+ end
283
+
284
+ def branches_for_line(line_number)
285
+ branches_report.fetch(line_number, [])
286
+ end
287
+
288
+ #
289
+ # Check if any branches missing on given line number
290
+ #
291
+ # @param [Integer] line_number
292
+ #
293
+ # @return [Boolean]
294
+ #
295
+ def line_with_missed_branch?(line_number)
296
+ branches_for_line(line_number).select { |count, _sign| count.zero? }.any?
297
+ end
298
+
299
+ #
300
+ # Build full branches report
301
+ # Root branches represent the wrapper of all condition state that
302
+ # have inside the branches
303
+ #
304
+ # @return [Hash]
305
+ #
306
+ def build_branches_report
307
+ branches.reject(&:skipped?).each_with_object({}) do |branch, coverage_statistics|
308
+ coverage_statistics[branch.report_line] ||= []
309
+ coverage_statistics[branch.report_line] << branch.report
310
+ end
201
311
  end
202
312
  end
203
313
  end