ruby-coverage 0.0.1
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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/ext/extconf.rb +35 -0
- data/ext/ruby/coverage/coverage.c +13 -0
- data/ext/ruby/coverage/coverage.h +8 -0
- data/ext/ruby/coverage/tracer.c +345 -0
- data/ext/ruby/coverage/tracer.h +8 -0
- data/lib/ruby/coverage/version.rb +12 -0
- data/lib/ruby/coverage.rb +134 -0
- data/license.md +21 -0
- data/readme.md +71 -0
- data/releases.md +3 -0
- data.tar.gz.sig +0 -0
- metadata +80 -0
- metadata.gz.sig +0 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8f625d52f5f7030429cd75eaa690bb45a62fa1e2a11b964e366d4ddb1c320009
|
|
4
|
+
data.tar.gz: ff482bc87fe61d48313e3e190333af9faabb272fb209edba68ab0ab4dd44bef0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 34f4e2fe8a19578a25a80c9db42ec761e154726ed55b590d7350b1b143cbe0643fcf5d28037cb75b212961b0474b2218e9893f1c8c59845e62190077042ebe19
|
|
7
|
+
data.tar.gz: b22011f57b243cd95166f2495963386a40517a8cea439f0f5ed9057f8e8342aa9030ad5f5417aac354720df85b6a5638d6c0f50b47d2a6d6322176b537ccb672
|
checksums.yaml.gz.sig
ADDED
|
Binary file
|
data/ext/extconf.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Released under the MIT License.
|
|
5
|
+
# Copyright, 2026, by Samuel Williams.
|
|
6
|
+
|
|
7
|
+
require "mkmf"
|
|
8
|
+
|
|
9
|
+
gem_name = File.basename(__dir__)
|
|
10
|
+
extension_name = "Ruby_Coverage"
|
|
11
|
+
|
|
12
|
+
append_cflags(["-Wall", "-Wno-unknown-pragmas", "-std=c99"])
|
|
13
|
+
|
|
14
|
+
if ENV.key?("RUBY_DEBUG")
|
|
15
|
+
$stderr.puts "Enabling debug mode..."
|
|
16
|
+
|
|
17
|
+
append_cflags(["-DRUBY_DEBUG", "-O0"])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
$srcs = ["ruby/coverage/coverage.c", "ruby/coverage/tracer.c"]
|
|
21
|
+
$VPATH << "$(srcdir)/ruby/coverage"
|
|
22
|
+
|
|
23
|
+
if ENV.key?("RUBY_SANITIZE")
|
|
24
|
+
$stderr.puts "Enabling sanitizers..."
|
|
25
|
+
|
|
26
|
+
append_cflags(["-fsanitize=address", "-fsanitize=undefined", "-fno-omit-frame-pointer"])
|
|
27
|
+
$LDFLAGS << " -fsanitize=address -fsanitize=undefined"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
have_func("rb_tracearg_instruction_sequence", "ruby/debug.h")
|
|
31
|
+
|
|
32
|
+
create_header
|
|
33
|
+
|
|
34
|
+
# Generate the makefile to compile the native binary into `ext/`:
|
|
35
|
+
create_makefile(extension_name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Released under the MIT License.
|
|
2
|
+
// Copyright, 2025, by Samuel Williams.
|
|
3
|
+
|
|
4
|
+
#include "coverage.h"
|
|
5
|
+
#include "tracer.h"
|
|
6
|
+
|
|
7
|
+
void Init_Ruby_Coverage(void)
|
|
8
|
+
{
|
|
9
|
+
VALUE Ruby = rb_define_module("Ruby");
|
|
10
|
+
VALUE Ruby_Coverage = rb_define_module_under(Ruby, "Coverage");
|
|
11
|
+
|
|
12
|
+
Init_Ruby_Coverage_Tracer(Ruby_Coverage);
|
|
13
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// Released under the MIT License.
|
|
2
|
+
// Copyright, 2025, by Samuel Williams.
|
|
3
|
+
|
|
4
|
+
#include "tracer.h"
|
|
5
|
+
|
|
6
|
+
#include <ruby.h>
|
|
7
|
+
#include <ruby/debug.h>
|
|
8
|
+
|
|
9
|
+
static const int DEBUG = 0;
|
|
10
|
+
|
|
11
|
+
static ID id_call;
|
|
12
|
+
static ID id_instruction_sequence;
|
|
13
|
+
static ID id_path;
|
|
14
|
+
|
|
15
|
+
struct Ruby_Coverage_Tracer {
|
|
16
|
+
// The Ruby callback: called with (path, iseq) the first time a new file is
|
|
17
|
+
// compiled. iseq is the RubyVM::InstructionSequence from the script_compiled
|
|
18
|
+
// event. Must return an Array to use as the line-count store, or nil to
|
|
19
|
+
// skip the file.
|
|
20
|
+
VALUE callback;
|
|
21
|
+
|
|
22
|
+
// Hash: { path String => counts Array }
|
|
23
|
+
VALUE counts;
|
|
24
|
+
|
|
25
|
+
// Cache of the most recently seen rb_sourcefile() pointer. Stable for the
|
|
26
|
+
// lifetime of the current ISeq, so pointer equality is an O(1) same-file
|
|
27
|
+
// check without a hash lookup on every line event.
|
|
28
|
+
uintptr_t last_path_pointer;
|
|
29
|
+
|
|
30
|
+
// The counts Array for the file identified by last_path_pointer.
|
|
31
|
+
VALUE last_counts;
|
|
32
|
+
|
|
33
|
+
// Re-entrancy guard. Set while the user callback is being invoked so that
|
|
34
|
+
// any RUBY_EVENT_LINE events fired by the callback itself are ignored.
|
|
35
|
+
int in_callback;
|
|
36
|
+
|
|
37
|
+
// Used on Rubies where rb_tracearg_instruction_sequence is not part of the
|
|
38
|
+
// public extension headers.
|
|
39
|
+
VALUE script_compiled_tracepoint;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
static void Ruby_Coverage_Tracer_mark(void *pointer)
|
|
43
|
+
{
|
|
44
|
+
struct Ruby_Coverage_Tracer *tracer = pointer;
|
|
45
|
+
rb_gc_mark_movable(tracer->callback);
|
|
46
|
+
rb_gc_mark_movable(tracer->counts);
|
|
47
|
+
rb_gc_mark_movable(tracer->last_counts);
|
|
48
|
+
rb_gc_mark_movable(tracer->script_compiled_tracepoint);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static void Ruby_Coverage_Tracer_free(void *pointer)
|
|
52
|
+
{
|
|
53
|
+
xfree(pointer);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static void Ruby_Coverage_Tracer_compact(void *pointer)
|
|
57
|
+
{
|
|
58
|
+
struct Ruby_Coverage_Tracer *tracer = pointer;
|
|
59
|
+
tracer->callback = rb_gc_location(tracer->callback);
|
|
60
|
+
tracer->counts = rb_gc_location(tracer->counts);
|
|
61
|
+
tracer->last_counts = rb_gc_location(tracer->last_counts);
|
|
62
|
+
tracer->script_compiled_tracepoint = rb_gc_location(tracer->script_compiled_tracepoint);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static const rb_data_type_t Ruby_Coverage_Tracer_type = {
|
|
66
|
+
.wrap_struct_name = "Ruby::Coverage::Tracer",
|
|
67
|
+
.function = {
|
|
68
|
+
.dmark = Ruby_Coverage_Tracer_mark,
|
|
69
|
+
.dfree = Ruby_Coverage_Tracer_free,
|
|
70
|
+
.dsize = NULL,
|
|
71
|
+
.dcompact = Ruby_Coverage_Tracer_compact,
|
|
72
|
+
},
|
|
73
|
+
.flags = RUBY_TYPED_FREE_IMMEDIATELY,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
struct Ruby_Coverage_Tracer_CurrentISeq {
|
|
77
|
+
VALUE iseq;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
struct Ruby_Coverage_Tracer_CallbackArguments {
|
|
81
|
+
struct Ruby_Coverage_Tracer *tracer;
|
|
82
|
+
VALUE path;
|
|
83
|
+
VALUE iseq;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
static VALUE Ruby_Coverage_Tracer_current_iseq_callback(const rb_debug_inspector_t *debug_inspector, void *data)
|
|
87
|
+
{
|
|
88
|
+
struct Ruby_Coverage_Tracer_CurrentISeq *current_iseq = data;
|
|
89
|
+
current_iseq->iseq = rb_debug_inspector_frame_iseq_get(debug_inspector, 0);
|
|
90
|
+
|
|
91
|
+
return Qnil;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static VALUE Ruby_Coverage_Tracer_current_iseq(void)
|
|
95
|
+
{
|
|
96
|
+
struct Ruby_Coverage_Tracer_CurrentISeq current_iseq = {.iseq = Qnil};
|
|
97
|
+
rb_debug_inspector_open(Ruby_Coverage_Tracer_current_iseq_callback, ¤t_iseq);
|
|
98
|
+
|
|
99
|
+
return current_iseq.iseq;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static VALUE Ruby_Coverage_Tracer_call_callback(VALUE data)
|
|
103
|
+
{
|
|
104
|
+
struct Ruby_Coverage_Tracer_CallbackArguments *arguments = (struct Ruby_Coverage_Tracer_CallbackArguments *)data;
|
|
105
|
+
|
|
106
|
+
return rb_funcall(arguments->tracer->callback, id_call, 2, arguments->path, arguments->iseq);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static VALUE Ruby_Coverage_Tracer_leave_callback(VALUE data)
|
|
110
|
+
{
|
|
111
|
+
struct Ruby_Coverage_Tracer_CallbackArguments *arguments = (struct Ruby_Coverage_Tracer_CallbackArguments *)data;
|
|
112
|
+
arguments->tracer->in_callback -= 1;
|
|
113
|
+
|
|
114
|
+
return Qnil;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
static VALUE Ruby_Coverage_Tracer_invoke_callback(struct Ruby_Coverage_Tracer *tracer, VALUE path, VALUE iseq)
|
|
118
|
+
{
|
|
119
|
+
struct Ruby_Coverage_Tracer_CallbackArguments arguments = {
|
|
120
|
+
.tracer = tracer,
|
|
121
|
+
.path = path,
|
|
122
|
+
.iseq = iseq,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
tracer->in_callback += 1;
|
|
126
|
+
|
|
127
|
+
return rb_ensure(
|
|
128
|
+
Ruby_Coverage_Tracer_call_callback,
|
|
129
|
+
(VALUE)&arguments,
|
|
130
|
+
Ruby_Coverage_Tracer_leave_callback,
|
|
131
|
+
(VALUE)&arguments
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static VALUE Ruby_Coverage_Tracer_allocate(VALUE klass)
|
|
136
|
+
{
|
|
137
|
+
struct Ruby_Coverage_Tracer *tracer;
|
|
138
|
+
VALUE self = TypedData_Make_Struct(klass, struct Ruby_Coverage_Tracer, &Ruby_Coverage_Tracer_type, tracer);
|
|
139
|
+
|
|
140
|
+
tracer->callback = Qnil;
|
|
141
|
+
tracer->counts = rb_hash_new();
|
|
142
|
+
tracer->last_path_pointer = 0;
|
|
143
|
+
tracer->last_counts = Qnil;
|
|
144
|
+
tracer->in_callback = 0;
|
|
145
|
+
tracer->script_compiled_tracepoint = Qnil;
|
|
146
|
+
|
|
147
|
+
return self;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static VALUE Ruby_Coverage_Tracer_initialize(VALUE self)
|
|
151
|
+
{
|
|
152
|
+
struct Ruby_Coverage_Tracer *tracer;
|
|
153
|
+
TypedData_Get_Struct(self, struct Ruby_Coverage_Tracer,
|
|
154
|
+
&Ruby_Coverage_Tracer_type, tracer);
|
|
155
|
+
|
|
156
|
+
RB_OBJ_WRITE(self, &tracer->callback, rb_block_proc());
|
|
157
|
+
|
|
158
|
+
return self;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Fires when any Ruby script is compiled and invokes the user callback
|
|
162
|
+
// immediately so the counts array is registered before the first line event.
|
|
163
|
+
//
|
|
164
|
+
// Also invalidates the rb_sourcefile() pointer cache so the line hook
|
|
165
|
+
// re-evaluates which counts array to use.
|
|
166
|
+
#ifdef HAVE_RB_TRACEARG_INSTRUCTION_SEQUENCE
|
|
167
|
+
// Installed via rb_add_event_hook2 with RUBY_EVENT_HOOK_FLAG_RAW_ARG.
|
|
168
|
+
// Uses the direct C API to extract the compiled instruction sequence.
|
|
169
|
+
static void Ruby_Coverage_Tracer_on_script_compiled(VALUE data, const rb_trace_arg_t *trace_arg)
|
|
170
|
+
{
|
|
171
|
+
struct Ruby_Coverage_Tracer *tracer;
|
|
172
|
+
TypedData_Get_Struct(data, struct Ruby_Coverage_Tracer,
|
|
173
|
+
&Ruby_Coverage_Tracer_type, tracer);
|
|
174
|
+
|
|
175
|
+
tracer->last_path_pointer = 0;
|
|
176
|
+
|
|
177
|
+
// Guard the entire body: rb_funcall (for #path and the user callback) can
|
|
178
|
+
// fire RUBY_EVENT_LINE, and without the guard on_line would run and
|
|
179
|
+
// potentially invoke the user callback before we've finished here.
|
|
180
|
+
if (tracer->in_callback) return;
|
|
181
|
+
|
|
182
|
+
VALUE iseq = rb_tracearg_instruction_sequence((rb_trace_arg_t *)trace_arg);
|
|
183
|
+
if (NIL_P(iseq)) { return; }
|
|
184
|
+
|
|
185
|
+
VALUE path = rb_funcall(iseq, id_path, 0);
|
|
186
|
+
if (NIL_P(path)) { return; }
|
|
187
|
+
|
|
188
|
+
// Reuse existing counts for re-evals of the same path so hit counts
|
|
189
|
+
// accumulate rather than reset.
|
|
190
|
+
if (!NIL_P(rb_hash_lookup(tracer->counts, path))) { return; }
|
|
191
|
+
|
|
192
|
+
VALUE counts = Ruby_Coverage_Tracer_invoke_callback(tracer, path, iseq);
|
|
193
|
+
|
|
194
|
+
if (!NIL_P(counts)) {
|
|
195
|
+
rb_hash_aset(tracer->counts, path, counts);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
#else
|
|
199
|
+
// Installed via rb_tracepoint_new.
|
|
200
|
+
// Uses the public TracePoint object API to extract the compiled instruction
|
|
201
|
+
// sequence when the direct C helper is not declared in ruby/debug.h.
|
|
202
|
+
static void Ruby_Coverage_Tracer_on_script_compiled(VALUE tpval, void *data)
|
|
203
|
+
{
|
|
204
|
+
struct Ruby_Coverage_Tracer *tracer = data;
|
|
205
|
+
|
|
206
|
+
tracer->last_path_pointer = 0;
|
|
207
|
+
|
|
208
|
+
if (tracer->in_callback) return;
|
|
209
|
+
|
|
210
|
+
VALUE iseq = rb_funcall(tpval, id_instruction_sequence, 0);
|
|
211
|
+
if (NIL_P(iseq)) { return; }
|
|
212
|
+
|
|
213
|
+
VALUE path = rb_funcall(iseq, id_path, 0);
|
|
214
|
+
if (NIL_P(path)) { return; }
|
|
215
|
+
|
|
216
|
+
if (!NIL_P(rb_hash_lookup(tracer->counts, path))) { return; }
|
|
217
|
+
|
|
218
|
+
VALUE counts = Ruby_Coverage_Tracer_invoke_callback(tracer, path, iseq);
|
|
219
|
+
|
|
220
|
+
if (!NIL_P(counts)) {
|
|
221
|
+
rb_hash_aset(tracer->counts, path, counts);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
#endif
|
|
225
|
+
|
|
226
|
+
// Installed via rb_add_event_hook. Fires on every new source line.
|
|
227
|
+
//
|
|
228
|
+
// Uses rb_sourcefile() pointer comparison as an O(1) same-file sentinel.
|
|
229
|
+
// On first entry to a new file, checks if the script_compiled hook already
|
|
230
|
+
// registered a counts array. If not (file was compiled before the tracer
|
|
231
|
+
// started), falls back to rb_profile_frames to get the iseq and invokes the
|
|
232
|
+
// user callback, matching the behaviour of the script_compiled path.
|
|
233
|
+
static void Ruby_Coverage_Tracer_on_line(rb_event_flag_t event, VALUE data, VALUE self, ID method_id, VALUE klass)
|
|
234
|
+
{
|
|
235
|
+
struct Ruby_Coverage_Tracer *tracer;
|
|
236
|
+
TypedData_Get_Struct(data, struct Ruby_Coverage_Tracer, &Ruby_Coverage_Tracer_type, tracer);
|
|
237
|
+
|
|
238
|
+
if (tracer->in_callback) return;
|
|
239
|
+
|
|
240
|
+
uintptr_t current_path_pointer = (uintptr_t)rb_sourcefile();
|
|
241
|
+
|
|
242
|
+
if (tracer->last_path_pointer != current_path_pointer) {
|
|
243
|
+
tracer->last_path_pointer = current_path_pointer;
|
|
244
|
+
|
|
245
|
+
const char *path_cstr = rb_sourcefile();
|
|
246
|
+
VALUE counts = Qnil;
|
|
247
|
+
|
|
248
|
+
if (path_cstr) {
|
|
249
|
+
VALUE path = rb_str_new_cstr(path_cstr);
|
|
250
|
+
counts = rb_hash_lookup(tracer->counts, path);
|
|
251
|
+
|
|
252
|
+
if (NIL_P(counts)) {
|
|
253
|
+
// File was compiled before the tracer started; inspect the current
|
|
254
|
+
// frame to recover the active instruction sequence.
|
|
255
|
+
VALUE iseq = Ruby_Coverage_Tracer_current_iseq();
|
|
256
|
+
|
|
257
|
+
if (!NIL_P(iseq)) {
|
|
258
|
+
counts = Ruby_Coverage_Tracer_invoke_callback(tracer, path, iseq);
|
|
259
|
+
|
|
260
|
+
if (!NIL_P(counts)) {
|
|
261
|
+
rb_hash_aset(tracer->counts, path, counts);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
RB_OBJ_WRITE(data, &tracer->last_counts, counts);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (NIL_P(tracer->last_counts)) return;
|
|
271
|
+
|
|
272
|
+
int line = rb_sourceline();
|
|
273
|
+
|
|
274
|
+
// Counts are 1-indexed: index 0 is unused (nil), index N is the hit count
|
|
275
|
+
// for source line N. Grow the array if necessary.
|
|
276
|
+
while (RARRAY_LEN(tracer->last_counts) <= line) {
|
|
277
|
+
rb_ary_push(tracer->last_counts, Qnil);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
VALUE current = rb_ary_entry(tracer->last_counts, line);
|
|
281
|
+
rb_ary_store(tracer->last_counts, line,
|
|
282
|
+
NIL_P(current) ? INT2FIX(1) : INT2FIX(FIX2INT(current) + 1));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
static VALUE Ruby_Coverage_Tracer_start(VALUE self)
|
|
286
|
+
{
|
|
287
|
+
struct Ruby_Coverage_Tracer *tracer;
|
|
288
|
+
TypedData_Get_Struct(self, struct Ruby_Coverage_Tracer, &Ruby_Coverage_Tracer_type, tracer);
|
|
289
|
+
|
|
290
|
+
#ifdef HAVE_RB_TRACEARG_INSTRUCTION_SEQUENCE
|
|
291
|
+
rb_add_event_hook2(
|
|
292
|
+
(rb_event_hook_func_t)Ruby_Coverage_Tracer_on_script_compiled,
|
|
293
|
+
RUBY_EVENT_SCRIPT_COMPILED,
|
|
294
|
+
self,
|
|
295
|
+
RUBY_EVENT_HOOK_FLAG_SAFE | RUBY_EVENT_HOOK_FLAG_RAW_ARG
|
|
296
|
+
);
|
|
297
|
+
#else
|
|
298
|
+
if (NIL_P(tracer->script_compiled_tracepoint)) {
|
|
299
|
+
RB_OBJ_WRITE(self, &tracer->script_compiled_tracepoint,
|
|
300
|
+
rb_tracepoint_new(Qnil, RUBY_EVENT_SCRIPT_COMPILED,
|
|
301
|
+
Ruby_Coverage_Tracer_on_script_compiled, tracer));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
rb_tracepoint_enable(tracer->script_compiled_tracepoint);
|
|
305
|
+
#endif
|
|
306
|
+
|
|
307
|
+
rb_add_event_hook(Ruby_Coverage_Tracer_on_line, RUBY_EVENT_LINE, self);
|
|
308
|
+
|
|
309
|
+
return self;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
static VALUE Ruby_Coverage_Tracer_stop(VALUE self)
|
|
313
|
+
{
|
|
314
|
+
struct Ruby_Coverage_Tracer *tracer;
|
|
315
|
+
TypedData_Get_Struct(self, struct Ruby_Coverage_Tracer, &Ruby_Coverage_Tracer_type, tracer);
|
|
316
|
+
|
|
317
|
+
#ifdef HAVE_RB_TRACEARG_INSTRUCTION_SEQUENCE
|
|
318
|
+
rb_remove_event_hook_with_data((rb_event_hook_func_t)Ruby_Coverage_Tracer_on_script_compiled, self);
|
|
319
|
+
#else
|
|
320
|
+
if (!NIL_P(tracer->script_compiled_tracepoint)) {
|
|
321
|
+
rb_tracepoint_disable(tracer->script_compiled_tracepoint);
|
|
322
|
+
}
|
|
323
|
+
#endif
|
|
324
|
+
|
|
325
|
+
rb_remove_event_hook_with_data(Ruby_Coverage_Tracer_on_line, self);
|
|
326
|
+
|
|
327
|
+
tracer->last_path_pointer = 0;
|
|
328
|
+
RB_OBJ_WRITE(self, &tracer->last_counts, Qnil);
|
|
329
|
+
|
|
330
|
+
return self;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
void Init_Ruby_Coverage_Tracer(VALUE Ruby_Coverage)
|
|
334
|
+
{
|
|
335
|
+
id_call = rb_intern("call");
|
|
336
|
+
id_instruction_sequence = rb_intern("instruction_sequence");
|
|
337
|
+
id_path = rb_intern("path");
|
|
338
|
+
|
|
339
|
+
VALUE Ruby_Coverage_Tracer = rb_define_class_under(Ruby_Coverage, "Tracer", rb_cObject);
|
|
340
|
+
|
|
341
|
+
rb_define_alloc_func(Ruby_Coverage_Tracer, Ruby_Coverage_Tracer_allocate);
|
|
342
|
+
rb_define_method(Ruby_Coverage_Tracer, "initialize", Ruby_Coverage_Tracer_initialize, 0);
|
|
343
|
+
rb_define_method(Ruby_Coverage_Tracer, "start", Ruby_Coverage_Tracer_start, 0);
|
|
344
|
+
rb_define_method(Ruby_Coverage_Tracer, "stop", Ruby_Coverage_Tracer_stop, 0);
|
|
345
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "coverage/version"
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
require "Ruby_Coverage"
|
|
10
|
+
rescue LoadError => error
|
|
11
|
+
warn "Could not load native coverage extension: #{error}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Ruby
|
|
15
|
+
# A reimplementation of Ruby's built-in `Coverage` module backed by
|
|
16
|
+
# `rb_add_event_hook(RUBY_EVENT_LINE)` rather than ISeq counters.
|
|
17
|
+
#
|
|
18
|
+
# The key behavioural difference: re-evaluating a file under the same path
|
|
19
|
+
# (e.g. via `module_eval`) accumulates hit counts rather than resetting
|
|
20
|
+
# them, because the counter store is owned here rather than inside the ISeq.
|
|
21
|
+
#
|
|
22
|
+
# The module-level API mirrors `::Coverage` closely enough that `covered`
|
|
23
|
+
# can multiplex between the two, preferring this implementation when
|
|
24
|
+
# available and falling back to `::Coverage` otherwise.
|
|
25
|
+
module Coverage
|
|
26
|
+
@tracer = nil
|
|
27
|
+
@files = {}
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Walk the instruction sequence to find which lines carry a
|
|
31
|
+
# RUBY_EVENT_LINE event — these are the executable lines.
|
|
32
|
+
#
|
|
33
|
+
# Lines without this event (comments, `else`, `end`, blank lines)
|
|
34
|
+
# remain nil in the counts array, matching the nil/0 distinction
|
|
35
|
+
# used by Ruby's built-in Coverage module.
|
|
36
|
+
#
|
|
37
|
+
# Recurses into child ISeqs (methods, blocks, lambdas) via
|
|
38
|
+
# `each_child` so that all executable lines across the entire
|
|
39
|
+
# compilation unit are collected.
|
|
40
|
+
#
|
|
41
|
+
# @parameter iseq [RubyVM::InstructionSequence]
|
|
42
|
+
# @returns [Array(Integer)] Sorted, deduplicated executable line numbers.
|
|
43
|
+
def executable_lines(iseq)
|
|
44
|
+
lines = []
|
|
45
|
+
current_line = nil
|
|
46
|
+
|
|
47
|
+
iseq.to_a[13].each do |element|
|
|
48
|
+
case element
|
|
49
|
+
when Integer then current_line = element
|
|
50
|
+
when :RUBY_EVENT_LINE then lines << current_line
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
iseq.each_child{|child| lines.concat(executable_lines(child))}
|
|
55
|
+
|
|
56
|
+
lines.sort!
|
|
57
|
+
lines.uniq!
|
|
58
|
+
lines
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Start coverage tracking.
|
|
62
|
+
#
|
|
63
|
+
# The callback receives (path, iseq) for each newly compiled file and
|
|
64
|
+
# must return a Ruby Array to use as the line-count store for that
|
|
65
|
+
# file, or nil to skip it. Executable lines are pre-initialised to 0;
|
|
66
|
+
# non-executable lines remain nil.
|
|
67
|
+
#
|
|
68
|
+
# Safe to call multiple times; subsequent calls are no-ops.
|
|
69
|
+
# Returns self.
|
|
70
|
+
def start
|
|
71
|
+
return self if @tracer
|
|
72
|
+
|
|
73
|
+
@files = {}
|
|
74
|
+
@tracer = Tracer.new do |path, iseq|
|
|
75
|
+
@files[path] ||= begin
|
|
76
|
+
counts = []
|
|
77
|
+
executable_lines(iseq).each{|line| counts[line] = 0}
|
|
78
|
+
counts
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
@tracer.start
|
|
82
|
+
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Whether coverage is currently being tracked.
|
|
87
|
+
#
|
|
88
|
+
# @returns [Boolean]
|
|
89
|
+
def running?
|
|
90
|
+
!@tracer.nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Return the current line-count data without stopping the tracer.
|
|
94
|
+
#
|
|
95
|
+
# The returned hash has the same shape as `::Coverage.peek_result`:
|
|
96
|
+
# { "/absolute/path.rb" => { lines: [nil, 0, 3, nil, ...] }, ... }
|
|
97
|
+
#
|
|
98
|
+
# @returns [Hash]
|
|
99
|
+
def peek_result
|
|
100
|
+
@files.transform_values{|counts| {lines: counts}}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Return coverage results, optionally stopping or clearing the tracer.
|
|
104
|
+
#
|
|
105
|
+
# @parameter stop [Boolean] Stop tracking after returning results
|
|
106
|
+
# (default: true).
|
|
107
|
+
# @parameter clear [Boolean] Clear accumulated data and restart without
|
|
108
|
+
# stopping (default: false).
|
|
109
|
+
# @returns [Hash] Same shape as {peek_result}.
|
|
110
|
+
def result(stop: true, clear: false)
|
|
111
|
+
result = peek_result
|
|
112
|
+
|
|
113
|
+
if stop
|
|
114
|
+
@tracer&.stop
|
|
115
|
+
@tracer = nil
|
|
116
|
+
@files = {}
|
|
117
|
+
elsif clear
|
|
118
|
+
@tracer&.stop
|
|
119
|
+
@files = {}
|
|
120
|
+
@tracer = Tracer.new do |path, iseq|
|
|
121
|
+
@files[path] ||= begin
|
|
122
|
+
counts = []
|
|
123
|
+
executable_lines(iseq).each{|line| counts[line] = 0}
|
|
124
|
+
counts
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
@tracer.start
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
result
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/license.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright, 2026, by Samuel Williams.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/readme.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Ruby::Coverage
|
|
2
|
+
|
|
3
|
+
A native reimplementation of Ruby's built-in `Coverage` module, backed by `rb_add_event_hook(RUBY_EVENT_LINE)` rather than ISeq counters.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/socketry/ruby-coverage/actions?workflow=Test)
|
|
6
|
+
|
|
7
|
+
## Motivation
|
|
8
|
+
|
|
9
|
+
Ruby's built-in `Coverage` module ties its counter store to the ISeq. When a file is re-evaluated under the same path — for example when a test framework loads a file into a fresh anonymous module via `module_eval` — Ruby allocates a fresh counter array and discards the previous one. Any coverage accumulated before the re-eval is lost.
|
|
10
|
+
|
|
11
|
+
`Ruby::Coverage` owns its own counter store. Re-evaluating a file under the same path simply continues incrementing the same counters. The `covered` gem can optionally use `Ruby::Coverage` in place of `::Coverage` to get correct results in test suites that load files multiple times.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Please see the [project documentation](https://socketry.github.io/ruby-coverage/) for more details.
|
|
16
|
+
|
|
17
|
+
- [Getting Started](https://socketry.github.io/ruby-coverage/guides/getting-started/index) - This guide explains how to use `ruby-coverage` to collect coverage for code that may be evaluated multiple times under the same path.
|
|
18
|
+
|
|
19
|
+
### Drop-in module API
|
|
20
|
+
|
|
21
|
+
``` ruby
|
|
22
|
+
require "ruby/coverage"
|
|
23
|
+
|
|
24
|
+
Ruby::Coverage.start
|
|
25
|
+
|
|
26
|
+
# ... run code ...
|
|
27
|
+
|
|
28
|
+
result = Ruby::Coverage.result
|
|
29
|
+
# => { "/path/to/file.rb" => { lines: [nil, 3, 1, nil, 0, ...] }, ... }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Low-level `Tracer`
|
|
33
|
+
|
|
34
|
+
`Ruby::Coverage::Tracer` is the primitive that the module API is built on. It accepts a block that is called once each time execution enters a new file. The block receives the absolute path and must return a Ruby `Array` to use as the line-count store for that file, or `nil` to skip tracking it.
|
|
35
|
+
|
|
36
|
+
The block controls the caching strategy: returning the same `Array` for the same path accumulates counts across re-evals; returning a fresh `Array` each time gives per-ISeq isolation.
|
|
37
|
+
|
|
38
|
+
``` ruby
|
|
39
|
+
files = {}
|
|
40
|
+
|
|
41
|
+
tracer = Ruby::Coverage::Tracer.new do |path|
|
|
42
|
+
# Accumulate across re-evals of the same path:
|
|
43
|
+
files[path] ||= []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
tracer.start
|
|
47
|
+
# ... run tests ...
|
|
48
|
+
tracer.stop
|
|
49
|
+
|
|
50
|
+
# files is now populated with line-count arrays keyed by path.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Integration with `covered`
|
|
54
|
+
|
|
55
|
+
`covered` multiplexes between `Ruby::Coverage` and `::Coverage` internally. Add `ruby-coverage` to your `Gemfile` and `covered` will prefer it automatically.
|
|
56
|
+
|
|
57
|
+
``` ruby
|
|
58
|
+
# gems.rb / Gemfile
|
|
59
|
+
gem "ruby-coverage"
|
|
60
|
+
gem "covered"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Releases
|
|
64
|
+
|
|
65
|
+
Please see the [project releases](https://socketry.github.io/ruby-coverage/releases/index) for all releases.
|
|
66
|
+
|
|
67
|
+
### v0.0.1
|
|
68
|
+
|
|
69
|
+
## See Also
|
|
70
|
+
|
|
71
|
+
- [covered](https://github.com/socketry/covered) — the coverage reporting gem that uses this library.
|
data/releases.md
ADDED
data.tar.gz.sig
ADDED
|
Binary file
|
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby-coverage
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Samuel Williams
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain:
|
|
10
|
+
- |
|
|
11
|
+
-----BEGIN CERTIFICATE-----
|
|
12
|
+
MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11
|
|
13
|
+
ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK
|
|
14
|
+
CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz
|
|
15
|
+
MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd
|
|
16
|
+
MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj
|
|
17
|
+
bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB
|
|
18
|
+
igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2
|
|
19
|
+
9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW
|
|
20
|
+
sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE
|
|
21
|
+
e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN
|
|
22
|
+
XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss
|
|
23
|
+
RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn
|
|
24
|
+
tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM
|
|
25
|
+
zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW
|
|
26
|
+
xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O
|
|
27
|
+
BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs
|
|
28
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs
|
|
29
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE
|
|
30
|
+
cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl
|
|
31
|
+
xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/
|
|
32
|
+
c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp
|
|
33
|
+
8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws
|
|
34
|
+
JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP
|
|
35
|
+
eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt
|
|
36
|
+
Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
|
|
37
|
+
voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
|
|
38
|
+
-----END CERTIFICATE-----
|
|
39
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
40
|
+
dependencies: []
|
|
41
|
+
executables: []
|
|
42
|
+
extensions:
|
|
43
|
+
- ext/extconf.rb
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- ext/extconf.rb
|
|
47
|
+
- ext/ruby/coverage/coverage.c
|
|
48
|
+
- ext/ruby/coverage/coverage.h
|
|
49
|
+
- ext/ruby/coverage/tracer.c
|
|
50
|
+
- ext/ruby/coverage/tracer.h
|
|
51
|
+
- lib/ruby/coverage.rb
|
|
52
|
+
- lib/ruby/coverage/version.rb
|
|
53
|
+
- license.md
|
|
54
|
+
- readme.md
|
|
55
|
+
- releases.md
|
|
56
|
+
homepage: https://github.com/socketry/ruby-coverage
|
|
57
|
+
licenses:
|
|
58
|
+
- MIT
|
|
59
|
+
metadata:
|
|
60
|
+
documentation_uri: https://socketry.github.io/ruby-coverage/
|
|
61
|
+
source_code_uri: https://github.com/socketry/ruby-coverage.git
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '3.3'
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 4.0.6
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: A native reimplementation of Ruby's Coverage module with accumulating line
|
|
79
|
+
counts.
|
|
80
|
+
test_files: []
|
metadata.gz.sig
ADDED
|
Binary file
|