simplecov 1.0.0 → 1.1.1

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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -1451
  3. data/lib/simplecov/atomic_file.rb +70 -0
  4. data/lib/simplecov/cli/clean.rb +43 -3
  5. data/lib/simplecov/cli/command_helpers.rb +55 -0
  6. data/lib/simplecov/cli/coverage.rb +23 -28
  7. data/lib/simplecov/cli/coverage_file.rb +65 -0
  8. data/lib/simplecov/cli/diff.rb +43 -47
  9. data/lib/simplecov/cli/dotfile.rb +21 -10
  10. data/lib/simplecov/cli/merge.rb +17 -13
  11. data/lib/simplecov/cli/open.rb +3 -5
  12. data/lib/simplecov/cli/report.rb +38 -25
  13. data/lib/simplecov/cli/run.rb +1 -1
  14. data/lib/simplecov/cli/serve/report_preparer.rb +29 -0
  15. data/lib/simplecov/cli/serve/static_file_handler.rb +122 -0
  16. data/lib/simplecov/cli/serve.rb +45 -99
  17. data/lib/simplecov/cli/uncovered.rb +20 -24
  18. data/lib/simplecov/cli.rb +15 -2
  19. data/lib/simplecov/color.rb +10 -1
  20. data/lib/simplecov/combine/branches_combiner.rb +43 -18
  21. data/lib/simplecov/combine/coverage_accumulator.rb +268 -0
  22. data/lib/simplecov/combine/identity_interner.rb +30 -0
  23. data/lib/simplecov/combine/interned_counts.rb +30 -0
  24. data/lib/simplecov/combine/lines_combiner.rb +48 -20
  25. data/lib/simplecov/combine/methods_combiner.rb +53 -5
  26. data/lib/simplecov/combine/results_combiner.rb +12 -36
  27. data/lib/simplecov/combine.rb +5 -23
  28. data/lib/simplecov/command_guesser.rb +66 -9
  29. data/lib/simplecov/configuration/coverage.rb +17 -16
  30. data/lib/simplecov/configuration/coverage_criteria.rb +37 -39
  31. data/lib/simplecov/configuration/eval_coverage.rb +41 -0
  32. data/lib/simplecov/configuration/filters.rb +11 -44
  33. data/lib/simplecov/configuration/formatting.rb +33 -14
  34. data/lib/simplecov/configuration/groups.rb +42 -0
  35. data/lib/simplecov/configuration/merging.rb +8 -7
  36. data/lib/simplecov/configuration/thresholds.rb +27 -22
  37. data/lib/simplecov/configuration.rb +21 -42
  38. data/lib/simplecov/coverage_json.rb +24 -0
  39. data/lib/simplecov/coverage_statistics.rb +5 -2
  40. data/lib/simplecov/coverage_violations.rb +17 -6
  41. data/lib/simplecov/defaults.rb +10 -6
  42. data/lib/simplecov/directive.rb +9 -4
  43. data/lib/simplecov/exit_codes/check.rb +33 -0
  44. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +11 -20
  45. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +4 -17
  46. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +14 -23
  47. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +14 -25
  48. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +7 -20
  49. data/lib/simplecov/exit_codes.rb +17 -0
  50. data/lib/simplecov/exit_handling.rb +13 -43
  51. data/lib/simplecov/file_list.rb +14 -12
  52. data/lib/simplecov/filter.rb +43 -9
  53. data/lib/simplecov/formatter/base.rb +18 -1
  54. data/lib/simplecov/formatter/coverage_json_writer.rb +97 -0
  55. data/lib/simplecov/formatter/html_formatter/public/index.html +36 -5
  56. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +96 -0
  57. data/lib/simplecov/formatter/html_formatter.rb +71 -45
  58. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +54 -50
  59. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +78 -88
  60. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +70 -76
  61. data/lib/simplecov/formatter/json_formatter.rb +5 -46
  62. data/lib/simplecov/formatter/multi_formatter.rb +8 -3
  63. data/lib/simplecov/formatter/simple_formatter.rb +8 -3
  64. data/lib/simplecov/formatter.rb +12 -0
  65. data/lib/simplecov/group_names.rb +32 -0
  66. data/lib/simplecov/last_run.rb +16 -9
  67. data/lib/simplecov/lines_classifier.rb +29 -8
  68. data/lib/simplecov/load_global_config.rb +5 -2
  69. data/lib/simplecov/parallel_adapters/base.rb +17 -0
  70. data/lib/simplecov/parallel_adapters/generic.rb +9 -8
  71. data/lib/simplecov/parallel_adapters/parallel_tests.rb +2 -2
  72. data/lib/simplecov/parallel_adapters.rb +4 -2
  73. data/lib/simplecov/parallel_coordination.rb +6 -1
  74. data/lib/simplecov/parallel_result_merger.rb +230 -0
  75. data/lib/simplecov/process.rb +22 -0
  76. data/lib/simplecov/report_deferral.rb +49 -0
  77. data/lib/simplecov/report_stamp.rb +28 -0
  78. data/lib/simplecov/result.rb +40 -10
  79. data/lib/simplecov/result_adapter.rb +79 -10
  80. data/lib/simplecov/result_merger/resultset_file.rb +43 -7
  81. data/lib/simplecov/result_merger/resultset_run_identity.rb +67 -0
  82. data/lib/simplecov/result_merger/resultset_store.rb +15 -12
  83. data/lib/simplecov/result_merger/unloaded_files.rb +103 -0
  84. data/lib/simplecov/result_merger.rb +69 -42
  85. data/lib/simplecov/result_processing.rb +85 -46
  86. data/lib/simplecov/run_identity.rb +77 -0
  87. data/lib/simplecov/simulate_coverage.rb +37 -12
  88. data/lib/simplecov/source_file/line.rb +2 -2
  89. data/lib/simplecov/source_file/method.rb +8 -2
  90. data/lib/simplecov/source_file/method_builder.rb +3 -1
  91. data/lib/simplecov/source_file/ruby_data_parser.rb +25 -3
  92. data/lib/simplecov/source_file/skip_chunks.rb +7 -10
  93. data/lib/simplecov/source_file/source_loader.rb +25 -9
  94. data/lib/simplecov/source_file/statistics.rb +24 -16
  95. data/lib/simplecov/source_file.rb +2 -2
  96. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +239 -0
  97. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +236 -0
  98. data/lib/simplecov/static_coverage_extractor/method_collector.rb +7 -0
  99. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +55 -0
  100. data/lib/simplecov/static_coverage_extractor/value_position.rb +74 -0
  101. data/lib/simplecov/static_coverage_extractor/visitor.rb +74 -62
  102. data/lib/simplecov/unloaded_file_injector.rb +75 -0
  103. data/lib/simplecov/version.rb +1 -1
  104. data/lib/simplecov.rb +37 -17
  105. data/sig/simplecov.rbs +1777 -0
  106. metadata +32 -16
  107. data/doc/alternate-formatters.md +0 -66
  108. data/doc/commercial-services.md +0 -25
  109. data/doc/editor-integration.md +0 -18
  110. data/lib/simplecov/combine/files_combiner.rb +0 -30
  111. data/lib/simplecov/formatter/html_formatter/public/application.css +0 -1
  112. data/lib/simplecov/formatter/html_formatter/public/application.js +0 -18
  113. data/lib/simplecov/formatter/html_formatter/public/favicon_green.png +0 -0
  114. data/lib/simplecov/formatter/html_formatter/public/favicon_red.png +0 -0
  115. data/lib/simplecov/formatter/html_formatter/public/favicon_yellow.png +0 -0
  116. data/schemas/coverage-v1.0.schema.json +0 -306
  117. data/schemas/coverage.schema.json +0 -306
