lib/covered/coverage.rb



# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2018-2025, by Samuel Williams.

require_relative "source"

module Covered
	# Computes common coverage ratios from executed and executable line counts.
	module Ratio
		# The fraction of executable lines that were executed.
		# @returns [Rational | Float] The executed-to-executable line ratio, or `1.0` when there are no executable lines.
		def ratio
			return 1.0 if executable_count.zero?
			
			Rational(executed_count, executable_count)
		end
		
		# Whether all executable lines were executed.
		# @returns [Boolean] Whether `executed_count` equals `executable_count`.
		def complete?
			executed_count == executable_count
		end
		
		# The coverage ratio as a percentage.
		# @returns [Numeric] The coverage ratio multiplied by `100`.
		def percentage
			ratio * 100
		end
	end
	
	# Stores line execution counts and source metadata for a single file.
	class Coverage
		include Ratio
		
		# Build coverage for the given source path.
		# @parameter path [String] The source path.
		# @parameter options [Hash] Options forwarded to {Covered::Source.for}.
		# @returns [Covered::Coverage] The coverage object for the source path.
		def self.for(path, **options)
			self.new(Source.for(path, **options))
		end
		
		# Initialize coverage with the given source, line counts and annotations.
		# @parameter source [Covered::Source] The covered source metadata.
		# @parameter counts [Array(Integer | Nil)] Line execution counts indexed by line number.
		# @parameter annotations [Hash(Integer, Array(String))] Line annotations indexed by line number.
		def initialize(source, counts = [], annotations = {})
			@source = source
			@counts = counts
			@annotations = annotations
		end
		
		# @attribute [Covered::Source] The covered source metadata.
		attr_accessor :source
		
		# @attribute [Array(Integer | Nil)] Line execution counts indexed by line number.
		attr :counts
		
		# @attribute [Hash(Integer, Array(String))] Line annotations indexed by line number.
		attr :annotations
		
		# The total number of executions across all tracked lines.
		# @returns [Integer] The sum of all non-`nil` execution counts.
		def total
			counts.sum{|count| count || 0}
		end
		
		# Create an empty coverage with the same source.
		# @returns [Covered::Coverage] Coverage with the same source and `nil` counts.
		def empty
			self.class.new(@source, [nil] * @counts.size)
		end
		
		# Add an annotation to the given line number.
		# @parameter line_number [Integer] The line number to annotate.
		# @parameter annotation [String] The annotation text.
		def annotate(line_number, annotation)
			@annotations[line_number] ||= []
			@annotations[line_number] << annotation
		end
		
		# Add the given execution count to one or more line numbers.
		# @parameter line_number [Integer] The first line number to mark.
		# @parameter value [Integer | Array(Integer)] The execution count or counts to add.
		def mark(line_number, value = 1)
			# As currently implemented, @counts is base-zero rather than base-one.
			# Line numbers generally start at line 1, so the first line, line 1, is at index 1. This means that index[0] is usually nil.
			Array(value).each_with_index do |value, index|
				offset = line_number + index
				if @counts[offset]
					@counts[offset] += value
				else
					@counts[offset] = value
				end
			end
		end
		
		# Merge another coverage object into this coverage object.
		# @parameter other [Covered::Coverage] The coverage object to merge.
		def merge!(other)
			# If the counts are non-zero and don't match, that can indicate a problem.
			
			other.counts.each_with_index do |count, index|
				if count
					@counts[index] ||= 0
					@counts[index] += count
				end
			end
			
			@annotations.merge!(other.annotations) do |line_number, a, b|
				Array(a) + Array(b)
			end
		end
		
		# Construct a new coverage object for the given line numbers. Only the given line numbers will be considered for the purposes of computing coverage.
		# @parameter line_numbers [Array(Integer)] The line numbers to include in the new coverage object.
		# @returns [Covered::Coverage] A coverage object containing counts for the selected lines.
		def for_lines(line_numbers)
			counts = [nil] * @counts.size
			line_numbers.each do |line_number|
				counts[line_number] = @counts[line_number]
			end
			
			self.class.new(@source, counts, @annotations)
		end
		
		# The covered source path.
		# @returns [String] The covered source path.
		def path
			@source.path
		end
		
		# Assign the covered source path.
		# @parameter value [String] The new covered source path.
		def path= value
			@source.path = value
		end
		
		# Whether the source file has not changed since this coverage was recorded.
		# @returns [Boolean] Whether the source exists and its modification time is not newer than the recorded time.
		def fresh?
			if @source.modified_time.nil?
				# We don't know when the file was last modified, so we assume it is stale:
				return false
			end
			
			unless File.exist?(@source.path)
				# The file no longer exists, so we assume it is stale:
				return false
			end
			
			if @source.modified_time >= File.mtime(@source.path)
				# The file has not been modified since we last processed it, so we assume it is fresh:
				return true
			end
			
			return false
		end
		
		# Read the covered source.
		# @yields {|file| ...} If a block is given, yields an open source file.
		# 	@parameter file [File] The open source file.
		# @returns [String | Object] The source contents without a block, or the block result with a block.
		def read(&block)
			@source.read(&block)
		end
		
		# Freeze this coverage and its mutable collections.
		# @returns [Covered::Coverage] This frozen coverage object.
		def freeze
			return self if frozen?
			
			@counts.freeze
			@annotations.freeze
			
			super
		end
		
		# The raw coverage counts array.
		# @returns [Array(Integer | Nil)] The raw coverage counts.
		def to_a
			@counts
		end
		
		# Whether this coverage has no executions.
		# @returns [Boolean] Whether the total execution count is zero.
		def zero?
			total.zero?
		end
		
		# The raw coverage count for the given line number.
		# @parameter line_number [Integer] The line number to query.
		# @returns [Integer | Nil] The execution count for the line.
		def [] line_number
			@counts[line_number]
		end
		
		# Counts for lines that are executable.
		# @returns [Array(Integer)] Execution counts for executable lines.
		def executable_lines
			@counts.compact
		end
		
		# The number of executable lines.
		# @returns [Integer] The number of executable lines.
		def executable_count
			executable_lines.count
		end
		
		# Counts for executable lines that were executed.
		# @returns [Array(Integer)] Non-zero execution counts for executable lines.
		def executed_lines
			executable_lines.reject(&:zero?)
		end
		
		# The number of executable lines that were executed.
		# @returns [Integer] The number of executed lines.
		def executed_count
			executed_lines.count
		end
		
		# The number of executable lines that were not executed.
		# @returns [Integer] The number of missing executable lines.
		def missing_count
			executable_count - executed_count
		end
		
		# Print a human-readable coverage summary.
		# @parameter output [IO] The output stream.
		def print(output)
			output.puts "** #{executed_count}/#{executable_count} lines executed; #{percentage.to_f.round(2)}% covered."
		end
		
		# A human-readable representation of this coverage object.
		# @returns [String] A summary including the source path and percentage.
		def to_s
			"\#<#{self.class} path=#{self.path} #{self.percentage.to_f.round(2)}% covered>"
		end
		
		# A JSON-compatible representation of this coverage object.
		# @returns [Hash] The coverage counts and summary statistics.
		def as_json
			{
				counts: counts,
				executable_count: executable_count,
				executed_count: executed_count,
				percentage: percentage.to_f.round(2),
			}
		end
		
		# Serialize this coverage object with the given packer.
		# @parameter packer [Object] The MessagePack-compatible packer.
		def serialize(packer)
			packer.write(@source)
			packer.write(@counts)
			packer.write(@annotations)
		end
		
		# Deserialize a coverage object from the given unpacker.
		# @parameter unpacker [Object] The MessagePack-compatible unpacker.
		# @returns [Covered::Coverage] The deserialized coverage object.
		def self.deserialize(unpacker)
			source = unpacker.read
			counts = unpacker.read
			annotations = unpacker.read
			
			self.new(source, counts, annotations)
		end
	end
end