simplecov 0.22.0 → 1.0.0

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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +1058 -508
  4. data/doc/alternate-formatters.md +0 -5
  5. data/doc/commercial-services.md +5 -5
  6. data/exe/simplecov +11 -0
  7. data/lib/minitest/simplecov_plugin.rb +13 -5
  8. data/lib/simplecov/autostart.rb +11 -0
  9. data/lib/simplecov/cli/clean.rb +47 -0
  10. data/lib/simplecov/cli/coverage.rb +91 -0
  11. data/lib/simplecov/cli/diff.rb +151 -0
  12. data/lib/simplecov/cli/dotfile.rb +100 -0
  13. data/lib/simplecov/cli/merge.rb +116 -0
  14. data/lib/simplecov/cli/open.rb +50 -0
  15. data/lib/simplecov/cli/report.rb +84 -0
  16. data/lib/simplecov/cli/run.rb +36 -0
  17. data/lib/simplecov/cli/serve.rb +139 -0
  18. data/lib/simplecov/cli/uncovered.rb +107 -0
  19. data/lib/simplecov/cli.rb +150 -0
  20. data/lib/simplecov/color.rb +74 -0
  21. data/lib/simplecov/combine/branches_combiner.rb +27 -5
  22. data/lib/simplecov/combine/files_combiner.rb +7 -1
  23. data/lib/simplecov/combine/lines_combiner.rb +19 -17
  24. data/lib/simplecov/combine/methods_combiner.rb +26 -0
  25. data/lib/simplecov/combine/results_combiner.rb +5 -4
  26. data/lib/simplecov/command_guesser.rb +46 -32
  27. data/lib/simplecov/configuration/coverage.rb +171 -0
  28. data/lib/simplecov/configuration/coverage_criteria.rb +156 -0
  29. data/lib/simplecov/configuration/filters.rb +197 -0
  30. data/lib/simplecov/configuration/formatting.rb +119 -0
  31. data/lib/simplecov/configuration/ignored_entries.rb +63 -0
  32. data/lib/simplecov/configuration/merging.rb +164 -0
  33. data/lib/simplecov/configuration/thresholds.rb +174 -0
  34. data/lib/simplecov/configuration.rb +91 -407
  35. data/lib/simplecov/coverage_statistics.rb +12 -9
  36. data/lib/simplecov/coverage_violations.rb +148 -0
  37. data/lib/simplecov/defaults.rb +27 -20
  38. data/lib/simplecov/deprecation.rb +45 -0
  39. data/lib/simplecov/directive.rb +162 -0
  40. data/lib/simplecov/exit_codes/exit_code_handling.rb +8 -2
  41. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +19 -57
  42. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +45 -0
  43. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +17 -27
  44. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +41 -0
  45. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +38 -21
  46. data/lib/simplecov/exit_codes.rb +3 -0
  47. data/lib/simplecov/exit_handling.rb +158 -0
  48. data/lib/simplecov/file_list.rb +61 -17
  49. data/lib/simplecov/filter.rb +69 -24
  50. data/lib/simplecov/formatter/base.rb +101 -0
  51. data/lib/simplecov/formatter/html_formatter/public/application.css +1 -0
  52. data/lib/simplecov/formatter/html_formatter/public/application.js +18 -0
  53. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  54. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  55. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  56. data/lib/simplecov/formatter/html_formatter/public/index.html +56 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +79 -0
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +84 -0
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +128 -0
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +99 -0
  61. data/lib/simplecov/formatter/json_formatter.rb +87 -0
  62. data/lib/simplecov/formatter/multi_formatter.rb +4 -5
  63. data/lib/simplecov/formatter/simple_formatter.rb +9 -11
  64. data/lib/simplecov/formatter.rb +4 -0
  65. data/lib/simplecov/last_run.rb +10 -3
  66. data/lib/simplecov/lines_classifier.rb +25 -13
  67. data/lib/simplecov/load_global_config.rb +9 -4
  68. data/lib/simplecov/parallel_adapters/base.rb +61 -0
  69. data/lib/simplecov/parallel_adapters/generic.rb +44 -0
  70. data/lib/simplecov/parallel_adapters/parallel_tests.rb +93 -0
  71. data/lib/simplecov/parallel_adapters.rb +83 -0
  72. data/lib/simplecov/parallel_coordination.rb +129 -0
  73. data/lib/simplecov/process.rb +31 -13
  74. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  75. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  76. data/lib/simplecov/profiles/rails.rb +24 -10
  77. data/lib/simplecov/profiles/root_filter.rb +6 -5
  78. data/lib/simplecov/profiles/strict.rb +32 -0
  79. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  80. data/lib/simplecov/profiles.rb +32 -3
  81. data/lib/simplecov/result/missing_source_files_reporter.rb +49 -0
  82. data/lib/simplecov/result/source_file_builder.rb +51 -0
  83. data/lib/simplecov/result.rb +108 -19
  84. data/lib/simplecov/result_adapter.rb +70 -8
  85. data/lib/simplecov/result_merger/legacy_format_adapter.rb +28 -0
  86. data/lib/simplecov/result_merger/resultset_file.rb +38 -0
  87. data/lib/simplecov/result_merger/resultset_store.rb +50 -0
  88. data/lib/simplecov/result_merger.rb +59 -91
  89. data/lib/simplecov/result_processing.rb +179 -0
  90. data/lib/simplecov/simulate_coverage.rb +54 -8
  91. data/lib/simplecov/source_file/branch.rb +1 -3
  92. data/lib/simplecov/source_file/branch_builder.rb +114 -0
  93. data/lib/simplecov/source_file/builder_context.rb +28 -0
  94. data/lib/simplecov/source_file/line.rb +7 -2
  95. data/lib/simplecov/source_file/line_builder.rb +43 -0
  96. data/lib/simplecov/source_file/method.rb +52 -0
  97. data/lib/simplecov/source_file/method_builder.rb +58 -0
  98. data/lib/simplecov/source_file/ruby_data_parser.rb +88 -0
  99. data/lib/simplecov/source_file/skip_chunks.rb +75 -0
  100. data/lib/simplecov/source_file/source_loader.rb +63 -0
  101. data/lib/simplecov/source_file/statistics.rb +57 -0
  102. data/lib/simplecov/source_file.rb +66 -232
  103. data/lib/simplecov/static_coverage_extractor/method_collector.rb +55 -0
  104. data/lib/simplecov/static_coverage_extractor/visitor.rb +171 -0
  105. data/lib/simplecov/static_coverage_extractor.rb +110 -0
  106. data/lib/simplecov/useless_results_remover.rb +16 -7
  107. data/lib/simplecov/version.rb +1 -1
  108. data/lib/simplecov-html.rb +4 -0
  109. data/lib/simplecov.rb +156 -371
  110. data/lib/simplecov_json_formatter.rb +4 -0
  111. data/schemas/coverage-v1.0.schema.json +306 -0
  112. data/schemas/coverage.schema.json +306 -0
  113. metadata +90 -57
  114. data/CHANGELOG.md +0 -191
  115. data/lib/simplecov/default_formatter.rb +0 -20
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "static_coverage_extractor"
4
+
3
5
  module SimpleCov
