fast_cov 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ #include "fast_cov.h"
2
+ #include <ruby.h>
3
+ #include <string.h>
4
+
5
+ // Check if path is within root directory.
6
+ // Handles trailing slashes on root and ensures we don't match
7
+ // /a/b/c against /a/b/cd (sibling directory with longer name).
8
+ bool fast_cov_is_within_root(const char *path, long path_len,
9
+ const char *root, long root_len) {
10
+ // Normalize: strip trailing slash from root for comparison
11
+ long effective_root_len = root_len;
12
+ if (effective_root_len > 0 && root[effective_root_len - 1] == '/') {
13
+ effective_root_len--;
14
+ }
15
+
16
+ // Path must be at least as long as root
17
+ if (path_len < effective_root_len) {
18
+ return false;
19
+ }
20
+
21
+ // Check prefix match
22
+ if (strncmp(path, root, effective_root_len) != 0) {
23
+ return false;
24
+ }
25
+
26
+ // Path is exactly root (rare but valid)
27
+ if (path_len == effective_root_len) {
28
+ return true;
29
+ }
30
+
31
+ // Path must have '/' immediately after root prefix
32
+ // This prevents /a/b/c from matching /a/b/cd
33
+ return path[effective_root_len] == '/';
34
+ }
35
+
36
+ bool fast_cov_is_path_included(const char *path, const char *root_path,
37
+ long root_path_len, const char *ignored_path,
38
+ long ignored_path_len) {
39
+ long path_len = (long)strlen(path);
40
+
41
+ if (!fast_cov_is_within_root(path, path_len, root_path, root_path_len)) {
42
+ return false;
43
+ }
44
+ if (ignored_path_len > 0 &&
45
+ fast_cov_is_within_root(path, path_len, ignored_path, ignored_path_len)) {
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ char *fast_cov_ruby_strndup(const char *str, size_t size) {
52
+ char *dup = xmalloc(size + 1);
53
+ memcpy(dup, str, size);
54
+ dup[size] = '\0';
55
+ return dup;
56
+ }
57
+
58
+ VALUE fast_cov_rescue_nil(VALUE (*fn)(VALUE), VALUE arg) {
59
+ int exception_state;
60
+ VALUE result = rb_protect(fn, arg, &exception_state);
61
+ if (exception_state != 0) {
62
+ rb_set_errinfo(Qnil);
63
+ return Qnil;
64
+ }
65
+ return result;
66
+ }
67
+
68
+ VALUE fast_cov_get_const_source_location(VALUE const_name_str) {
69
+ return rb_funcall(rb_cObject, rb_intern("const_source_location"), 1,
70
+ const_name_str);
71
+ }
72
+
73
+ VALUE fast_cov_safely_get_const_source_location(VALUE const_name_str) {
74
+ return fast_cov_rescue_nil(fast_cov_get_const_source_location,
75
+ const_name_str);
76
+ }
77
+
78
+ VALUE fast_cov_resolve_const_to_file(VALUE const_name_str) {
79
+ // Check cache first
80
+ VALUE const_locations_hash =
81
+ rb_hash_lookup(fast_cov_cache_hash, ID2SYM(rb_intern("const_locations")));
82
+ VALUE cached = rb_hash_lookup(const_locations_hash, const_name_str);
83
+ if (cached != Qnil) {
84
+ return cached;
85
+ }
86
+
87
+ // Cache miss - resolve via Object.const_source_location
88
+ VALUE source_location =
89
+ fast_cov_safely_get_const_source_location(const_name_str);
90
+ if (NIL_P(source_location) || !RB_TYPE_P(source_location, T_ARRAY) ||
91
+ RARRAY_LEN(source_location) == 0) {
92
+ return Qnil;
93
+ }
94
+
95
+ VALUE filename = RARRAY_AREF(source_location, 0);
96
+ if (NIL_P(filename) || !RB_TYPE_P(filename, T_STRING)) {
97
+ return Qnil;
98
+ }
99
+
100
+ // Cache the result
101
+ rb_hash_aset(const_locations_hash, const_name_str, filename);
102
+
103
+ return filename;
104
+ }
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module FastCov
7
+ module Benchmark
8
+ class Runner
9
+ DEFAULT_ITERATIONS = 1000
10
+ DEFAULT_SAMPLES = 5
11
+ WARMUP_ITERATIONS = 20
12
+
13
+ attr_reader :iterations, :samples, :baseline_path
14
+
15
+ def initialize(iterations: DEFAULT_ITERATIONS, samples: DEFAULT_SAMPLES, baseline_path: nil)
16
+ @iterations = iterations
17
+ @samples = samples
18
+ @baseline_path = baseline_path || default_baseline_path
19
+ @scenarios = []
20
+ end
21
+
22
+ def scenario(name, &block)
23
+ @scenarios << { name: name, block: block }
24
+ end
25
+
26
+ def run(save_baseline: false)
27
+ baseline = load_baseline unless save_baseline
28
+ results = run_scenarios
29
+
30
+ print_results(results, baseline)
31
+
32
+ if save_baseline
33
+ save_baseline_to_disk(results)
34
+ elsif baseline
35
+ puts "Baseline: #{@baseline_path} (saved #{baseline["saved_at"]})"
36
+ else
37
+ puts "No baseline found. Run with --baseline to save one."
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def run_scenarios
44
+ results = {}
45
+
46
+ @scenarios.each do |scenario|
47
+ measurement = measure(scenario[:block])
48
+ results[scenario[:name]] = measurement
49
+ end
50
+
51
+ results
52
+ end
53
+
54
+ def measure(block)
55
+ # Warmup: let JIT, caches, and memory settle
56
+ WARMUP_ITERATIONS.times { block.call }
57
+
58
+ # Collect multiple samples, take the median to filter outliers
59
+ elapsed_samples = Array.new(@samples) do
60
+ GC.start
61
+ GC.compact
62
+
63
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ @iterations.times { block.call }
65
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
66
+ end
67
+
68
+ median_elapsed = median(elapsed_samples)
69
+
70
+ {
71
+ "avg_ms" => (median_elapsed / @iterations) * 1000.0,
72
+ "ips" => @iterations / median_elapsed
73
+ }
74
+ end
75
+
76
+ def median(values)
77
+ sorted = values.sort
78
+ mid = sorted.length / 2
79
+ if sorted.length.odd?
80
+ sorted[mid]
81
+ else
82
+ (sorted[mid - 1] + sorted[mid]) / 2.0
83
+ end
84
+ end
85
+
86
+ def print_results(results, baseline)
87
+ has_baseline = baseline && baseline["results"]
88
+
89
+ puts "FastCov Benchmark Suite"
90
+ puts "Ruby #{RUBY_VERSION}, #{RUBY_PLATFORM}"
91
+ puts "#{@iterations} iterations x #{@samples} samples (median)"
92
+ puts "=" * 72
93
+ puts
94
+
95
+ header = format(" %-38s %10s %12s", "", "avg (ms)", "ips")
96
+ header += format(" %14s", "vs baseline") if has_baseline
97
+ puts header
98
+ puts "-" * header.length
99
+
100
+ results.each do |name, result|
101
+ line = format(" %-38s %10.3f %12.1f", name, result["avg_ms"], result["ips"])
102
+
103
+ if has_baseline && (base = baseline["results"][name])
104
+ base_avg = base["avg_ms"]
105
+ if base_avg > 0
106
+ delta_pct = ((result["avg_ms"] - base_avg) / base_avg) * 100.0
107
+ sign = delta_pct >= 0 ? "+" : ""
108
+ line += format(" %13s", "#{sign}#{"%.1f" % delta_pct}%")
109
+ end
110
+ end
111
+
112
+ puts line
113
+ end
114
+
115
+ puts
116
+ end
117
+
118
+ def load_baseline
119
+ return nil unless File.exist?(@baseline_path)
120
+
121
+ JSON.parse(File.read(@baseline_path))
122
+ rescue JSON::ParserError
123
+ nil
124
+ end
125
+
126
+ def save_baseline_to_disk(results)
127
+ FileUtils.mkdir_p(File.dirname(@baseline_path))
128
+
129
+ payload = {
130
+ "saved_at" => Time.now.strftime("%Y-%m-%d %H:%M:%S"),
131
+ "ruby_version" => RUBY_VERSION,
132
+ "platform" => RUBY_PLATFORM,
133
+ "iterations" => @iterations,
134
+ "samples" => @samples,
135
+ "results" => results
136
+ }
137
+
138
+ File.write(@baseline_path, JSON.pretty_generate(payload))
139
+ puts "Baseline saved to #{@baseline_path}"
140
+ end
141
+
142
+ def default_baseline_path
143
+ File.expand_path("tmp/benchmark_baseline.json", Dir.pwd)
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastCov
4
+ module Benchmark
5
+ module Scenarios
6
+ def self.register(runner, fixtures_dir:)
7
+ root_calculator = File.join(fixtures_dir, "calculator")
8
+ root_app = File.join(fixtures_dir, "app")
9
+ root_all = fixtures_dir
10
+ ignored_path = File.join(fixtures_dir, "vendor")
11
+
12
+ calculator = Calculator.new
13
+
14
+ runner.scenario("Line coverage (small)") do
15
+ cov = FastCov::Coverage.new(root: root_calculator)
16
+ cov.start
17
+ calculator.add(1, 2)
18
+ calculator.subtract(3, 1)
19
+ cov.stop
20
+ end
21
+
22
+ runner.scenario("Line coverage (many files)") do
23
+ cov = FastCov::Coverage.new(root: root_all)
24
+ cov.start
25
+ calculator.add(1, 2)
26
+ calculator.subtract(3, 1)
27
+ calculator.multiply(2, 3)
28
+ calculator.divide(6, 2)
29
+ ConstantReader.new.operations
30
+ MyModel.new
31
+ User.new("test", "test@test.com")
32
+ DynamicModel.new.some_method
33
+ cov.stop
34
+ end
35
+
36
+ runner.scenario("Line coverage (single-threaded)") do
37
+ cov = FastCov::Coverage.new(root: root_calculator, threads: false)
38
+ cov.start
39
+ calculator.add(1, 2)
40
+ calculator.subtract(3, 1)
41
+ cov.stop
42
+ end
43
+
44
+ runner.scenario("Line coverage (with ignored_path)") do
45
+ cov = FastCov::Coverage.new(root: root_all, ignored_path: ignored_path)
46
+ cov.start
47
+ calculator.add(1, 2)
48
+ calculator.subtract(3, 1)
49
+ cov.stop
50
+ end
51
+
52
+ runner.scenario("Allocation tracing") do
53
+ cov = FastCov::Coverage.new(root: root_app, allocations: true)
54
+ cov.start
55
+ MyModel.new
56
+ User.new("test", "test@test.com")
57
+ DynamicModel.new
58
+ cov.stop
59
+ end
60
+
61
+ runner.scenario("Constant resolution (cold cache)") do
62
+ FastCov::Cache.clear
63
+ cov = FastCov::Coverage.new(root: root_calculator)
64
+ cov.start
65
+ ConstantReader.new.operations
66
+ calculator.add(1, 2)
67
+ cov.stop
68
+ end
69
+
70
+ runner.scenario("Constant resolution (warm cache)") do
71
+ warm = FastCov::Coverage.new(root: root_calculator)
72
+ warm.start
73
+ ConstantReader.new.operations
74
+ warm.stop
75
+
76
+ cov = FastCov::Coverage.new(root: root_calculator)
77
+ cov.start
78
+ ConstantReader.new.operations
79
+ calculator.add(1, 2)
80
+ cov.stop
81
+ end
82
+
83
+ runner.scenario("Rapid start/stop (100x)") do
84
+ cov = FastCov::Coverage.new(root: root_calculator)
85
+ 100.times do
86
+ cov.start
87
+ calculator.add(1, 2)
88
+ cov.stop
89
+ end
90
+ end
91
+
92
+ runner.scenario("Multi-threaded coverage") do
93
+ cov = FastCov::Coverage.new(root: root_calculator)
94
+ cov.start
95
+ t = Thread.new { calculator.add(1, 2) }
96
+ calculator.multiply(2, 3)
97
+ t.join
98
+ cov.stop
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+ require "digest/md5"
6
+
7
+ module FastCov
8
+ module Compiler
9
+ EXT_DIR = File.expand_path("../../ext/fast_cov", __dir__)
10
+ LIB_DIR = File.expand_path("../fast_cov", __dir__)
11
+
12
+ def self.compile!
13
+ Dir.chdir(EXT_DIR) do
14
+ system(RbConfig.ruby, "extconf.rb") || raise("FastCov: extconf.rb failed")
15
+ system("make") || raise("FastCov: make failed")
16
+ system("make install sitearchdir=#{LIB_DIR} sitelibdir=#{LIB_DIR}") || raise("FastCov: make install failed")
17
+ end
18
+
19
+ write_digest
20
+ end
21
+
22
+ def self.stale?
23
+ return true unless extension_exists?
24
+
25
+ stored = read_digest
26
+ return true unless stored
27
+
28
+ stored != source_digest
29
+ end
30
+
31
+ def self.extension_exists?
32
+ ext_name = "fast_cov.#{RUBY_VERSION}_#{RUBY_PLATFORM}"
33
+ Dir.glob(File.join(LIB_DIR, "#{ext_name}.{bundle,so}")).any?
34
+ end
35
+
36
+ def self.source_digest
37
+ files = Dir.glob(File.join(EXT_DIR, "*.{c,h,rb}")).sort
38
+ combined = files.map { |f| Digest::MD5.file(f).hexdigest }.join
39
+ Digest::MD5.hexdigest(combined)
40
+ end
41
+
42
+ def self.digest_path
43
+ File.join(LIB_DIR, ".source_digest.#{RUBY_VERSION}_#{RUBY_PLATFORM}")
44
+ end
45
+
46
+ def self.write_digest
47
+ File.write(digest_path, source_digest)
48
+ end
49
+
50
+ def self.read_digest
51
+ File.read(digest_path).strip
52
+ rescue Errno::ENOENT
53
+ nil
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastCov
4
+ class Configuration
5
+ attr_accessor :root, :ignored_path, :threads
6
+
7
+ def initialize
8
+ @root = Dir.pwd
9
+ @ignored_path = nil
10
+ @threads = true
11
+ @trackers = []
12
+ end
13
+
14
+ def use(tracker_class, **options)
15
+ @trackers << {klass: tracker_class, options: options}
16
+ end
17
+
18
+ def trackers
19
+ @trackers
20
+ end
21
+
22
+ def reset
23
+ initialize
24
+ self
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module FastCov
6
+ module ConstantExtractor
7
+ def self.extract(filename)
8
+ result = Prism.parse_file(filename)
9
+ constants = []
10
+ collect_constants(result.value, constants)
11
+ constants
12
+ end
13
+
14
+ class << self
15
+ private
16
+
17
+ def collect_constants(node, constants)
18
+ case node
19
+ when Prism::ConstantPathNode
20
+ path = resolve_constant_path(node)
21
+ if path
22
+ constants << path
23
+ return
24
+ end
25
+ # Dynamic parent (e.g., expr::Foo) — fall through to walk children
26
+ when Prism::ConstantReadNode
27
+ constants << node.name.to_s
28
+ return
29
+ end
30
+
31
+ node.compact_child_nodes.each { |child| collect_constants(child, constants) }
32
+ end
33
+
34
+ def resolve_constant_path(node)
35
+ parts = []
36
+ current = node
37
+
38
+ while current.is_a?(Prism::ConstantPathNode)
39
+ parts.unshift(current.name.to_s)
40
+ current = current.parent
41
+ end
42
+
43
+ if current.is_a?(Prism::ConstantReadNode)
44
+ parts.unshift(current.name.to_s)
45
+ elsif !current.nil?
46
+ return nil
47
+ end
48
+
49
+ parts.join("::")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-compile entrypoint for local development with path: Gemfile references.
4
+ #
5
+ # Usage in a consuming project's Gemfile:
6
+ # gem "fast_cov", path: "../fast_cov", require: "fast_cov/dev"
7
+ #
8
+ # Compiles the C extension if:
9
+ # - It hasn't been compiled for this Ruby version yet
10
+ # - The C source files have changed since the last compilation
11
+
12
+ require_relative "compiler"
13
+
14
+ if FastCov::Compiler.stale?
15
+ $stdout.puts "[FastCov] Compiling extension for Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})..."
16
+ FastCov::Compiler.compile!
17
+ $stdout.puts "[FastCov] Compilation complete."
18
+ else
19
+ $stdout.puts "[FastCov] Extension up to date for Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"
20
+ end
21
+
22
+ require_relative "../fast_cov"
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastCov
4
+ # Base class for trackers that record file paths during coverage.
5
+ #
6
+ # Provides common functionality:
7
+ # - Path filtering (root, ignored_path)
8
+ # - Thread-aware recording (threads option)
9
+ # - File collection and lifecycle management
10
+ #
11
+ # Descendants override hooks: on_start, on_stop, on_record
12
+ # Or implement start, stop, record directly without inheriting.
13
+ #
14
+ # Threading behavior:
15
+ # - threads: true -> record from ALL threads (global tracking)
16
+ # - threads: false -> only record from the thread that called start
17
+ class AbstractTracker
18
+ def initialize(config, **options)
19
+ @root = options.fetch(:root, config.root)
20
+ @ignored_path = options.fetch(:ignored_path, config.ignored_path)
21
+ @threads = options.fetch(:threads, config.threads)
22
+ @files = nil
23
+ @started_thread = nil
24
+ end
25
+
26
+ # Public API - called by FastCov framework
27
+
28
+ def start
29
+ @files = Set.new
30
+ @started_thread = Thread.current unless @threads
31
+ self.class.active = self
32
+ on_start
33
+ end
34
+
35
+ def stop
36
+ self.class.active = nil
37
+ @started_thread = nil
38
+ on_stop
39
+ result = @files
40
+ @files = nil
41
+ result
42
+ end
43
+
44
+ def record(abs_path)
45
+ return if !@threads && Thread.current != @started_thread
46
+ return unless abs_path.start_with?(@root)
47
+ return if @ignored_path && abs_path.start_with?(@ignored_path)
48
+ @files.add(abs_path) if on_record(abs_path)
49
+ end
50
+
51
+ # Hooks for descendants - override as needed
52
+
53
+ def install; end
54
+ def on_start; end
55
+ def on_stop; end
56
+
57
+ def on_record(abs_path)
58
+ true
59
+ end
60
+
61
+ class << self
62
+ attr_accessor :active
63
+
64
+ def record(abs_path)
65
+ @active&.record(abs_path)
66
+ end
67
+
68
+ def reset
69
+ @active = nil
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastCov
4
+ # Wraps the FastCov::Coverage C extension as a tracker plugin.
5
+ # Handles line coverage, allocation tracing, and constant resolution.
6
+ #
7
+ # Register via: config.use FastCov::CoverageTracker
8
+ # Options: root, ignored_path, threads, constant_references, allocations
9
+ class CoverageTracker
10
+ def initialize(config, **options)
11
+ @coverage = Coverage.new(
12
+ root: options.fetch(:root, config.root),
13
+ ignored_path: options.fetch(:ignored_path, config.ignored_path),
14
+ threads: options.fetch(:threads, config.threads),
15
+ constant_references: options.fetch(:constant_references, true),
16
+ allocations: options.fetch(:allocations, true)
17
+ )
18
+ end
19
+
20
+ def start
21
+ @coverage.start
22
+ end
23
+
24
+ def stop
25
+ Set.new(@coverage.stop.each_key)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "abstract_tracker"
4
+
5
+ module FastCov
6
+ # Tracks FactoryBot factory definition files when factories are used.
7
+ #
8
+ # Factory files are typically loaded at boot time, before coverage starts.
9
+ # This tracker intercepts FactoryBot.factories.find (called by create/build)
10
+ # to record the source file where each factory was defined.
11
+ #
12
+ # Register via: config.use FastCov::FactoryBotTracker
13
+ # Options: root, ignored_path, threads (all default from config)
14
+ class FactoryBotTracker < AbstractTracker
15
+ def install
16
+ unless defined?(::FactoryBot)
17
+ raise LoadError, "FactoryBotTracker requires the factory_bot gem to be installed"
18
+ end
19
+
20
+ ::FactoryBot.factories.singleton_class.prepend(RegistryPatch)
21
+ end
22
+
23
+ module RegistryPatch
24
+ def find(name)
25
+ factory = super
26
+ FastCov::FactoryBotTracker.record_factory_files(factory)
27
+ factory
28
+ end
29
+ end
30
+
31
+ class << self
32
+ def record_factory_files(factory)
33
+ return unless @active
34
+
35
+ definition = factory.definition
36
+ declarations = definition.instance_variable_get(:@declarations)
37
+ return unless declarations
38
+
39
+ declarations.each do |decl|
40
+ block = decl.instance_variable_get(:@block)
41
+ next unless block.is_a?(Proc)
42
+
43
+ location = block.source_location
44
+ next unless location
45
+
46
+ file_path = location[0]
47
+ @active.record(file_path)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end