• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

type-ruby / t-ruby / 20477240232

24 Dec 2025 03:05AM UTC coverage: 75.685% (+1.4%) from 74.246%
20477240232

push

github

yhk1038
chore: add rbs gem and fix watcher/spec for type inference

- Add rbs gem dependency for E2E test RBS validation
- Fix watcher to handle single file paths (not just directories)
- Fix ir_spec to use keyword argument format (name: String)

9 of 9 new or added lines in 1 file covered. (100.0%)

166 existing lines in 3 files now uncovered.

4974 of 6572 relevant lines covered (75.68%)

1181.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

38.38
/lib/t_ruby/watcher.rb
1
# frozen_string_literal: true
2

3
require "listen"
1✔
4

5
module TRuby
1✔
6
  class Watcher
1✔
7
    # ANSI color codes (similar to tsc output style)
8
    COLORS = {
1✔
9
      reset: "\e[0m",
10
      bold: "\e[1m",
11
      dim: "\e[2m",
12
      red: "\e[31m",
13
      green: "\e[32m",
14
      yellow: "\e[33m",
15
      blue: "\e[34m",
16
      cyan: "\e[36m",
17
      gray: "\e[90m",
18
    }.freeze
19

20
    attr_reader :incremental_compiler, :stats
1✔
21

22
    def initialize(paths: ["."], config: nil, incremental: true, cross_file_check: true, parallel: true)
1✔
23
      @paths = paths.map { |p| File.expand_path(p) }
53✔
24
      @config = config || Config.new
26✔
25
      @compiler = Compiler.new(@config)
26✔
26
      @error_count = 0
26✔
27
      @file_count = 0
26✔
28
      @use_colors = $stdout.tty?
26✔
29

30
      # Enhanced features
31
      @incremental = incremental
26✔
32
      @cross_file_check = cross_file_check
26✔
33
      @parallel = parallel
26✔
34

35
      # Initialize incremental compiler
36
      if @incremental
26✔
37
        @incremental_compiler = EnhancedIncrementalCompiler.new(
26✔
38
          @compiler,
39
          enable_cross_file: @cross_file_check
40
        )
41
      end
42

43
      # Parallel processor
44
      @parallel_processor = ParallelProcessor.new if @parallel
26✔
45

46
      # Statistics
47
      @stats = {
26✔
48
        total_compilations: 0,
49
        incremental_hits: 0,
50
        total_time: 0.0,
51
      }
52
    end
53

54
    def watch
1✔
55
      print_start_message
×
56

57
      # Initial compilation
58
      start_time = Time.now
×
59
      compile_all
×
60
      @stats[:total_time] += Time.now - start_time
×
61

62
      # Start watching (.trb and .rb files)
63
      listener = Listen.to(*watch_directories, only: /\.(trb|rb)$/) do |modified, added, removed|
×
64
        handle_changes(modified, added, removed)
×
65
      end
66

67
      listener.start
×
68

69
      print_watching_message
×
70

71
      # Keep the process running
72
      begin
73
        sleep
×
74
      rescue Interrupt
75
        puts "\n#{colorize(:dim, timestamp)} #{colorize(:cyan, "Stopping watch mode...")}"
×
76
        print_stats if @incremental
×
77
        listener.stop
×
78
      end
79
    end
80

81
    private
1✔
82

83
    def watch_directory(path)
1✔
84
      File.directory?(path) ? path : File.dirname(path)
×
85
    end
86

87
    def watch_directories
1✔
88
      if @paths == [File.expand_path(".")]
1✔
89
        # Default case: only watch source_include directories from config
90
        @config.source_include.map { |dir| File.expand_path(dir) }.select { |dir| Dir.exist?(dir) }
3✔
91
      else
92
        # Specific paths provided: watch those paths
UNCOV
93
        @paths.map { |path| watch_directory(path) }.uniq
×
94
      end
95
    end
96

97
    def handle_changes(modified, added, removed)
1✔
UNCOV
98
      changed_files = (modified + added)
×
99
                      .select { |f| f.end_with?(".trb") || f.end_with?(".rb") }
×
100
                      .reject { |f| @config.excluded?(f) }
×
UNCOV
101
      return if changed_files.empty? && removed.empty?
×
102

103
      puts
×
104
      print_file_change_message
×
105

106
      if removed.any?
×
UNCOV
107
        removed.each do |file|
×
UNCOV
108
          puts "#{colorize(:gray, timestamp)} #{colorize(:yellow, "File removed:")} #{relative_path(file)}"
×
109
          # Clear from incremental compiler cache
110
          @incremental_compiler&.file_hashes&.delete(file)
×
111
        end
112
      end
113

UNCOV
114
      if changed_files.any?
×
115
        start_time = Time.now