4
6
  #
5
7
  # Responsible for producing file coverage metrics.
@@ -8,22 +10,66 @@ module SimpleCov
8
10
  module_function
9
11
 
10
12
  #
11
- # Simulate normal file coverage report on
12
- # ruby 2.5 and return similar hash with lines and branches keys
13
+ # Simulate a file coverage report for a file that was tracked but never
14
+ # required. Returns the same hash shape as `Coverage.result` (lines,
15
+ # branches, methods).
16
+ #
17
+ # The line classification comes from `Coverage.line_stub` — the same
18
+ # classification the runtime would have produced if the file had been
19
+ # required — overlaid with SimpleCov's `# :nocov:` toggles and
20
+ # `# simplecov:disable line` directive ranges, which `Coverage` doesn't
21
+ # know about. This keeps "relevant lines" identical whether a file was
22
+ # loaded or just tracked, fixing the multi-line statement discrepancy
23
+ # in https://github.com/simplecov-ruby/simplecov/issues/654.
13
24
  #
14
- # Happens when a file wasn't required but still tracked.
25
+ # Branches and methods are enumerated by static analysis (via
26
+ # `StaticCoverageExtractor`, which uses Prism). Earlier behavior left
27
+ # both as empty hashes, which made unloaded files invisible to the
28
+ # branch/method denominators while their lines DID count — so a
29
+ # `track_files`/`cover` glob that picked up files without specs
30
+ # silently inflated branch% relative to line%. See
31
+ # https://github.com/simplecov-ruby/simplecov/issues/1059. When Prism
32
+ # isn't loadable (Ruby < 3.3 without the prism gem) or the file
33
+ # can't be parsed, fall back to the old empty hashes — old behavior,
34
+ # old tradeoff.
15
35
  #
