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

type-ruby / t-ruby / 20569579414

29 Dec 2025 09:31AM UTC coverage: 80.005% (+0.9%) from 79.076%
20569579414

Pull #30

github

web-flow
Merge 6db8c3a50 into a7c451da7
Pull Request #30: feat: improve error messages with tsc-style diagnostics

525 of 636 new or added lines in 14 files covered. (82.55%)

10 existing lines in 4 files now uncovered.

7110 of 8887 relevant lines covered (80.0%)

896.37 hits per line

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

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

3
# listen gem is optional - only required for watch mode
4
# This allows T-Ruby core functionality to work on Ruby 4.0+ where listen/ffi may not be available
5
begin
6
  require "listen"
1✔
7
  LISTEN_AVAILABLE = true
1✔
8
rescue LoadError
9
  LISTEN_AVAILABLE = false
×
10
end
11

12
module TRuby
1✔
13
  class Watcher
1✔
14
    # ANSI color codes (similar to tsc output style)
15
    COLORS = {
1✔
16
      reset: "\e[0m",
17
      bold: "\e[1m",
18
      dim: "\e[2m",
19
      red: "\e[31m",
20
      green: "\e[32m",
21
      yellow: "\e[33m",
22
      blue: "\e[34m",
23
      cyan: "\e[36m",
24
      gray: "\e[90m",
25
    }.freeze
26

27
    attr_reader :incremental_compiler, :stats
1✔
28

29
    def initialize(paths: ["."], config: nil, incremental: true, cross_file_check: true, parallel: true)
1✔
30
      @paths = paths.map { |p| File.expand_path(p) }
55✔
31
      @config = config || Config.new
27✔
32
      @compiler = Compiler.new(@config)
27✔
33
      @error_count = 0
27✔
34
      @file_count = 0
27✔
35
      @use_colors = $stdout.tty?
27✔
36
      @file_diagnostics = {} # Cache diagnostics per file for incremental updates
27✔
37

38
      # Enhanced features
39
      @incremental = incremental
27✔
40
      @cross_file_check = cross_file_check
27✔
41
      @parallel = parallel
27✔
42

43
      # Initialize incremental compiler
44
      if @incremental
27✔
45
        @incremental_compiler = EnhancedIncrementalCompiler.new(
27✔
46
          @compiler,
47
          enable_cross_file: @cross_file_check
48
        )
49
      end
50

51
      # Parallel processor
52
      @parallel_processor = ParallelProcessor.new if @parallel
27✔
53

54
      # Statistics
55
      @stats = {
27✔
56
        total_compilations: 0,
57
        incremental_hits: 0,
58
        total_time: 0.0,
59
      }
60
    end
61

62
    def watch
1✔
63
      unless LISTEN_AVAILABLE
×
64
        puts colorize(:red, "Error: Watch mode requires the 'listen' gem.")
×
65
        puts colorize(:yellow, "The 'listen' gem is not available (possibly due to Ruby 4.0+ ffi compatibility).")
×
66
        puts colorize(:dim, "Install with: gem install listen")
×
67
        puts colorize(:dim, "Or run without watch mode: trc")
×
68
        exit 1
×
69
      end
70

71
      print_start_message
×
72

73
      # Initial compilation
74
      start_time = Time.now
×
75
      compile_all
×
76
      @stats[:total_time] += Time.now - start_time
×
77

78
      # Start watching (.trb and .rb files)
79
      listener = Listen.to(*watch_directories, only: /\.(trb|rb)$/) do |modified, added, removed|
×
80
        handle_changes(modified, added, removed)
×
81
      end
82

83
      listener.start
×
84

85
      print_watching_message
×
86

87
      # Keep the process running
88
      begin
89
        sleep
×
90
      rescue Interrupt
91
        puts "\n#{colorize(:dim, timestamp)} #{colorize(:cyan, "Stopping watch mode...")}"
×
92
        print_stats if @incremental
×
93
        listener.stop
×
94
      end
95
    end
96

97
    private
1✔
98

99
    def watch_directory(path)
1✔
100
      File.directory?(path) ? path : File.dirname(path)
×
101
    end
102

103
    def watch_directories
1✔
104
      if @paths == [File.expand_path(".")]
1✔
105
        # Default case: only watch source_include directories from config
106
        @config.source_include.map { |dir| File.expand_path(dir) }.select { |dir| Dir.exist?(dir) }
3✔
107
      else
108
        # Specific paths provided: watch those paths
109
        @paths.map { |path| watch_directory(path) }.uniq
×
110
      end
111
    end
112

113
    def handle_changes(modified, added, removed)
1✔
114
      changed_files = (modified + added)
×
115
                      .select { |f| f.end_with?(".trb") || f.end_with?(".rb") }
×
116
                      .reject { |f| @config.excluded?(f) }
×
117
      return if changed_files.empty? && removed.empty?
×
118

119
      puts
×
120
      print_file_change_message
×
121

122
      if removed.any?
×
123
        removed.each do |file|
×
124
          puts "#{colorize(:gray, timestamp)} #{colorize(:yellow, "File removed:")} #{relative_path(file)}"
×
125
          # Clear from incremental compiler cache
126
          @incremental_compiler&.file_hashes&.delete(file)
×
127
        end
128
      end
129

130
      if changed_files.any?
×
131
        start_time = Time.now
×
132
        compile_files_incremental(changed_files)
×
133
        @stats[:total_time] += Time.now - start_time
×
134
      else
135
        print_watching_message
×
136
      end
137
    end
138

139
    def compile_all
1✔
140
      @error_count = 0
×
141
      @file_count = 0
×
NEW
142
      @file_diagnostics = {} # Reset diagnostics cache on full compile
×
143

144
      trb_files = find_trb_files
×
145
      rb_files = find_rb_files
×
146
      all_files = trb_files + rb_files
×
147
      @file_count = all_files.size
×
148

149
      # Use unified compile_with_diagnostics for all files
150
      # Note: compile_file increments @stats[:total_compilations] internally
NEW
151
      all_files.each do |file|
×
NEW
152
        result = compile_file(file)
×
153
        # Cache diagnostics per file
NEW
154
        @file_diagnostics[file] = result[:diagnostics] || []
×
155
      end
156

NEW
157
      all_diagnostics = @file_diagnostics.values.flatten
×
NEW
158
      print_errors(all_diagnostics)
×
UNCOV
159
      print_summary
×
160
    end
161

162
    def compile_files_incremental(files)
1✔
163
      compiled_count = 0
×
164

165
      if @incremental
×
166
        files.each do |file|
×
167
          if @incremental_compiler.needs_compile?(file)
×
168
            @stats[:total_compilations] += 1
×
169
            result = compile_file_with_ir(file)
×
170
            # Update cached diagnostics for this file
NEW
171
            @file_diagnostics[file] = result[:diagnostics] || []
×
UNCOV
172
            compiled_count += 1
×
173
          else
174
            @stats[:incremental_hits] += 1
×
175
            puts "#{colorize(:gray, timestamp)} #{colorize(:dim, "Skipping unchanged:")} #{relative_path(file)}"
×
176
          end
177
        end
178

179
        # Run cross-file check if enabled
180
        if @cross_file_check && @incremental_compiler.cross_file_checker
×
181
          check_result = @incremental_compiler.cross_file_checker.check_all
×
182
          check_result[:errors].each do |e|
×
183
            # Add cross-file errors (these are not file-specific)
NEW
184
            @file_diagnostics[:cross_file] ||= []
×
NEW
185
            @file_diagnostics[:cross_file] << create_diagnostic_from_cross_file_error(e)
×
186
          end
187
        end
188
      else
189
        files.each do |file|
×
190
          result = compile_file(file)
×
191
          # Update cached diagnostics for this file
NEW
192
          @file_diagnostics[file] = result[:diagnostics] || []
×
UNCOV
193
          compiled_count += 1
×
194
        end
195
      end
196

197
      # Collect all diagnostics from cache (includes unchanged files' errors)
NEW
198
      all_diagnostics = @file_diagnostics.values.flatten
×
199

200
      # Update error count from all cached diagnostics
NEW
201
      @error_count = all_diagnostics.size
×
202

203
      @file_count = compiled_count
×
NEW
204
      print_errors(all_diagnostics)
×
205
      print_summary
×
206
      print_watching_message
×
207
    end
208

209
    def compile_file_with_ir(file)
1✔
210
      # Use unified compile_with_diagnostics from Compiler (same as compile_file)
211
      # This ensures incremental compile returns the same diagnostics as full compile
NEW
212
      compile_result = @compiler.compile_with_diagnostics(file)
×
213

214
      # Update incremental compiler's file hash to track changes
NEW
215
      @incremental_compiler&.update_file_hash(file)
×
216

217
      {
NEW
218
        file: file,
×
219
        diagnostics: compile_result[:diagnostics],
220
        success: compile_result[:success],
221
      }
222
    end
223

224
    def compile_file(file)
1✔
225
      # Use unified compile_with_diagnostics from Compiler
226
      compile_result = @compiler.compile_with_diagnostics(file)
4✔
227

228
      @error_count += compile_result[:diagnostics].size
4✔
229
      @stats[:total_compilations] += 1
4✔
230

231
      {
232
        file: file,
4✔
233
        diagnostics: compile_result[:diagnostics],
234
        success: compile_result[:success],
235
      }
236
    end
237

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

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

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

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

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

268
      files.uniq
9✔
269
    end
270

271
    # Create a Diagnostic for cross-file check errors
272
    def create_diagnostic_from_cross_file_error(error)
1✔
NEW
273
      file = error[:file]
×
NEW
274
      source = File.exist?(file) ? File.read(file) : nil
×
NEW
275
      create_generic_diagnostic(file, error[:message], source)
×
276
    end
277

278
    # Create a generic Diagnostic for standard errors
279
    def create_generic_diagnostic(file, message, source = nil)
1✔
280
      line = 1
3✔
281
      col = 1
3✔
282

283
      # Try to extract line info from error message
284
      if message =~ /line (\d+)/i
3✔
285
        line = ::Regexp.last_match(1).to_i
1✔
286
      end
287

288
      source_line = source&.split("\n")&.at(line - 1)
3✔
289

290
      Diagnostic.new(
3✔
291
        code: "TR0001",
292
        message: message,
293
        file: relative_path(file),
294
        line: line,
295
        column: col,
296
        source_line: source_line
297
      )
298
    end
299

300
    def print_errors(diagnostics)
1✔
NEW
301
      return if diagnostics.empty?
×
302

NEW
303
      formatter = DiagnosticFormatter.new(use_colors: @use_colors)
×
NEW
304
      diagnostics.each do |diagnostic|
×
UNCOV
305
        puts
×
NEW
306
        puts formatter.format(diagnostic)
×
307
      end
308
    end
309

310
    def print_start_message
1✔
311
      puts "#{colorize(:gray, timestamp)} #{colorize(:bold, "Starting compilation in watch mode...")}"
×
312
      puts
×
313
    end
314

315
    def print_file_change_message
1✔
316
      puts "#{colorize(:gray,
×
317
                       timestamp)} #{colorize(:bold, "File change detected. Starting incremental compilation...")}"
318
      puts
×
319
    end
320

321
    def print_summary
1✔
322
      puts
×
323
      if @error_count.zero?
×
324
        msg = "Found #{colorize(:green, "0 errors")}. Watching for file changes."
×
325
      else
326
        error_word = @error_count == 1 ? "error" : "errors"
×
327
        msg = "Found #{colorize(:red, "#{@error_count} #{error_word}")}. Watching for file changes."
×
328
      end
329
      puts "#{colorize(:gray, timestamp)} #{msg}"
×
330
    end
331

332
    def print_watching_message
1✔
333
      # Just print a blank line for readability
334
    end
335

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

351
    def timestamp
1✔
352
      Time.now.strftime("[%I:%M:%S %p]")
1✔
353
    end
354

355
    def relative_path(file)
1✔
356
      file.sub("#{Dir.pwd}/", "")
5✔
357
    end
358

359
    def colorize(color, text)
1✔
360
      return text unless @use_colors
1✔
361
      return text unless COLORS[color]
×
362

363
      "#{COLORS[color]}#{text}#{COLORS[:reset]}"
×
364
    end
365
  end
366
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