From 90e17eb543ec9ac16b8cec244e5f930096e78b77 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 14 Jun 2026 23:14:23 +1200 Subject: [PATCH] Use ruby-coverage for capture --- covered.gemspec | 1 + lib/covered/capture.rb | 42 +++++++++++++++++++---- lib/covered/config.rb | 4 +-- lib/covered/coverage.rb | 4 +-- lib/covered/forks.rb | 26 ++++++++------ test/covered/capture.rb | 69 ++++++++++++++++++++++++++++++++++++++ test/covered/persist.rb | 6 ++-- test/covered/source/erb.rb | 2 +- test/covered/validate.rb | 2 +- 9 files changed, 129 insertions(+), 27 deletions(-) create mode 100644 test/covered/capture.rb diff --git a/covered.gemspec b/covered.gemspec index dee0e67..a2e30cb 100644 --- a/covered.gemspec +++ b/covered.gemspec @@ -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 diff --git a/lib/covered/capture.rb b/lib/covered/capture.rb index fa55488..c794129 100644 --- a/lib/covered/capture.rb +++ b/lib/covered/capture.rb @@ -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 = { @@ -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 @@ -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 diff --git a/lib/covered/config.rb b/lib/covered/config.rb index 5db7e9d..481f153 100644 --- a/lib/covered/config.rb +++ b/lib/covered/config.rb @@ -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") diff --git a/lib/covered/coverage.rb b/lib/covered/coverage.rb index 8153166..2ca3bc6 100644 --- a/lib/covered/coverage.rb +++ b/lib/covered/coverage.rb @@ -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 diff --git a/lib/covered/forks.rb b/lib/covered/forks.rb index 3dc9bb3..0154687 100644 --- a/lib/covered/forks.rb +++ b/lib/covered/forks.rb @@ -17,7 +17,7 @@ def start # Stop tracking coverage and remove the fork handler state. def finish - Handler.finish + Handler.finish(self) super end @@ -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 diff --git a/test/covered/capture.rb b/test/covered/capture.rb new file mode 100644 index 0000000..b76e587 --- /dev/null +++ b/test/covered/capture.rb @@ -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 diff --git a/test/covered/persist.rb b/test/covered/persist.rb index e16d59d..cdf5e45 100644 --- a/test/covered/persist.rb +++ b/test/covered/persist.rb @@ -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! @@ -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! diff --git a/test/covered/source/erb.rb b/test/covered/source/erb.rb index b64709e..8c43c39 100644 --- a/test/covered/source/erb.rb +++ b/test/covered/source/erb.rb @@ -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) diff --git a/test/covered/validate.rb b/test/covered/validate.rb index dbad831..9afa5e3 100644 --- a/test/covered/validate.rb +++ b/test/covered/validate.rb @@ -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)