lib/covered/coverage.rb



# frozen_string_literal: true

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

module Covered
	module Ratio
		def ratio
			return 1 if executable_count.zero?
			
			Rational(executed_count, executable_count)
		end
		
		def complete?
			executed_count == executable_count
		end
		
		def percentage
			ratio * 100
		end
	end
	
	class Source
		def self.for(path, code, line_offset)
			self.new(path, code: code, line_offset: line_offset)
		end
		
		def initialize(path, code: nil, line_offset: 1, modified_time: nil)
			@path = path
			@code = code
			@line_offset = line_offset
			@modified_time = modified_time
		end
		
		attr_accessor :path
		attr :code
		attr :line_offset
		attr :modified_time
		
		def to_s
			"\#<#{self.class} path=#{path}>"
		end
		
		def read(&block)
			if block_given?
				File.open(self.path, "r", &block)
			else
				File.read(self.path)
			end
		end
		
		# The actual code which is being covered. If a template generates the source, this is the generated code, while the path refers to the template itself.
		def code!
			self.code || self.read
		end
		
		def code?
			!!self.code
		end
		
		def serialize(packer)
			packer.write(self.path)
			packer.write(self.code)
			packer.write(self.line_offset)
			packer.write(self.modified_time)
		end
		
		def self.deserialize(unpacker)
			path = unpacker.read
			code = unpacker.read
			line_offset = unpacker.read
			modified_time = unpacker.read
			
			self.new(path, code: code, line_offset: line_offset, modified_time: modified_time)
		end
	end
	
	class Coverage
		include Ratio
		
		def self.for(path, **options)
			self.new(Source.new(path, **options))
		end
		
		def initialize(source, counts = [], annotations = {}, total = nil)
			@source = source
			@counts = counts
			@annotations = annotations
			
			@total = total || counts.sum{|count| count || 0}
			
			# Memoized metrics:
			@executable_lines = nil
			@executed_lines = nil
		end
		
		def path
			@source.path
		end
		
		def path= value
			@source.path = value
		end
		
		def fresh?
			if @source.modified_time.nil?
				# We don't know when the file was last modified, so we assume it is fresh:
				return true
			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
		
		attr_accessor :source
		
		attr :counts
		attr :total
		
		attr :annotations
		
		def read(&block)
			@source.read(&block)
		end
		
		def freeze
			return self if frozen?
			
			@counts.freeze
			@annotations.freeze
			
			super
		end
		
		def to_a
			@counts
		end
		
		def zero?
			@total.zero?
		end
		
		def [] lineno
			@counts[lineno]
		end
		
		def executable_lines
			@executable_lines ||= @counts.compact
		end
		
		def executable_count
			executable_lines.count
		end
		
		def executed_lines
			@executed_lines ||= executable_lines.reject(&:zero?)
		end
		
		def executed_count
			executed_lines.count
		end
		
		def missing_count
			executable_count - executed_count
		end
		
		def print(output)
			output.puts "** #{executed_count}/#{executable_count} lines executed; #{percentage.to_f.round(2)}% covered."
		end
		
		def to_s
			"\#<#{self.class} path=#{self.path} #{self.summary.percentage.to_f.round(2)}% covered>"
		end
		
		def serialize(packer)
			packer.write(@source)
			packer.write(@counts)
			packer.write(@annotations)
			packer.write(@total)
		end
		
		def self.deserialize(unpacker)
			source = unpacker.read
			counts = unpacker.read
			annotations = unpacker.read
			total = unpacker.read
			
			self.new(source, counts, annotations, total)
		end
	end
end