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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a6639b7742fe7c1be6b94ddb2e7ea7e5f65f66d4525931f5a916a92ebe45a90
4
- data.tar.gz: 0ef9c6a423201a35123193b951577f6f1aa2e375cbabdd20d5d34a35bdd47335
3
+ metadata.gz: bbf5c504422362129bd97b61be21e564e0c4fc4eddced045f97320a14d77d999
4
+ data.tar.gz: 2a2d74b99b040fe345f67ae83f52b7f759b24c5a2b86a644f3673d0539676023
5
5
  SHA512:
6
- metadata.gz: 252f099b49231033ba5b59d7108c79f920247e357d80ac236b2ead0772b09e5af9aa023855b91597d73586e1c9d78a16724f8d57d7a1d12cddb082ce35e23de7
7
- data.tar.gz: bb50dfed0ede92e95c55a74a047a9382498605579c28c6a874cbd5837ceb5a4e13d94b1360446dca0af22ab556a35c9e9ea7919f10235fb4332f52c7f1529129
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.tcl_eval('wm title . "Hello Teek"')
15
+ app.set_window_title('Hello Teek')
16
16
 
17
- # Create widgets with tcl_eval
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
- Register Ruby procs as Tcl callbacks using `app.register_callback`:
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
- cb = app.register_callback(proc { |*args|
39
- puts "clicked!"
40
- })
41
- app.tcl_eval("button .b -text Click -command {ruby_callback #{cb}}")
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
- cb = app.register_callback(proc { |*|
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
@@ -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
- @control_port = nil # Prevent further message sends
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
- @worker_ractor&.close_incoming
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
@@ -232,7 +232,7 @@ module Teek
232
232
  @impl.stop
233
233
  end
234
234
 
235
- # Adapter for old yielder API
235
+ # @api private
236
236
  class StreamYielder
237
237
  def initialize(task)
238
238
  @task = task
data/lib/teek/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teek
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end