@@ -22,19 +22,16 @@ module SimpleCov
22
22
  @src = src
23
23
  end
24
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.
25
+ # Every criterion honors the deprecated all-criteria `# :nocov:`
26
+ # chunks plus its own per-criterion directive ranges. Methods
27
+ # included: they carry a source range, and nocov has always meant
28
+ # "exclude everything here".
28
29
  def for(criterion)
29
- if criterion == :method
30
- directive_chunks.fetch(:method)
31
- else
32
- nocov_chunks + directive_chunks.fetch(criterion)
33
- end
30
+ nocov_chunks + directive_chunks.fetch(criterion)
34
31
  end
35
32
 
36
- # no_cov_chunks is zero indexed to work directly with the array
37
- # holding the lines.
33
+ # Ranges of 1-based line numbers (see `with_index(1)` below);
34
+ # consumers subtract 1 to index into the zero-based lines array.
38
35
  def nocov_chunks
39
36
  @nocov_chunks ||= build_nocov_chunks
40
37
  end
@@ -14,20 +14,30 @@ module SimpleCov
14
14
  module_function
15
15
 
16
16
  def call(filename)
17
- lines = []
17
+ lines = [] #: Array[String]
18
18
  # The default encoding is UTF-8
19
19
  File.open(filename, "rb:UTF-8") do |file|
20
- current_line = file.gets
20
+ current_line = scrub_invalid(file.gets)
21
21
 
22
- if shebang?(current_line)
22
+ if current_line && shebang?(current_line)
23
23
  lines << current_line
24
- current_line = file.gets
24
+ current_line = scrub_invalid(file.gets)
25
25
  end
26
26
 
27
27
  read_lines(file, lines, current_line)
28
28
  end
29
29
  end
30
30
 