×
UNCOV
116
        compile_files_incremental(changed_files)
×
UNCOV
117
        @stats[:total_time] += Time.now - start_time
×
118
      else
UNCOV
119
        print_watching_message
×
120
      end
121
    end
122

123
    def compile_all
1✔
124
      @error_count = 0
×
125
      @file_count = 0
×
126
      errors = []
×
127

UNCOV
128
      trb_files = find_trb_files
×
129
      rb_files = find_rb_files
×
UNCOV
130
      all_files = trb_files + rb_files
×
131
      @file_count = all_files.size
×
132

133
      if @incremental && @cross_file_check
×
134
        # Use enhanced incremental compiler with cross-file checking
UNCOV
135
        result = @incremental_compiler.compile_all_with_checking(trb_files)
×
UNCOV
136
        errors = result[:errors].map { |e| format_error(e[:file], e[:error] || e[:message]) }
×
137
        @error_count = errors.size
×
138
        @stats[:total_compilations] += trb_files.size
×
139

140
        # Also compile .rb files
141
        rb_files.each do |file|
×
UNCOV
142
          result = compile_file(file)
×
143
          errors.concat(result[:errors]) if result[:errors].any?
×
144
        end
UNCOV
145
      elsif @parallel && all_files.size > 1
×
146
        # Parallel compilation
147
        results = @parallel_processor.process_files(all_files) do |file|
×
UNCOV
148
          compile_file(file)
×
149
        end
UNCOV
150
        results.each do |result|
×
151
          errors.concat(result[:errors]) if result[:errors]&.any?
×
152
        end
153
      else
154
        # Sequential compilation
UNCOV
155
        all_files.each do |file|
×
UNCOV
156
          result = compile_file(file)
×
157
          errors.concat(result[:errors]) if result[:errors].any?
×
158
        end
159
      end
160

UNCOV
161
      print_errors(errors)
×
162
      print_summary
×
163
    end
164

165
    def compile_files_incremental(files)
1✔
166
      @error_count = 0
×
167
      errors = []
×
168
      compiled_count = 0
×
169

170
      if @incremental
×
171
        files.each do |file|
×
172
          if @incremental_compiler.needs_compile?(file)
×
UNCOV
173
            @stats[:total_compilations] += 1
×
174
            result = compile_file_with_ir(file)
×
175
            errors.concat(result[:errors]) if result[:errors].any?
×
UNCOV
176
            compiled_count += 1
×
177
          else
UNCOV
178
            @stats[:incremental_hits] += 1
×
UNCOV
179
            puts "#{colorize(:gray, timestamp)} #{colorize(:dim, "Skipping unchanged:")} #{relative_path(file)}"
×
180
          end
181
        end
182

183
        # Run cross-file check if enabled
UNCOV
184
        if @cross_file_check && @incremental_compiler.cross_file_checker
×
UNCOV
185
          check_result = @incremental_compiler.cross_file_checker.check_all
×
UNCOV
186
          check_result[:errors].each do |e|
×
187
            errors << format_error(e[:file], e[:message])
×
188
          end
189
        end
190
      else
UNCOV
191
        files.each do |file|
×
UNCOV
192
          result = compile_file(file)
×
UNCOV
193
          errors.concat(result[:errors]) if result[:errors].any?
×
194
          compiled_count += 1
×
195
        end
196
      end
197

UNCOV
198
      @file_count = compiled_count
×
UNCOV
199
      print_errors(errors)
×
UNCOV
200
      print_summary
×
201
      print_watching_message
×
202
    end
203

204
    def compile_file_with_ir(file)
1✔
205
      result = { file: file, errors: [], success: false }
×
206

207
      begin
208
        @incremental_compiler.compile_with_ir(file)
×
UNCOV
209
        result[:success] = true
×
210
      rescue ArgumentError => e
211
        @error_count += 1
×
UNCOV
212
        result[:errors] << format_error(file, e.message)
×
213
      rescue StandardError => e
214
        @error_count += 1
×
UNCOV
215
        result[:errors] << format_error(file, e.message)
×
216
      end
217

UNCOV
218
      result
×
219
    end
220

221
    def compile_file(file)
1✔
222
      result = { file: file, errors: [], success: false }
4✔
223

224
      begin
225
        @compiler.compile(file)
4✔
226
        result[:success] = true
3✔
227
        @stats[:total_compilations] += 1
3✔
228
      rescue ArgumentError => e
229
        @error_count += 1
1✔
230
        result[:errors] << format_error(file, e.message)
1✔
231
      rescue StandardError => e
UNCOV
232
        @error_count += 1
×
UNCOV
233
        result[:errors] << format_error(file, e.message)
×
234
      end
235

236
      result
4✔
237
    end
238

