teek 0.1.0 → 0.1.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 +4 -4
- data/README.md +53 -15
- data/Rakefile +44 -1
- data/ext/teek/tcltkbridge.c +0 -110
- data/lib/teek/background_ractor4x.rb +4 -3
- data/lib/teek/debugger.rb +37 -32
- data/lib/teek/method_coverage_service.rb +265 -0
- data/lib/teek/ractor_support.rb +1 -1
- data/lib/teek/version.rb +1 -1
- data/lib/teek/widget.rb +104 -0
- data/lib/teek.rb +141 -0
- data/sample/calculator.rb +16 -21
- data/sample/debug_demo.rb +20 -22
- data/sample/threading_demo.rb +127 -132
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bbf5c504422362129bd97b61be21e564e0c4fc4eddced045f97320a14d77d999
|
|
4
|
+
data.tar.gz: 2a2d74b99b040fe345f67ae83f52b7f759b24c5a2b86a644f3673d0539676023
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a380c5190906c2537aaca29e7da5c6def0abb727db6ca1137ace00f2fd9f85876753b192cd1ff327108a62123b25c8db2db20dbe98c01ca82dbb8a2de11333d
|
|
7
|
+
data.tar.gz: c6ed01ff47f5edf05407b3e60bd1108a94461988053e013becaadbed0fa9697990e92e28ceed55341299d7f42a11dcecdc21b8b803cef86ff50538efe6fcee1b
|
data/README.md
CHANGED
|
@@ -12,14 +12,13 @@ require 'teek'
|
|
|
12
12
|
app = Teek::App.new
|
|
13
13
|
|
|
14
14
|
app.show
|
|
15
|
-
app.
|
|
15
|
+
app.set_window_title('Hello Teek')
|
|
16
16
|
|
|
17
|
-
# Create widgets with
|
|
18
|
-
app.tcl_eval('ttk::label .lbl -text "Hello, world!"')
|
|
19
|
-
app.tcl_eval('pack .lbl -pady 10')
|
|
20
|
-
|
|
21
|
-
# Or use the command helper — Ruby values are auto-quoted,
|
|
17
|
+
# Create widgets with the command helper — Ruby values are auto-quoted,
|
|
22
18
|
# symbols pass through bare, and procs become callbacks
|
|
19
|
+
app.command('ttk::label', '.lbl', text: 'Hello, world!')
|
|
20
|
+
app.command(:pack, '.lbl', pady: 10)
|
|
21
|
+
|
|
23
22
|
app.command('ttk::button', '.btn', text: 'Click me', command: proc {
|
|
24
23
|
app.command('.lbl', :configure, text: 'Clicked!')
|
|
25
24
|
})
|
|
@@ -28,17 +27,57 @@ app.command(:pack, '.btn', pady: 10)
|
|
|
28
27
|
app.mainloop
|
|
29
28
|
```
|
|
30
29
|
|
|
30
|
+
## Widgets
|
|
31
|
+
|
|
32
|
+
`create_widget` returns a `Teek::Widget` — a thin wrapper that holds the widget path and provides convenience methods. Paths are auto-generated from the widget type.
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
btn = app.create_widget('ttk::button', text: 'Click me')
|
|
36
|
+
btn.pack(pady: 10)
|
|
37
|
+
|
|
38
|
+
btn.command(:configure, text: 'Updated') # widget subcommand
|
|
39
|
+
btn.destroy
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Nest widgets under a parent:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
frame = app.create_widget('ttk::frame')
|
|
46
|
+
frame.pack(fill: :both, expand: 1)
|
|
47
|
+
|
|
48
|
+
label = app.create_widget('ttk::label', parent: frame, text: 'Hello')
|
|
49
|
+
label.pack(pady: 5)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Widgets work anywhere a path string is expected (via `to_s`):
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
app.command(:pack, btn, pady: 10) # equivalent to btn.pack(pady: 10)
|
|
56
|
+
app.tcl_eval("#{btn} configure -text New") # string interpolation works
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The raw `app.command` approach still works for cases where you don't need a wrapper:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
app.command('ttk::label', '.mylabel', text: 'Direct')
|
|
63
|
+
app.command(:pack, '.mylabel')
|
|
64
|
+
```
|
|
65
|
+
|
|
31
66
|
## Callbacks
|
|
32
67
|
|
|
33
|
-
|
|
68
|
+
Pass a `proc` to `command` and it becomes a Tcl callback automatically:
|
|
34
69
|
|
|
35
70
|
```ruby
|
|
36
71
|
app = Teek::App.new
|
|
37
72
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
73
|
+
app.command(:button, '.b', text: 'Click', command: proc { puts "clicked!" })
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use `bind` for event bindings with optional substitutions:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
app.bind('.b', 'Enter') { puts "hovered" }
|
|
80
|
+
app.bind('.c', 'Button-1', :x, :y) { |x, y| puts "#{x},#{y}" }
|
|
42
81
|
```
|
|
43
82
|
|
|
44
83
|
### Stopping event propagation
|
|
@@ -46,11 +85,10 @@ app.tcl_eval("button .b -text Click -command {ruby_callback #{cb}}")
|
|
|
46
85
|
In `bind` handlers, you can stop an event from propagating to subsequent binding tags by throwing `:teek_break`:
|
|
47
86
|
|
|
48
87
|
```ruby
|
|
49
|
-
|
|
50
|
-
puts "handled - stop here"
|
|
88
|
+
app.bind('.entry', 'KeyPress', :keysym) { |key|
|
|
89
|
+
puts "handled #{key} - stop here"
|
|
51
90
|
throw :teek_break
|
|
52
|
-
}
|
|
53
|
-
app.tcl_eval("bind .entry <Key-Return> {ruby_callback #{cb}}")
|
|
91
|
+
}
|
|
54
92
|
```
|
|
55
93
|
|
|
56
94
|
This is equivalent to Tcl's `break` command in a bind script.
|
data/Rakefile
CHANGED
|
@@ -26,8 +26,18 @@ namespace :docs do
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
desc "Generate per-method coverage JSON from SimpleCov data"
|
|
30
|
+
task :method_coverage do
|
|
31
|
+
if Dir.exist?('coverage/results')
|
|
32
|
+
require_relative 'lib/teek/method_coverage_service'
|
|
33
|
+
Teek::MethodCoverageService.new(coverage_dir: 'coverage').call
|
|
34
|
+
else
|
|
35
|
+
puts "No coverage data found (run tests with COVERAGE=1 first)"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
desc "Generate API docs (YARD JSON -> HTML)"
|
|
30
|
-
task yard: :yard_json do
|
|
40
|
+
task yard: [:yard_json, :method_coverage] do
|
|
31
41
|
Bundler.with_unbundled_env do
|
|
32
42
|
sh 'BUNDLE_GEMFILE=docs_site/Gemfile bundle exec ruby docs_site/build_api_docs.rb'
|
|
33
43
|
end
|
|
@@ -261,6 +271,39 @@ namespace :docker do
|
|
|
261
271
|
|
|
262
272
|
Rake::Task['docker:test'].enhance { Rake::Task['docker:prune'].invoke }
|
|
263
273
|
|
|
274
|
+
namespace :test do
|
|
275
|
+
desc "Run tests with coverage and generate report"
|
|
276
|
+
task coverage: 'docker:build' do
|
|
277
|
+
tcl_version = tcl_version_from_env
|
|
278
|
+
ruby_version = ruby_version_from_env
|
|
279
|
+
image_name = docker_image_name(tcl_version, ruby_version)
|
|
280
|
+
|
|
281
|
+
require 'fileutils'
|
|
282
|
+
FileUtils.rm_rf('coverage')
|
|
283
|
+
FileUtils.mkdir_p('coverage/results')
|
|
284
|
+
|
|
285
|
+
# Run tests with coverage enabled
|
|
286
|
+
ENV['COVERAGE'] = '1'
|
|
287
|
+
ENV['COVERAGE_NAME'] ||= 'main'
|
|
288
|
+
Rake::Task['docker:test'].invoke
|
|
289
|
+
|
|
290
|
+
# Collate inside Docker (paths match /app/lib/...)
|
|
291
|
+
puts "Collating coverage results..."
|
|
292
|
+
cmd = "docker run --rm --init"
|
|
293
|
+
cmd += " -v #{Dir.pwd}/coverage:/app/coverage"
|
|
294
|
+
cmd += " #{image_name}"
|
|
295
|
+
cmd += " bundle exec rake coverage:collate"
|
|
296
|
+
|
|
297
|
+
sh cmd
|
|
298
|
+
|
|
299
|
+
# Generate per-method coverage (runs locally, just needs Prism)
|
|
300
|
+
puts "Generating per-method coverage..."
|
|
301
|
+
Rake::Task['docs:method_coverage'].invoke
|
|
302
|
+
|
|
303
|
+
puts "Coverage report: coverage/index.html"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
264
307
|
# Scan sample files for # teek-record magic comment
|
|
265
308
|
# Format: # teek-record: title=My Demo, codec=vp9
|
|
266
309
|
def find_recordable_samples
|
data/ext/teek/tcltkbridge.c
CHANGED
|
@@ -130,9 +130,6 @@ static void interp_deleted_callback(ClientData, Tcl_Interp *);
|
|
|
130
130
|
/* 16ms ≈ 60fps - balances UI responsiveness with scheduler contention */
|
|
131
131
|
#define DEFAULT_TIMER_INTERVAL_MS 16
|
|
132
132
|
|
|
133
|
-
/* Global timer interval for TclTkLib.mainloop (mutable) */
|
|
134
|
-
static int g_thread_timer_ms = DEFAULT_TIMER_INTERVAL_MS;
|
|
135
|
-
|
|
136
133
|
/* struct tcltk_interp is defined in tcltkbridge.h */
|
|
137
134
|
|
|
138
135
|
/* ---------------------------------------------------------
|
|
@@ -1066,109 +1063,6 @@ interp_mainloop(VALUE self)
|
|
|
1066
1063
|
* yields between events).
|
|
1067
1064
|
* --------------------------------------------------------- */
|
|
1068
1065
|
|
|
1069
|
-
/* Global timer handler - re-registers itself using global interval */
|
|
1070
|
-
static void
|
|
1071
|
-
global_keepalive_timer_proc(ClientData clientData)
|
|
1072
|
-
{
|
|
1073
|
-
if (g_thread_timer_ms > 0) {
|
|
1074
|
-
Tcl_CreateTimerHandler(g_thread_timer_ms, global_keepalive_timer_proc, NULL);
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
static VALUE
|
|
1079
|
-
lib_mainloop(int argc, VALUE *argv, VALUE self)
|
|
1080
|
-
{
|
|
1081
|
-
int check_root = 1; /* default: exit when no windows remain */
|
|
1082
|
-
int event_flags = TCL_ALL_EVENTS;
|
|
1083
|
-
|
|
1084
|
-
/* Optional check_root argument:
|
|
1085
|
-
* true (default): exit when Tk_GetNumMainWindows() == 0
|
|
1086
|
-
* false: keep running even with no windows (for timers, traces, etc.)
|
|
1087
|
-
*/
|
|
1088
|
-
if (argc > 0 && argv[0] != Qnil) {
|
|
1089
|
-
check_root = RTEST(argv[0]);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
for (;;) {
|
|
1093
|
-
/* Exit if check_root enabled and no windows remain */
|
|
1094
|
-
if (check_root && Tk_GetNumMainWindows() <= 0) {
|
|
1095
|
-
break;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
if (rb_thread_alone()) {
|
|
1099
|
-
/* No other threads - simple blocking wait */
|
|
1100
|
-
Tcl_DoOneEvent(event_flags);
|
|
1101
|
-
} else {
|
|
1102
|
-
/* Other threads exist - use polling with brief sleep.
|
|
1103
|
-
*
|
|
1104
|
-
* We tried rb_thread_call_without_gvl() with Tcl_ThreadAlert to
|
|
1105
|
-
* efficiently release GVL during blocking waits, but it proved
|
|
1106
|
-
* unstable - crashes in Digest and other C extensions, UI freezes,
|
|
1107
|
-
* and unreliable notifier wakeup across platforms.
|
|
1108
|
-
*
|
|
1109
|
-
* This polling approach is simple and stable:
|
|
1110
|
-
* - Process any pending events without blocking
|
|
1111
|
-
* - If no events, brief sleep to avoid spinning (uses ~1-3% CPU idle)
|
|
1112
|
-
* - rb_thread_schedule() lets background threads run during sleep
|
|
1113
|
-
*/
|
|
1114
|
-
int had_event = Tcl_DoOneEvent(event_flags | TCL_DONT_WAIT);
|
|
1115
|
-
if (!had_event) {
|
|
1116
|
-
rb_thread_schedule();
|
|
1117
|
-
#ifdef _WIN32
|
|
1118
|
-
Sleep(5); /* 5ms */
|
|
1119
|
-
#else
|
|
1120
|
-
struct timespec ts = {0, 5000000}; /* 5ms */
|
|
1121
|
-
nanosleep(&ts, NULL);
|
|
1122
|
-
#endif
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
/* Check for Ruby interrupts (Ctrl-C, etc) */
|
|
1127
|
-
rb_thread_check_ints();
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
return Qnil;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
static VALUE
|
|
1134
|
-
lib_get_thread_timer_ms(VALUE self)
|
|
1135
|
-
{
|
|
1136
|
-
return INT2NUM(g_thread_timer_ms);
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
static VALUE
|
|
1140
|
-
lib_set_thread_timer_ms(VALUE self, VALUE val)
|
|
1141
|
-
{
|
|
1142
|
-
int ms = NUM2INT(val);
|
|
1143
|
-
if (ms < 0) {
|
|
1144
|
-
rb_raise(rb_eArgError, "thread_timer_ms must be >= 0 (got %d)", ms);
|
|
1145
|
-
}
|
|
1146
|
-
g_thread_timer_ms = ms;
|
|
1147
|
-
return val;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
/* ---------------------------------------------------------
|
|
1151
|
-
* TclTkLib.do_one_event(flags = ALL_EVENTS) - Process single event
|
|
1152
|
-
*
|
|
1153
|
-
* Global function - Tcl_DoOneEvent doesn't require an interpreter.
|
|
1154
|
-
* Returns true if event was processed, false if nothing to do.
|
|
1155
|
-
* --------------------------------------------------------- */
|
|
1156
|
-
|
|
1157
|
-
static VALUE
|
|
1158
|
-
lib_do_one_event(int argc, VALUE *argv, VALUE self)
|
|
1159
|
-
{
|
|
1160
|
-
int flags = TCL_ALL_EVENTS;
|
|
1161
|
-
int result;
|
|
1162
|
-
|
|
1163
|
-
if (argc > 0) {
|
|
1164
|
-
flags = NUM2INT(argv[0]);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
result = Tcl_DoOneEvent(flags);
|
|
1168
|
-
|
|
1169
|
-
return result ? Qtrue : Qfalse;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
1066
|
/* ---------------------------------------------------------
|
|
1173
1067
|
* Interp#thread_timer_ms / #thread_timer_ms= - Get/set timer interval
|
|
1174
1068
|
* --------------------------------------------------------- */
|
|
@@ -1539,10 +1433,6 @@ Init_tcltklib(void)
|
|
|
1539
1433
|
rb_define_module_function(mTeek, "split_list", teek_split_list, 1);
|
|
1540
1434
|
rb_define_module_function(mTeek, "tcl_to_bool", teek_tcl_to_bool, 1);
|
|
1541
1435
|
|
|
1542
|
-
/* Global thread timer - doesn't require an interpreter */
|
|
1543
|
-
rb_define_module_function(mTeek, "thread_timer_ms", lib_get_thread_timer_ms, 0);
|
|
1544
|
-
rb_define_module_function(mTeek, "thread_timer_ms=", lib_set_thread_timer_ms, 1);
|
|
1545
|
-
|
|
1546
1436
|
/* Callback depth detection for unsafe operation warnings */
|
|
1547
1437
|
rb_define_module_function(mTeek, "in_callback?", lib_in_callback_p, 0);
|
|
1548
1438
|
|
|
@@ -153,13 +153,14 @@ module Teek
|
|
|
153
153
|
# @return [self]
|
|
154
154
|
def close
|
|
155
155
|
@done = true
|
|
156
|
-
|
|
156
|
+
# Send stop to let the worker terminate itself — Ruby 4.x doesn't
|
|
157
|
+
# allow closing a Ractor from outside.
|
|
157
158
|
begin
|
|
158
|
-
@
|
|
159
|
-
@worker_ractor&.close_outgoing
|
|
159
|
+
@control_port&.send(:stop)
|
|
160
160
|
rescue Ractor::ClosedError
|
|
161
161
|
# Already closed
|
|
162
162
|
end
|
|
163
|
+
@control_port = nil
|
|
163
164
|
self
|
|
164
165
|
end
|
|
165
166
|
|
data/lib/teek/debugger.rb
CHANGED
|
@@ -75,6 +75,43 @@ module Teek
|
|
|
75
75
|
$stderr.puts "teek debugger: on_widget_destroyed(#{path}): #{e.message}"
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Add a variable watch by name. Registers a Tcl trace so changes are recorded.
|
|
79
|
+
def add_watch(name)
|
|
80
|
+
return if @watches.key?(name)
|
|
81
|
+
|
|
82
|
+
cb_id = @app.register_callback(proc { |var_name, index, *|
|
|
83
|
+
record_watch(var_name, index)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
@app.tcl_eval("trace add variable #{Teek.make_list(name)} write {ruby_callback #{cb_id}}")
|
|
87
|
+
|
|
88
|
+
@watches[name] = { cb_id: cb_id, values: [] }
|
|
89
|
+
record_watch(name, nil)
|
|
90
|
+
update_watches_ui
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Remove a variable watch by name.
|
|
94
|
+
def remove_watch(name)
|
|
95
|
+
info = @watches.delete(name)
|
|
96
|
+
return unless info
|
|
97
|
+
|
|
98
|
+
@app.tcl_eval(
|
|
99
|
+
"trace remove variable #{Teek.make_list(name)} write {ruby_callback #{info[:cb_id]}}"
|
|
100
|
+
)
|
|
101
|
+
@app.unregister_callback(info[:cb_id])
|
|
102
|
+
|
|
103
|
+
# Remove the tree item
|
|
104
|
+
watch_tree = "#{NB}.watches.tree"
|
|
105
|
+
item_id = "watch_#{name}"
|
|
106
|
+
if @app.command(watch_tree, 'exists', item_id) == "1"
|
|
107
|
+
@app.command(watch_tree, 'delete', item_id)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
update_watches_ui
|
|
111
|
+
rescue Teek::TclError => e
|
|
112
|
+
$stderr.puts "teek debugger: remove_watch(#{name}): #{e.message}"
|
|
113
|
+
end
|
|
114
|
+
|
|
78
115
|
private
|
|
79
116
|
|
|
80
117
|
def setup_ui
|
|
@@ -507,38 +544,6 @@ module Teek
|
|
|
507
544
|
})
|
|
508
545
|
end
|
|
509
546
|
|
|
510
|
-
def add_watch(name)
|
|
511
|
-
return if @watches.key?(name)
|
|
512
|
-
|
|
513
|
-
# Register Tcl trace on the variable
|
|
514
|
-
cb_id = @app.register_callback(proc { |var_name, index, *|
|
|
515
|
-
record_watch(var_name, index)
|
|
516
|
-
})
|
|
517
|
-
|
|
518
|
-
@app.tcl_eval("trace add variable #{Teek.make_list(name)} write {ruby_callback #{cb_id}}")
|
|
519
|
-
|
|
520
|
-
@watches[name] = { cb_id: cb_id, values: [] }
|
|
521
|
-
|
|
522
|
-
# Capture current value
|
|
523
|
-
record_watch(name, nil)
|
|
524
|
-
|
|
525
|
-
update_watches_ui
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
def remove_watch(name)
|
|
529
|
-
info = @watches.delete(name)
|
|
530
|
-
return unless info
|
|
531
|
-
|
|
532
|
-
# Remove Tcl trace
|
|
533
|
-
@app.tcl_eval(
|
|
534
|
-
"trace remove variable #{Teek.make_list(name)} write {ruby_callback #{info[:cb_id]}}"
|
|
535
|
-
)
|
|
536
|
-
@app.unregister_callback(info[:cb_id])
|
|
537
|
-
|
|
538
|
-
update_watches_ui
|
|
539
|
-
rescue Teek::TclError => e
|
|
540
|
-
$stderr.puts "teek debugger: remove_watch(#{name}): #{e.message}"
|
|
541
|
-
end
|
|
542
547
|
|
|
543
548
|
def record_watch(name, index)
|
|
544
549
|
watch = @watches[name]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Teek
|
|
7
|
+
# @api private
|
|
8
|
+
# Transforms SimpleCov line coverage data into per-method coverage.
|
|
9
|
+
#
|
|
10
|
+
# Merges coverage data from all test suite result files, uses Prism to
|
|
11
|
+
# parse Ruby source files and find method definitions, then maps
|
|
12
|
+
# SimpleCov line coverage to calculate per-method percentages.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# service = MethodCoverageService.new(
|
|
16
|
+
# coverage_dir: "coverage",
|
|
17
|
+
# source_dirs: ["lib"]
|
|
18
|
+
# )
|
|
19
|
+
# service.call
|
|
20
|
+
# # => writes coverage/method_coverage.json
|
|
21
|
+
#
|
|
22
|
+
class MethodCoverageService
|
|
23
|
+
attr_reader :coverage_dir, :source_dirs, :output_path
|
|
24
|
+
|
|
25
|
+
def initialize(coverage_dir:, source_dirs: ["lib"], output_path: nil)
|
|
26
|
+
@coverage_dir = coverage_dir
|
|
27
|
+
@source_dirs = source_dirs
|
|
28
|
+
@output_path = output_path || File.join(coverage_dir, "method_coverage.json")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
coverage_files = Dir.glob(File.join(coverage_dir, "results", "*", "coverage.json"))
|
|
33
|
+
resultset_files = Dir.glob(File.join(coverage_dir, "results", "*", ".resultset.json"))
|
|
34
|
+
if coverage_files.empty? && resultset_files.empty?
|
|
35
|
+
warn "No coverage files found in #{coverage_dir}/results/"
|
|
36
|
+
return nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
coverage_data = load_and_merge_coverage(coverage_files)
|
|
40
|
+
result = {}
|
|
41
|
+
|
|
42
|
+
# Collect all methods from all files
|
|
43
|
+
all_methods = []
|
|
44
|
+
source_files.each do |file|
|
|
45
|
+
all_methods.concat(extract_methods(file))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Group by class path and calculate coverage
|
|
49
|
+
all_methods.group_by { |m| m[:class_path] }.each do |class_path, methods|
|
|
50
|
+
class_result = { "class_methods" => {}, "instance_methods" => {} }
|
|
51
|
+
total_covered = 0
|
|
52
|
+
total_relevant = 0
|
|
53
|
+
|
|
54
|
+
methods.each do |method|
|
|
55
|
+
file_coverage = coverage_data[method[:file]]
|
|
56
|
+
next unless file_coverage
|
|
57
|
+
|
|
58
|
+
cov = calculate_coverage(file_coverage, method[:start_line], method[:end_line])
|
|
59
|
+
next unless cov
|
|
60
|
+
|
|
61
|
+
# Store [percent, lines_string] - compact format
|
|
62
|
+
method_data = [cov[:percent], cov[:lines]]
|
|
63
|
+
if method[:scope] == :class
|
|
64
|
+
class_result["class_methods"][method[:name]] = method_data
|
|
65
|
+
else
|
|
66
|
+
class_result["instance_methods"][method[:name]] = method_data
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
total_covered += cov[:covered]
|
|
70
|
+
total_relevant += cov[:relevant]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
next if class_result["class_methods"].empty? && class_result["instance_methods"].empty?
|
|
74
|
+
|
|
75
|
+
if total_relevant > 0
|
|
76
|
+
class_result["total"] = (total_covered.to_f / total_relevant * 100).round(1)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
result[class_path] = class_result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
File.write(output_path, JSON.pretty_generate(result))
|
|
83
|
+
puts "Generated method coverage: #{output_path} (#{result.size} classes/modules)"
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def load_and_merge_coverage(coverage_files)
|
|
90
|
+
merged = {}
|
|
91
|
+
|
|
92
|
+
# Read from coverage.json files
|
|
93
|
+
coverage_files.each do |file|
|
|
94
|
+
data = JSON.parse(File.read(file))
|
|
95
|
+
merge_coverage_data(merged, data["coverage"])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Also read from .resultset.json files (worker/subprocess results)
|
|
99
|
+
resultset_files = Dir.glob(File.join(coverage_dir, "results", "*", ".resultset.json"))
|
|
100
|
+
resultset_files.each do |file|
|
|
101
|
+
data = JSON.parse(File.read(file))
|
|
102
|
+
# Resultsets are nested: { "suite_name" => { "coverage" => { ... } } }
|
|
103
|
+
data.each do |_suite_name, suite_data|
|
|
104
|
+
next unless suite_data.is_a?(Hash) && suite_data["coverage"]
|
|
105
|
+
merge_coverage_data(merged, suite_data["coverage"])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
merged
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def merge_coverage_data(merged, coverage_hash)
|
|
113
|
+
coverage_hash.each do |path, info|
|
|
114
|
+
# Normalize Docker /app/... paths to local paths
|
|
115
|
+
local_path = path.sub(%r{^/app/}, "")
|
|
116
|
+
lines = info["lines"]
|
|
117
|
+
if merged[local_path]
|
|
118
|
+
merged[local_path] = merge_line_coverage(merged[local_path], lines)
|
|
119
|
+
else
|
|
120
|
+
merged[local_path] = lines
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def merge_line_coverage(lines_a, lines_b)
|
|
126
|
+
max_len = [lines_a.size, lines_b.size].max
|
|
127
|
+
(0...max_len).map do |i|
|
|
128
|
+
a = lines_a[i]
|
|
129
|
+
b = lines_b[i]
|
|
130
|
+
# nil means not relevant (comments, blank lines), "ignored" also not relevant
|
|
131
|
+
# If EITHER run says nil, treat as not relevant - a comment can't become executable
|
|
132
|
+
# This handles cases where different test suites have slightly different coverage metadata
|
|
133
|
+
if a.nil? || a == "ignored" || b.nil? || b == "ignored"
|
|
134
|
+
# Both nil -> nil, one nil -> nil (trust the nil, it means non-executable)
|
|
135
|
+
# Unless both are numeric, in which case take max
|
|
136
|
+
if (a.nil? || a == "ignored") && (b.nil? || b == "ignored")
|
|
137
|
+
nil
|
|
138
|
+
elsif a.nil? || a == "ignored"
|
|
139
|
+
# a is nil, b is numeric - but nil means not relevant, so prefer nil
|
|
140
|
+
# unless b > 0 (was actually executed, so must be real code)
|
|
141
|
+
b.to_i > 0 ? b : nil
|
|
142
|
+
else
|
|
143
|
+
# b is nil, a is numeric
|
|
144
|
+
a.to_i > 0 ? a : nil
|
|
145
|
+
end
|
|
146
|
+
else
|
|
147
|
+
[a.to_i, b.to_i].max
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def source_files
|
|
153
|
+
source_dirs.flat_map { |dir| Dir.glob("#{dir}/**/*.rb") }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def extract_methods(file)
|
|
157
|
+
source = File.read(file)
|
|
158
|
+
result = Prism.parse(source)
|
|
159
|
+
methods = []
|
|
160
|
+
|
|
161
|
+
visitor = MethodVisitor.new(methods, file)
|
|
162
|
+
result.value.accept(visitor)
|
|
163
|
+
|
|
164
|
+
methods
|
|
165
|
+
rescue => e
|
|
166
|
+
warn "Failed to parse #{file}: #{e.message}"
|
|
167
|
+
[]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def calculate_coverage(file_lines, start_line, end_line)
|
|
171
|
+
relevant = 0
|
|
172
|
+
covered = 0
|
|
173
|
+
lines_str = +"" # unfrozen string
|
|
174
|
+
|
|
175
|
+
# Skip first line (def) and last line (end) - only count method body
|
|
176
|
+
body_start = start_line + 1
|
|
177
|
+
body_end = end_line - 1
|
|
178
|
+
|
|
179
|
+
return nil if body_start > body_end # empty method body
|
|
180
|
+
|
|
181
|
+
(body_start..body_end).each do |line_num|
|
|
182
|
+
next if line_num < 1 || line_num > file_lines.size
|
|
183
|
+
line_cov = file_lines[line_num - 1] # array is 0-indexed
|
|
184
|
+
if line_cov.nil? || line_cov == "ignored"
|
|
185
|
+
lines_str << "-" # not relevant
|
|
186
|
+
elsif line_cov.to_i > 0
|
|
187
|
+
lines_str << "1" # covered
|
|
188
|
+
relevant += 1
|
|
189
|
+
covered += 1
|
|
190
|
+
else
|
|
191
|
+
lines_str << "0" # not covered
|
|
192
|
+
relevant += 1
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
return nil if relevant == 0
|
|
197
|
+
{ covered: covered, relevant: relevant, percent: (covered.to_f / relevant * 100).round(1), lines: lines_str }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Prism AST visitor to extract method definitions with class context
|
|
201
|
+
class MethodVisitor < Prism::Visitor
|
|
202
|
+
def initialize(methods, file)
|
|
203
|
+
@methods = methods
|
|
204
|
+
@file = file
|
|
205
|
+
@namespace_stack = [] # track current class/module nesting
|
|
206
|
+
@singleton_depth = 0 # track if we're inside class << self
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def visit_class_node(node)
|
|
210
|
+
name = constant_path_to_string(node.constant_path)
|
|
211
|
+
@namespace_stack.push(name)
|
|
212
|
+
super
|
|
213
|
+
@namespace_stack.pop
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def visit_module_node(node)
|
|
217
|
+
name = constant_path_to_string(node.constant_path)
|
|
218
|
+
@namespace_stack.push(name)
|
|
219
|
+
super
|
|
220
|
+
@namespace_stack.pop
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def visit_singleton_class_node(node)
|
|
224
|
+
@singleton_depth += 1
|
|
225
|
+
super
|
|
226
|
+
@singleton_depth -= 1
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def visit_def_node(node)
|
|
230
|
+
return super if @namespace_stack.empty? # skip top-level methods
|
|
231
|
+
|
|
232
|
+
scope = if node.receiver || @singleton_depth > 0
|
|
233
|
+
:class
|
|
234
|
+
else
|
|
235
|
+
:instance
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
@methods << {
|
|
239
|
+
name: node.name.to_s,
|
|
240
|
+
scope: scope,
|
|
241
|
+
start_line: node.location.start_line,
|
|
242
|
+
end_line: node.location.end_line,
|
|
243
|
+
class_path: @namespace_stack.join("::"),
|
|
244
|
+
file: @file
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
super
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def constant_path_to_string(node)
|
|
253
|
+
case node
|
|
254
|
+
when Prism::ConstantReadNode
|
|
255
|
+
node.name.to_s
|
|
256
|
+
when Prism::ConstantPathNode
|
|
257
|
+
parent = node.parent ? constant_path_to_string(node.parent) + "::" : ""
|
|
258
|
+
parent + node.name.to_s
|
|
259
|
+
else
|
|
260
|
+
node.to_s
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
data/lib/teek/ractor_support.rb
CHANGED
data/lib/teek/version.rb
CHANGED