simplecov 0.22.0 → 1.3.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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +173 -875
  4. data/exe/simplecov +8 -0
  5. data/lib/minitest/simplecov_plugin.rb +18 -7
  6. data/lib/simplecov/atomic_file.rb +67 -0
  7. data/lib/simplecov/autostart.rb +9 -0
  8. data/lib/simplecov/baseline/parser.rb +58 -0
  9. data/lib/simplecov/baseline.rb +170 -0
  10. data/lib/simplecov/cli/affected/changed_files.rb +57 -0
  11. data/lib/simplecov/cli/affected/selection.rb +117 -0
  12. data/lib/simplecov/cli/affected.rb +148 -0
  13. data/lib/simplecov/cli/annotations.rb +166 -0
  14. data/lib/simplecov/cli/badge/svg.rb +70 -0
  15. data/lib/simplecov/cli/badge.rb +62 -0
  16. data/lib/simplecov/cli/clean.rb +86 -0
  17. data/lib/simplecov/cli/command_helpers.rb +91 -0
  18. data/lib/simplecov/cli/completions/scripts.rb +114 -0
  19. data/lib/simplecov/cli/completions.rb +83 -0
  20. data/lib/simplecov/cli/coverage.rb +83 -0
  21. data/lib/simplecov/cli/coverage_file.rb +109 -0
  22. data/lib/simplecov/cli/dead_code/output.rb +100 -0
  23. data/lib/simplecov/cli/dead_code.rb +139 -0
  24. data/lib/simplecov/cli/diff/output.rb +45 -0
  25. data/lib/simplecov/cli/diff.rb +115 -0
  26. data/lib/simplecov/cli/dotfile.rb +156 -0
  27. data/lib/simplecov/cli/git.rb +41 -0
  28. data/lib/simplecov/cli/history/output.rb +135 -0
  29. data/lib/simplecov/cli/history.rb +67 -0
  30. data/lib/simplecov/cli/merge.rb +115 -0
  31. data/lib/simplecov/cli/open.rb +45 -0
  32. data/lib/simplecov/cli/patch/changed_lines.rb +149 -0
  33. data/lib/simplecov/cli/patch/output.rb +151 -0
  34. data/lib/simplecov/cli/patch.rb +175 -0
  35. data/lib/simplecov/cli/ratchet/output.rb +44 -0
  36. data/lib/simplecov/cli/ratchet.rb +83 -0
  37. data/lib/simplecov/cli/report.rb +91 -0
  38. data/lib/simplecov/cli/run.rb +35 -0
  39. data/lib/simplecov/cli/serve/report_preparer.rb +30 -0
  40. data/lib/simplecov/cli/serve/static_file_handler.rb +128 -0
  41. data/lib/simplecov/cli/serve.rb +88 -0
  42. data/lib/simplecov/cli/show/annotator.rb +87 -0
  43. data/lib/simplecov/cli/show/sweep.rb +36 -0
  44. data/lib/simplecov/cli/show.rb +133 -0
  45. data/lib/simplecov/cli/status/facts.rb +87 -0
  46. data/lib/simplecov/cli/status.rb +93 -0
  47. data/lib/simplecov/cli/tests/redundancy.rb +78 -0
  48. data/lib/simplecov/cli/tests.rb +148 -0
  49. data/lib/simplecov/cli/uncovered/misses.rb +33 -0
  50. data/lib/simplecov/cli/uncovered.rb +127 -0
  51. data/lib/simplecov/cli/usage.rb +153 -0
  52. data/lib/simplecov/cli/watch/live_report.rb +73 -0
  53. data/lib/simplecov/cli/watch/narrator.rb +55 -0
  54. data/lib/simplecov/cli/watch/poller.rb +45 -0
  55. data/lib/simplecov/cli/watch/session.rb +134 -0
  56. data/lib/simplecov/cli/watch/test_plan.rb +43 -0
  57. data/lib/simplecov/cli/watch.rb +85 -0
  58. data/lib/simplecov/cli.rb +114 -0
  59. data/lib/simplecov/color.rb +65 -0
  60. data/lib/simplecov/combine/branches_combiner.rb +44 -21
  61. data/lib/simplecov/combine/coverage_accumulator.rb +175 -0
  62. data/lib/simplecov/combine/identity_interner.rb +27 -0
  63. data/lib/simplecov/combine/interned_counts.rb +28 -0
  64. data/lib/simplecov/combine/lines_combiner.rb +35 -29
  65. data/lib/simplecov/combine/methods_combiner.rb +48 -0
  66. data/lib/simplecov/combine/results_combiner.rb +9 -49
  67. data/lib/simplecov/combine.rb +0 -24
  68. data/lib/simplecov/command_guesser.rb +99 -42
  69. data/lib/simplecov/configuration/baseline.rb +31 -0
  70. data/lib/simplecov/configuration/coverage.rb +184 -0
  71. data/lib/simplecov/configuration/coverage_criteria.rb +143 -0
  72. data/lib/simplecov/configuration/deprecations.rb +21 -0
  73. data/lib/simplecov/configuration/eval_coverage.rb +37 -0
  74. data/lib/simplecov/configuration/filters.rb +131 -0
  75. data/lib/simplecov/configuration/formatting.rb +129 -0
  76. data/lib/simplecov/configuration/groups.rb +48 -0
  77. data/lib/simplecov/configuration/history.rb +44 -0
  78. data/lib/simplecov/configuration/ignored_entries.rb +72 -0
  79. data/lib/simplecov/configuration/merging.rb +197 -0
  80. data/lib/simplecov/configuration/missed_caps.rb +51 -0
  81. data/lib/simplecov/configuration/production.rb +23 -0
  82. data/lib/simplecov/configuration/test_tracking.rb +48 -0
  83. data/lib/simplecov/configuration/thresholds.rb +150 -0
  84. data/lib/simplecov/configuration/view_coverage.rb +37 -0
  85. data/lib/simplecov/configuration.rb +100 -443
  86. data/lib/simplecov/configuration_error.rb +5 -0
  87. data/lib/simplecov/context_map/union.rb +81 -0
  88. data/lib/simplecov/context_map.rb +187 -0
  89. data/lib/simplecov/coverage_json.rb +21 -0
  90. data/lib/simplecov/coverage_statistics.rb +24 -28
  91. data/lib/simplecov/coverage_violations.rb +254 -0
  92. data/lib/simplecov/current_run.rb +61 -0
  93. data/lib/simplecov/defaults.rb +20 -28
  94. data/lib/simplecov/deprecation.rb +63 -0
  95. data/lib/simplecov/directive/erb.rb +44 -0
  96. data/lib/simplecov/directive/haml.rb +33 -0
  97. data/lib/simplecov/directive/indented_template.rb +37 -0
  98. data/lib/simplecov/directive/slim.rb +35 -0
  99. data/lib/simplecov/directive/template.rb +29 -0
  100. data/lib/simplecov/directive.rb +152 -0
  101. data/lib/simplecov/exit_codes/baseline_check.rb +46 -0
  102. data/lib/simplecov/exit_codes/check.rb +39 -0
  103. data/lib/simplecov/exit_codes/exit_code_handling.rb +24 -5
  104. data/lib/simplecov/exit_codes/maximum_coverage_drop_check.rb +17 -67
  105. data/lib/simplecov/exit_codes/maximum_missed_check.rb +26 -0
  106. data/lib/simplecov/exit_codes/maximum_missed_per_file_check.rb +34 -0
  107. data/lib/simplecov/exit_codes/maximum_overall_coverage_check.rb +27 -0
  108. data/lib/simplecov/exit_codes/minimum_coverage_by_file_check.rb +20 -38
  109. data/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb +28 -0
  110. data/lib/simplecov/exit_codes/minimum_overall_coverage_check.rb +37 -34
  111. data/lib/simplecov/exit_codes.rb +26 -0
  112. data/lib/simplecov/exit_handling.rb +163 -0
  113. data/lib/simplecov/file_list.rb +63 -48
  114. data/lib/simplecov/filter.rb +98 -42
  115. data/lib/simplecov/formatter/base.rb +100 -0
  116. data/lib/simplecov/formatter/baseline_formatter.rb +98 -0
  117. data/lib/simplecov/formatter/coverage_json_writer.rb +95 -0
  118. data/lib/simplecov/formatter/html_formatter/public/index.html +93 -0
  119. data/lib/simplecov/formatter/html_formatter/viewer_data_validator.rb +153 -0
  120. data/lib/simplecov/formatter/html_formatter.rb +88 -0
  121. data/lib/simplecov/formatter/json_formatter/errors_formatter.rb +122 -0
  122. data/lib/simplecov/formatter/json_formatter/production_section_formatter.rb +48 -0
  123. data/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +132 -0
  124. data/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +97 -0
  125. data/lib/simplecov/formatter/json_formatter.rb +40 -0
  126. data/lib/simplecov/formatter/multi_formatter.rb +8 -10
  127. data/lib/simplecov/formatter/simple_formatter.rb +12 -15
  128. data/lib/simplecov/formatter.rb +21 -1
  129. data/lib/simplecov/group_names.rb +33 -0
  130. data/lib/simplecov/history.rb +115 -0
  131. data/lib/simplecov/last_run.rb +16 -5
  132. data/lib/simplecov/lines_classifier.rb +67 -19
  133. data/lib/simplecov/load_global_config.rb +12 -4
  134. data/lib/simplecov/parallel_adapters/base.rb +50 -0
  135. data/lib/simplecov/parallel_adapters/generic.rb +33 -0
  136. data/lib/simplecov/parallel_adapters/parallel_tests.rb +72 -0
  137. data/lib/simplecov/parallel_adapters.rb +54 -0
  138. data/lib/simplecov/parallel_coordination.rb +124 -0
  139. data/lib/simplecov/parallel_result_merger/worker_payload.rb +33 -0
  140. data/lib/simplecov/parallel_result_merger.rb +171 -0
  141. data/lib/simplecov/process.rb +34 -12
  142. data/lib/simplecov/production/error.rb +11 -0
  143. data/lib/simplecov/production/file_sink.rb +130 -0
  144. data/lib/simplecov/production.rb +249 -0
  145. data/lib/simplecov/profiles/bundler_filter.rb +1 -1
  146. data/lib/simplecov/profiles/hidden_filter.rb +1 -1
  147. data/lib/simplecov/profiles/rails.rb +13 -10
  148. data/lib/simplecov/profiles/root_filter.rb +2 -5
  149. data/lib/simplecov/profiles/strict.rb +22 -0
  150. data/lib/simplecov/profiles/test_frameworks.rb +1 -4
  151. data/lib/simplecov/profiles.rb +26 -19
  152. data/lib/simplecov/report_deferral.rb +45 -0
  153. data/lib/simplecov/report_stamp.rb +25 -0
  154. data/lib/simplecov/result/filter_config.rb +19 -0
  155. data/lib/simplecov/result/missing_source_files_reporter.rb +48 -0
  156. data/lib/simplecov/result/serialization.rb +43 -0
  157. data/lib/simplecov/result/source_file_builder.rb +46 -0
  158. data/lib/simplecov/result.rb +123 -47
  159. data/lib/simplecov/result_adapter.rb +130 -11
  160. data/lib/simplecov/result_merger/contexts.rb +22 -0
  161. data/lib/simplecov/result_merger/legacy_format_adapter.rb +24 -0
  162. data/lib/simplecov/result_merger/resultset_file.rb +67 -0
  163. data/lib/simplecov/result_merger/resultset_run_identity.rb +65 -0
  164. data/lib/simplecov/result_merger/resultset_store.rb +62 -0
  165. data/lib/simplecov/result_merger/unloaded_files.rb +95 -0
  166. data/lib/simplecov/result_merger.rb +120 -124
  167. data/lib/simplecov/result_processing.rb +188 -0
  168. data/lib/simplecov/run_identity.rb +75 -0
  169. data/lib/simplecov/simulate_coverage.rb +72 -17
  170. data/lib/simplecov/source_file/branch.rb +8 -35
  171. data/lib/simplecov/source_file/branch_builder.rb +95 -0
  172. data/lib/simplecov/source_file/builder_context.rb +28 -0
  173. data/lib/simplecov/source_file/line.rb +17 -32
  174. data/lib/simplecov/source_file/line_builder.rb +37 -0
  175. data/lib/simplecov/source_file/method.rb +54 -0
  176. data/lib/simplecov/source_file/method_builder.rb +51 -0
  177. data/lib/simplecov/source_file/ruby_data_parser.rb +103 -0
  178. data/lib/simplecov/source_file/skip_chunks.rb +72 -0
  179. data/lib/simplecov/source_file/source_loader.rb +89 -0
  180. data/lib/simplecov/source_file/statistics.rb +76 -0
  181. data/lib/simplecov/source_file.rb +64 -260
  182. data/lib/simplecov/static_coverage_extractor/condition_folding.rb +155 -0
  183. data/lib/simplecov/static_coverage_extractor/location_conventions.rb +191 -0
  184. data/lib/simplecov/static_coverage_extractor/method_collector.rb +45 -0
  185. data/lib/simplecov/static_coverage_extractor/prism_compat.rb +46 -0
  186. data/lib/simplecov/static_coverage_extractor/value_position.rb +61 -0
  187. data/lib/simplecov/static_coverage_extractor/visitor.rb +163 -0
  188. data/lib/simplecov/static_coverage_extractor.rb +78 -0
  189. data/lib/simplecov/test_tracker/accessors.rb +34 -0
  190. data/lib/simplecov/test_tracker/constant_watch.rb +49 -0
  191. data/lib/simplecov/test_tracker/delta.rb +69 -0
  192. data/lib/simplecov/test_tracker/framework_hooks.rb +130 -0
  193. data/lib/simplecov/test_tracker.rb +162 -0
  194. data/lib/simplecov/unloaded_file_injector.rb +62 -0
  195. data/lib/simplecov/useless_results_remover.rb +17 -7
  196. data/lib/simplecov/version.rb +1 -1
  197. data/lib/simplecov/view_coverage/template_compiler.rb +83 -0
  198. data/lib/simplecov/view_coverage.rb +55 -0
  199. data/lib/simplecov-html.rb +5 -0
  200. data/lib/simplecov.rb +143 -394
  201. data/lib/simplecov_json_formatter.rb +5 -0
  202. data/man/simplecov.1 +354 -0
  203. data/sig/simplecov.rbs +1710 -0
  204. metadata +183 -61
  205. data/CHANGELOG.md +0 -191
  206. data/doc/alternate-formatters.md +0 -71
  207. data/doc/commercial-services.md +0 -25
  208. data/doc/editor-integration.md +0 -18
  209. data/lib/simplecov/combine/files_combiner.rb +0 -24
  210. data/lib/simplecov/default_formatter.rb +0 -20
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Detects the `if` / `unless` / ternary conditions CRuby folds away. When a
6
+ # condition is a statically-known-truthy/falsy literal the compiler
7
+ # eliminates the dead arm and Coverage emits no branch, so the extractor
8
+ # must not synthesize one either: the arm would be a phantom no loaded run
9
+ # can hit, the same unmergeable-tuple failure as #1226 / #1233.
10
+ module ConditionFolding
11
+ # CRuby 3.4 rebuilt the fold on the Prism compiler, and the parse.y fold
12
+ # it replaced still differs on 3.3, where `__FILE__` folds.
13
+ FOLDS_SOURCE_FILE = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
14
+ # Container literals in discarded position are eliminated from 3.3 on, but
15
+ # 3.3's compile.c elides a container whose contents are merely
16
+ # effect-free (`[x]`), while the Prism compiler demands fully static
17
+ # literals (`[1]` goes, `[x]` stays).
18
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS = !FOLDS_SOURCE_FILE
19
+
20
+ # The literals that fold. `while` / `until` do NOT fold (`while true` is a
21
+ # real branch), so only the if-like visitors consult this. Regexp and
22
+ # Range literals are excluded on purpose: as conditions they mean
23
+ # `=~ $_` / flip-flop, which Coverage does branch on. `[]`, `{}`, and
24
+ # interpolated strings do not fold either, and `->` folds while a
25
+ # `lambda` call does not: a method named `lambda` proves nothing.
26
+ # simplecov:disable branch — which arm runs is fixed by the running Ruby's version
27
+ STATIC_CONDITION_TYPES = [
28
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
29
+ ::Prism::ImaginaryNode, ::Prism::SymbolNode, ::Prism::StringNode,
30
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
31
+ ::Prism::SourceLineNode, ::Prism::SourceEncodingNode, ::Prism::LambdaNode,
32
+ *(::Prism::SourceFileNode if FOLDS_SOURCE_FILE)
33
+ ].freeze
34
+ # simplecov:enable branch
35
+
36
+ FALSY_CONDITION_TYPES = [::Prism::FalseNode, ::Prism::NilNode].freeze
37
+
38
+ # CRuby folds `if nil`, `if "x"`, and `if -> {}` but keeps a real branch
39
+ # for `if (nil)`, `if ("x")`, and `if (-> {})`, while every other literal
40
+ # folds parenthesized or not.
41
+ PAREN_OPAQUE_TYPES = [
42
+ ::Prism::NilNode, ::Prism::StringNode, ::Prism::LambdaNode, ::Prism::SourceFileNode
43
+ ].freeze
44
+
45
+ # A multi-statement paren condition (`if (1; 2)`) folds by its last
46
+ # expression only when every leading statement is eliminated when
47
+ # discarded, and these always are, bare or composing an Array/Hash/Range.
48
+ STATIC_LITERAL_LEAF_TYPES = [
49
+ ::Prism::IntegerNode, ::Prism::FloatNode, ::Prism::RationalNode,
50
+ ::Prism::ImaginaryNode, ::Prism::StringNode, ::Prism::SymbolNode,
51
+ ::Prism::TrueNode, ::Prism::FalseNode, ::Prism::NilNode,
52
+ ::Prism::SourceLineNode, ::Prism::SourceFileNode, ::Prism::SourceEncodingNode, ::Prism::RegularExpressionNode
53
+ ].freeze
54
+
55
+ # Non-literal reads that are also eliminated when discarded. Anything that
56
+ # can raise or run hooks is never eliminated and keeps the branch real.
57
+ ELIMINABLE_READ_TYPES = [
58
+ ::Prism::SelfNode, ::Prism::LocalVariableReadNode,
59
+ ::Prism::InstanceVariableReadNode, ::Prism::DefinedNode
60
+ ].freeze
61
+
62
+ private
63
+
64
+ # `visit` is nil-safe, so a missing arm just visits nothing.
65
+ def visit_folded_arms(verdict, truthy_arm, falsy_arm)
66
+ visit(verdict.equal?(:truthy) ? truthy_arm : falsy_arm)
67
+ end
68
+
69
+ # `:truthy` or `:falsy` when the compiler folds, nil when it doesn't. The
70
+ # compiler eliminates the losing arm's entire subtree, so everything
71
+ # inside it must go unvisited too, not just the folded condition's tuple.
72
+ #
73
+ # Compound forms (`!true`, `true || x`) are deliberately not folded: `!`
74
+ # never folds, and `||` / `&&` constant-propagation diverges across Ruby
75
+ # versions, so matching it would trade a rare gain for real risk.
76
+ def folded_condition(node)
77
+ unwrapped = unwrap_parentheses(node)
78
+ return nil unless foldable?(node, unwrapped)
79
+
80
+ (FALSY_CONDITION_TYPES.any? { |type| unwrapped.instance_of?(type) }) ? :falsy : :truthy
81
+ end
82
+
83
+ # `unwrapped` differing from `node` is what says parentheses were seen
84
+ # through.
85
+ def foldable?(node, unwrapped)
86
+ return false unless STATIC_CONDITION_TYPES.any? { |type| unwrapped.instance_of?(type) }
87
+
88
+ unwrapped.equal?(node) || PAREN_OPAQUE_TYPES.none? { |type| unwrapped.instance_of?(type) }
89
+ end
90
+
91
+ # A multi-statement body (`if (1; 2)`) folds by its LAST expression, but
92
+ # only when the compiler eliminates every leading statement. Stopping at
93
+ # multi-statement bodies synthesized a phantom then/else pair.
94
+ def unwrap_parentheses(node)
95
+ # @type var current: untyped
96
+ current = node
97
+ while current.instance_of?(Prism::ParenthesesNode)
98
+ # Empty parentheses carry no statements node at all, so a
99
+ # body that is one always holds at least one statement.
100
+ body = current.body
101
+ break unless body.instance_of?(Prism::StatementsNode)
102
+
103
+ statements = body.body
104
+ break unless statements.take(statements.size - 1).all? { |leading| eliminable_when_discarded?(leading) }
105
+
106
+ current = statements.last
107
+ end
108
+ current
109
+ end
110
+
111
+ def eliminable_when_discarded?(node)
112
+ return true if static_container_literal?(node)
113
+ return true if ELIMINABLE_READ_TYPES.any? { |type| node.instance_of?(type) }
114
+
115
+ node.instance_of?(Prism::ParenthesesNode) &&
116
+ node.body.instance_of?(Prism::StatementsNode) &&
117
+ node.body.body.all? { |statement| eliminable_when_discarded?(statement) }
118
+ end
119
+
120
+ def static_container_literal?(node)
121
+ return true if STATIC_LITERAL_LEAF_TYPES.any? { |type| node.instance_of?(type) }
122
+
123
+ static_container?(node)
124
+ end
125
+
126
+ def static_container?(node)
127
+ case node
128
+ when Prism::ArrayNode then static_array_literal?(node)
129
+ when Prism::HashNode then static_hash_literal?(node)
130
+ when Prism::RangeNode then static_range_literal?(node)
131
+ else false
132
+ end
133
+ end
134
+
135
+ def static_array_literal?(node)
136
+ node.elements.all? { |element| container_contents_eliminable?(element) }
137
+ end
138
+
139
+ def static_hash_literal?(node)
140
+ node.elements.all? do |element|
141
+ element.instance_of?(Prism::AssocNode) &&
142
+ container_contents_eliminable?(element.key) && container_contents_eliminable?(element.value)
143
+ end
144
+ end
145
+
146
+ def static_range_literal?(node)
147
+ [node.left, node.right].all? { |bound| bound.nil? || container_contents_eliminable?(bound) }
148
+ end
149
+
150
+ def container_contents_eliminable?(node)
151
+ CONTAINER_CONTENTS_NEED_STATIC_LITERALS ? static_container_literal?(node) : eliminable_when_discarded?(node)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,191 @@
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 arms,
8
+ # resolved from Prism nodes. Simulated entries only ever merge with real
9
+ # entries produced by the running Ruby, and CRuby 3.4 changed several of
10
+ # these conventions, so every resolver here emits whichever shape this
11
+ # Ruby's Coverage uses (#1226, #1233). The "runtime tuple equivalence" spec
12
+ # exercises the module against real Coverage output on every CI Ruby, and
13
+ # is the authority on what each resolver should answer.
14
+ #
15
+ module LocationConventions
16
+ LEGACY_COVERAGE_LOCATIONS = Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.4")
17
+
18
+ # A zero-width stand-in for Prism locations, for the arms Coverage
19
+ # anchors to a point rather than a range. Resolvers answer whichever of a
20
+ # node, one of its locations, or this is nearest to hand: all three
21
+ # answer the four position accessors the emitted tuples are built from.
22
+ PointLocation = Data.define(:start_line, :start_column, :end_line, :end_column)
23
+
24
+ private
25
+
26
+ # Which arm of each conditional below runs is fixed by the running Ruby's
27
+ # version, so no single process can cover both sides.
28
+
29
+ # 3.2/3.3 end an `elsif` clause's range at its last content rather than
30
+ # at the shared `end` keyword the clause doesn't own.
31
+ def if_like_location(node, type)
32
+ return node unless LEGACY_COVERAGE_LOCATIONS && type.equal?(:if) && elsif_node?(node)
33
+
34
+ span(node, legacy_content_end(node))
35
+ end
36
+
37
+ def elsif_node?(node)
38
+ keyword = node.if_keyword_loc
39
+ !keyword.nil? && keyword.slice.eql?("elsif")
40
+ end
41
+
42
+ # The deepest trailing clause's statements, or that clause's predicate /
43
+ # `else` keyword when its body is empty.
44
+ def legacy_content_end(node)
45
+ tail = node
46
+ while tail.instance_of?(Prism::IfNode)
47
+ sub = PrismCompat.subsequent(tail)
48
+ break unless sub
49
+
50
+ tail = sub
51
+ end
52
+ return tail.statements || tail.predicate if tail.instance_of?(Prism::IfNode)
53
+
54
+ tail.statements || tail.else_keyword_loc
55
+ end
56
+
57
+ def if_like_then_location(node, type)
58
+ return node.statements if node.statements
59
+ return point_at_end(node.predicate) if empty_arm_collapses?(node, type)
60
+
61
+ if_like_location(node, type)
62
+ end
63
+
64
+ # With no else/elsif present, the synthesized else inherits the
65
+ # condition's range.
66
+ def if_like_else_location(node, type)
67
+ sub = PrismCompat.subsequent(node)
68
+ return if_like_location(node, type) unless sub
69
+ # An `elsif` arrives as a nested IfNode, and Coverage attributes the
70
+ # outer else arm to the clause's own range, not its then body (which is
71
+ # what created phantom unmergeable arms).
72
+ return if_like_location(sub, :if) if sub.instance_of?(Prism::IfNode)
73
+ return sub.statements if sub.statements
74
+
75
+ empty_else_location(node, sub, type)
76
+ end
77
+
78
+ def empty_else_location(node, sub, type)
79
+ return sub if !LEGACY_COVERAGE_LOCATIONS && type.equal?(:if)
80
+ # Void position is a legacy distinction: a Ruby that does not need the
81
+ # value-position pass answers every node as value.
82
+ return point_at_end(sub.else_keyword_loc) unless value_position?(node)
83
+
84
+ if_like_location(node, type)
85
+ end
86
+
87
+ def case_arm_location(case_node, when_node, when_type)
88
+ return when_node.statements if when_node.statements
89
+ return when_node unless LEGACY_COVERAGE_LOCATIONS
90
+ return point_at_end(when_node.pattern) if when_type.equal?(:in)
91
+ return point_at_end(when_node) unless value_position?(case_node)
92
+
93
+ legacy_when_value_location(case_node, when_node)
94
+ end
95
+
96
+ def legacy_when_value_location(case_node, when_node)
97
+ span(when_node, legacy_case_tail_end(case_node, when_node))
98
+ end
99
+
100
+ # Only a `when` reaches here, and a `when` always carries at least one
101
+ # condition, so the fallback is always available.
102
+ def legacy_case_tail_end(case_node, when_node)
103
+ following_case_content(case_node, when_node).last || when_node.conditions.last
104
+ end
105
+
106
+ def following_case_content(case_node, when_node)
107
+ clauses = case_node.conditions
108
+ # A when-clause's own location ends where its body ends (or at its
109
+ # condition when empty), so the whole clause is what extends the range
110
+ # through trailing empty clauses that have no `statements`.
111
+ index = clauses.index { |clause| clause.equal?(when_node) }
112
+ content = clauses.drop(index + 1)
113
+ else_statements = PrismCompat.else_clause(case_node)&.statements
114
+ content += [else_statements] if else_statements
115
+ content
116
+ end
117
+
118
+ def else_arm_location(node)
119
+ else_clause = PrismCompat.else_clause(node)
120
+ return node unless else_clause
121
+ return else_clause.statements if else_clause.statements
122
+ return else_clause unless LEGACY_COVERAGE_LOCATIONS
123
+ return point_at_end(else_clause.else_keyword_loc) unless value_position?(node)
124
+
125
+ node
126
+ end
127
+
128
+ def loop_body_location(node)
129
+ return legacy_do_while_body_location(node) if LEGACY_COVERAGE_LOCATIONS && begin_modifier_loop?(node)
130
+ return node.statements if node.statements
131
+ return point_at_end(node.predicate) if LEGACY_COVERAGE_LOCATIONS
132
+
133
+ node
134
+ end
135
+
136
+ # The accessor arrived in Prism 0.25, and this gem supports older ones,
137
+ # where nothing answers the question.
138
+ def begin_modifier_loop?(node)
139
+ node.respond_to?(:begin_modifier?) && node.begin_modifier?
140
+ end
141
+
142
+ # `begin ... end while cond` parses as a while whose sole statement is
143
+ # the BeginNode. Modern Coverage attributes the body to that whole
144
+ # `begin ... end` span, but 3.3 uses the begin's inner statements.
145
+ def legacy_do_while_body_location(node)
146
+ begin_node, = node.statements.body
147
+ inner = begin_node.statements
148
+ inner || point_at_end(begin_node.begin_keyword_loc)
149
+ end
150
+
151
+ # Coverage's safe-navigation branch spans the receiver through the end of
152
+ # the call's arguments (or just the message when there are none), but
153
+ # never includes a trailing block: `x&.foo { ... }` ends exactly where
154
+ # `x&.foo` would. `node.location` would include the block (#1233).
155
+ def safe_navigation_location(node)
156
+ span(node, node.closing_loc || node.arguments || node.message_loc)
157
+ end
158
+
159
+ def span(from, to)
160
+ PointLocation.new(
161
+ start_line: from.start_line, start_column: from.start_column,
162
+ end_line: to.end_line, end_column: to.end_column
163
+ )
164
+ end
165
+
166
+ def point_at_end(location)
167
+ PointLocation.new(
168
+ start_line: location.end_line, start_column: location.end_column,
169
+ end_line: location.end_line, end_column: location.end_column
170
+ )
171
+ end
172
+
173
+ # Modern Coverage collapses an empty then arm for every `if` but not
174
+ # `unless`; legacy Coverage does it only in void position, for both.
175
+ def empty_arm_collapses?(node, type)
176
+ return type.equal?(:if) unless LEGACY_COVERAGE_LOCATIONS
177
+
178
+ !value_position?(node)
179
+ end
180
+
181
+ # `@value_positions` is computed once per parse by ValuePositions, and
182
+ # only on legacy Rubies. Nil elsewhere, which reads as "value": the safe,
183
+ # pre-audit default.
184
+ def value_position?(node)
185
+ return true if @value_positions.nil?
186
+
187
+ @value_positions.key?(node)
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # Visitor mixin that collects method tuples and tracks the lexical class /
6
+ # module nesting that names them, in the shape Ruby's `Coverage` reports
7
+ # methods. Module and Class are both namespaces here, since `Coverage` reports
8
+ # both as the constant.
9
+ module MethodCollector
10
+ def visit_class_node(node)
11
+ with_class(constant_name(node.constant_path)) { super }
12
+ end
13
+
14
+ def visit_module_node(node)
15
+ with_class(constant_name(node.constant_path)) { super }
16
+ end
17
+
18
+ # `def name(...)` and `def self.name(...)` both produce DefNode. The class
19
+ # context is the surrounding lexical class or module, or `Object` at the top
20
+ # level, matching `Coverage`'s convention.
21
+ def visit_def_node(node)
22
+ class_name = @class_stack.last || "Object"
23
+ key = [class_name, node.name, node.start_line, node.start_column, node.end_line, node.end_column]
24
+ @methods[key] = 0
25
+ super
26
+ end
27
+
28
+ private
29
+
30
+ def constant_name(node)
31
+ return "<anonymous>" if node.nil?
32
+ return node.slice if node.respond_to?(:slice)
33
+
34
+ node.to_s
35
+ end
36
+
37
+ def with_class(name)
38
+ @class_stack.push(name)
39
+ yield
40
+ ensure
41
+ @class_stack.pop
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCov
4
+ module StaticCoverageExtractor
5
+ # The Prism 1.3 accessor renames for the trailing clause of conditional
6
+ # nodes, resolved once at load so the per-node hot paths stay branch-free.
7
+ # Ruby 3.3's stdlib Prism predates the renames; 3.4+ and any installed prism
8
+ # gem post-date them, and reaching for the modern name on the old one raised
9
+ # NoMethodError inside the extractor, which `call` swallowed, silently
10
+ # falling back to no simulated data.
11
+ #
12
+ # This lives in its own file, required by every consumer, because the
13
+ # constants are referenced from several extractor files and defining them
14
+ # after those files were loaded worked only while the references happened at
15
+ # call time: a load-order trap for the next editor.
16
+ module PrismCompat
17
+ extend self
18
+
19
+ # simplecov:disable
20
+ IF_NODE_SUBSEQUENT_METHOD =
21
+ if Prism::IfNode.method_defined?(:subsequent)
22
+ :subsequent
23
+ else
24
+ :consequent
25
+ end
26
+
27
+ ELSE_CLAUSE_METHOD =
28
+ if Prism::CaseNode.method_defined?(:else_clause)
29
+ :else_clause
30
+ else
31
+ :consequent
32
+ end
33
+ # simplecov:enable
34
+
35
+ def subsequent(node)
36
+ node.instance_of?(Prism::IfNode) ? node.public_send(IF_NODE_SUBSEQUENT_METHOD) : else_clause(node)
37
+ end
38
+
39
+ # The rename hit `UnlessNode`, `CaseNode`, and `CaseMatchNode` together, so
40
+ # one constant probed off CaseNode covers all three.
41
+ def else_clause(node)
42
+ node.public_send(ELSE_CLAUSE_METHOD)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prism_compat"
4
+
5
+ module SimpleCov
6
+ module StaticCoverageExtractor
7
+ # Ruby 3.3 value-position analysis for the extractor's legacy branch
8
+ # conventions (#1233). On 3.3 the source range Coverage assigns to an empty
9
+ # branch arm depends on whether its construct is in value position, where its
10
+ # result is the method's return value, or void position, where the result is
11
+ # discarded. Value position keeps the whole-construct range; void collapses
12
+ # the arm to a point at its header's end. Ruby 3.4 dropped the distinction.
13
+ #
14
+ # "Value position" here is strictly method-return (tail) position. It reaches
15
+ # a node only through statement tails and `if`/`unless`/`when` arms.
16
+ # Assignments, blocks, lambdas, method arguments, `case/in` arms, and loop
17
+ # bodies all discard it, so `tail_children` names the constructs that forward
18
+ # tail position and everything else falls through to the void default.
19
+ module ValuePositions
20
+ extend self
21
+
22
+ # An identity set of the Prism nodes Coverage treats as being in value
23
+ # position. This pass runs natively only on legacy Rubies; elsewhere the
24
+ # specs drive it directly, pinned by the differential tuple-equivalence spec
25
+ # against real Coverage on 3.3.
26
+ def call(root)
27
+ positions = {} #: Hash[untyped, bool]
28
+ positions.compare_by_identity
29
+ mark(root, true, positions)
30
+ positions
31
+ end
32
+
33
+ def mark(node, in_value, positions)
34
+ return unless node.is_a?(Prism::Node)
35
+
36
+ positions[node] = true if in_value
37
+ children = tail_children(node, in_value)
38
+ node.compact_child_nodes.each do |child|
39
+ mark(child, children.any? { |c| c.equal?(child) }, positions)
40
+ end
41
+ end
42
+
43
+ # A method body is a tail context even when the `def` itself is not, since
44
+ # the method still returns its last expression, so it is included regardless
45
+ # of `in_value`. `case/in` is intentionally absent: its `in` arms and `else`
46
+ # both discard tail position.
47
+ def tail_children(node, in_value)
48
+ return [node.body] if node.instance_of?(Prism::DefNode)
49
+ return [] unless in_value
50
+
51
+ case node
52
+ when Prism::StatementsNode then [node.body.last]
53
+ when Prism::IfNode, Prism::UnlessNode then [node.statements, PrismCompat.subsequent(node)]
54
+ when Prism::CaseNode then [*node.conditions, PrismCompat.else_clause(node)]
55
+ when Prism::ElseNode, Prism::WhenNode, Prism::BeginNode, Prism::ProgramNode then [node.statements]
56
+ else []
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "prism_compat"
4
+ require_relative "condition_folding"
5
+ require_relative "location_conventions"
6
+ require_relative "method_collector"
7
+ require_relative "value_position"
8
+
9
+ module SimpleCov
10
+ module StaticCoverageExtractor
11
+ # Prism visitor that accumulates branch and method tuples in the shape
12
+ # Ruby's `Coverage` reports. Tuple ids are sequential across the file like
13
+ # `Coverage`'s, but the numbering order can differ. That's fine: the
14
+ # combiners intern on source span and the report output drops ids, so
15
+ # nothing downstream compares them.
16
+ class Visitor < Prism::Visitor
17
+ include MethodCollector
18
+ include LocationConventions
19
+ include ConditionFolding
20
+
21
+ attr_reader :branches, :methods
22
+
23
+ # Prism's Visitor is a stateless dispatch table whose initializer is
24
+ # Object's, so no mutation of the `super` call can be told apart. It
25
+ # stays for the day that stops being true.
26
+ # mutant:disable
27
+ def initialize
28
+ super
29
+ @branches = {}
30
+ @methods = {}
31
+ @next_id = 0
32
+ @class_stack = []
33
+ @value_positions = nil
34
+ end
35
+
36
+ # On legacy Rubies the location of an empty branch arm depends on whether
37
+ # its construct is in value (tail) position, so precompute that once for
38
+ # the whole tree before emitting anything.
39
+ def visit_program_node(node)
40
+ @value_positions = ValuePositions.call(node) if LEGACY_COVERAGE_LOCATIONS
41
+ super
42
+ end
43
+
44
+ # `if` / `unless` / postfix / ternary all parse as IfNode (or UnlessNode).
45
+ # Both carry a `then` arm and an optional `subsequent` (an ElseNode for
46
+ # `else`, another IfNode for `elsif`). When the subsequent is missing,
47
+ # Coverage synthesizes a `:else` arm attributed to the whole condition's
48
+ # range, and so do we.
49
+ #
50
+ # A folded condition emits no tuple, and only its live arm is descended
51
+ # into: the compiler eliminates the dead arm's entire subtree, so a branch
52
+ # or method nested there would be a phantom no loaded run can produce.
53
+ def visit_if_node(node)
54
+ verdict = folded_condition(node.predicate)
55
+ return visit_folded_arms(verdict, node.statements, PrismCompat.subsequent(node)) if verdict
56
+
57
+ emit_if_like(node, :if)
58
+ super
59
+ end
60
+
61
+ def visit_unless_node(node)
62
+ verdict = folded_condition(node.predicate)
63
+ return visit_folded_arms(verdict, PrismCompat.else_clause(node), node.statements) if verdict
64
+
65
+ emit_if_like(node, :unless)
66
+ super
67
+ end
68
+
69
+ def visit_call_node(node)
70
+ emit_safe_navigation(node) if node.respond_to?(:safe_navigation?) && node.safe_navigation?
71
+ super
72
+ end
73
+
74
+ # When there's no explicit `else`, Coverage synthesizes one at the case's
75
+ # range.
76
+ def visit_case_node(node)
77
+ emit_case_like(node, :when)
78
+ super
79
+ end
80
+
81
+ def visit_case_match_node(node)
82
+ emit_case_like(node, :in)
83
+ super
84
+ end
85
+
86
+ # One-line pattern matching: `x => pattern` and `x in pattern`. Ruby 3.3's
87
+ # Coverage reports these as a `:case` with an `:in` and an `:else` arm;
88
+ # 3.4 dropped them entirely, so this is legacy-only. The two forms differ
89
+ # only in where Coverage anchors the synthesized `:else`: `=>` uses the
90
+ # whole expression, `in` uses just the pattern.
91
+ def visit_match_required_node(node)
92
+ emit_oneline_pattern(node, node) if LEGACY_COVERAGE_LOCATIONS
93
+ super
94
+ end
95
+
96
+ def visit_match_predicate_node(node)
97
+ emit_oneline_pattern(node, node.pattern) if LEGACY_COVERAGE_LOCATIONS
98
+ super
99
+ end
100
+
101
+ # A loop gets a single `:body` arm and no synthetic else.
102
+ def visit_while_node(node)
103
+ emit_loop(node, :while)
104
+ super
105
+ end
106
+
107
+ def visit_until_node(node)
108
+ emit_loop(node, :until)
109
+ super
110
+ end
111
+
112
+ private
113
+
114
+ # IfNode and UnlessNode share a shape but expose the trailing arm under
115
+ # different accessors, which `if_like_else_location` hides.
116
+ def emit_if_like(node, type)
117
+ then_loc = if_like_then_location(node, type)
118
+ else_loc = if_like_else_location(node, type)
119
+ @branches[build_tuple(type, if_like_location(node, type))] = {
120
+ build_tuple(:then, then_loc) => 0,
121
+ build_tuple(:else, else_loc) => 0
122
+ }
123
+ end
124
+
125
+ def emit_safe_navigation(node)
126
+ loc = safe_navigation_location(node)
127
+ @branches[build_tuple(:"&.", loc)] = {
128
+ build_tuple(:then, loc) => 0,
129
+ build_tuple(:else, loc) => 0
130
+ }
131
+ end
132
+
133
+ def emit_oneline_pattern(node, else_span)
134
+ @branches[build_tuple(:case, node)] = {
135
+ build_tuple(:in, node.pattern) => 0,
136
+ build_tuple(:else, else_span) => 0
137
+ }
138
+ end
139
+
140
+ def emit_case_like(node, when_type)
141
+ arms = node.conditions.to_h do |when_node|
142
+ [build_tuple(when_type, case_arm_location(node, when_node, when_type)), 0]
143
+ end
144
+ arms[build_tuple(:else, else_arm_location(node))] = 0
145
+ @branches[build_tuple(:case, node)] = arms
146
+ end
147
+
148
+ def emit_loop(node, type)
149
+ cond_tuple = build_tuple(type, node)
150
+ @branches[cond_tuple] = {build_tuple(:body, loop_body_location(node)) => 0}
151
+ end
152
+
153
+ # `span` is anything that answers the four position accessors: a location
154
+ # from LocationConventions, or the node itself where the range wanted is
155
+ # the node's own.
156
+ def build_tuple(type, span)
157
+ id = @next_id
158
+ @next_id += 1
159
+ [type, id, span.start_line, span.start_column, span.end_line, span.end_column]
160
+ end
161
+ end
162
+ end
163
+ end