16
36
  # @return [Hash]
17
37
  #
18
38
  def call(absolute_path)
19
- lines = File.foreach(absolute_path)
39
+ source_lines = read_lines(absolute_path)
40
+ lines = coverage_stub(absolute_path, source_lines) ||
41
+ LinesClassifier.new.classify(source_lines)
42
+ synthesized = StaticCoverageExtractor.call(source_lines.join) ||
43
+ {"branches" => {}, "methods" => {}}
20
44
 
21
45
  {
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" => {}
46
+ "lines" => lines,
47
+ "branches" => synthesized["branches"],
48
+ "methods" => synthesized["methods"]
26
49
  }
27
50
  end
51
+
52
+ def read_lines(path)
53
+ File.readlines(path)
54
+ rescue Errno::ENOENT
55
+ []
56
+ end
57
+
58
+ # Combine `Coverage.line_stub` (which gets multi-line statements right)
59
+ # with `LinesClassifier` (which knows about `# :nocov:` toggles and
60
+ # `# simplecov:disable line` ranges). Returns nil — and the caller
61
+ # falls back to `LinesClassifier` alone — when `Coverage` can't read
62
+ # or parse the file, or when the runtime doesn't expose `line_stub`
63
+ # (JRuby and TruffleRuby).
64
+ def coverage_stub(path, source_lines)
65
+ return nil unless Coverage.respond_to?(:line_stub)
66
+
67
+ stub = Coverage.line_stub(path)
68
+ classifier_output = LinesClassifier.new.classify(source_lines)
69
+ stub.each_index { |idx| stub[idx] = nil if classifier_output[idx].nil? }
70
+ stub
71
+ rescue Errno::ENOENT, SyntaxError
72
+ nil
73
+ end
28
74
  end
29
75
  end
@@ -8,7 +8,6 @@ module SimpleCov
8
8
  class Branch
9
9
  attr_reader :start_line, :end_line, :coverage, :type
10
10
 
11
- # rubocop:disable Metrics/ParameterLists
12
11
  def initialize(start_line:, end_line:, coverage:, inline:, type:)
13
12
  @start_line = start_line
14
13
  @end_line = end_line
@@ -17,7 +16,6 @@ module SimpleCov
17
16
  @type = type
18
17
  @skipped = false
19
18
  end
20
- # rubocop:enable Metrics/ParameterLists
21
19
 
22
20
  def inline?
23
21
  @inline
@@ -33,7 +31,7 @@ module SimpleCov
33
31
  end
34
32
 
35
33
  #
36
- # Check if branche missed or not
34
+ # Check if branch missed or not
37
35
  #
38
36
  # @return [Boolean]