31
+ # A line read as UTF-8 can still carry invalid bytes (a Latin-1
32
+ # source file without a magic comment, say). Replace them before
33
+ # any regex sees the line: the shebang and magic-comment checks
34
+ # would otherwise raise ArgumentError and take the report down.
35
+ def scrub_invalid(line)
36
+ return line if line.nil? || line.valid_encoding?
37
+
38
+ line.scrub
39
+ end
40
+
31
41
  def shebang?(line)
32
42
  SHEBANG_REGEX.match?(line)
33
43
  end
@@ -47,14 +57,20 @@ module SimpleCov
47
57
  end
48
58
  end
49
59
 
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.
60
+ # Guarantee every line leaves the loader as valid UTF-8, replacing
61
+ # what can't be represented: transcode non-UTF-8 lines (setting
62
+ # invalid/undef options on `file.set_encoding` doesn't work
63
+ # properly, and this also works around a JRuby incompatibility)
64
+ # and scrub UTF-8-tagged lines that carry invalid bytes, which
65
+ # would otherwise raise from every regex the classifier runs.
54
66
  def ensure_remove_undefs(file_lines)
55
67
  file_lines.each do |line|
56
68
  # 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
69
+ if line.encoding == Encoding::UTF_8
70
+ line.scrub! unless line.valid_encoding?
71
+ else
72
+ line.encode!("UTF-8", invalid: :replace, undef: :replace)
73
+ end
58
74
  # simplecov:enable
59
75
  end
60
76
  end
@@ -22,35 +22,43 @@ module SimpleCov
22
22
  private
23
23
 
24
24
  def line_statistics
