|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +begin |
| 4 | + require "prism" |
| 5 | +rescue LoadError |
| 6 | + # Prism isn't available on this Ruby (older than 3.3 without the gem). |
| 7 | + # `StaticCoverageExtractor.available?` will return false and callers |
| 8 | + # fall back to the previous "empty hashes" behavior. |
| 9 | +end |
| 10 | + |
| 11 | +module SimpleCov |
| 12 | + # Static enumeration of the branches and methods Ruby's `Coverage` library |
| 13 | + # WOULD have reported if a file had been loaded with `branches: true` / |
| 14 | + # `methods: true`. Used by `SimulateCoverage` to backfill data for files |
| 15 | + # added via `cover` / `track_files` that were never `require`'d during the |
| 16 | + # run — so unloaded files contribute to the branch/method denominators |
| 17 | + # symmetrically with their line coverage, instead of vanishing from the |
| 18 | + # totals (see #1059). |
| 19 | + # |
| 20 | + # Implementation uses Prism (stdlib in Ruby 3.3+, gem on older Rubies). |
| 21 | + # When Prism isn't available, `available?` returns false and SimulateCoverage |
| 22 | + # falls back to the previous behavior — older Rubies keep working, just |
| 23 | + # without the synthesized data. |
| 24 | + # |
| 25 | + # The emitted shape mirrors `Coverage.result[path]` for the same file: |
| 26 | + # branches are nested as `{condition_tuple => {arm_tuple => 0, ...}}` and |
| 27 | + # methods as `{["ClassName", :name, lines/cols] => 0}`. Position info |
| 28 | + # comes from Prism's reported source locations; it doesn't always match |
| 29 | + # `Coverage`'s byte-for-byte (the two parsers report slightly different |
| 30 | + # column conventions for some constructs), but lines are reliable and |
| 31 | + # downstream consumers that key off line numbers (the HTML formatter, |
| 32 | + # SonarQube, etc.) see the data they expect. |
| 33 | + module StaticCoverageExtractor |
| 34 | + module_function |
| 35 | + |
| 36 | + # simplecov:disable branch |
| 37 | + # The Prism-unavailable arm of this ternary is unreachable when Prism |
| 38 | + # itself IS loadable — i.e., on every engine that exercises the dogfood |
| 39 | + # report. Asserted-on by callers; tested indirectly via the |
| 40 | + # `available?`-returns-false fallback path in SimulateCoverage's spec. |
| 41 | + def available? |
| 42 | + defined?(::Prism) ? true : false |
| 43 | + end |
| 44 | + # simplecov:enable branch |
| 45 | + |
| 46 | + # Parse `source` (a string of Ruby) and return a hash of the form |
| 47 | + # `{"branches" => {...}, "methods" => {...}}` matching the shape that |
| 48 | + # `Coverage.result[path]` produces. Returns nil on parse failure or |
| 49 | + # when Prism isn't available; callers should treat that as "couldn't |
| 50 | + # extract — fall back to empty hashes." |
| 51 | + def call(source) |
| 52 | + # simplecov:disable branch — `then` arm unreachable when Prism IS loadable |
| 53 | + return nil unless available? |
| 54 | + |
| 55 | + # simplecov:enable branch |
| 56 | + |
| 57 | + result = ::Prism.parse(source) |
| 58 | + return nil if result.failure? |
| 59 | + |
| 60 | + visitor = Visitor.new |
| 61 | + visitor.visit(result.value) |
| 62 | + {"branches" => visitor.branches, "methods" => visitor.methods} |
| 63 | + rescue StandardError |
| 64 | + # simplecov:disable line |
| 65 | + # Parser errors beyond the .failure? check, unsupported AST shapes, |
| 66 | + # or anything else: fall back to empty hashes rather than crashing |
| 67 | + # the whole report. Defensive; hard to trigger from a real source |
| 68 | + # input that Prism accepts at parse time. |
| 69 | + nil |
| 70 | + # simplecov:enable line |
| 71 | + end |
| 72 | + |
| 73 | + # simplecov:disable branch |
| 74 | + # The `else` arm (Prism missing) is unreachable on engines where the |
| 75 | + # dogfood report runs; the Visitor class only matters when Prism is |
| 76 | + # loadable. |
| 77 | + if available? |
| 78 | + # simplecov:enable branch |
| 79 | + |
| 80 | + # `Prism::IfNode#subsequent` was renamed from `consequent` in Prism |
| 81 | + # 1.3 (Dec 2024). Ruby 3.3's stdlib still ships an older Prism that |
| 82 | + # only exposes `consequent`; 3.4+ and any project that's done |
| 83 | + # `gem install prism` exposes `subsequent`. Resolve the method name |
| 84 | + # ONCE here so the per-node hot path stays branch-free. The |
| 85 | + # not-taken arm on whichever Prism version we're on can't be |
| 86 | + # exercised by our own dogfood (we only run on one Prism at a time). |
| 87 | + # simplecov:disable |
| 88 | + IF_NODE_SUBSEQUENT_METHOD = |
| 89 | + if ::Prism::IfNode.method_defined?(:subsequent) |
| 90 | + :subsequent |
| 91 | + else |
| 92 | + :consequent |
| 93 | + end |
| 94 | + # simplecov:enable |
| 95 | + # Prism visitor that accumulates branch and method tuples in the |
| 96 | + # shape Ruby's `Coverage` reports. Tuple ids are sequential across |
| 97 | + # the file — `Coverage` uses sequential ids too, so this matches the |
| 98 | + # conventional shape. Only defined when Prism is loadable; |
| 99 | + # `available?` is the runtime gate. |
| 100 | + class Visitor < ::Prism::Visitor |
| 101 | + attr_reader :branches, :methods |
| 102 | + |
| 103 | + def initialize |
| 104 | + super |
| 105 | + @branches = {} |
| 106 | + @methods = {} |
| 107 | + @next_id = 0 |
| 108 | + @class_stack = [] |
| 109 | + end |
| 110 | + |
| 111 | + # `if` / `unless` / postfix-if / postfix-unless / ternary all parse |
| 112 | + # as IfNode (or UnlessNode). Both carry a `then` arm (the |
| 113 | + # statements body) and an optional `subsequent` (an ElseNode for |
| 114 | + # `else`, another IfNode for `elsif`). When the subsequent is |
| 115 | + # missing, Coverage synthesizes a `:else` arm attributed to the |
| 116 | + # whole condition's range — we do the same. |
| 117 | + def visit_if_node(node) |
| 118 | + emit_if_like(node) |
| 119 | + super |
| 120 | + end |
| 121 | + |
| 122 | + def visit_unless_node(node) |
| 123 | + emit_if_like(node) |
| 124 | + super |
| 125 | + end |
| 126 | + |
| 127 | + # `case`/`when` and `case`/`in` (pattern matching) parse as CaseNode |
| 128 | + # and CaseMatchNode respectively. When there's no explicit `else`, |
| 129 | + # Coverage synthesizes one at the case's range. |
| 130 | + def visit_case_node(node) |
| 131 | + emit_case_like(node, :when) |
| 132 | + super |
| 133 | + end |
| 134 | + |
| 135 | + def visit_case_match_node(node) |
| 136 | + emit_case_like(node, :in) |
| 137 | + super |
| 138 | + end |
| 139 | + |
| 140 | + # `while` / `until` loops get a single `:body` arm. No synthetic |
| 141 | + # else (the loop either runs the body or doesn't). |
| 142 | + def visit_while_node(node) |
| 143 | + emit_loop(node, :while) |
| 144 | + super |
| 145 | + end |
| 146 | + |
| 147 | + def visit_until_node(node) |
| 148 | + emit_loop(node, :until) |
| 149 | + super |
| 150 | + end |
| 151 | + |
| 152 | + # Track class/module nesting so method tuples carry the lexical |
| 153 | + # class name. Module + Class are both treated as namespaces here |
| 154 | + # since `Coverage` reports both as the constant. |
| 155 | + def visit_class_node(node) |
| 156 | + with_class(constant_name(node.constant_path)) { super } |
| 157 | + end |
| 158 | + |
| 159 | + def visit_module_node(node) |
| 160 | + with_class(constant_name(node.constant_path)) { super } |
| 161 | + end |
| 162 | + |
| 163 | + # `def name(...)` and `def self.name(...)` both produce DefNode. |
| 164 | + # The class context is the surrounding lexical class/module (or |
| 165 | + # `Object` at the top level, matching `Coverage`'s convention). |
| 166 | + def visit_def_node(node) |
| 167 | + loc = node.location |
| 168 | + class_name = @class_stack.last || "Object" |
| 169 | + key = [class_name, node.name, loc.start_line, loc.start_column, loc.end_line, loc.end_column] |
| 170 | + @methods[key] = 0 |
| 171 | + super |
| 172 | + end |
| 173 | + |
| 174 | + private |
| 175 | + |
| 176 | + # IfNode and UnlessNode are the same structural shape (predicate + |
| 177 | + # then body + optional else/elsif), but they use different |
| 178 | + # accessors: IfNode#subsequent (or #consequent on older Prism — |
| 179 | + # see IF_NODE_SUBSEQUENT_METHOD above), which can be either an |
| 180 | + # ElseNode for `else` or another IfNode for `elsif`; |
| 181 | + # UnlessNode#else_clause (always an ElseNode, since `elsif` after |
| 182 | + # `unless` isn't valid syntax). Treat them uniformly through |
| 183 | + # `if_like_else_location`. |
| 184 | + def emit_if_like(node) |
| 185 | + then_loc = arm_location(node.statements, node.location) |
| 186 | + else_loc = if_like_else_location(node) |
| 187 | + @branches[build_tuple(:if, node.location)] = { |
| 188 | + build_tuple(:then, then_loc) => 0, |
| 189 | + build_tuple(:else, else_loc) => 0 |
| 190 | + } |
| 191 | + end |
| 192 | + |
| 193 | + # Resolve the source range Coverage attributes to a real-or-synthetic |
| 194 | + # `:else` arm of an if-like construct. IfNode uses |
| 195 | + # `subsequent` / `consequent` depending on Prism version (resolved |
| 196 | + # to `IF_NODE_SUBSEQUENT_METHOD` at load time); UnlessNode uses |
| 197 | + # `else_clause`. When neither is present, the synthesized else |
| 198 | + # inherits the whole condition's range (matches Coverage's |
| 199 | + # convention). |
| 200 | + def if_like_else_location(node) |
| 201 | + sub = if node.is_a?(::Prism::IfNode) |
| 202 | + node.public_send(IF_NODE_SUBSEQUENT_METHOD) |
| 203 | + else |
| 204 | + node.else_clause |
| 205 | + end |
| 206 | + return node.location unless sub |
| 207 | + |
| 208 | + arm_location(else_body_of(sub), sub.location) |
| 209 | + end |
| 210 | + |
| 211 | + def emit_case_like(node, when_type) |
| 212 | + arms = node.conditions.to_h do |when_node| |
| 213 | + loc = arm_location(when_node.statements, when_node.location) |
| 214 | + [build_tuple(when_type, loc), 0] |
| 215 | + end |
| 216 | + arms[build_tuple(:else, else_arm_location(node))] = 0 |
| 217 | + @branches[build_tuple(:case, node.location)] = arms |
| 218 | + end |
| 219 | + |
| 220 | + # Resolve the source range Coverage attributes to a synthetic-or-real |
| 221 | + # `:else` arm of a case construct: the body of an explicit else, |
| 222 | + # or the case's full range when no else is present. |
| 223 | + def else_arm_location(node) |
| 224 | + return node.location unless node.else_clause |
| 225 | + |
| 226 | + arm_location(else_body_of(node.else_clause), node.else_clause.location) |
| 227 | + end |
| 228 | + |
| 229 | + def emit_loop(node, type) |
| 230 | + cond_tuple = build_tuple(type, node.location) |
| 231 | + body_loc = arm_location(node.statements, node.location) |
| 232 | + @branches[cond_tuple] = {build_tuple(:body, body_loc) => 0} |
| 233 | + end |
| 234 | + |
| 235 | + # Body location for an arm. Prism's `statements` is a |
| 236 | + # StatementsNode containing one or more expressions; the location |
| 237 | + # of the StatementsNode itself spans them. When the arm body is |
| 238 | + # empty (e.g., `if cond then end`), fall back to the parent's |
| 239 | + # location so we always have a usable tuple. |
| 240 | + def arm_location(statements, fallback_location) |
| 241 | + statements&.location || fallback_location |
| 242 | + end |
| 243 | + |
| 244 | + # simplecov:disable branch |
| 245 | + # The `else_node` fallback is defensive: every Prism node passed |
| 246 | + # in here in practice responds to `:statements`. |
| 247 | + # ElseNode wraps a `statements` body. We want the body's location, |
| 248 | + # not the `else` keyword + body span — Coverage reports the body. |
| 249 | + def else_body_of(else_node) |
| 250 | + else_node.respond_to?(:statements) ? else_node.statements : else_node |
| 251 | + end |
| 252 | + # simplecov:enable branch |
| 253 | + |
| 254 | + def build_tuple(type, location) |
| 255 | + id = @next_id |
| 256 | + @next_id += 1 |
| 257 | + [type, id, location.start_line, location.start_column, location.end_line, location.end_column] |
| 258 | + end |
| 259 | + |
| 260 | + # Render a constant path (e.g., `Foo::Bar`) as its source-form |
| 261 | + # string. Coverage uses the actual Class constant in the live case; |
| 262 | + # since we're not loading the file we approximate with the string. |
| 263 | + # The nil-check and to_s fallback are defensive: ClassNode and |
| 264 | + # ModuleNode always carry a constant_path, and every Prism node |
| 265 | + # responds to `slice`. |
| 266 | + # simplecov:disable |
| 267 | + def constant_name(node) |
| 268 | + return "<anonymous>" if node.nil? |
| 269 | + return node.slice if node.respond_to?(:slice) |
| 270 | + |
| 271 | + node.to_s |
| 272 | + end |
| 273 | + # simplecov:enable |
| 274 | + |
| 275 | + def with_class(name) |
| 276 | + @class_stack.push(name) |
| 277 | + yield |
| 278 | + ensure |
| 279 | + @class_stack.pop |
| 280 | + end |
| 281 | + end |
| 282 | + end |
| 283 | + end |
| 284 | +end |
0 commit comments