39
37
  #
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Builds the `SourceFile::Branch` objects for a source file from
6
+ # the raw branch data Ruby's Coverage library reports. Applies the
7
+ # `ignore_branches :eval_generated` / `:implicit_else` filters and
8
+ # marks branches inside `# simplecov:disable` / `# :nocov:` chunks
9
+ # as skipped.
10
+ class BranchBuilder
11
+ def initialize(source_file)
12
+ @source_file = source_file
13
+ end
14
+
15
+ def call
16
+ coverage_branch_data = @source_file.coverage_data["branches"] || {}
17
+ branches = coverage_branch_data.flat_map do |condition, coverage_branches|
18
+ next [] if eval_generated_condition_to_ignore?(condition)
19
+
20
+ build_branches_from(condition, coverage_branches)
21
+ end
22
+
23
+ process_skipped(branches)
24
+ end
25
+
26
+ private
27
+
28
+ # Detect a Coverage-reported branch condition that originates from
29
+ # `eval`/`module_eval`/`class_eval`/`instance_eval` rather than from
30
+ # the file's literal source. Coverage attributes such branches to the
31
+ # caller's `__FILE__`/`__LINE__`, so a Rails `delegate :foo, to: :bar`
32
+ # call surfaces inside the source file as if there were branches at
33
+ # the `delegate` line. Prism never sees those branches in the static
34
+ # source, so a condition whose start_line isn't in the real-source
35
+ # branch set must be eval-generated. Only consulted when the user has
36
+ # opted in via `SimpleCov.ignore_branches :eval_generated`. See #1046.
37
+ def eval_generated_condition_to_ignore?(condition)
38
+ return false unless SimpleCov.ignored_branch?(:eval_generated)
39
+
40
+ positions = @source_file.real_source_positions
41
+ # simplecov:disable branch — nil branch fires only when Prism is unavailable
42
+ return false unless positions
43
+
44
+ # simplecov:enable branch
45
+
46
+ _type, _id, start_line, * = RubyDataParser.call(condition)
47
+ !positions[:branches].include?(start_line)
48
+ end
49
+
50
+ def build_branches_from(condition, branches)
51
+ # the format handed in from the coverage data is like this:
52
+ #
53
+ # [:then, 4, 6, 6, 6, 10]
54
+ #
55
+ # which is [type, id, start_line, start_col, end_line, end_col]
56
+ _condition_type, _condition_id, *condition_range = RubyDataParser.call(condition)
57
+
58
+ branches.filter_map do |branch_data, hit_count|
59
+ build_branch(RubyDataParser.call(branch_data), hit_count, condition_range)
60
+ end
61
+ end
62
+
63
+ def build_branch(branch_data, hit_count, condition_range)
64
+ type, _id, start_line, start_col, end_line, end_col = branch_data
65
+ return nil if implicit_else_to_ignore?(type, [start_line, start_col, end_line, end_col], condition_range)
66
+
67
+ SourceFile::Branch.new(
68
+ start_line: start_line,
69
+ end_line: end_line,
70
+ coverage: hit_count,
71
+ inline: start_line == condition_range.first,
72
+ type: type
73
+ )
74
+ end
75
+
76
+ # Detect synthetic `:else` branches that Ruby's Coverage library reports
77
+ # for constructs with no literal `else` keyword in source (`case/in` /
78
+ # `case/when` without else, `||=`, `&&=`, `if`/`unless` without else,
79
+ # and the postfix `return if cond` shape). The signal is structural:
80
+ # a synthetic else reuses its parent condition's *full source range*
81
+ # (start_line, start_col, end_line, end_col all identical), while an
82
+ # explicit `else` arm carries a narrower range — its own keyword/body
83
+ # position rather than the whole conditional. Comparing the full range
84
+ # (not just `start_line`) is what distinguishes a ternary's explicit
85
+ # else on the same line as the condition — `arg == 42 ? :yes : :no`,
86
+ # where the else's columns differ from the parent's — from a postfix
87
+ # `return if cond` where the synthetic else inherits the full range.
88
+ # Only consulted when the user has opted in via
89
+ # `SimpleCov.ignore_branches :implicit_else`. See #1033.
90
+ def implicit_else_to_ignore?(type, branch_range, condition_range)
91
+ return false unless type == :else
92
+ return false unless SimpleCov.ignored_branch?(:implicit_else)
93
+
94
+ branch_range == condition_range
95
+ end
96
+
97
+ def process_skipped(branches)
98
+ chunks = @source_file.skip_chunks_for(:branch)
99
+ return branches if chunks.empty?
100
+
101
+ # A non-inline branch's source range starts on its arm body (e.g. the
102
+ # `:yes` line of `if cond / :yes / else / :no / end`), but `report_line`
103
+ # is the condition line above it — that's where the user sees the
104
+ # branch in the report and where they would naturally place an inline
105
+ # `# simplecov:disable branch` directive. Honour both.
106
+ branches.each do |branch|
107
+ branch.skipped! if chunks.any? { |chunk| branch.overlaps_with?(chunk) || chunk.include?(branch.report_line) }
108
+ end
109
+
110
+ branches
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Helpers shared by the per-criterion builders (LineBuilder /
6
+ # BranchBuilder / MethodBuilder). Mixed into SourceFile so each
7
+ # builder can ask the file for its skip-chunk ranges and Prism-derived
8
+ # real source positions without duplicating the memoization.
9
+ module BuilderContext
10
+ # Skip-chunk lookup for the named criterion (`:line`, `:branch`,
11
+ # `:method`).
12
+ def skip_chunks_for(criterion)
13
+ (@skip_chunks ||= SkipChunks.new(filename, src)).for(criterion)
14
+ end
15
+
16
+ # Memoized set of real source positions (branch start lines, method
17
+ # name+line pairs) extracted via Prism. Returns nil when Prism is
18
+ # unavailable or parsing fails, signaling callers to keep every
19
+ # Coverage entry (no false drops). The `defined?` guard preserves a
20
+ # nil memoization across calls.
21
+ def real_source_positions
22
+ return @real_source_positions if defined?(@real_source_positions)
23
+
24
+ @real_source_positions = StaticCoverageExtractor.real_source_positions(src.join)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -25,7 +25,9 @@ module SimpleCov
25
25
  def initialize(src, line_number, coverage)
26
26
  raise ArgumentError, "Only String accepted for source" unless src.is_a?(String)
27
27
  raise ArgumentError, "Only Integer accepted for line_number" unless line_number.is_a?(Integer)
28
- raise ArgumentError, "Only Integer and nil accepted for coverage" unless coverage.is_a?(Integer) || coverage.nil?
28
+ unless coverage.is_a?(Integer) || coverage.nil?
29
+ raise ArgumentError, "Only Integer and nil accepted for coverage"
30
+ end
29
31
 
30
32
  @src = src
31
33
  @line_number = line_number
@@ -65,7 +67,10 @@ module SimpleCov
65
67
  return "skipped" if skipped?
66
68
  return "never" if never?
67
69
  return "missed" if missed?
68
- return "covered" if covered?
70
+
71
+ # simplecov:disable — defensive: covered? is the only state left after the three above
72
+ "covered" if covered?
73
+ # simplecov:enable
69
74
  end
70
75
  end
71
76
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Builds the `SourceFile::Line` objects for a source file from the
6
+ # raw line-coverage array. Each line carries its source text, its
7
+ # 1-based line number, and the Coverage hit count (or nil for
8
+ # never-counted lines). Applies `# simplecov:disable` /
9
+ # `# :nocov:` block ranges via `skipped!`.
10
+ class LineBuilder
11
+ def initialize(source_file)
12
+ @source_file = source_file
13
+ end
14
+
15
+ def call
16
+ lines = build_lines
17
+ mark_skipped(lines, @source_file.skip_chunks_for(:line))
18
+ lines
19
+ end
20
+
21
+ private
22
+
23
+ # When `:line` coverage is disabled, the Ruby Coverage module
24
+ # doesn't emit "lines" data, so look up `nil` (never-counted) for
25
+ # every position. The source rows are still useful — e.g. for the
26
+ # HTML report's source view — even without per-line hits.
27
+ def build_lines
28
+ line_coverage = @source_file.coverage_data["lines"] || []
29
+ @source_file.src.map.with_index(1) do |src, i|
30
+ SourceFile::Line.new(src, i, line_coverage[i - 1])
31
+ end
32
+ end
33
+
34
+ # The array the lines are kept in is 0-based whereas the line
35
+ # numbers in the chunks are 1-based (more understandable elsewhere),
36
+ # so each range needs to be shifted down by one to slice into the
37
+ # `lines` array.
38
+ def mark_skipped(lines, chunks)
39
+ chunks.each { |chunk| lines[(chunk.begin - 1)..(chunk.end - 1)].each(&:skipped!) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Represents a single method detected in coverage data.
6
+ # Provides helpers similar to Branch for coverage status.
7
+ class Method
8
+ attr_reader :source_file, :coverage, :class_name, :method_name,
9
+ :start_line, :start_col, :end_line, :end_col
10
+
11
+ def initialize(source_file, info, coverage)
12
+ @source_file = source_file
13
+ @class_name, @method_name, @start_line, @start_col, @end_line, @end_col = info
14
+ @coverage = coverage
15
+ end
16
+
17
+ def covered?
18
+ !skipped? && coverage.positive?
19
+ end
20
+
21
+ def skipped?
22
+ return @skipped if defined?(@skipped)
23
+
24
+ @skipped = lines.all?(&:skipped?)
25
+ end
26
+
27
+ # Flag the method as skipped directly, without going through its lines.
28
+ def skipped!
29
+ @skipped = true
30
+ end
31
+
32
+ def missed?
33
+ !skipped? && coverage.zero?
34
+ end
35
+
36
+ def lines
37
+ @lines ||= start_line && end_line ? source_file.lines[(start_line - 1)..(end_line - 1)] : []
38
+ end
39
+
40
+ # Whether this method's source range intersects the given inclusive line range.
41
+ def overlaps_with?(line_range)
42
+ return false unless start_line && end_line
43
+
44
+ start_line <= line_range.end && end_line >= line_range.begin
45
+ end
46
+
47
+ def to_s
48
+ "#{class_name}##{method_name}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Builds the `SourceFile::Method` objects for a source file from
6
+ # the raw method-coverage data. Applies the
7
+ # `ignore_methods :eval_generated` filter and marks methods inside
8
+ # `# simplecov:disable` / `# :nocov:` chunks as skipped.
9
+ class MethodBuilder
10
+ def initialize(source_file)
11
+ @source_file = source_file
12
+ end
13
+
14
+ def call
15
+ methods = @source_file.coverage_data.fetch("methods", {}).filter_map do |info, hit_count|
16
+ info = RubyDataParser.call(info)
17
+ next if eval_generated_to_ignore?(info)
18
+
19
+ SourceFile::Method.new(@source_file, info, hit_count)
20
+ end
21
+
22
+ process_skipped(methods)
23
+ end
24
+
25
+ private
26
+
27
+ # See `BranchBuilder#eval_generated_condition_to_ignore?` for the
28
+ # rationale. Coverage reports an eval'd `def` at the eval caller's
29
+ # line and name, so a method whose `(name, start_line)` is absent
30
+ # from the real-source `def` set is eval-generated. Only consulted
31
+ # when the user has opted in via
32
+ # `SimpleCov.ignore_methods :eval_generated`. See #1046.
33
+ def eval_generated_to_ignore?(info)
34
+ return false unless SimpleCov.ignored_method?(:eval_generated)
35
+
36
+ positions = @source_file.real_source_positions
37
+ # simplecov:disable branch — nil branch fires only when Prism is unavailable
38
+ return false unless positions
39
+
40
+ # simplecov:enable branch
41
+
42
+ _class_name, name, start_line, * = info
43
+ !positions[:methods].include?([name, start_line])
44
+ end
45
+
46
+ def process_skipped(methods)
47
+ chunks = @source_file.skip_chunks_for(:method)
48
+ return methods if chunks.empty?
49
+
50
+ methods.each do |method|
51
+ method.skipped! if chunks.any? { |chunk| method.overlaps_with?(chunk) }
52
+ end
53
+
54
+ methods
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module SimpleCov
6
+ class SourceFile
7
+ # `Coverage.result` reports condition and method keys as Ruby
8
+ # arrays. When the resultset is round-tripped through JSON those
9
+ # array keys become their stringified inspect form, so this parser
10
+ # walks the literal back into a real Array without using `eval` (see
11
+ # #801). The grammar covers symbols, strings, integers, unary minus,
12
+ # and constant paths — every shape Coverage ever emits.
13
+ module RubyDataParser
14
+ module_function
15
+
16
+ # Tests use the real data structures (except for integration tests)
17
+ # so no need to put them through here.
18
+ def call(structure)
19
+ return structure if structure.is_a?(Array)
20
+
21
+ parse_array_string(structure.to_s)
22
+ end
23
+
24
+ # Parse a string like '[:if, 0, 3, 4, 3, 21]' or
25
+ # '["ClassName", :method1, 2, 2, 5, 5]' back into a Ruby array.
26
+ def parse_array_string(str)
27
+ # Try plain Ripper first; only pre-quote `#<...>` inspect segments
28
+ # if the input isn't already valid Ruby (otherwise we corrupt
29
+ # `"#<Class:Foo>"` strings that *are* valid Ruby literals — exactly
30
+ # the shape simplecov-on-simplecov method-coverage keys take).
31
+ sexp = Ripper.sexp(str) || Ripper.sexp(quote_inspected_class_segments(str))
32
+ # simplecov:disable — defensive: Ripper.sexp returning nil from both passes requires malformed input
33
+ array_node = sexp&.dig(1, 0)
34
+ # simplecov:enable
35
+ raise ArgumentError, "expected array literal: #{str.inspect}" unless array_node && array_node[0] == :array
36
+
37
+ Array(array_node[1]).map { |element| parse_element(element) }
38
+ end
39
+
40
+ def parse_element(node)
41
+ case node[0]
42
+ when :@int, :unary then parse_integer_node(node)
43
+ when :symbol_literal, :dyna_symbol then parse_symbol_node(node)
44
+ when :string_literal then unescape_ruby(string_literal_text(node[1]))
45
+ when :var_ref then node.dig(1, 1) # `Foo`
46
+ when :const_path_ref then "#{parse_element(node[1])}::#{node[2][1]}" # `Foo::Bar`
47
+ else
48
+ # simplecov:disable — defensive fallback for unexpected Ripper node shapes
49
+ raise ArgumentError, "unexpected element: #{node.inspect}"
50
+ # simplecov:enable
51
+ end
52
+ end
53
+
54
+ def parse_integer_node(node)
55
+ node[0] == :@int ? node[1].to_i : -node[2][1].to_i
56
+ end
57
+
58
+ def parse_symbol_node(node)
59
+ if node[0] == :symbol_literal
60
+ node.dig(1, 1, 1).to_sym
61
+ else
62
+ unescape_ruby(string_literal_text(node[1])).to_sym
63
+ end
64
+ end
65
+
66
+ # Concatenate the text fragments of a `:string_content` node. Ripper
67
+ # may emit zero, one, or many `:@tstring_content` children depending
68
+ # on the literal.
69
+ def string_literal_text(string_content)
70
+ Array(string_content[1..]).map { |child| child[1] }.join
71
+ end
72
+
73
+ # Undo the same backslash-prefix escapes the previous hand-rolled
74
+ # parser undid: `\X` → `X` for any X.
75
+ def unescape_ruby(raw)
76
+ raw.gsub(/\\(.)/) { ::Regexp.last_match(1) }
77
+ end
78
+
79
+ # Method coverage keys can contain inspect-format class references
80
+ # like `#<Class:Foo>` or `#<Class:0x...>`, which aren't valid Ruby
81
+ # syntax. Wrap them in quotes so Ripper can parse the surrounding
82
+ # array literal; downstream we treat them as opaque strings.
83
+ def quote_inspected_class_segments(str)
84
+ str.gsub(/#<[^>]*>/) { |segment| %("#{segment.gsub('"', '\\"')}") }
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Computes the set of line ranges that should be excluded from a
6
+ # SourceFile's coverage for each criterion. Two sources contribute:
7
+ #
8
+ # * The deprecated `# :nocov:` block toggle (lines wrapped between
9
+ # even-numbered pairs of nocov markers are excluded from line and
10
+ # branch coverage).
11
+ # * `# simplecov:disable` / `# simplecov:enable` block directives,
12
+ # which can be scoped per-criterion (`# simplecov:disable branch`,
13
+ # etc.) — see `SimpleCov::Directive`.
14
+ class SkipChunks
15
+ @nocov_warned = Set.new
16
+ class << self
17
+ attr_reader :nocov_warned
18
+ end
19
+
20
+ def initialize(filename, src)
21
+ @filename = filename
22
+ @src = src
23
+ end
24
+
25
+ # `:method` ignores nocov chunks (Ruby's Coverage doesn't tie
26
+ # method entries to line ranges); `:line` / `:branch` honor both
27
+ # the nocov chunks and the per-criterion directive ranges.
28
+ def for(criterion)
29
+ if criterion == :method
30
+ directive_chunks.fetch(:method)
31
+ else
32
+ nocov_chunks + directive_chunks.fetch(criterion)
33
+ end
34
+ end
35
+
36
+ # no_cov_chunks is zero indexed to work directly with the array
37
+ # holding the lines.
38
+ def nocov_chunks
39
+ @nocov_chunks ||= build_nocov_chunks
40
+ end
41
+
42
+ def directive_chunks
43
+ @directive_chunks ||= Directive.disabled_ranges(@src)
44
+ end
45
+
46
+ private
47
+
48
+ def build_nocov_chunks
49
+ no_cov_lines = @src.map.with_index(1).select { |line_src, _index| LinesClassifier.no_cov_line?(line_src) }
50
+
51
+ warn_nocov_deprecation(no_cov_lines.first.last) if no_cov_lines.any?
52
+
53
+ # If we have an uneven number of nocovs we assume they go to the
54
+ # end of the file, the source doesn't really matter. Can't deal
55
+ # with this within the each_slice due to differing behavior in
56
+ # JRuby: jruby/jruby#6048
57
+ no_cov_lines << ["", @src.size] if no_cov_lines.size.odd?
58
+
59
+ no_cov_lines.each_slice(2).map do |(_line_src_start, index_start), (_line_src_end, index_end)|
60
+ index_start..index_end
61
+ end
62
+ end
63
+
64
+ # Emit a one-time-per-file deprecation warning pointing the user
65
+ # at the `# simplecov:disable` / `# simplecov:enable` replacement.
66
+ def warn_nocov_deprecation(first_line_number)
67
+ return unless self.class.nocov_warned.add?(@filename)
68
+
69
+ token = SimpleCov.current_nocov_token
70
+ warn "#{@filename}:#{first_line_number}: [DEPRECATION] `# :#{token}:` is deprecated and will be removed " \
71
+ "in a future release. Replace with `# simplecov:disable` / `# simplecov:enable` block comments."
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ class SourceFile
5
+ # Reads a source file into an array of lines, honoring the source's
6
+ # shebang and `coding:` magic comment when present. Always
7
+ # transcodes to UTF-8 with invalid/undef bytes replaced — both for
8
+ # JRuby compatibility and to keep encoding shenanigans in one place
9
+ # (see #866).
10
+ module SourceLoader
11
+ SHEBANG_REGEX = /\A#!/
12
+ RUBY_FILE_ENCODING_MAGIC_COMMENT_REGEX = /\A#\s*(?:-\*-)?\s*(?:en)?coding:\s*(\S+)\s*(?:-\*-)?\s*\z/
13
+
14
+ module_function
15
+
16
+ def call(filename)
17
+ lines = []
18
+ # The default encoding is UTF-8
19
+ File.open(filename, "rb:UTF-8") do |file|
20
+ current_line = file.gets
21
+
22
+ if shebang?(current_line)
23
+ lines << current_line
24
+ current_line = file.gets
25
+ end
26
+
27
+ read_lines(file, lines, current_line)
28
+ end
29
+ end
30
+
31
+ def shebang?(line)
32
+ SHEBANG_REGEX.match?(line)
33
+ end
34
+
35
+ def read_lines(file, lines, current_line)
36
+ return lines unless current_line
37
+
38
+ set_encoding_based_on_magic_comment(file, current_line)
39
+ lines.concat([current_line], ensure_remove_undefs(file.readlines))
40
+ end
41
+
42
+ # Encoding magic comment must be placed at first line except for
43
+ # shebang.
44
+ def set_encoding_based_on_magic_comment(file, line)
45
+ if (match = RUBY_FILE_ENCODING_MAGIC_COMMENT_REGEX.match(line))
46
+ file.set_encoding(match[1], "UTF-8")
47
+ end
48
+ end
49
+
50
+ # invalid/undef replace are technically not really necessary but
51
+ # nice to have and work around a JRuby incompatibility. Setting
52
+ # these options on `file.set_encoding` doesn't seem to work
53
+ # properly, so it has to be done here.
54
+ def ensure_remove_undefs(file_lines)
55
+ file_lines.each do |line|
56
+ # simplecov:disable — defensive: only fires for non-UTF-8 source files
57
+ line.encode!("UTF-8", invalid: :replace, undef: :replace) unless line.encoding == Encoding::UTF_8
58
+ # simplecov:enable
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end