25
- sf = @source_file
26
25
  {
27
- line: CoverageStatistics.new(
28
- total_strength: sf.lines.sum { |line| line.coverage.to_i },
29
- covered: sf.covered_lines.size,
30
- missed: sf.missed_lines.size,
31
- omitted: sf.never_lines.size
26
+ line: coverage_statistics(
27
+ @source_file.covered_lines,
28
+ @source_file.missed_lines,
29
+ omitted: @source_file.never_lines.size
32
30
  )
33
31
  }
34
32
  end
35
33
 
34
+ # Files added via track_files but never loaded have no branch/method
35
+ # data. Report 0% instead of the empty-set default of 100% (see #902).
36
36
  def branch_statistics
37
37
  sf = @source_file
38
- # Files added via track_files but never loaded/required have no
39
- # branch data. Report 0% instead of misleading 100% (see #902).
40
- if sf.not_loaded? && sf.covered_branches.empty? && sf.missed_branches.empty?
41
- return {branch: CoverageStatistics.new(covered: 0, missed: 0, percent: 0.0)}
42
- end
38
+ covered = sf.covered_branches
39
+ missed = sf.missed_branches
40
+ percent = 0.0 if sf.not_loaded? && covered.empty? && missed.empty?
43
41
 
44
- {branch: CoverageStatistics.new(covered: sf.covered_branches.size, missed: sf.missed_branches.size)}
42
+ {branch: coverage_statistics(covered, missed, percent: percent)}
45
43
  end
46
44
 
47
45
  def method_statistics
48
46
  sf = @source_file
49
- if sf.not_loaded? && sf.covered_methods.empty? && sf.missed_methods.empty?
50
- return {method: CoverageStatistics.new(covered: 0, missed: 0, percent: 0.0)}
51
- end
47
+ covered = sf.covered_methods
48
+ missed = sf.missed_methods
49
+ percent = 0.0 if sf.not_loaded? && covered.empty? && missed.empty?
50
+
51
+ {method: coverage_statistics(covered, missed, percent: percent)}
52
+ end
52
53
 
53
- {method: CoverageStatistics.new(covered: sf.covered_methods.size, missed: sf.missed_methods.size)}
54
+ def coverage_statistics(covered, missed, omitted: 0, percent: nil)
55
+ CoverageStatistics.new(
56
+ total_strength: covered.sum { |item| item.coverage.to_i },
57
+ covered: covered.size,
58
+ missed: missed.size,
59
+ omitted: omitted,
60
+ percent: percent
61
+ )
54
62
  end
55
63
  end
56
64
  end
@@ -51,8 +51,8 @@ module SimpleCov
51
51
  # returns the `{line:, branch:, method:}` Hash; pass a criterion symbol
52
52
  # (`:line` / `:branch` / `:method`) to get that one CoverageStatistics.
53
53
  def coverage_statistics(criterion = nil)
54
- @coverage_statistics ||= Statistics.new(self).call
55
- criterion ? @coverage_statistics[criterion] : @coverage_statistics
54
+ stats = (@coverage_statistics ||= Statistics.new(self).call)
55
+ criterion ? stats[criterion] : stats
56
56
  end
57
57
 
58
58
  # Returns all source lines for this file as instances of SimpleCov::SourceFile::Line,
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Detects the `if` / `unless` / ternary conditions CRuby folds away.
6
+ # When a condition is a statically-known-truthy/falsy literal the
7
+ # compiler eliminates the dead arm and Coverage emits NO branch, so the
8
+ # extractor must not synthesize one either — otherwise the arm is a
9
+ # phantom that no loaded run can ever hit, the same unmergeable-tuple
10
+ # failure mode as #1226 / #1233.
11
+ module ConditionFolding
12
+ # CRuby 3.4 rebuilt the fold on the Prism compiler, and the
13
+ # parse.y-based fold it replaced differed in three observable ways,
14
+ # each pinned by the runtime tuple equivalence battery on CI:
15
+ # `__FILE__` folded on 3.2/3.3 but no longer does; parentheses were
16
+ # transparent for every literal on 3.2 (opacity starts at 3.3); and
17
+ # on 3.2 the dead arm's branch table entries survive the fold —
18
+ # parse.y instrumented branches before eliminating dead code — while
19
+ # its `def`s still never register.
20
+ FOLDS_SOURCE_FILE = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
21
+ PARENS_ALWAYS_TRANSPARENT = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.3")
22
+ DEAD_ARM_BRANCHES_SURVIVE = PARENS_ALWAYS_TRANSPARENT
23
+ # Container literals in discarded position are eliminated from 3.3
24
+ # on, but the contents rule differs: 3.3's compile.c elides a
25
+ # container whose contents are merely effect-free (`[x]`, `[self]`),
26
+ # while the Prism compiler (3.4+) demands fully static literals
27
+ # (`[1]` goes, `[x]` stays). See `static_container_literal?`.
28
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS = !FOLDS_SOURCE_FILE
29
+
30
+ # Prism node types for the literals that fold. `while` / `until` do
31
+ # NOT fold (`while true` is a real branch), so only the if-like
32
+ # visitors consult this. Regexp and Range literals are excluded on
33
+ # purpose: as conditions they mean `=~ $_` / flip-flop, which
34
+ # Coverage does branch on. `[]`, `{}`, and interpolated strings do
35
+ # not fold either, and `->` folds while a `lambda` call does not —
36
+ # the compiler only folds what it can prove at compile time, and a
37
+ # method named `lambda` proves nothing.
38
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
39
+ STATIC_CONDITION_TYPES = [
40
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
41
+ ::Prism::ImaginaryNode, ::Prism::SymbolNode, ::Prism::StringNode,
42
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
43
+ ::Prism::SourceLineNode, ::Prism::SourceEncodingNode, ::Prism::LambdaNode,
44
+ *(::Prism::SourceFileNode if FOLDS_SOURCE_FILE)
45
+ ].freeze
46
+ # simplecov:enable branch
47
+
48
+ # The literals whose fold eliminates the *then* side: `if false` /
49
+ # `if nil` keep only the else arm. Every other folded literal is
50
+ # truthy and keeps only the then arm.
51
+ FALSY_CONDITION_TYPES = [::Prism::FalseNode, ::Prism::NilNode].freeze
52
+
53
+ # The literals whose fold does NOT see through parentheses: CRuby
54
+ # folds `if nil`, `if "x"`, and `if -> {}` but keeps a real branch
55
+ # for `if (nil)`, `if ("x")`, and `if (-> {})` — verified against
56
+ # Coverage, and pinned by the runtime tuple equivalence battery —
57
+ # while every other literal folds parenthesized or not. `__FILE__`
58
+ # is opaque like other strings on the Rubies that fold it at all.
59
+ # Consulted only when PARENS_ALWAYS_TRANSPARENT is false.
60
+ PAREN_OPAQUE_TYPES = [
61
+ ::Prism::NilNode, ::Prism::StringNode, ::Prism::LambdaNode, ::Prism::SourceFileNode
62
+ ].freeze
63
+
64
+ # The scalar literals the compiler treats as fully static: a
65
+ # multi-statement paren condition (`if (1; 2)`) folds by its last
66
+ # expression only when every leading statement is eliminated when
67
+ # discarded, and these — bare or composing an Array/Hash/Range —
68
+ # always are. Pinned against real Coverage on 3.2 through 4.0.
69
+ STATIC_LITERAL_LEAF_TYPES = [
70
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
71
+ ::Prism::ImaginaryNode, ::Prism::StringNode, ::Prism::SymbolNode,
72
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
73
+ ::Prism::SourceLineNode, ::Prism::SourceFileNode, ::Prism::SourceEncodingNode, ::Prism::RegularExpressionNode
74
+ ].freeze
75
+
76
+ # Non-literal reads that are also eliminated when discarded.
77
+ # `self` is eliminated by every supported compiler; local/ivar/
78
+ # defined? elimination arrived with the Prism-era compilers.
79
+ # Anything that can raise or run hooks (constants, globals, calls,
80
+ # writes) is never eliminated and keeps the branch real.
81
+ PRISM_ERA_ELIMINABLE_READS = [
82
+ ::Prism::LocalVariableReadNode, ::Prism::InstanceVariableReadNode, ::Prism::DefinedNode
83
+ ].freeze
84
+
85
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
86
+ ELIMINABLE_READ_TYPES = [
87
+ ::Prism::SelfNode,
88
+ *(PRISM_ERA_ELIMINABLE_READS unless PARENS_ALWAYS_TRANSPARENT)
89
+ ].freeze
90
+ # simplecov:enable branch
91
+
92
+ private
93
+
94
+ # A truthy verdict keeps the first arm live, a falsy one the
95
+ # second. `visit` is nil-safe, so a missing arm just visits
96
+ # nothing. On 3.2 the dead arm's branch table entries survive the
97
+ # fold (parse.y instrumented branches before eliminating dead
98
+ # code, even inside dead `def` bodies and lambdas) while its
99
+ # methods never register, so the dead arm is visited with method
100
+ # collection suppressed.
101
+ def visit_folded_arms(verdict, truthy_arm, falsy_arm)
102
+ live, dead = verdict == :truthy ? [truthy_arm, falsy_arm] : [falsy_arm, truthy_arm]
103
+ visit(live)
104
+ # simplecov:disable branch — the taken arm is fixed by the running Ruby's version
105
+ visit_dead_arm(dead) if DEAD_ARM_BRANCHES_SURVIVE
106
+ # simplecov:enable branch
107
+ end
108
+
109
+ # simplecov:disable — 3.2-only; unreachable on the modern dogfood Ruby
110
+ def visit_dead_arm(arm)
111
+ # Save/restore rather than set/clear: a fold nested inside this
112
+ # dead arm re-enters here (possibly with a nil arm) and must not
113
+ # clear suppression for the rest of the outer dead arm.
114
+ previous = @suppress_methods
115
+ begin
116
+ @suppress_methods = true
117
+ visit(arm)
118
+ ensure
119
+ @suppress_methods = previous
120
+ end
121
+ end
122
+ # simplecov:enable
123
+
124
+ # The compiler's verdict on a condition: `:truthy` or `:falsy` when
125
+ # it folds, nil when it doesn't. The verdict decides which arm
126
+ # survives — the compiler eliminates the other arm's entire subtree,
127
+ # so everything inside it (nested branches, methods) must go
128
+ # unvisited too, not just the folded condition's own tuple.
129
+ #
130
+ # Parentheses are transparent to the fold for most literals
131
+ # (`if (1)` folds like `if 1`) but not all — see
132
+ # PAREN_OPAQUE_TYPES. Compound forms (`!true`, `true || x`) are
133
+ # deliberately not folded: `!` never folds, and `||` / `&&`
134
+ # constant-propagation diverges across Ruby versions, so matching
135
+ # it would trade a rare, version-specific gain for real risk.
136
+ def folded_condition(node)
137
+ unwrapped = unwrap_parentheses(node)
138
+ return nil unless foldable?(node, unwrapped)
139
+
140
+ FALSY_CONDITION_TYPES.any? { |type| unwrapped.is_a?(type) } ? :falsy : :truthy
141
+ end
142
+
143
+ # A folding literal, minus the ones parentheses shield from the
144
+ # fold (`unwrapped` differing from `node` is what says parentheses
145
+ # were seen through).
146
+ def foldable?(node, unwrapped)
147
+ return false unless STATIC_CONDITION_TYPES.any? { |type| unwrapped.is_a?(type) }
148
+
149
+ # simplecov:disable — which of these lines runs is fixed by the
150
+ # running Ruby's version: on 3.2 the early return always fires and
151
+ # the opacity check below it is dead code, so it must be excluded
152
+ # from line coverage too, not only branch coverage.
153
+ return true if PARENS_ALWAYS_TRANSPARENT
154
+
155
+ unwrapped.equal?(node) || PAREN_OPAQUE_TYPES.none? { |type| unwrapped.is_a?(type) }
156
+ # simplecov:enable
157
+ end
158
+
159
+ # Parentheses are transparent to the fold, and a multi-statement
160
+ # body (`if (1; 2)`) folds by its LAST expression — but only when
161
+ # the compiler eliminates every leading statement. Stopping at
162
+ # multi-statement bodies synthesized a phantom then/else pair no
163
+ # real run can ever hit.
164
+ def unwrap_parentheses(node)
165
+ # @type var current: untyped
166
+ current = node
167
+ while current.is_a?(::Prism::ParenthesesNode)
168
+ body = current.body
169
+ break unless body.is_a?(::Prism::StatementsNode) && !body.body.empty?
170
+
171
+ statements = body.body
172
+ break unless statements.take(statements.size - 1).all? { |leading| eliminable_when_discarded?(leading) }
173
+
174
+ current = statements.last
175
+ end
176
+ current
177
+ end
178
+
179
+ # Whether the compiler compiles `node` to nothing when its value is
180
+ # discarded.
181
+ def eliminable_when_discarded?(node)
182
+ return true if static_container_literal?(node)
183
+ return true if ELIMINABLE_READ_TYPES.any? { |type| node.is_a?(type) }
184
+
185
+ node.is_a?(::Prism::ParenthesesNode) &&
186
+ node.body.is_a?(::Prism::StatementsNode) &&
187
+ node.body.body.all? { |statement| eliminable_when_discarded?(statement) }
188
+ end
189
+
190
+ # A scalar literal leaf, or an Array/Hash/Range whose contents pass
191
+ # the running compiler's contents rule (see
192
+ # CONTAINER_CONTENTS_NEED_STATIC_LITERALS). Container elimination
193
+ # only exists from 3.3 on.
194
+ def static_container_literal?(node)
195
+ return true if STATIC_LITERAL_LEAF_TYPES.any? { |type| node.is_a?(type) }
196
+ # simplecov:disable — which of these lines runs is fixed by the
197
+ # running Ruby's version: parse.y (3.2) never eliminates
198
+ # container literals, so there the early return always fires and
199
+ # the dispatch below is dead code.
200
+ return false if PARENS_ALWAYS_TRANSPARENT
201
+
202
+ static_container?(node)
203
+ # simplecov:enable
204
+ end
205
+
206
+ # The container dispatch, version-free: the predicate specs
207
+ # exercise it directly on every supported Ruby, 3.2 included.
208
+ def static_container?(node)
209
+ case node
210
+ when ::Prism::ArrayNode then static_array_literal?(node)
211
+ when ::Prism::HashNode then static_hash_literal?(node)
212
+ when ::Prism::RangeNode then static_range_literal?(node)
213
+ else false
214
+ end
215
+ end
216
+
217
+ def static_array_literal?(node)
218
+ node.elements.all? { |element| container_contents_eliminable?(element) }
219
+ end
220
+
221
+ def static_hash_literal?(node)
222
+ node.elements.all? do |element|
223
+ element.is_a?(::Prism::AssocNode) &&
224
+ container_contents_eliminable?(element.key) && container_contents_eliminable?(element.value)
225
+ end
226
+ end
227
+
228
+ def static_range_literal?(node)
229
+ [node.left, node.right].all? { |bound| bound.nil? || container_contents_eliminable?(bound) }
230
+ end
231
+
232
+ def container_contents_eliminable?(node)
233
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
234
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS ? static_container_literal?(node) : eliminable_when_discarded?(node)
235
+ # simplecov:enable branch
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prism_compat"
4
+
5
+ module SimpleCov
6
+ module StaticCoverageExtractor
7
+ # The source ranges Ruby's Coverage assigns to branch conditions and
8
+ # arms, resolved from Prism nodes. Simulated entries only ever merge
9
+ # with real entries produced by the running Ruby, and CRuby 3.4
10
+ # changed several of these conventions, so every resolver here emits
11
+ # whichever shape this Ruby's Coverage uses. See issues #1226 / #1233.
12
+ #
13
+ # rubocop:disable Metrics/ModuleLength -- one cohesive home for the
14
+ # per-construct, per-Ruby-version Coverage location conventions;
15
+ # splitting it would scatter closely-related resolvers.
16
+ module LocationConventions
17
+ LEGACY_COVERAGE_LOCATIONS = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
18
+
19
+ # A zero-width stand-in for Prism locations, for the arms Coverage
20
+ # anchors to a point rather than a range.
21
+ PointLocation = Data.define(:start_line, :start_column, :end_line, :end_column)
22
+
23
+ private
24
+
25
+ # simplecov:disable
26
+ # Which arm of each conditional below runs is fixed by the running
27
+ # Ruby's version, so no single process can cover both sides, and
28
+ # the legacy-only helpers are unreachable on modern Rubies (and
29
+ # vice versa). The "runtime tuple equivalence" spec exercises this
30
+ # module against real Coverage output on every CI Ruby.
31
+
32
+ # The range Coverage assigns to an if-like node itself. Modern
33
+ # CRuby uses the node's full source range for every form; 3.2/3.3
34
+ # end an `elsif` clause's range at its last content instead of the
35
+ # shared `end` keyword the clause doesn't own.
36
+ def if_like_location(node, type)
37
+ return node.location unless LEGACY_COVERAGE_LOCATIONS && type == :if && elsif_node?(node)
38
+
39
+ span(node.location, legacy_content_end(node))
40
+ end
41
+
42
+ def elsif_node?(node)
43
+ keyword = node.if_keyword_loc
44
+ !keyword.nil? && keyword.slice == "elsif"
45
+ end
46
+
47
+ # Where an if/elsif chain's content ends, for the legacy range
48
+ # convention: the deepest trailing clause's statements, or that
49
+ # clause's predicate / `else` keyword when its body is empty.
50
+ def legacy_content_end(node)
51
+ tail = node
52
+ while tail.is_a?(::Prism::IfNode)
53
+ sub = PrismCompat.subsequent(tail)
54
+ break unless sub
55
+
56
+ tail = sub
57
+ end
58
+ return (tail.statements || tail.predicate).location if tail.is_a?(::Prism::IfNode)
59
+
60
+ tail.statements ? tail.statements.location : tail.else_keyword_loc
61
+ end
62
+
63
+ # Location of the then arm. Coverage uses the body statements'
64
+ # range; with an empty then body the arm collapses to a zero-width
65
+ # point at the predicate's end — always on a modern `if`, and on
66
+ # legacy Rubies only when the construct is in void position (a
67
+ # trailing statement discards its value). In value (tail) position,
68
+ # legacy Rubies and `unless` fall back to the node's range.
69
+ def if_like_then_location(node, type)
70
+ return node.statements.location if node.statements
71
+ return point_at_end(node.predicate.location) if empty_arm_collapses?(node, type)
72
+
73
+ if_like_location(node, type)
74
+ end
75
+
76
+ # Resolve the source range Coverage attributes to a real-or-synthetic
77
+ # `:else` arm of an if-like construct (`PrismCompat` hides the
78
+ # per-Prism-version accessor split). When no else/elsif is present,
79
+ # the synthesized else inherits the condition's range (matches
80
+ # Coverage's convention).
81
+ def if_like_else_location(node, type)
82
+ sub = PrismCompat.subsequent(node)
83
+ return if_like_location(node, type) unless sub
84
+ # An `elsif` arrives as a nested IfNode. Coverage attributes the
85
+ # outer else arm to the clause's own range, not its then body
86
+ # (which is what created phantom unmergeable arms).
87
+ return if_like_location(sub, :if) if sub.is_a?(::Prism::IfNode)
88
+ return sub.statements.location if sub.statements
89
+
90
+ empty_else_location(node, sub, type)
91
+ end
92
+
93
+ # Location of an empty explicit `else`: a modern `if` uses the
94
+ # else..end span; a legacy Ruby in void position collapses to a point
95
+ # at the `else` keyword's end; otherwise (legacy value position, or
96
+ # `unless`) it uses the condition's range.
97
+ def empty_else_location(node, sub, type)
98
+ return sub.location if !LEGACY_COVERAGE_LOCATIONS && type == :if
99
+ return point_at_end(sub.else_keyword_loc) if LEGACY_COVERAGE_LOCATIONS && !value_position?(node)
100
+
101
+ if_like_location(node, type)
102
+ end
103
+
104
+ # Arm location for a when/in clause: its body statements, or — when
105
+ # the body is empty — the clause's own range on modern Rubies, a
106
+ # point at the pattern's end for a legacy `in`, and for a legacy
107
+ # `when` a point at the clause's end in void position or the tail
108
+ # convention (keyword through the case's remaining content) in value.
109
+ def case_arm_location(case_node, when_node, when_type)
110
+ return when_node.statements.location if when_node.statements
111
+ return when_node.location unless LEGACY_COVERAGE_LOCATIONS
112
+ return point_at_end(when_node.pattern.location) if when_type == :in
113
+ return point_at_end(when_node.location) unless value_position?(case_node)
114
+
115
+ legacy_when_value_location(case_node, when_node)
116
+ end
117
+
118
+ def legacy_when_value_location(case_node, when_node)
119
+ span(when_node.location, legacy_case_tail_end(case_node, when_node))
120
+ end
121
+
122
+ # The last body content in the case after `when_node`, falling
123
+ # back to the clause's final condition value.
124
+ def legacy_case_tail_end(case_node, when_node)
125
+ following_case_content(case_node, when_node).last ||
126
+ (when_node.conditions.last || when_node).location
127
+ end
128
+
129
+ def following_case_content(case_node, when_node)
130
+ clauses = case_node.conditions
131
+ index = clauses.index { |clause| clause.equal?(when_node) } || 0
132
+ # A when-clause's own location ends where its body ends (or at its
133
+ # condition when empty), so the whole clause extends the range
134
+ # through trailing EMPTY clauses that have no `statements`.
135
+ content = clauses.drop(index + 1).map(&:location)
136
+ else_statements = PrismCompat.else_clause(case_node)&.statements
137
+ content << else_statements.location if else_statements
138
+ content
139
+ end
140
+
141
+ # Resolve the source range Coverage attributes to a synthetic-or-real
142
+ # `:else` arm of a case construct: the body of an explicit else,
143
+ # the case's full range when no else is present, and — for an
144
+ # explicit else with an empty body — the else..end span on modern
145
+ # Rubies or the case's full range on legacy ones.
146
+ def else_arm_location(node)
147
+ else_clause = PrismCompat.else_clause(node)
148
+ return node.location unless else_clause
149
+ return else_clause.statements.location if else_clause.statements
150
+ return else_clause.location unless LEGACY_COVERAGE_LOCATIONS
151
+ # Empty explicit `else`: a point at the `else` keyword's end in void
152
+ # position, the whole case's range in value position.
153
+ return point_at_end(else_clause.else_keyword_loc) unless value_position?(node)
154
+
155
+ node.location
156
+ end
157
+
158
+ # An empty loop body falls back to the loop's range on modern
159
+ # Rubies and collapses to a point at the predicate's end on legacy
160
+ # ones.
161
+ def loop_body_location(node)
162
+ return legacy_do_while_body_location(node) if LEGACY_COVERAGE_LOCATIONS && begin_modifier_loop?(node)
163
+ return node.statements.location if node.statements
164
+ return point_at_end(node.predicate.location) if LEGACY_COVERAGE_LOCATIONS
165
+
166
+ node.location
167
+ end
168
+
169
+ # `begin ... end while/until cond` (the do-while form) parses as a
170
+ # while/until whose sole statement is the BeginNode. Modern Coverage
171
+ # attributes the body to that whole `begin ... end` span (which the
172
+ # generic `node.statements.location` already yields), but 3.3 uses
173
+ # the begin's inner statements instead — or a point at the end of
174
+ # the `begin` keyword when the body is empty.
175
+ def begin_modifier_loop?(node)
176
+ node.respond_to?(:begin_modifier?) && node.begin_modifier?
177
+ end
178
+
179
+ def legacy_do_while_body_location(node)
180
+ begin_node = node.statements.body.first
181
+ inner = begin_node.statements
182
+ inner ? inner.location : point_at_end(begin_node.begin_keyword_loc)
183
+ end
184
+
185
+ # Coverage's safe-navigation branch spans the receiver through the
186
+ # end of the call's arguments (or just the message when there are
187
+ # none), but never includes a trailing block: `x&.foo { ... }` and
188
+ # `x&.foo(1) { ... }` both end exactly where `x&.foo` / `x&.foo(1)`
189
+ # would without the block. `node.location` includes an attached
190
+ # block, so build the end position from `closing_loc` (closing
191
+ # paren) / `arguments` (paren-less args) / `message_loc` instead.
192
+ # This convention is the same on legacy and modern Rubies. See
193
+ # issue #1233.
194
+ def safe_navigation_location(node)
195
+ span(node.location, node.closing_loc || node.arguments&.location || node.message_loc)
196
+ end
197
+
198
+ # The range from `from`'s start through `to`'s end.
199
+ def span(from, to)
200
+ PointLocation.new(
201
+ start_line: from.start_line, start_column: from.start_column,
202
+ end_line: to.end_line, end_column: to.end_column
203
+ )
204
+ end
205
+
206
+ def point_at_end(location)
207
+ PointLocation.new(
208
+ start_line: location.end_line, start_column: location.end_column,
209
+ end_line: location.end_line, end_column: location.end_column
210
+ )
211
+ end
212
+
213
+ # Whether an empty then arm collapses to a point at the predicate's
214
+ # end. Modern Coverage does this for every `if` (but not `unless`);
215
+ # legacy Coverage does it only in void position, for both.
216
+ def empty_arm_collapses?(node, type)
217
+ return type == :if unless LEGACY_COVERAGE_LOCATIONS
218
+
219
+ !value_position?(node)
220
+ end
221
+
222
+ # Whether `node` sits in value (method-return) position, which on
223
+ # legacy Rubies keeps an empty arm's range instead of collapsing it
224
+ # to a point. `@value_positions` is computed once per parse by
225
+ # ValuePositions (only on legacy; nil elsewhere, which reads as
226
+ # "value" — the safe, pre-audit default).
227
+ def value_position?(node)
228
+ return true if @value_positions.nil?
229
+
230
+ @value_positions.key?(node)
231
+ end
232
+ # simplecov:enable
233
+ end
234
+ # rubocop:enable Metrics/ModuleLength
235
+ end
236
+ end
@@ -22,7 +22,14 @@ module SimpleCov
22
22
  # `def name(...)` and `def self.name(...)` both produce DefNode.
23
23
  # The class context is the surrounding lexical class/module (or
24
24
  # `Object` at the top level, matching `Coverage`'s convention).
25
+ # Suppression covers 3.2's folded dead arms, where nested branches
26
+ # stay instrumented but a `def` never registers (see
27
+ # Visitor#visit_dead_arm).
25
28
  def visit_def_node(node)
29
+ # simplecov:disable branch — suppression is reachable on 3.2 only
30
+ return super if @suppress_methods
31
+
32
+ # simplecov:enable branch
26
33
  loc = node.location
27
34
  class_name = @class_stack.last || "Object"
28
35
  key = [class_name, node.name, loc.start_line, loc.start_column, loc.end_line, loc.end_column]