lib/simplecov/combine/files_combiner.rb



# frozen_string_literal: true

module SimpleCov
  module Combine
    #
    # Handle combining two coverage results for same file
    #
    # Should be called through `SimpleCov.combine`.
    module FilesCombiner
    module_function

      empty_table = {} #: Hash[untyped, untyped]
      EMPTY_TABLE = empty_table.freeze
      private_constant :EMPTY_TABLE

      # Branch/method tuples drawn from a simulated (never-loaded) file
      # when the other side of the merge was actually executed.
      NO_SYNTHESIZED = {"branches" => EMPTY_TABLE, "methods" => EMPTY_TABLE}.freeze

      #
      # Combines the results for 2 coverages of a file.
      #
      # @return [Hash]
      #
      def combine(coverage_a, coverage_b)
        source_a, source_b = reconcile_synthesized(coverage_a, coverage_b)

        combination = {"lines" => Combine.combine(LinesCombiner, coverage_a["lines"], coverage_b["lines"])}
        if SimpleCov.branch_coverage?
          combined_branches = Combine.combine(BranchesCombiner, source_a["branches"], source_b["branches"])
          combination["branches"] = combined_branches || {}
        end
        if SimpleCov.method_coverage?
          combination["methods"] = Combine.combine(MethodsCombiner, source_a["methods"], source_b["methods"])
        end
        combination
      end

      # When exactly one side of the merge was actually executed, its branch
      # and method tuples are authoritative and the other side's are dropped.
      # A simulated entry (SimulateCoverage backfills tracked-but-unloaded
      # files) synthesizes those tuples statically, so a location that drifts
      # from what Coverage emits would otherwise be unioned in by position
      # and survive as a phantom, permanently-missed branch (see #1233). This
      # contains any such drift to denominator inflation for files no process
      # loaded, rather than a false miss on a covered file. Lines are never
      # dropped: a simulated file's line shape is correct and carries the
      # unloaded-file denominator (#1059).
      #
      # Returns the two coverages to draw branch/method tuples from, blanking
      # the non-executed side only when the other side was executed. When
      # both sides were executed (two real runs) or neither was (all
      # simulated), both are returned unchanged and combine normally.
      def reconcile_synthesized(coverage_a, coverage_b)
        executed_a = executed?(coverage_a)
        executed_b = executed?(coverage_b)
        return [coverage_a, coverage_b] if executed_a == executed_b

        executed_a ? [coverage_a, NO_SYNTHESIZED] : [NO_SYNTHESIZED, coverage_b]
      end

      # A file some process actually loaded has at least one executed line;
      # a simulated (never-loaded) file's lines are all `nil` or `0`.
      def executed?(coverage)
        lines = Array(coverage["lines"]) #: Array[Integer?]
        lines.any? { |count| count&.positive? }
      end
    end
  end
end