Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions covered.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Gem::Specification.new do |spec|

spec.add_dependency "console", "~> 1.0"
spec.add_dependency "msgpack", "~> 1.0"
spec.add_dependency "ruby-coverage", "~> 0.0.1"
end
42 changes: 36 additions & 6 deletions lib/covered/capture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,36 @@

require_relative "wrapper"

require "coverage"
require "ruby/coverage"

module Covered
# Captures Ruby coverage data and forwards it to another coverage output.
class Capture < Wrapper
# Initialize capture with independent tracer state.
def initialize(...)
super

@tracer = nil
@files = {}
end

# Start Ruby coverage collection.
def start
super

::Coverage.start(lines: true, eval: true)
@files = {}
@tracer = build_tracer
@tracer.start
end

# Clear any collected coverage data without stopping coverage.
def clear
super

::Coverage.result(stop: false, clear: true)
@tracer&.stop
@files = {}
@tracer = build_tracer
@tracer.start
end

EVAL_PATHS = {
Expand All @@ -33,22 +46,25 @@ def clear
# Stop coverage collection and add the collected results to the output.
# Ignores Ruby's anonymous eval paths and files that no longer exist.
def finish
results = ::Coverage.result
@tracer&.stop

results.each do |path, result|
@files.each do |path, lines|
next if EVAL_PATHS.include?(path)

path = self.expand_path(path)

# Skip files which don't exist. This can happen if `eval` is used with an invalid/incorrect path:
if File.exist?(path)
@output.mark(path, 1, result[:lines])
@output.mark(path, 0, lines)
else
# warn "Skipping coverage for #{path.inspect} because it doesn't exist!"
# Ignore.
end
end

@tracer = nil
@files = {}

super
end

Expand All @@ -63,5 +79,19 @@ def execute(source, binding: TOPLEVEL_BINDING)
ensure
finish
end

private

def build_tracer
::Ruby::Coverage::Tracer.new do |path, iseq|
@files[path] ||= begin
lines = []
::Ruby::Coverage.executable_lines(iseq).each do |line|
lines[line] = 0
end
lines
end
end
end
end
end
4 changes: 1 addition & 3 deletions lib/covered/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,7 @@ def autostart!

ENV["RUBYOPT"] = rubyopt

unless ENV["COVERED_ROOT"]
ENV["COVERED_ROOT"] = @root
end
ENV["COVERED_ROOT"] = @root

# Don't report coverage in child processes:
ENV.delete("COVERAGE")
Expand Down
4 changes: 2 additions & 2 deletions lib/covered/coverage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ def annotate(line_number, annotation)
# @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
next if offset < 1

if @counts[offset]
@counts[offset] += value
else
Expand Down
26 changes: 15 additions & 11 deletions lib/covered/forks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def start

# Stop tracking coverage and remove the fork handler state.
def finish
Handler.finish
Handler.finish(self)

super
end
Expand All @@ -27,26 +27,30 @@ module Handler
LOCK = Mutex.new

class << self
# @attribute [Covered::Forks | Nil] The currently registered coverage wrapper.
attr :coverage
# The currently registered coverage wrapper.
# @returns [Covered::Forks | Nil]
def coverage
LOCK.synchronize do
@coverages&.last
end
end

# Register coverage for fork handling.
# @parameter coverage [Covered::Forks] The coverage wrapper to use in forked children.
# @raises [ArgumentError] If coverage is already registered.
def start(coverage)
LOCK.synchronize do
if @coverage
raise ArgumentError, "Coverage is already being tracked!"
end

@coverage = coverage
(@coverages ||= []) << coverage
end
end

# Clear the registered coverage.
def finish
def finish(coverage = nil)
LOCK.synchronize do
@coverage = nil
if coverage
@coverages&.delete(coverage)
else
@coverages&.pop
end
end
end

Expand Down
69 changes: 69 additions & 0 deletions test/covered/capture.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "covered/files"
require "covered/capture"

require "tmpdir"

describe Covered::Capture do
it "accumulates coverage for files loaded multiple times" do
path = File.expand_path("capture-load-test.rb", Dir.tmpdir)
File.write(path, "x = 1\n")

files = Covered::Files.new
capture = Covered::Capture.new(files)

capture.start
load path
load path
capture.finish

expect(files[path].counts[1]).to be == 2
ensure
File.delete(path) if path && File.exist?(path)
end

it "ignores line zero when marking coverage" do
files = Covered::Files.new

files.mark("line-zero.rb", 0, [1, 2])

expect(files["line-zero.rb"].counts[0]).to be_nil
expect(files["line-zero.rb"].counts[1]).to be == 2
end

it "supports nested independent captures" do
outer_path = File.expand_path("capture-outer-test.rb", Dir.tmpdir)
inner_path = File.expand_path("capture-inner-test.rb", Dir.tmpdir)

File.write(outer_path, "x = 1\n")
File.write(inner_path, "y = 1\n")

outer_files = Covered::Files.new
outer_capture = Covered::Capture.new(outer_files)

inner_files = Covered::Files.new
inner_capture = Covered::Capture.new(inner_files)

outer_capture.start
load outer_path

inner_capture.start
load inner_path
inner_capture.finish

load outer_path
outer_capture.finish

expect(outer_files[outer_path].counts[1]).to be == 2
expect(outer_files[inner_path].counts[1]).to be == 1
expect(inner_files[inner_path].counts[1]).to be == 1
expect(inner_files.paths).not.to have_keys(outer_path)
ensure
File.delete(outer_path) if outer_path && File.exist?(outer_path)
File.delete(inner_path) if inner_path && File.exist?(inner_path)
end
end
6 changes: 3 additions & 3 deletions test/covered/persist.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def ignore_paths
File.write(lib_path, "puts :lib\n")

output = Covered::Files.new
output.add(Covered::Coverage.new(Covered::Source.new(example_path), [1]))
output.add(Covered::Coverage.new(Covered::Source.new(lib_path), [1]))
output.add(Covered::Coverage.new(Covered::Source.new(example_path), [nil, 1]))
output.add(Covered::Coverage.new(Covered::Source.new(lib_path), [nil, 1]))

database_path = File.join(root, Covered::Persist::DEFAULT_PATH)
Covered::Persist.new(output, database_path).save!
Expand All @@ -65,7 +65,7 @@ def ignore_paths
File.write(lib_path, "puts :lib\n")

output = Covered::Files.new
output.add(Covered::Coverage.new(Covered::Source.new(lib_path), [1]))
output.add(Covered::Coverage.new(Covered::Source.new(lib_path), [nil, 1]))

database_path = File.join(root, "coverage", "artifact.covered.db")
Covered::Persist.new(output, database_path).save!
Expand Down
2 changes: 1 addition & 1 deletion test/covered/source/erb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
template.result_with_hash(items: [1, 2, 3])
capture.finish

expect(files.paths[__FILE__].counts).not.to be(:include?, 0)
expect(files.paths[__FILE__].counts).to be(:include?, 0)

# Show the actual coverage:
# Covered::Summary.new(threshold: nil).call(files, $stderr)
Expand Down
2 changes: 1 addition & 1 deletion test/covered/validate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
let(:recipe) {context.lookup("covered:validate")}
let(:validate) {recipe.instance}
let(:file) {__FILE__}
let(:coverage) {Covered::Coverage.new(Covered::Source.new(file), [1])}
let(:coverage) {Covered::Coverage.new(Covered::Source.new(file), [nil, 1])}
let(:policy) do
Covered::Policy.new.tap do |policy|
policy.add(coverage)
Expand Down
Loading