lib/covered/files.rb
# frozen_string_literal: true # Released under the MIT License. # Copyright, 2018-2025, by Samuel Williams. require_relative "coverage" require_relative "wrapper" require "set" module Covered # Collects coverage information keyed by source path. class Files < Base # Initialize an empty coverage collection. def initialize(*) super @paths = {} end # @attribute [Hash(String, Covered::Coverage)] Coverage indexed by expanded source path. attr_accessor :paths # Get or create coverage for the given path. # @parameter path [String] The source path. # @returns [Covered::Coverage] The coverage object for the path. def [](path) @paths[path] ||= Coverage.for(path) end # Whether there are no tracked paths. # @returns [Boolean] Whether no coverage paths are tracked. def empty? @paths.empty? end # Mark a line in the given path as executed. # @parameter path [String] The source path. # @parameter line_number [Integer] The line number to mark. # @parameter value [Integer | Array(Integer)] The execution count or counts to add. def mark(path, line_number, value) self[path].mark(line_number, value) end # Add an annotation to a line in the given path. # @parameter path [String] The source path. # @parameter line_number [Integer] The line number to annotate. # @parameter value [String] The annotation text. def annotate(path, line_number, value) self[path].annotate(line_number, value) end # Merge coverage for the given path into this collection. # @parameter coverage [Covered::Coverage] The coverage object to merge. def add(coverage) self[coverage.path].merge!(coverage) end # Enumerate tracked coverage objects. # @yields {|coverage| ...} Each tracked coverage object. # @parameter coverage [Covered::Coverage] The current coverage object. # @returns [Enumerator | Nil] An enumerator without a block. def each return to_enum unless block_given? @paths.each_value do |coverage| yield coverage end end # Remove all tracked coverage data. # @returns [Hash] The cleared path map. def clear @paths.clear end end # Includes coverage for files matching a glob pattern. class Include < Wrapper # Initialize an include filter for the given glob pattern. # @parameter output [Covered::Base] The output to wrap. # @parameter pattern [String] The glob pattern to include. # @parameter base [String] The base path used to expand the glob. def initialize(output, pattern, base = "") super(output) @pattern = pattern @base = base end # @attribute [String] The glob pattern to include. attr :pattern # Resolve the include pattern to real file paths. # @returns [Set(String)] The real paths matched by the include pattern. def glob paths = Set.new root = self.expand_path(@base) pattern = File.expand_path(@pattern, root) Dir.glob(pattern) do |path| unless File.directory?(path) paths << File.realpath(path) end end return paths end # Enumerate existing coverage and synthesize empty coverage for unmatched included files. # @yields {|coverage| ...} Each existing or synthesized coverage object. # @parameter coverage [Covered::Coverage] The current coverage object. def each(&block) paths = glob super do |coverage| paths.delete(coverage.path) yield coverage end paths.each do |path| yield Coverage.for(path) end end end # Excludes coverage for paths matching a pattern. class Skip < Filter # Initialize a skip filter for the given pattern. # @parameter output [Covered::Base] The output to wrap. # @parameter pattern [Regexp] The pattern to exclude. def initialize(output, pattern) super(output) @pattern = pattern end # @attribute [Regexp] The pattern to exclude. attr :pattern if Regexp.instance_methods.include? :match? # This is better as it doesn't allocate a MatchData instance which is essentially useless: def match? path !@pattern.match?(path) end else def match? path !(@pattern =~ path) end end end # Only includes coverage for paths matching a pattern. class Only < Filter # Initialize an only filter for the given pattern. # @parameter output [Covered::Base] The output to wrap. # @parameter pattern [Object] The pattern matched with `===`. def initialize(output, pattern) super(output) @pattern = pattern end # @attribute [Object] The pattern matched with `===`. attr :pattern # Whether the given path matches the only pattern. # @parameter path [String] The source path. # @returns [Boolean] Whether the path matches the pattern. def match?(path) @pattern === path end end # Restricts coverage to a project root and converts paths relative to it. class Root < Filter # Initialize a root filter for the given path. # @parameter output [Covered::Base] The output to wrap. # @parameter path [String] The root path. def initialize(output, path) super(output) @path = path end # @attribute [String] The root path. attr :path # Expand a path relative to this root. # @parameter path [String] The path to expand. # @returns [String] The expanded path. def expand_path(path) File.expand_path(super, @path) end # Convert a path under this root to a relative path. # @parameter path [String] The path to relativize. # @returns [String] The relative path when under this root, otherwise the wrapped output result. def relative_path(path) if path.start_with?(@path) path.slice(@path.size+1, path.size) else super end end # Whether the given path is under this root. # @parameter path [String] The source path. # @returns [Boolean] Whether the path starts with this root. def match?(path) path.start_with?(@path) end end end