239
    def find_trb_files
1✔
240
      find_source_files_by_extension(".trb")
8✔
241
    end
242

243
    def find_rb_files
1✔
244
      find_source_files_by_extension(".rb")
1✔
245
    end
246

247
    def find_source_files_by_extension(ext)
1✔
248
      files = []
9✔
249

250
      # Always search in source_include directories only
251
      source_paths = if @paths == [File.expand_path(".")]
9✔
252
                       @config.source_include.map { |dir| File.expand_path(dir) }
3✔
253
                     else
254
                       @paths.map { |path| File.expand_path(path) }
16✔
255
                     end
256

257
      source_paths.each do |path|
9✔
258
        if File.file?(path)
10✔
259
          # Handle single file path
260
          files << path if path.end_with?(ext) && !@config.excluded?(path)
1✔
261
        elsif Dir.exist?(path)
9✔
262
          # Handle directory path
263
          Dir.glob(File.join(path, "**", "*#{ext}")).each do |file|
9✔
264
            files << file unless @config.excluded?(file)
12✔
265
          end
266
        end
267
      end
268

269
      files.uniq
9✔
270
    end
271

272
    def format_error(file, message)
1✔
273
      # Parse error message for line/column info if available
274
      # Format: file:line:col - error TRB0001: message
275
      line = 1
3✔
276
      col = 1
3✔
277

278
      # Try to extract line info from error message
279
      if message =~ /line (\d+)/i
3✔
280
        line = ::Regexp.last_match(1).to_i
1✔
281
      end
282

283
      {
284
        file: file,
3✔
285
        line: line,
286
        col: col,
287
        message: message,
288
      }
289
    end
290

291
    def print_errors(errors)
1✔
292
      errors.each do |error|
×
UNCOV
293
        puts
×
294
        # TypeScript-style error format: file:line:col - error TSXXXX: message
UNCOV
295
        location = "#{colorize(:cyan,
×
296
                               relative_path(error[:file]))}:#{colorize(:yellow,
297
                                                                        error[:line])}:#{colorize(:yellow,
298
                                                                                                  error[:col])}"
UNCOV
299
        puts "#{location} - #{colorize(:red, "error")} #{colorize(:gray, "TRB0001")}: #{error[:message]}"
×
300
      end
301
    end
302

303
    def print_start_message
1✔
304
      puts "#{colorize(:gray, timestamp)} #{colorize(:bold, "Starting compilation in watch mode...")}"
×
UNCOV
305
      puts
×
306
    end
307

308
    def print_file_change_message
1✔
UNCOV
309
      puts "#{colorize(:gray,
×
310
                       timestamp)} #{colorize(:bold, "File change detected. Starting incremental compilation...")}"
UNCOV
311
      puts
×
312
    end
313

314
    def print_summary
1✔
315
      puts
×
316
      if @error_count.zero?
×
UNCOV
317
        msg = "Found #{colorize(:green, "0 errors")}. Watching for file changes."
×
318
      else
319
        error_word = @error_count == 1 ? "error" : "errors"
×
UNCOV
320
        msg = "Found #{colorize(:red, "#{@error_count} #{error_word}")}. Watching for file changes."
×
321
      end
UNCOV
322
      puts "#{colorize(:gray, timestamp)} #{msg}"
×
323
    end
324

325
    def print_watching_message
1✔
326
      # Just print a blank line for readability
327
    end
328

329
    def print_stats
1✔
330
      puts
×
331
      puts "#{colorize(:gray, timestamp)} #{colorize(:bold, "Watch Mode Statistics:")}"
×
332
      puts "  Total compilations: #{@stats[:total_compilations]}"
×
333
      puts "  Incremental cache hits: #{@stats[:incremental_hits]}"
×
334
      total = @stats[:total_compilations] + @stats[:incremental_hits]
×
335
      hit_rate = if total.positive?
×
UNCOV
336
                   (@stats[:incremental_hits].to_f / total * 100).round(1)
×
337
                 else
UNCOV
338
                   0
×
339
                 end
340
      puts "  Cache hit rate: #{hit_rate}%"
×
UNCOV
341
      puts "  Total compile time: #{@stats[:total_time].round(2)}s"
×
342
    end
343

344
    def timestamp
1✔
345
      Time.now.strftime("[%I:%M:%S %p]")
1✔
346
    end
347

348
    def relative_path(file)
1✔
349
      file.sub("#{Dir.pwd}/", "")
2✔
350
    end
351

352
    def colorize(color, text)
1✔
353
      return text unless @use_colors
1✔
UNCOV
354
      return text unless COLORS[color]
×
355

UNCOV
356
      "#{COLORS[color]}#{text}#{COLORS[:reset]}"
×
357
    end
358
  end
359
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc